From 7ee3e67db961363301f4819410b5296f0ea7bbfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 4 Dec 2025 11:37:11 +0100 Subject: [PATCH 01/95] unconditional sim --- addons/netfox/netfox.gd | 4 ++ addons/netfox/rollback/network-rollback.gd | 1 + .../netfox/rollback/rollback-synchronizer.gd | 16 +++++- .../servers/rollback-simulation-server.gd | 51 +++++++++++++++++++ project.godot | 1 + 5 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 addons/netfox/servers/rollback-simulation-server.gd diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index dfdfcd56..7ba5224d 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -145,6 +145,10 @@ const AUTOLOADS: Array[Dictionary] = [ { "name": "NetworkPerformance", "path": ROOT + "/network-performance.gd" + }, + { + "name": "RollbackSimulationServer", + "path": ROOT + "/servers/rollback-simulation-server.gd" } ] diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 7a216f27..d876ff70 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -352,6 +352,7 @@ func _rollback() -> void: # If authority: Latest input >= tick >= Latest state # If not: Latest input >= tick >= Earliest input _rollback_stage = _STAGE_SIMULATE + RollbackSimulationServer.simulate(NetworkTime.ticktime, tick) on_process_tick.emit(tick) after_process_tick.emit(tick) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 06bb435a..8e873845 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -94,11 +94,18 @@ static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("RollbackSynchronize var _history_transmitter: _RollbackHistoryTransmitter var _history_recorder: _RollbackHistoryRecorder +var _registered_nodes: Array[Node] = [] + ## Process settings. ## ## Call this after any change to configuration. Updates based on authority too ## ( calls process_authority ). func process_settings() -> void: + # Unregister all nodes + for node in _registered_nodes: + RollbackSimulationServer.deregister_node(node) + + # Clear _property_cache.root = root _property_cache.clear() _freshness_store.clear() @@ -115,6 +122,10 @@ func process_settings() -> void: _nodes = _nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it)) _nodes.erase(self) + for node in _nodes: + RollbackSimulationServer.register(node._rollback_tick) + _registered_nodes.append(node) + _history_transmitter.sync_settings(root, enable_input_broadcast, full_state_interval, diff_ack_interval) _history_transmitter.configure(_states, _inputs, _state_property_config, _input_property_config, visibility_filter, _property_cache, _skipset) _history_recorder.configure(_states, _inputs, _state_property_config, _input_property_config, _property_cache, _skipset) @@ -275,8 +286,9 @@ func _on_prepare_tick(tick: int) -> void: _prepare_tick_process(tick) func _process_tick(tick: int) -> void: - _run_rollback_tick(tick) - _push_simset_metrics() +# _run_rollback_tick(tick) +# _push_simset_metrics() + pass func _on_record_tick(tick: int) -> void: _history_recorder.record_state(tick) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd new file mode 100644 index 00000000..6e7f35ab --- /dev/null +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -0,0 +1,51 @@ +extends Node +class_name _RollbackSimulationServer + +# node to callback +# TODO: Consider allowing any Object, not just nodes +var _callbacks := {} + +var _group := StringName("__nf_rollback_sim" + str(get_instance_id())) + +static var _logger := NetfoxLogger._for_netfox("RollbackSimulationServer") + +func register(callback: Callable) -> void: + if not is_instance_valid(callback.get_object()): + _logger.error("Trying to register callback that belongs to an invalid object!") + return + + _callbacks[callback.get_object()] = callback + +func deregister(callback: Callable) -> void: + if not is_instance_valid(callback.get_object()): + return + + if _callbacks[callback.get_object()] != callback: + return + + _callbacks.erase(callback.get_object()) + +func deregister_node(node: Node) -> void: + _callbacks.erase(node) + +func get_nodes_to_simulate() -> Array[Node]: + var result: Array[Node] = [] + result.assign(_callbacks.keys()) + return result + +func simulate(delta: float, tick: int) -> void: + var nodes := get_nodes_to_simulate() + + # Sort based on SceneTree order + for node in nodes: + node.add_to_group(_group) + nodes = get_tree().get_nodes_in_group(_group) + + # Run callbacks and clear group + for node in nodes: + var callback := _callbacks[node] as Callable + callback.call(delta, tick, false) # TODO: is_fresh + node.remove_from_group(_group) + + # Metrics + NetworkPerformance.push_rollback_nodes_simulated(nodes.size()) diff --git a/project.godot b/project.godot index 6ec07406..2a763656 100644 --- a/project.godot +++ b/project.godot @@ -30,6 +30,7 @@ NetworkEvents="*res://addons/netfox/network-events.gd" NetworkPerformance="*res://addons/netfox/network-performance.gd" WindowTiler="*res://addons/netfox.extras/window-tiler.gd" NetworkSimulator="*res://addons/netfox.extras/network-simulator.gd" +RollbackSimulationServer="*res://addons/netfox/servers/rollback-simulation-server.gd" [display] From fdc5e4a29a7b024de56afef84f9989659d13fd49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 4 Dec 2025 12:19:46 +0100 Subject: [PATCH 02/95] history server --- addons/netfox/netfox.gd | 4 ++ addons/netfox/rollback/network-rollback.gd | 4 ++ .../netfox/rollback/rollback-synchronizer.gd | 18 +++-- .../netfox/servers/rollback-history-server.gd | 72 +++++++++++++++++++ project.godot | 1 + 5 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 addons/netfox/servers/rollback-history-server.gd diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 7ba5224d..602f3829 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -149,6 +149,10 @@ const AUTOLOADS: Array[Dictionary] = [ { "name": "RollbackSimulationServer", "path": ROOT + "/servers/rollback-simulation-server.gd" + }, + { + "name": "RollbackHistoryServer", + "path": ROOT + "/servers/rollback-history-server.gd" } ] diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index d876ff70..eff46878 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -342,6 +342,7 @@ func _rollback() -> void: # Done individually by Rewindables ( usually Rollback Synchronizers ) # Restore input and state for tick _rollback_stage = _STAGE_PREPARE + RollbackHistoryServer.restore_tick(tick) on_prepare_tick.emit(tick) after_prepare_tick.emit(tick) @@ -358,10 +359,13 @@ func _rollback() -> void: # Record state for tick + 1 _rollback_stage = _STAGE_RECORD + RollbackHistoryServer.record_tick(tick + 1) on_record_tick.emit(tick + 1) # Restore display state _rollback_stage = _STAGE_AFTER + RollbackHistoryServer.restore_tick(display_tick) + RollbackHistoryServer.trim_history(history_start) after_loop.emit() # Cleanup diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 8e873845..faaf1055 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -101,7 +101,7 @@ var _registered_nodes: Array[Node] = [] ## Call this after any change to configuration. Updates based on authority too ## ( calls process_authority ). func process_settings() -> void: - # Unregister all nodes + # Deregister all nodes for node in _registered_nodes: RollbackSimulationServer.deregister_node(node) @@ -136,12 +136,21 @@ func process_settings() -> void: ## RollbackSynchronizer changes. Make sure to do this at the same time on all ## peers. func process_authority(): + # Deregister all recorded properties + for prop in _get_recorded_state_props() + _get_recorded_input_props(): + RollbackHistoryServer.deregister_property(prop.node, prop.property) + + # Process authority _state_property_config.local_peer_id = multiplayer.get_unique_id() _input_property_config.local_peer_id = multiplayer.get_unique_id() _state_property_config.set_properties_from_paths(state_properties, _property_cache) _input_property_config.set_properties_from_paths(input_properties, _property_cache) + # Register new recorded properties + for prop in _get_recorded_state_props() + _get_recorded_input_props(): + RollbackHistoryServer.register_property(prop.node, prop.property) + ## Add a state property. ## [br][br] ## Settings will be automatically updated. The [param node] may be a string or @@ -270,7 +279,8 @@ func _disconnect_signals() -> void: NetworkRollback.after_loop.disconnect(_after_rollback_loop) func _before_tick(_dt: float, tick: int) -> void: - _history_recorder.apply_state(tick) +# _history_recorder.apply_state(tick) + pass func _after_tick(_dt: float, tick: int) -> void: _history_recorder.record_input(tick) @@ -282,7 +292,7 @@ func _before_rollback_loop() -> void: _notify_resim() func _on_prepare_tick(tick: int) -> void: - _history_recorder.apply_tick(tick) +# _history_recorder.apply_tick(tick) _prepare_tick_process(tick) func _process_tick(tick: int) -> void: @@ -295,7 +305,7 @@ func _on_record_tick(tick: int) -> void: _history_transmitter.transmit_state(tick) func _after_rollback_loop() -> void: - _history_recorder.apply_display_state() +# _history_recorder.apply_display_state() _history_transmitter.conclude_tick_loop() func _notification(what: int) -> void: diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd new file mode 100644 index 00000000..f3e6dbbb --- /dev/null +++ b/addons/netfox/servers/rollback-history-server.gd @@ -0,0 +1,72 @@ +extends Node +class_name _RollbackHistoryServer + +var _recorded_properties: Array[RecordedProperty] = [] +var _snapshots: Dictionary = {} # tick to Snapshot + +func register_property(node: Node, property: NodePath) -> void: + var entry := RecordedProperty.new(node, property) + + # TODO: Accelerate this check, maybe with _Set + if not _recorded_properties.has(entry): + _recorded_properties.append(entry) + +func deregister_property(node: Node, property: NodePath) -> void: + # TODO: Accelerate, maybe with _Set + _recorded_properties.erase(RecordedProperty.new(node, property)) + +func record_tick(tick: int) -> void: + # Ensure snapshot + var snapshot := _snapshots.get(tick) as Snapshot + if snapshot == null: + snapshot = Snapshot.new(tick) + + # Record values + for entry in _recorded_properties: + var recorded_property := entry as RecordedProperty + snapshot.data[recorded_property] = recorded_property.extract_value() + +func restore_tick(tick: int) -> bool: + if not _snapshots.has(tick): + return false + + var snapshot := _snapshots[tick] as Snapshot + for entry in snapshot.data.keys(): + var recorded_property := entry as RecordedProperty + var value = snapshot.data[entry] + recorded_property.apply_value(value) + return true + +func trim_history(earliest_tick: int) -> void: + while not _snapshots.is_empty(): + var earliest_stored_tick := _snapshots.keys().min() + if earliest_stored_tick >= earliest_tick: + break + _snapshots.erase(earliest_stored_tick) + +class RecordedProperty: + var node: Node + var property: NodePath + + func _init(p_node: Node, p_property: NodePath): + node = p_node + property = p_property + + func extract_value() -> Variant: + return node.get_indexed(property) + + func apply_value(value: Variant) -> void: + node.set_indexed(property, value) + +class Snapshot: + var tick: int + var data: Dictionary = {} # RecordedProperty to Variant value + + func _init(p_tick: int): + tick = p_tick + + func has_node(node: Node) -> bool: + for entry in data.keys(): + if (entry as RecordedProperty).node == node: + return true + return false diff --git a/project.godot b/project.godot index 2a763656..ce851e96 100644 --- a/project.godot +++ b/project.godot @@ -31,6 +31,7 @@ NetworkPerformance="*res://addons/netfox/network-performance.gd" WindowTiler="*res://addons/netfox.extras/window-tiler.gd" NetworkSimulator="*res://addons/netfox.extras/network-simulator.gd" RollbackSimulationServer="*res://addons/netfox/servers/rollback-simulation-server.gd" +RollbackHistoryServer="*res://addons/netfox/servers/rollback-history-server.gd" [display] From 527dd95b18591bd4fca89da3c7cd54c92de53195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 4 Dec 2025 22:34:53 +0100 Subject: [PATCH 03/95] sync server --- addons/netfox/netfox.gd | 4 + addons/netfox/rollback/network-rollback.gd | 4 + .../netfox/rollback/rollback-synchronizer.gd | 12 ++ .../netfox/servers/data/recorded-property.gd | 15 +++ addons/netfox/servers/data/snapshot.gd | 14 +++ .../netfox/servers/rollback-history-server.gd | 35 ++---- .../rollback-synchronization-server.gd | 107 ++++++++++++++++++ project.godot | 1 + 8 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 addons/netfox/servers/data/recorded-property.gd create mode 100644 addons/netfox/servers/data/snapshot.gd create mode 100644 addons/netfox/servers/rollback-synchronization-server.gd diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 602f3829..8428c6b2 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -153,6 +153,10 @@ const AUTOLOADS: Array[Dictionary] = [ { "name": "RollbackHistoryServer", "path": ROOT + "/servers/rollback-history-server.gd" + }, + { + "name": "RollbackSynchronizationServer", + "path": ROOT + "/servers/rollback-synchronization-server.gd" } ] diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index eff46878..4adec833 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -295,6 +295,9 @@ func free_input_submission_data_for(root_node: Node) -> void: func _ready(): NetfoxLogger.register_tag(_get_rollback_tag) NetworkTime.after_tick_loop.connect(_rollback) + NetworkTime.after_tick.connect(func(_dt, tick): + RollbackSynchronizationServer.synchronize_input(tick) + ) func _exit_tree(): NetfoxLogger.free_tag(_get_rollback_tag) @@ -360,6 +363,7 @@ func _rollback() -> void: # Record state for tick + 1 _rollback_stage = _STAGE_RECORD RollbackHistoryServer.record_tick(tick + 1) + RollbackSynchronizationServer.synchronize_state(tick + 1) on_record_tick.emit(tick + 1) # Restore display state diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index faaf1055..7b95691c 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -140,6 +140,12 @@ func process_authority(): for prop in _get_recorded_state_props() + _get_recorded_input_props(): RollbackHistoryServer.deregister_property(prop.node, prop.property) + for prop in _get_owned_input_props(): + RollbackSynchronizationServer.deregister_input(prop.node, prop.property) + + for prop in _get_owned_state_props(): + RollbackSynchronizationServer.deregister_state(prop.node, prop.property) + # Process authority _state_property_config.local_peer_id = multiplayer.get_unique_id() _input_property_config.local_peer_id = multiplayer.get_unique_id() @@ -151,6 +157,12 @@ func process_authority(): for prop in _get_recorded_state_props() + _get_recorded_input_props(): RollbackHistoryServer.register_property(prop.node, prop.property) + for prop in _get_owned_input_props(): + RollbackSynchronizationServer.register_input(prop.node, prop.property) + + for prop in _get_owned_state_props(): + RollbackSynchronizationServer.register_state(prop.node, prop.property) + ## Add a state property. ## [br][br] ## Settings will be automatically updated. The [param node] may be a string or diff --git a/addons/netfox/servers/data/recorded-property.gd b/addons/netfox/servers/data/recorded-property.gd new file mode 100644 index 00000000..951f4973 --- /dev/null +++ b/addons/netfox/servers/data/recorded-property.gd @@ -0,0 +1,15 @@ +extends RefCounted +class_name RecordedProperty + +var node: Node +var property: NodePath + +func _init(p_node: Node, p_property: NodePath): + node = p_node + property = p_property + +func extract_value() -> Variant: + return node.get_indexed(property) + +func apply_value(value: Variant) -> void: + node.set_indexed(property, value) diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd new file mode 100644 index 00000000..74483d85 --- /dev/null +++ b/addons/netfox/servers/data/snapshot.gd @@ -0,0 +1,14 @@ +extends RefCounted +class_name Snapshot + +var tick: int +var data: Dictionary = {} # RecordedProperty to Variant value + +func _init(p_tick: int): + tick = p_tick + +func has_node(node: Node) -> bool: + for entry in data.keys(): + if (entry as RecordedProperty).node == node: + return true + return false diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index f3e6dbbb..ddb05c99 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -44,29 +44,18 @@ func trim_history(earliest_tick: int) -> void: break _snapshots.erase(earliest_stored_tick) -class RecordedProperty: - var node: Node - var property: NodePath +# TODO: Keep snapshots private +func get_snapshot(tick: int) -> Snapshot: + return _snapshots.get(tick) - func _init(p_node: Node, p_property: NodePath): - node = p_node - property = p_property +# TODO: Keep snapshots private +func merge_snapshot(snapshot: Snapshot) -> Snapshot: + var tick := snapshot.tick + if not _snapshots.has(snapshot.tick): + _snapshots[tick] = snapshot + return snapshot - func extract_value() -> Variant: - return node.get_indexed(property) + var stored_snapshot := _snapshots[tick] as Snapshot + stored_snapshot.data.merge(snapshot.data) - func apply_value(value: Variant) -> void: - node.set_indexed(property, value) - -class Snapshot: - var tick: int - var data: Dictionary = {} # RecordedProperty to Variant value - - func _init(p_tick: int): - tick = p_tick - - func has_node(node: Node) -> bool: - for entry in data.keys(): - if (entry as RecordedProperty).node == node: - return true - return false + return stored_snapshot diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd new file mode 100644 index 00000000..af9c2839 --- /dev/null +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -0,0 +1,107 @@ +extends Node +class_name _RollbackSynchronizationServer + +var _input_properties: Array[RecordedProperty] = [] +var _state_properties: Array[RecordedProperty] = [] + +static var _logger := NetfoxLogger._for_netfox("RollbackSynchronizationServer") + +func register_input(node: Node, property: NodePath) -> void: + var entry := RecordedProperty.new(node, property) + if _input_properties.has(entry): return + _input_properties.append(entry) + +func register_state(node: Node, property: NodePath) -> void: + var entry := RecordedProperty.new(node, property) + if _state_properties.has(entry): return + _state_properties.append(entry) + +func deregister_input(node: Node, property: NodePath) -> void: + _input_properties.erase(RecordedProperty.new(node, property)) + +func deregister_state(node: Node, property: NodePath) -> void: + _state_properties.erase(RecordedProperty.new(node, property)) + +func synchronize_input(tick: int) -> void: + # Grab snapshot from RollbackHistoryServer + var snapshot := RollbackHistoryServer.get_snapshot(tick) + if not snapshot: + return + + # Filter to input properties + var input_snapshot := Snapshot.new(tick) + for property in _input_properties: + if not snapshot.data.has(property): + continue + input_snapshot.data[property] = snapshot.data[property] + + # Transmit + _submit_input.rpc(_serialize_snapshot(input_snapshot)) + +func synchronize_state(tick: int) -> void: + # Grab snapshot from RollbackHistoryServer + var snapshot := RollbackHistoryServer.get_snapshot(tick) + if not snapshot: + return + + # Filter to state properties + var state_snapshot := Snapshot.new(tick) + for property in _state_properties: + if not snapshot.data.has(property): + continue + state_snapshot.data[property] = snapshot.data[property] + + # Transmit + _submit_state.rpc(_serialize_snapshot(state_snapshot)) + +func _serialize_snapshot(snapshot: Snapshot) -> Variant: + var serialized_properties := [] + + for entry in snapshot.data.keys(): + var property := entry as RecordedProperty + var value = snapshot.data[property] + + serialized_properties.append([str(property.node.get_path()), property.property, value]) + + serialized_properties.append(snapshot.tick) + return serialized_properties + +func _deserialize_snapshot(data: Variant) -> Snapshot: + var values := data as Array + var tick := values.pop_back() as int + + var snapshot := Snapshot.new(tick) + for entry in values: + var entry_data := entry as Array + + var node_path := entry_data[0] as String + var property := entry_data[1] as String + var value = entry_data[2] + + var node := get_tree().root.get_node(node_path) + if not node: + _logger.warning("Can't find node at path %s, ignoring", [node_path]) + continue + + # TODO: Dicts might fail if recorded property's equal but not identical + snapshot.data[RecordedProperty.new(node, property)] = value + + return snapshot + +@rpc("any_peer", "call_remote", "reliable") +func _submit_input(snapshot_data: Variant): + var snapshot := _deserialize_snapshot(snapshot_data) + + # TODO: Sanitize + + var merged := RollbackHistoryServer.merge_snapshot(snapshot) + _logger.debug("Merged input; %s", [merged]) + +@rpc("any_peer", "call_remote", "unreliable") +func _submit_state(snapshot_data: Variant): + var snapshot := _deserialize_snapshot(snapshot_data) + + # TODO: Sanitize + + var merged := RollbackHistoryServer.merge_snapshot(snapshot) + _logger.debug("Merged state; %s", [merged]) diff --git a/project.godot b/project.godot index ce851e96..768c42e3 100644 --- a/project.godot +++ b/project.godot @@ -32,6 +32,7 @@ WindowTiler="*res://addons/netfox.extras/window-tiler.gd" NetworkSimulator="*res://addons/netfox.extras/network-simulator.gd" RollbackSimulationServer="*res://addons/netfox/servers/rollback-simulation-server.gd" RollbackHistoryServer="*res://addons/netfox/servers/rollback-history-server.gd" +RollbackSynchronizationServer="*res://addons/netfox/servers/rollback-synchronization-server.gd" [display] From 86793e77bba949ba83e202c75c247bf641987e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 4 Dec 2025 23:22:29 +0100 Subject: [PATCH 04/95] fix recorded property identity issue --- addons/netfox/rollback/network-rollback.gd | 3 +- .../netfox/rollback/rollback-synchronizer.gd | 14 ++++-- .../netfox/servers/data/recorded-property.gd | 19 +++++++ addons/netfox/servers/data/snapshot.gd | 3 ++ .../netfox/servers/rollback-history-server.gd | 50 +++++++++++++------ .../rollback-synchronization-server.gd | 44 +++++++++------- project.godot | 2 +- 7 files changed, 97 insertions(+), 38 deletions(-) diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 4adec833..20e3a23d 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -296,6 +296,7 @@ func _ready(): NetfoxLogger.register_tag(_get_rollback_tag) NetworkTime.after_tick_loop.connect(_rollback) NetworkTime.after_tick.connect(func(_dt, tick): + RollbackHistoryServer.record_input(tick) RollbackSynchronizationServer.synchronize_input(tick) ) @@ -362,7 +363,7 @@ func _rollback() -> void: # Record state for tick + 1 _rollback_stage = _STAGE_RECORD - RollbackHistoryServer.record_tick(tick + 1) + RollbackHistoryServer.record_state(tick + 1) RollbackSynchronizationServer.synchronize_state(tick + 1) on_record_tick.emit(tick + 1) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 7b95691c..749be89e 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -137,8 +137,11 @@ func process_settings() -> void: ## peers. func process_authority(): # Deregister all recorded properties - for prop in _get_recorded_state_props() + _get_recorded_input_props(): - RollbackHistoryServer.deregister_property(prop.node, prop.property) + for prop in _get_recorded_state_props(): + RollbackHistoryServer.deregister_state(prop.node, prop.property) + + for prop in _get_recorded_input_props(): + RollbackHistoryServer.deregister_input(prop.node, prop.property) for prop in _get_owned_input_props(): RollbackSynchronizationServer.deregister_input(prop.node, prop.property) @@ -154,8 +157,11 @@ func process_authority(): _input_property_config.set_properties_from_paths(input_properties, _property_cache) # Register new recorded properties - for prop in _get_recorded_state_props() + _get_recorded_input_props(): - RollbackHistoryServer.register_property(prop.node, prop.property) + for prop in _get_recorded_state_props(): + RollbackHistoryServer.register_state(prop.node, prop.property) + + for prop in _get_recorded_input_props(): + RollbackHistoryServer.register_input(prop.node, prop.property) for prop in _get_owned_input_props(): RollbackSynchronizationServer.register_input(prop.node, prop.property) diff --git a/addons/netfox/servers/data/recorded-property.gd b/addons/netfox/servers/data/recorded-property.gd index 951f4973..02fb2380 100644 --- a/addons/netfox/servers/data/recorded-property.gd +++ b/addons/netfox/servers/data/recorded-property.gd @@ -1,6 +1,19 @@ extends RefCounted class_name RecordedProperty +static func key_of(p_node: Node, p_property: NodePath) -> Array: + return [p_node, p_property] + +static func extract(key: Array) -> Variant: + var node := key[0] as Node + var property := key[1] as NodePath + return node.get_indexed(property) + +static func apply(key: Array, value: Variant): + var node := key[0] as Node + var property := key[1] as NodePath + node.set_indexed(property, value) + var node: Node var property: NodePath @@ -13,3 +26,9 @@ func extract_value() -> Variant: func apply_value(value: Variant) -> void: node.set_indexed(property, value) + +func equals(other: RecordedProperty) -> bool: + return node == other.node and property == other.property + +func _to_string() -> String: + return "$(%s:%s)" % [node, property] diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index 74483d85..7553c94a 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -12,3 +12,6 @@ func has_node(node: Node) -> bool: if (entry as RecordedProperty).node == node: return true return false + +func _to_string() -> String: + return "Snapshot(#%d, %s)" % [tick, str(data)] diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index ddb05c99..50586b74 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -1,30 +1,53 @@ extends Node class_name _RollbackHistoryServer -var _recorded_properties: Array[RecordedProperty] = [] +var _input_properties: Array = [] +var _state_properties: Array = [] + var _snapshots: Dictionary = {} # tick to Snapshot -func register_property(node: Node, property: NodePath) -> void: - var entry := RecordedProperty.new(node, property) +static var _logger := NetfoxLogger._for_netfox("RollbackHistoryServer") + +func register_property(node: Node, property: NodePath, pool: Array) -> void: + var entry := RecordedProperty.key_of(node, property) # TODO: Accelerate this check, maybe with _Set - if not _recorded_properties.has(entry): - _recorded_properties.append(entry) + if not pool.has(entry): + pool.append(entry) + +func deregister_property(node: Node, property: NodePath, pool: Array) -> void: + pool.erase([node, property]) + +func register_state(node: Node, property: NodePath) -> void: + register_property(node, property, _state_properties) + +func deregister_state(node: Node, property: NodePath) -> void: + deregister_property(node, property, _state_properties) -func deregister_property(node: Node, property: NodePath) -> void: - # TODO: Accelerate, maybe with _Set - _recorded_properties.erase(RecordedProperty.new(node, property)) +func register_input(node: Node, property: NodePath) -> void: + register_property(node, property, _input_properties) -func record_tick(tick: int) -> void: +func deregister_input(node: Node, property: NodePath) -> void: + deregister_property(node, property, _input_properties) + +func record_tick(tick: int, properties: Array) -> void: # Ensure snapshot var snapshot := _snapshots.get(tick) as Snapshot if snapshot == null: snapshot = Snapshot.new(tick) + _snapshots[tick] = snapshot # Record values - for entry in _recorded_properties: - var recorded_property := entry as RecordedProperty - snapshot.data[recorded_property] = recorded_property.extract_value() + for entry in properties: + snapshot.data[entry] = RecordedProperty.extract(entry) + +# _logger.debug("Recorded %d properties; %s", [properties.size(), snapshot]) + +func record_input(tick: int) -> void: + record_tick(tick, _input_properties) + +func record_state(tick: int) -> void: + record_tick(tick, _state_properties) func restore_tick(tick: int) -> bool: if not _snapshots.has(tick): @@ -32,9 +55,8 @@ func restore_tick(tick: int) -> bool: var snapshot := _snapshots[tick] as Snapshot for entry in snapshot.data.keys(): - var recorded_property := entry as RecordedProperty var value = snapshot.data[entry] - recorded_property.apply_value(value) + RecordedProperty.apply(entry, value) return true func trim_history(earliest_tick: int) -> void: diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index af9c2839..61699b12 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -1,26 +1,32 @@ extends Node class_name _RollbackSynchronizationServer -var _input_properties: Array[RecordedProperty] = [] -var _state_properties: Array[RecordedProperty] = [] +var _input_properties: Array = [] +var _state_properties: Array = [] static var _logger := NetfoxLogger._for_netfox("RollbackSynchronizationServer") -func register_input(node: Node, property: NodePath) -> void: - var entry := RecordedProperty.new(node, property) - if _input_properties.has(entry): return - _input_properties.append(entry) +func register_property(node: Node, property: NodePath, pool: Array) -> void: + var entry := RecordedProperty.key_of(node, property) -func register_state(node: Node, property: NodePath) -> void: - var entry := RecordedProperty.new(node, property) - if _state_properties.has(entry): return - _state_properties.append(entry) + # TODO: Accelerate this check, maybe with _Set + if not pool.has(entry): + pool.append(entry) -func deregister_input(node: Node, property: NodePath) -> void: - _input_properties.erase(RecordedProperty.new(node, property)) +func deregister_property(node: Node, property: NodePath, pool: Array) -> void: + pool.erase([node, property]) + +func register_state(node: Node, property: NodePath) -> void: + register_property(node, property, _state_properties) func deregister_state(node: Node, property: NodePath) -> void: - _state_properties.erase(RecordedProperty.new(node, property)) + deregister_property(node, property, _state_properties) + +func register_input(node: Node, property: NodePath) -> void: + register_property(node, property, _input_properties) + +func deregister_input(node: Node, property: NodePath) -> void: + deregister_property(node, property, _input_properties) func synchronize_input(tick: int) -> void: # Grab snapshot from RollbackHistoryServer @@ -36,6 +42,7 @@ func synchronize_input(tick: int) -> void: input_snapshot.data[property] = snapshot.data[property] # Transmit +# _logger.debug("Submitting input: %s", [input_snapshot]) _submit_input.rpc(_serialize_snapshot(input_snapshot)) func synchronize_state(tick: int) -> void: @@ -52,16 +59,18 @@ func synchronize_state(tick: int) -> void: state_snapshot.data[property] = snapshot.data[property] # Transmit +# _logger.debug("Submitting state: %s", [state_snapshot]) _submit_state.rpc(_serialize_snapshot(state_snapshot)) func _serialize_snapshot(snapshot: Snapshot) -> Variant: var serialized_properties := [] for entry in snapshot.data.keys(): - var property := entry as RecordedProperty - var value = snapshot.data[property] + var node := entry[0] as Node + var property := entry[1] as NodePath + var value = snapshot.data[entry] - serialized_properties.append([str(property.node.get_path()), property.property, value]) + serialized_properties.append([str(node.get_path()), str(property), value]) serialized_properties.append(snapshot.tick) return serialized_properties @@ -83,8 +92,7 @@ func _deserialize_snapshot(data: Variant) -> Snapshot: _logger.warning("Can't find node at path %s, ignoring", [node_path]) continue - # TODO: Dicts might fail if recorded property's equal but not identical - snapshot.data[RecordedProperty.new(node, property)] = value + snapshot.data[RecordedProperty.key_of(node, property)] = value return snapshot diff --git a/project.godot b/project.godot index 768c42e3..0774da11 100644 --- a/project.godot +++ b/project.godot @@ -127,7 +127,7 @@ escape={ [netfox] general/clear_settings=false -time/tickrate=24 +time/tickrate=8 extras/auto_tile_windows=true autoconnect/enabled=false From c5c2d0f91ce8f260c5be25d857e7813e6f391485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 6 Dec 2025 15:11:33 +0100 Subject: [PATCH 05/95] wip --- addons/netfox/rollback/network-rollback.gd | 20 +++++++++++++++++++ .../netfox/rollback/rollback-synchronizer.gd | 11 ++++++---- .../netfox/servers/rollback-history-server.gd | 1 + .../servers/rollback-simulation-server.gd | 1 + .../rollback-synchronization-server.gd | 18 +++++++++++------ project.godot | 2 +- 6 files changed, 42 insertions(+), 11 deletions(-) diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 20e3a23d..1fc37ba0 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -170,6 +170,9 @@ var _simulated_nodes: _Set = _Set.new() var _mutated_nodes: Dictionary = {} var _input_submissions: Dictionary = {} +var _earliest_input := -1 +var _latest_state := -1 + const _STAGE_BEFORE := "B" const _STAGE_PREPARE := "P" const _STAGE_SIMULATE := "S" @@ -299,6 +302,16 @@ func _ready(): RollbackHistoryServer.record_input(tick) RollbackSynchronizationServer.synchronize_input(tick) ) + + RollbackSynchronizationServer.on_input.connect(func(snapshot: Snapshot): + if _earliest_input < 0 or snapshot.tick < _earliest_input: + _earliest_input = snapshot.tick + ) + + RollbackSynchronizationServer.on_state.connect(func(snapshot: Snapshot): + if _latest_state < 0 or snapshot.tick > _latest_state: + _latest_state = snapshot.tick + ) func _exit_tree(): NetfoxLogger.free_tag(_get_rollback_tag) @@ -316,6 +329,13 @@ func _rollback() -> void: # Ask all rewindables to submit their earliest inputs _resim_from = NetworkTime.tick before_loop.emit() + + # TODO: Move to RollbackSimulationServer? + if _earliest_input >= 0: + _resim_from = mini(_resim_from, _earliest_input) + if _latest_state >= 0: + _resim_from = mini(_resim_from, _latest_state) + _resim_from = mini(_resim_from, NetworkTime.tick - 1) # Only set _is_rollback *after* emitting before_loop _is_rollback = true diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 749be89e..9e9708a8 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -224,6 +224,7 @@ func get_input_age() -> int: ## simulated and recorded, but will not be broadcast, nor considered ## authoritative. func is_predicting() -> bool: + return false return _is_predicted_tick ## Ignore a node's prediction for the current rollback tick. @@ -301,8 +302,8 @@ func _before_tick(_dt: float, tick: int) -> void: pass func _after_tick(_dt: float, tick: int) -> void: - _history_recorder.record_input(tick) - _history_transmitter.transmit_input(tick) +# _history_recorder.record_input(tick) +# _history_transmitter.transmit_input(tick) _history_recorder.trim_history() _freshness_store.trim() @@ -320,11 +321,12 @@ func _process_tick(tick: int) -> void: func _on_record_tick(tick: int) -> void: _history_recorder.record_state(tick) - _history_transmitter.transmit_state(tick) +# _history_transmitter.transmit_state(tick) func _after_rollback_loop() -> void: # _history_recorder.apply_display_state() - _history_transmitter.conclude_tick_loop() +# _history_transmitter.conclude_tick_loop() + pass func _notification(what: int) -> void: if what == NOTIFICATION_EDITOR_PRE_SAVE: @@ -382,6 +384,7 @@ func _exit_tree() -> void: _disconnect_signals() func _notify_resim() -> void: + return if _get_owned_input_props().is_empty(): # We don't have any inputs we own, simulate from earliest we've received NetworkRollback.notify_resimulation_start(_history_transmitter.get_earliest_input_tick()) diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 50586b74..f968f28b 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -54,6 +54,7 @@ func restore_tick(tick: int) -> bool: return false var snapshot := _snapshots[tick] as Snapshot + _logger.debug("Restoring snapshot: %s", [snapshot]) for entry in snapshot.data.keys(): var value = snapshot.data[entry] RecordedProperty.apply(entry, value) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 6e7f35ab..7b617e15 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -35,6 +35,7 @@ func get_nodes_to_simulate() -> Array[Node]: func simulate(delta: float, tick: int) -> void: var nodes := get_nodes_to_simulate() +# _logger.debug("Simulating %d nodes", [nodes.size()]) # Sort based on SceneTree order for node in nodes: diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 61699b12..811f9b8e 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -6,6 +6,9 @@ var _state_properties: Array = [] static var _logger := NetfoxLogger._for_netfox("RollbackSynchronizationServer") +signal on_input(snapshot: Snapshot) +signal on_state(snapshot: Snapshot) + func register_property(node: Node, property: NodePath, pool: Array) -> void: var entry := RecordedProperty.key_of(node, property) @@ -42,8 +45,9 @@ func synchronize_input(tick: int) -> void: input_snapshot.data[property] = snapshot.data[property] # Transmit -# _logger.debug("Submitting input: %s", [input_snapshot]) - _submit_input.rpc(_serialize_snapshot(input_snapshot)) + if not input_snapshot.data.is_empty(): + _logger.debug("Submitting input: %s", [input_snapshot]) + _submit_input.rpc(_serialize_snapshot(input_snapshot)) func synchronize_state(tick: int) -> void: # Grab snapshot from RollbackHistoryServer @@ -59,8 +63,9 @@ func synchronize_state(tick: int) -> void: state_snapshot.data[property] = snapshot.data[property] # Transmit -# _logger.debug("Submitting state: %s", [state_snapshot]) - _submit_state.rpc(_serialize_snapshot(state_snapshot)) + if not state_snapshot.data.is_empty(): + _logger.debug("Submitting state: %s", [state_snapshot]) + _submit_state.rpc(_serialize_snapshot(state_snapshot)) func _serialize_snapshot(snapshot: Snapshot) -> Variant: var serialized_properties := [] @@ -103,13 +108,14 @@ func _submit_input(snapshot_data: Variant): # TODO: Sanitize var merged := RollbackHistoryServer.merge_snapshot(snapshot) - _logger.debug("Merged input; %s", [merged]) +# _logger.debug("Merged input; %s", [merged]) @rpc("any_peer", "call_remote", "unreliable") func _submit_state(snapshot_data: Variant): var snapshot := _deserialize_snapshot(snapshot_data) +# _logger.debug("Received state snapshot: %s", [snapshot]) # TODO: Sanitize var merged := RollbackHistoryServer.merge_snapshot(snapshot) - _logger.debug("Merged state; %s", [merged]) +# _logger.debug("Merged state; %s", [merged]) diff --git a/project.godot b/project.godot index 0774da11..d5cb2979 100644 --- a/project.godot +++ b/project.godot @@ -127,7 +127,7 @@ escape={ [netfox] general/clear_settings=false -time/tickrate=8 +time/tickrate=4 extras/auto_tile_windows=true autoconnect/enabled=false From dd41aa09c64f838970de97c166fb87f4b5a09bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 6 Dec 2025 23:58:14 +0100 Subject: [PATCH 06/95] track auth in snapshot exp --- addons/netfox/servers/data/snapshot.gd | 12 ++++++++++++ addons/netfox/servers/rollback-history-server.gd | 9 ++++++--- .../servers/rollback-synchronization-server.gd | 10 ++++++---- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index 7553c94a..a7c3ca31 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -3,10 +3,22 @@ class_name Snapshot var tick: int var data: Dictionary = {} # RecordedProperty to Variant value +var _is_authoritative: Dictionary = {} # Property key to bool, not present means false func _init(p_tick: int): tick = p_tick +func set_property(node: Node, property: NodePath, value: Variant, is_authoritatve: bool = false) -> void: + data[RecordedProperty.key_of(node, property)] = value + _is_authoritative[RecordedProperty.key_of(node, property)] = is_authoritatve + +func merge(snapshot: Snapshot) -> void: + for prop_key in snapshot.data: + # Merge properties that we don't have, or don't have it authoritatively + if _is_authoritative.get(prop_key, false): + data[prop_key] = snapshot.data[prop_key] + _is_authoritative[prop_key] = snapshot._is_authoritative[prop_key] + func has_node(node: Node) -> bool: for entry in data.keys(): if (entry as RecordedProperty).node == node: diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index f968f28b..b499bfce 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -39,9 +39,11 @@ func record_tick(tick: int, properties: Array) -> void: # Record values for entry in properties: - snapshot.data[entry] = RecordedProperty.extract(entry) + var node := entry[0] as Node + var property := entry[1] as NodePath + snapshot.set_property(node, property, RecordedProperty.extract(entry), node.is_multiplayer_authority()) -# _logger.debug("Recorded %d properties; %s", [properties.size(), snapshot]) + _logger.debug("Recorded %d properties; %s", [properties.size(), snapshot]) func record_input(tick: int) -> void: record_tick(tick, _input_properties) @@ -79,6 +81,7 @@ func merge_snapshot(snapshot: Snapshot) -> Snapshot: return snapshot var stored_snapshot := _snapshots[tick] as Snapshot - stored_snapshot.data.merge(snapshot.data) + stored_snapshot.merge(snapshot) +# _snapshots[tick] = stored_snapshot return stored_snapshot diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 811f9b8e..fdc907cb 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -74,8 +74,9 @@ func _serialize_snapshot(snapshot: Snapshot) -> Variant: var node := entry[0] as Node var property := entry[1] as NodePath var value = snapshot.data[entry] + var is_auth := snapshot._is_authoritative.get(entry, false) - serialized_properties.append([str(node.get_path()), str(property), value]) + serialized_properties.append([str(node.get_path()), str(property), value, is_auth]) serialized_properties.append(snapshot.tick) return serialized_properties @@ -91,13 +92,14 @@ func _deserialize_snapshot(data: Variant) -> Snapshot: var node_path := entry_data[0] as String var property := entry_data[1] as String var value = entry_data[2] + var is_auth := entry_data[3] as bool var node := get_tree().root.get_node(node_path) if not node: _logger.warning("Can't find node at path %s, ignoring", [node_path]) continue - snapshot.data[RecordedProperty.key_of(node, property)] = value + snapshot.set_property(node, property, value, is_auth) return snapshot @@ -113,9 +115,9 @@ func _submit_input(snapshot_data: Variant): @rpc("any_peer", "call_remote", "unreliable") func _submit_state(snapshot_data: Variant): var snapshot := _deserialize_snapshot(snapshot_data) -# _logger.debug("Received state snapshot: %s", [snapshot]) + _logger.debug("Received state snapshot: %s", [snapshot]) # TODO: Sanitize var merged := RollbackHistoryServer.merge_snapshot(snapshot) -# _logger.debug("Merged state; %s", [merged]) + _logger.debug("Merged state; %s", [merged]) From a24f266c173b73b7bedf5ba6db184d32617b9d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 7 Dec 2025 00:36:01 +0100 Subject: [PATCH 07/95] don't half-ass auth flag on snapshots + emit events for rollback loop interval --- addons/netfox/rollback/rollback-synchronizer.gd | 3 ++- addons/netfox/servers/data/snapshot.gd | 16 ++++++++++++---- addons/netfox/servers/rollback-history-server.gd | 7 +++++-- .../servers/rollback-synchronization-server.gd | 9 ++++++++- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 9e9708a8..64594f63 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -308,7 +308,8 @@ func _after_tick(_dt: float, tick: int) -> void: _freshness_store.trim() func _before_rollback_loop() -> void: - _notify_resim() +# _notify_resim() + pass func _on_prepare_tick(tick: int) -> void: # _history_recorder.apply_tick(tick) diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index a7c3ca31..904536e9 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -8,14 +8,22 @@ var _is_authoritative: Dictionary = {} # Property key to bool, not present means func _init(p_tick: int): tick = p_tick -func set_property(node: Node, property: NodePath, value: Variant, is_authoritatve: bool = false) -> void: +func set_property(node: Node, property: NodePath, value: Variant, is_authoritative: bool = false) -> void: data[RecordedProperty.key_of(node, property)] = value - _is_authoritative[RecordedProperty.key_of(node, property)] = is_authoritatve + _is_authoritative[RecordedProperty.key_of(node, property)] = is_authoritative + +func merge_property(node: Node, property: NodePath, value: Variant, is_authoritative: bool = false) -> bool: + var prop_key := RecordedProperty.key_of(node, property) + if is_authoritative or not _is_authoritative.get(prop_key, false): + data[prop_key] = value + _is_authoritative[prop_key] = is_authoritative + return true + return false func merge(snapshot: Snapshot) -> void: for prop_key in snapshot.data: # Merge properties that we don't have, or don't have it authoritatively - if _is_authoritative.get(prop_key, false): + if snapshot._is_authoritative.get(prop_key, false) or not _is_authoritative.get(prop_key, false): data[prop_key] = snapshot.data[prop_key] _is_authoritative[prop_key] = snapshot._is_authoritative[prop_key] @@ -26,4 +34,4 @@ func has_node(node: Node) -> bool: return false func _to_string() -> String: - return "Snapshot(#%d, %s)" % [tick, str(data)] + return "Snapshot(#%d, %s)" % [tick, data] diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index b499bfce..68cb9ab8 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -38,12 +38,15 @@ func record_tick(tick: int, properties: Array) -> void: _snapshots[tick] = snapshot # Record values + var updated := [] for entry in properties: var node := entry[0] as Node var property := entry[1] as NodePath - snapshot.set_property(node, property, RecordedProperty.extract(entry), node.is_multiplayer_authority()) + + if snapshot.merge_property(node, property, RecordedProperty.extract(entry), node.is_multiplayer_authority()): + updated.append([node, property, RecordedProperty.extract(entry), node.is_multiplayer_authority()]) - _logger.debug("Recorded %d properties; %s", [properties.size(), snapshot]) + _logger.debug("Recorded %d properties: %s; %s", [properties.size(), updated, snapshot]) func record_input(tick: int) -> void: record_tick(tick, _input_properties) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index fdc907cb..9fb5aee2 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -43,6 +43,7 @@ func synchronize_input(tick: int) -> void: if not snapshot.data.has(property): continue input_snapshot.data[property] = snapshot.data[property] + input_snapshot._is_authoritative[property] = snapshot._is_authoritative[property] # Transmit if not input_snapshot.data.is_empty(): @@ -61,6 +62,7 @@ func synchronize_state(tick: int) -> void: if not snapshot.data.has(property): continue state_snapshot.data[property] = snapshot.data[property] + state_snapshot._is_authoritative[property] = snapshot._is_authoritative[property] # Transmit if not state_snapshot.data.is_empty(): @@ -106,11 +108,14 @@ func _deserialize_snapshot(data: Variant) -> Snapshot: @rpc("any_peer", "call_remote", "reliable") func _submit_input(snapshot_data: Variant): var snapshot := _deserialize_snapshot(snapshot_data) + _logger.debug("Received input snapshot: %s", [snapshot]) # TODO: Sanitize var merged := RollbackHistoryServer.merge_snapshot(snapshot) -# _logger.debug("Merged input; %s", [merged]) + _logger.debug("Merged input; %s", [merged]) + + on_input.emit(snapshot) @rpc("any_peer", "call_remote", "unreliable") func _submit_state(snapshot_data: Variant): @@ -121,3 +126,5 @@ func _submit_state(snapshot_data: Variant): var merged := RollbackHistoryServer.merge_snapshot(snapshot) _logger.debug("Merged state; %s", [merged]) + + on_state.emit(snapshot) From 841a4e55a4826c558fada7bd7eb788f12071f765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 7 Dec 2025 01:13:21 +0100 Subject: [PATCH 08/95] simulate only if input --- addons/netfox/rollback/network-rollback.gd | 4 +++ .../netfox/rollback/rollback-synchronizer.gd | 8 +++++ addons/netfox/servers/data/snapshot.gd | 13 ++++++-- .../servers/rollback-simulation-server.gd | 33 ++++++++++++++++--- project.godot | 5 ++- 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 1fc37ba0..3d1033f1 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -337,6 +337,10 @@ func _rollback() -> void: _resim_from = mini(_resim_from, _latest_state) _resim_from = mini(_resim_from, NetworkTime.tick - 1) + _earliest_input = -1 + _latest_state = -1 +# _resim_from = maxi(1, history_start + 1) + # Only set _is_rollback *after* emitting before_loop _is_rollback = true _rollback_stage = _STAGE_BEFORE diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 64594f63..db8b5a7b 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -122,8 +122,16 @@ func process_settings() -> void: _nodes = _nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it)) _nodes.erase(self) + var input_nodes = [] + for prop in _input_property_config.get_properties(): + var input_node := prop.node + if not input_nodes.has(input_node): + input_nodes.append(input_node) + for node in _nodes: RollbackSimulationServer.register(node._rollback_tick) + for input_node in input_nodes: + RollbackSimulationServer.register_input_for(node, input_node) _registered_nodes.append(node) _history_transmitter.sync_settings(root, enable_input_broadcast, full_state_interval, diff_ack_interval) diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index 904536e9..f74930df 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -27,10 +27,17 @@ func merge(snapshot: Snapshot) -> void: data[prop_key] = snapshot.data[prop_key] _is_authoritative[prop_key] = snapshot._is_authoritative[prop_key] -func has_node(node: Node) -> bool: +func has_node(node: Node, require_auth: bool = false) -> bool: for entry in data.keys(): - if (entry as RecordedProperty).node == node: - return true + var entry_node := entry[0] as Node + if entry_node != node: + continue + + var is_auth := _is_authoritative.get(entry, false) as bool + if require_auth and not is_auth: + continue + + return true return false func _to_string() -> String: diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 7b617e15..c462aea3 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -4,6 +4,9 @@ class_name _RollbackSimulationServer # node to callback # TODO: Consider allowing any Object, not just nodes var _callbacks := {} +# node to input node +# TODO: Support multiple input nodes for a single simulated node +var _input_for := {} var _group := StringName("__nf_rollback_sim" + str(get_instance_id())) @@ -27,15 +30,37 @@ func deregister(callback: Callable) -> void: func deregister_node(node: Node) -> void: _callbacks.erase(node) + deregister_input(node) -func get_nodes_to_simulate() -> Array[Node]: +func register_input_for(node: Node, input: Node) -> void: + _input_for[node] = input + +func deregister_input(node: Node) -> void: + _input_for.erase(node) + +func get_nodes_to_simulate(tick: int) -> Array[Node]: var result: Array[Node] = [] - result.assign(_callbacks.keys()) + var snapshot := RollbackHistoryServer.get_snapshot(tick) + if not snapshot: + return [] + + for node in _callbacks.keys(): + if not _input_for.has(node): + # Node has no input, simulate it + result.append(node) + continue + + var input := _input_for[node] as Node + if not snapshot.has_node(input, true): + continue + + result.append(node) + return result func simulate(delta: float, tick: int) -> void: - var nodes := get_nodes_to_simulate() -# _logger.debug("Simulating %d nodes", [nodes.size()]) + var nodes := get_nodes_to_simulate(tick) + _logger.debug("Simulating %d nodes: %s", [nodes.size(), nodes]) # Sort based on SceneTree order for node in nodes: diff --git a/project.godot b/project.godot index d5cb2979..ef4ff62d 100644 --- a/project.godot +++ b/project.godot @@ -38,6 +38,8 @@ RollbackSynchronizationServer="*res://addons/netfox/servers/rollback-synchroniza window/size/viewport_width=540 window/size/viewport_height=540 +window/size/transparent=true +window/per_pixel_transparency/allowed=true window/vsync/vsync_mode=0 [editor_plugins] @@ -127,13 +129,14 @@ escape={ [netfox] general/clear_settings=false -time/tickrate=4 +time/tickrate=16 extras/auto_tile_windows=true autoconnect/enabled=false [rendering] lights_and_shadows/directional_shadow/soft_shadow_filter_quality=3 +viewport/transparent_background=true anti_aliasing/quality/screen_space_aa=1 [vest] From 7736da79579ea4a6bb9a0f939d604e3b2e42320a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 7 Dec 2025 12:36:31 +0100 Subject: [PATCH 09/95] snapshot pimping --- addons/netfox/servers/data/snapshot.gd | 5 +++ .../netfox/servers/rollback-history-server.gd | 4 +-- .../servers/rollback-simulation-server.gd | 5 ++- project.godot | 2 +- test/rollback-simulation-server.test.gd | 36 +++++++++++++++++++ 5 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 test/rollback-simulation-server.test.gd diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index f74930df..12e526d4 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -27,6 +27,11 @@ func merge(snapshot: Snapshot) -> void: data[prop_key] = snapshot.data[prop_key] _is_authoritative[prop_key] = snapshot._is_authoritative[prop_key] +func apply() -> void: + for prop_key in data: + var value = data[prop_key] + RecordedProperty.apply(prop_key, value) + func has_node(node: Node, require_auth: bool = false) -> bool: for entry in data.keys(): var entry_node := entry[0] as Node diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 68cb9ab8..5b7550b9 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -60,9 +60,7 @@ func restore_tick(tick: int) -> bool: var snapshot := _snapshots[tick] as Snapshot _logger.debug("Restoring snapshot: %s", [snapshot]) - for entry in snapshot.data.keys(): - var value = snapshot.data[entry] - RecordedProperty.apply(entry, value) + snapshot.apply() return true func trim_history(earliest_tick: int) -> void: diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index c462aea3..43c93add 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -38,9 +38,8 @@ func register_input_for(node: Node, input: Node) -> void: func deregister_input(node: Node) -> void: _input_for.erase(node) -func get_nodes_to_simulate(tick: int) -> Array[Node]: +func get_nodes_to_simulate(snapshot: Snapshot) -> Array[Node]: var result: Array[Node] = [] - var snapshot := RollbackHistoryServer.get_snapshot(tick) if not snapshot: return [] @@ -59,7 +58,7 @@ func get_nodes_to_simulate(tick: int) -> Array[Node]: return result func simulate(delta: float, tick: int) -> void: - var nodes := get_nodes_to_simulate(tick) + var nodes := get_nodes_to_simulate(RollbackHistoryServer.get_snapshot(tick)) _logger.debug("Simulating %d nodes: %s", [nodes.size(), nodes]) # Sort based on SceneTree order diff --git a/project.godot b/project.godot index ef4ff62d..73495fa6 100644 --- a/project.godot +++ b/project.godot @@ -129,7 +129,7 @@ escape={ [netfox] general/clear_settings=false -time/tickrate=16 +time/tickrate=24 extras/auto_tile_windows=true autoconnect/enabled=false diff --git a/test/rollback-simulation-server.test.gd b/test/rollback-simulation-server.test.gd new file mode 100644 index 00000000..317d7b6e --- /dev/null +++ b/test/rollback-simulation-server.test.gd @@ -0,0 +1,36 @@ +extends VestTest + +func get_suite_name() -> String: + return "RollbackSimulationServer" + +func suite() -> void: + test("should not simulate without input", func(): + var node := RewindableNode.new() + var input_node := Node.new() + + var server := _RollbackSimulationServer.new() + server.register(node._rollback_tick) + server.register_input_for(node, input_node) + + var snapshot := Snapshot.new(1) + + expect_empty(server.get_nodes_to_simulate(snapshot)) + ) + + test("should simulate with input", func(): + var node := RewindableNode.new() + var input_node := Node.new() + + var server := _RollbackSimulationServer.new() + server.register(node._rollback_tick) + server.register_input_for(node, input_node) + + var snapshot := Snapshot.new(1) + snapshot.set_property(input_node, "editor_description", "Test input node", true) + + expect_equal(server.get_nodes_to_simulate(snapshot), [node]) + ) + +class RewindableNode extends Node: + func _rollback_tick(_dt, _t, _if) -> void: + pass From fad0b1b05dbd4282ece049ad50c3df3ddb4f4ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 7 Dec 2025 13:38:33 +0100 Subject: [PATCH 10/95] gut rbs --- .../netfox/rollback/rollback-synchronizer.gd | 216 +----------------- .../rollback-synchronization-server.gd | 8 +- 2 files changed, 14 insertions(+), 210 deletions(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index db8b5a7b..c6fdaa6a 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -70,30 +70,12 @@ var visibility_filter := PeerVisibilityFilter.new() var _state_property_config: _PropertyConfig = _PropertyConfig.new() var _input_property_config: _PropertyConfig = _PropertyConfig.new() -var _nodes: Array[Node] = [] - -var _simset: _Set = _Set.new() -var _skipset: _Set = _Set.new() var _properties_dirty: bool = false - var _property_cache := PropertyCache.new(root) -var _freshness_store := RollbackFreshnessStore.new() - -var _states := _PropertyHistoryBuffer.new() -var _inputs := _PropertyHistoryBuffer.new() -var _last_simulated_tick: int - -var _has_input: bool -var _input_tick: int -var _is_predicted_tick: bool static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("RollbackSynchronizer") -# Composition -var _history_transmitter: _RollbackHistoryTransmitter -var _history_recorder: _RollbackHistoryRecorder - var _registered_nodes: Array[Node] = [] ## Process settings. @@ -108,19 +90,14 @@ func process_settings() -> void: # Clear _property_cache.root = root _property_cache.clear() - _freshness_store.clear() - - _nodes.clear() - _states.clear() - _inputs.clear() process_authority() # Gather all rollback-aware nodes to simulate during rollbacks - _nodes = root.find_children("*") - _nodes.push_front(root) - _nodes = _nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it)) - _nodes.erase(self) + var nodes := root.find_children("*") as Array[Node] + nodes.push_front(root) + nodes = nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it)) + nodes.erase(self) var input_nodes = [] for prop in _input_property_config.get_properties(): @@ -128,16 +105,12 @@ func process_settings() -> void: if not input_nodes.has(input_node): input_nodes.append(input_node) - for node in _nodes: + for node in nodes: RollbackSimulationServer.register(node._rollback_tick) for input_node in input_nodes: RollbackSimulationServer.register_input_for(node, input_node) _registered_nodes.append(node) - _history_transmitter.sync_settings(root, enable_input_broadcast, full_state_interval, diff_ack_interval) - _history_transmitter.configure(_states, _inputs, _state_property_config, _input_property_config, visibility_filter, _property_cache, _skipset) - _history_recorder.configure(_states, _inputs, _state_property_config, _input_property_config, _property_cache, _skipset) - ## Process settings based on authority. ## ## Call this whenever the authority of any of the nodes managed by @@ -211,7 +184,7 @@ func add_input(node: Variant, property: String) -> void: ## [br][br] ## Returns true if input is available. func has_input() -> bool: - return _has_input + return false ## Get the age of currently available input in ticks. ## @@ -220,11 +193,7 @@ func has_input() -> bool: ## [br][br] ## Calling this when [member has_input] is false will yield an error. func get_input_age() -> int: - if has_input(): - return NetworkRollback.tick - _input_tick - else: - _logger.error("Trying to check input age without having input!") - return -1 + return 0 ## Check if the current tick is predicted. ## @@ -233,15 +202,13 @@ func get_input_age() -> int: ## authoritative. func is_predicting() -> bool: return false - return _is_predicted_tick ## Ignore a node's prediction for the current rollback tick. ## ## Call this when the input is too old to base predictions on. This call is ## ignored if [member enable_prediction] is false. func ignore_prediction(node: Node) -> void: - if enable_prediction: - _skipset.add(node) + return ## Get the tick of the last known input. ## [br][br] @@ -255,10 +222,6 @@ func ignore_prediction(node: Node) -> void: ## [br][br] ## Returns -1 if there's no known input. func get_last_known_input() -> int: - # If we own input, it is updated regularly, this will be the current tick - # If we don't own input, _inputs is only updated when input data is received - if not _inputs.is_empty(): - return _inputs.keys().max() return -1 ## Get the tick of the last known state. @@ -271,7 +234,8 @@ func get_last_known_state() -> int: # If we own state, this will be updated when recording and broadcasting # state, this will be the current tick # If we don't own state, this will be updated when state data is received - return _history_transmitter.get_latest_state_tick() +# return _history_transmitter.get_latest_state_tick() + return 0 func _ready() -> void: if Engine.is_editor_hint(): @@ -283,60 +247,6 @@ func _ready() -> void: process_settings.call_deferred() -func _connect_signals() -> void: - NetworkTime.before_tick.connect(_before_tick) - NetworkTime.after_tick.connect(_after_tick) - - NetworkRollback.on_prepare_tick.connect(_on_prepare_tick) - NetworkRollback.on_process_tick.connect(_process_tick) - NetworkRollback.on_record_tick.connect(_on_record_tick) - - NetworkRollback.before_loop.connect(_before_rollback_loop) - NetworkRollback.after_loop.connect(_after_rollback_loop) - -func _disconnect_signals() -> void: - NetworkTime.before_tick.disconnect(_before_tick) - NetworkTime.after_tick.disconnect(_after_tick) - - NetworkRollback.on_prepare_tick.disconnect(_on_prepare_tick) - NetworkRollback.on_process_tick.disconnect(_process_tick) - NetworkRollback.on_record_tick.disconnect(_on_record_tick) - - NetworkRollback.before_loop.disconnect(_before_rollback_loop) - NetworkRollback.after_loop.disconnect(_after_rollback_loop) - -func _before_tick(_dt: float, tick: int) -> void: -# _history_recorder.apply_state(tick) - pass - -func _after_tick(_dt: float, tick: int) -> void: -# _history_recorder.record_input(tick) -# _history_transmitter.transmit_input(tick) - _history_recorder.trim_history() - _freshness_store.trim() - -func _before_rollback_loop() -> void: -# _notify_resim() - pass - -func _on_prepare_tick(tick: int) -> void: -# _history_recorder.apply_tick(tick) - _prepare_tick_process(tick) - -func _process_tick(tick: int) -> void: -# _run_rollback_tick(tick) -# _push_simset_metrics() - pass - -func _on_record_tick(tick: int) -> void: - _history_recorder.record_state(tick) -# _history_transmitter.transmit_state(tick) - -func _after_rollback_loop() -> void: -# _history_recorder.apply_display_state() -# _history_transmitter.conclude_tick_loop() - pass - func _notification(what: int) -> void: if what == NOTIFICATION_EDITOR_PRE_SAVE: update_configuration_warnings() @@ -372,117 +282,11 @@ func _enter_tree() -> void: if not visibility_filter.get_parent(): add_child(visibility_filter) - if _history_transmitter == null: - _history_transmitter = _RollbackHistoryTransmitter.new() - add_child(_history_transmitter, true) - _history_transmitter.set_multiplayer_authority(get_multiplayer_authority()) - - if _history_recorder == null: - _history_recorder = _RollbackHistoryRecorder.new() - if not NetworkTime.is_initial_sync_done(): # Wait for time sync to complete await NetworkTime.after_sync - _connect_signals.call_deferred() process_settings.call_deferred() -func _exit_tree() -> void: - if Engine.is_editor_hint(): - return - - _disconnect_signals() - -func _notify_resim() -> void: - return - if _get_owned_input_props().is_empty(): - # We don't have any inputs we own, simulate from earliest we've received - NetworkRollback.notify_resimulation_start(_history_transmitter.get_earliest_input_tick()) - else: - # We own inputs, simulate from latest authorative state - NetworkRollback.notify_resimulation_start(_history_transmitter.get_latest_state_tick()) - -func _prepare_tick_process(tick: int) -> void: - _history_recorder.set_latest_state_tick(_history_transmitter._latest_state_tick) - - # Save data for input prediction - var retrieved_tick := _inputs.get_closest_tick(tick) - - # These are used as input for input age ( i.e. do we even have input, and if so, how old? ) - _has_input = retrieved_tick != -1 - _input_tick = retrieved_tick - - # Used to explicitly determine if this is a predicted tick - # ( even if we could grab *some* input ) - _is_predicted_tick = _is_predicted_tick_for(null, tick) - _history_transmitter.set_predicted_tick(_is_predicted_tick) - - # Reset the set of simulated and ignored nodes - _simset.clear() - _skipset.clear() - - # Gather nodes that can be simulated - for node in _nodes: - if _can_simulate(node, tick): - NetworkRollback.notify_simulated(node) - -func _can_simulate(node: Node, tick: int) -> bool: - if not enable_prediction and _is_predicted_tick_for(node, tick): - # Don't simulate if prediction is not allowed and tick is predicted - return false - if NetworkRollback.is_mutated(node, tick): - # Mutated nodes are always resimulated - return true - if input_properties.is_empty(): - # If we're running inputless and own the node, simulate it if we haven't - if node.is_multiplayer_authority(): - return tick > _last_simulated_tick - # If we're running inputless and don't own the node, only run as prediction - return enable_prediction - if node.is_multiplayer_authority(): - # Simulate from earliest input - # Don't simulate frames we don't have input for - return tick >= _history_transmitter.get_earliest_input_tick() - else: - # Simulate ONLY if we have state from server - # Simulate from latest authorative state - anything the server confirmed we don't rerun - # Don't simulate frames we don't have input for - return tick >= _history_transmitter.get_latest_state_tick() - -# `node` can be set to null, in case we're not simulating a specific node -func _is_predicted_tick_for(node: Node, tick: int) -> bool: - if input_properties.is_empty() and node != null: - # We're running without inputs - # It's only predicted if we don't own the node - return not node.is_multiplayer_authority() - else: - # We have input properties, it's only predicted if we don't have the input for the tick - return not _inputs.has(tick) - -func _run_rollback_tick(tick: int) -> void: - # Simulate rollback tick - # Method call on rewindables - # Rollback synchronizers go through each node they manage - # If current tick is in node's range, tick - # If authority: Latest input >= tick >= Latest state - # If not: Latest input >= tick >= Earliest input - for node in _nodes: - if not NetworkRollback.is_simulated(node): - continue - - var is_fresh := _freshness_store.is_fresh(node, tick) - _is_predicted_tick = _is_predicted_tick_for(node, tick) - NetworkRollback.process_rollback(node, NetworkTime.ticktime, tick, is_fresh) - - if _skipset.has(node): - continue - - _freshness_store.notify_processed(node, tick) - _simset.add(node) - -func _push_simset_metrics(): - # Push metrics - NetworkPerformance.push_rollback_nodes_simulated(_simset.size()) - func _reprocess_settings() -> void: if not _properties_dirty or Engine.is_editor_hint(): return diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 9fb5aee2..d36ef6d1 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -108,23 +108,23 @@ func _deserialize_snapshot(data: Variant) -> Snapshot: @rpc("any_peer", "call_remote", "reliable") func _submit_input(snapshot_data: Variant): var snapshot := _deserialize_snapshot(snapshot_data) - _logger.debug("Received input snapshot: %s", [snapshot]) +# _logger.debug("Received input snapshot: %s", [snapshot]) # TODO: Sanitize var merged := RollbackHistoryServer.merge_snapshot(snapshot) - _logger.debug("Merged input; %s", [merged]) +# _logger.debug("Merged input; %s", [merged]) on_input.emit(snapshot) @rpc("any_peer", "call_remote", "unreliable") func _submit_state(snapshot_data: Variant): var snapshot := _deserialize_snapshot(snapshot_data) - _logger.debug("Received state snapshot: %s", [snapshot]) +# _logger.debug("Received state snapshot: %s", [snapshot]) # TODO: Sanitize var merged := RollbackHistoryServer.merge_snapshot(snapshot) - _logger.debug("Merged state; %s", [merged]) +# _logger.debug("Merged state; %s", [merged]) on_state.emit(snapshot) From 19b421fe05367e120a4a0422065054876e65849f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 7 Dec 2025 22:33:06 +0100 Subject: [PATCH 11/95] lowkey support for predict, though issue not fixed --- addons/netfox/rollback/network-rollback.gd | 4 +++ addons/netfox/servers/data/snapshot.gd | 6 ++++- .../netfox/servers/rollback-history-server.gd | 12 +++++---- .../servers/rollback-simulation-server.gd | 27 +++++++++++++++++-- .../rollback-synchronization-server.gd | 6 +++-- .../multiplayer-netfox/characters/player.tscn | 1 + 6 files changed, 46 insertions(+), 10 deletions(-) diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 3d1033f1..33bf2285 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -331,11 +331,15 @@ func _rollback() -> void: before_loop.emit() # TODO: Move to RollbackSimulationServer? + var range_source = "notif" if _earliest_input >= 0: + range_source = "earliest input" _resim_from = mini(_resim_from, _earliest_input) if _latest_state >= 0: + range_source = "latest state" _resim_from = mini(_resim_from, _latest_state) _resim_from = mini(_resim_from, NetworkTime.tick - 1) + _logger.debug("Simulating range @%d>@%d using %s", [_resim_from, NetworkTime.tick, range_source]) _earliest_input = -1 _latest_state = -1 diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index 12e526d4..232e6be2 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -46,4 +46,8 @@ func has_node(node: Node, require_auth: bool = false) -> bool: return false func _to_string() -> String: - return "Snapshot(#%d, %s)" % [tick, data] + var result := "Snapshot(#%d" % [tick] + for entry in data: + result += ", %s(%s): %s" % [entry, _is_authoritative.get(entry, false), data[entry]] + result += ")" + return result diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 5b7550b9..ae812548 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -30,7 +30,7 @@ func register_input(node: Node, property: NodePath) -> void: func deregister_input(node: Node, property: NodePath) -> void: deregister_property(node, property, _input_properties) -func record_tick(tick: int, properties: Array) -> void: +func record_tick(tick: int, properties: Array, predicted_nodes: Array[Node]) -> void: # Ensure snapshot var snapshot := _snapshots.get(tick) as Snapshot if snapshot == null: @@ -42,17 +42,19 @@ func record_tick(tick: int, properties: Array) -> void: for entry in properties: var node := entry[0] as Node var property := entry[1] as NodePath + var is_auth := node.is_multiplayer_authority() and not predicted_nodes.has(node) - if snapshot.merge_property(node, property, RecordedProperty.extract(entry), node.is_multiplayer_authority()): - updated.append([node, property, RecordedProperty.extract(entry), node.is_multiplayer_authority()]) + if snapshot.merge_property(node, property, RecordedProperty.extract(entry), is_auth): + updated.append([node, property, RecordedProperty.extract(entry), is_auth]) _logger.debug("Recorded %d properties: %s; %s", [properties.size(), updated, snapshot]) func record_input(tick: int) -> void: - record_tick(tick, _input_properties) + record_tick(tick, _input_properties, []) func record_state(tick: int) -> void: - record_tick(tick, _state_properties) + # TODO: Servers preferably shouldn't depend on eachother + record_tick(tick, _state_properties, RollbackSimulationServer.get_predicted_nodes()) func restore_tick(tick: int) -> bool: if not _snapshots.has(tick): diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 43c93add..6457ef01 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -8,6 +8,8 @@ var _callbacks := {} # TODO: Support multiple input nodes for a single simulated node var _input_for := {} +var _predicted_nodes := [] as Array[Node] + var _group := StringName("__nf_rollback_sim" + str(get_instance_id())) static var _logger := NetfoxLogger._for_netfox("RollbackSimulationServer") @@ -48,7 +50,7 @@ func get_nodes_to_simulate(snapshot: Snapshot) -> Array[Node]: # Node has no input, simulate it result.append(node) continue - + var input := _input_for[node] as Node if not snapshot.has_node(input, true): continue @@ -57,8 +59,23 @@ func get_nodes_to_simulate(snapshot: Snapshot) -> Array[Node]: return result +func is_predicting(snapshot: Snapshot, node: Node) -> bool: + if not node.is_multiplayer_authority(): + # We don't own the node, so we can only guess - i.e. predict + return true + if not _input_for.has(node): + # We own the node, node doesn't depend on input, we're sure + return false + if not snapshot.has_node(_input_for[node], true): + # We own the node, node depends on input, we don't have data for input - predict + return true + # We own the node and we have data for node's input - we're sure + return false + func simulate(delta: float, tick: int) -> void: - var nodes := get_nodes_to_simulate(RollbackHistoryServer.get_snapshot(tick)) + var snapshot := RollbackHistoryServer.get_snapshot(tick) + var nodes := get_nodes_to_simulate(snapshot) + _predicted_nodes.clear() _logger.debug("Simulating %d nodes: %s", [nodes.size(), nodes]) # Sort based on SceneTree order @@ -71,6 +88,12 @@ func simulate(delta: float, tick: int) -> void: var callback := _callbacks[node] as Callable callback.call(delta, tick, false) # TODO: is_fresh node.remove_from_group(_group) + + if is_predicting(snapshot, node): + _predicted_nodes.append(node) # Metrics NetworkPerformance.push_rollback_nodes_simulated(nodes.size()) + +func get_predicted_nodes() -> Array[Node]: + return _predicted_nodes diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index d36ef6d1..d549c3e9 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -47,7 +47,8 @@ func synchronize_input(tick: int) -> void: # Transmit if not input_snapshot.data.is_empty(): - _logger.debug("Submitting input: %s", [input_snapshot]) + # TODO: Always submit, even if empty +# _logger.debug("Submitting input: %s", [input_snapshot]) _submit_input.rpc(_serialize_snapshot(input_snapshot)) func synchronize_state(tick: int) -> void: @@ -66,7 +67,8 @@ func synchronize_state(tick: int) -> void: # Transmit if not state_snapshot.data.is_empty(): - _logger.debug("Submitting state: %s", [state_snapshot]) + # TODO: Always submit, even if empty +# _logger.debug("Submitting state: %s", [state_snapshot]) _submit_state.rpc(_serialize_snapshot(state_snapshot)) func _serialize_snapshot(snapshot: Snapshot) -> Variant: diff --git a/examples/multiplayer-netfox/characters/player.tscn b/examples/multiplayer-netfox/characters/player.tscn index 4098f0c0..923b9758 100644 --- a/examples/multiplayer-netfox/characters/player.tscn +++ b/examples/multiplayer-netfox/characters/player.tscn @@ -27,6 +27,7 @@ input_properties = Array[String](["Input:movement"]) [node name="TickInterpolator" type="Node" parent="." node_paths=PackedStringArray("root")] script = ExtResource("3_dkpv5") root = NodePath("..") +enabled = false properties = Array[String]([":position"]) [node name="Input" type="Node" parent="."] From 555227cda062be266b9ecf3086bd46a8e4ec9bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 8 Dec 2025 22:38:22 +0100 Subject: [PATCH 12/95] holy shit i just fucked up the predict check placement --- .../netfox/servers/rollback-history-server.gd | 12 +++++++++ .../servers/rollback-simulation-server.gd | 9 ++++--- .../rollback-synchronization-server.gd | 11 ++++++++ .../multiplayer-netfox.tscn | 25 ++++++++++++++++--- examples/multiplayer-netfox/scripts/player.gd | 4 +-- project.godot | 5 ++-- 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index ae812548..05edb735 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -8,6 +8,18 @@ var _snapshots: Dictionary = {} # tick to Snapshot static var _logger := NetfoxLogger._for_netfox("RollbackHistoryServer") +func _ready(): + NetworkTime.on_tick.connect(func(dt, t): + if t != 160: return + + var data := "" + for tick in _snapshots: + var snapshot := _snapshots[tick] as Snapshot + data += "@%d: %s\n" % [tick, snapshot] + + _logger.info("History dump: \n" + data) + ) + func register_property(node: Node, property: NodePath, pool: Array) -> void: var entry := RecordedProperty.key_of(node, property) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 6457ef01..826adc9d 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -53,6 +53,7 @@ func get_nodes_to_simulate(snapshot: Snapshot) -> Array[Node]: var input := _input_for[node] as Node if not snapshot.has_node(input, true): + # We don't have input for node, don't simulate continue result.append(node) @@ -83,14 +84,16 @@ func simulate(delta: float, tick: int) -> void: node.add_to_group(_group) nodes = get_tree().get_nodes_in_group(_group) + # Determine predicted nodes + for node in _callbacks.keys(): + if is_predicting(snapshot, node): + _predicted_nodes.append(node) + # Run callbacks and clear group for node in nodes: var callback := _callbacks[node] as Callable callback.call(delta, tick, false) # TODO: is_fresh node.remove_from_group(_group) - - if is_predicting(snapshot, node): - _predicted_nodes.append(node) # Metrics NetworkPerformance.push_rollback_nodes_simulated(nodes.size()) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index d549c3e9..2036b78f 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -80,6 +80,10 @@ func _serialize_snapshot(snapshot: Snapshot) -> Variant: var value = snapshot.data[entry] var is_auth := snapshot._is_authoritative.get(entry, false) + if not is_auth: + # Don't broadcast data we're not sure about + continue + serialized_properties.append([str(node.get_path()), str(property), value, is_auth]) serialized_properties.append(snapshot.tick) @@ -124,6 +128,13 @@ func _submit_state(snapshot_data: Variant): var snapshot := _deserialize_snapshot(snapshot_data) # _logger.debug("Received state snapshot: %s", [snapshot]) + var stored_snapshot := RollbackHistoryServer.get_snapshot(snapshot.tick) + if stored_snapshot != null: + for entry in snapshot.data: + if snapshot.data[entry] != stored_snapshot.data[entry]: + pass + # _logger.warning("Server reconciliation for @%d/%s: %s -> %s", [snapshot.tick, entry, stored_snapshot.data[entry], snapshot.data[entry]]) + # TODO: Sanitize var merged := RollbackHistoryServer.merge_snapshot(snapshot) diff --git a/examples/multiplayer-netfox/multiplayer-netfox.tscn b/examples/multiplayer-netfox/multiplayer-netfox.tscn index 72115e3f..6960e22f 100644 --- a/examples/multiplayer-netfox/multiplayer-netfox.tscn +++ b/examples/multiplayer-netfox/multiplayer-netfox.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=8 format=3 uid="uid://b2nbnsert06me"] +[gd_scene load_steps=9 format=3 uid="uid://b2nbnsert06me"] [ext_resource type="Script" path="res://examples/multiplayer-netfox/scripts/player-spawner.gd" id="1_ngijx"] [ext_resource type="PackedScene" uid="uid://k3y73rql2u6e" path="res://examples/multiplayer-netfox/characters/player.tscn" id="2_xjsod"] @@ -14,6 +14,16 @@ sky_material = SubResource("ProceduralSkyMaterial_rrbfu") background_mode = 2 sky = SubResource("Sky_g0uhm") +[sub_resource type="GDScript" id="GDScript_s4com"] +script/source = "extends Label + +func _process(_dt): + if multiplayer.is_server(): + text = \"Server\" + else: + text = \"Client\" +" + [node name="Game Scene" type="Node3D"] [node name="Map" type="Node" parent="."] @@ -86,8 +96,15 @@ grow_vertical = 2 [node name="Network Popup" parent="UI" instance=ExtResource("4_lq3lq")] layout_mode = 1 -[node name="Time Display" type="Label" parent="UI"] -layout_mode = 0 +[node name="VBoxContainer" type="VBoxContainer" parent="UI"] +layout_mode = 1 offset_right = 40.0 -offset_bottom = 23.0 +offset_bottom = 40.0 + +[node name="Time Display" type="Label" parent="UI/VBoxContainer"] +layout_mode = 2 script = ExtResource("4_n66wk") + +[node name="Label" type="Label" parent="UI/VBoxContainer"] +layout_mode = 2 +script = SubResource("GDScript_s4com") diff --git a/examples/multiplayer-netfox/scripts/player.gd b/examples/multiplayer-netfox/scripts/player.gd index 0211a43f..5b1ba400 100644 --- a/examples/multiplayer-netfox/scripts/player.gd +++ b/examples/multiplayer-netfox/scripts/player.gd @@ -23,8 +23,8 @@ func _rollback_tick(delta, _tick, _is_fresh): velocity.x = direction.x * speed velocity.z = direction.z * speed else: - velocity.x = move_toward(velocity.x, 0, speed) - velocity.z = move_toward(velocity.z, 0, speed) + velocity.x = 0. * move_toward(velocity.x, 0, speed) + velocity.z = 0. * move_toward(velocity.z, 0, speed) # move_and_slide assumes physics delta # multiplying velocity by NetworkTime.physics_factor compensates for it diff --git a/project.godot b/project.godot index 73495fa6..e52c69d7 100644 --- a/project.godot +++ b/project.godot @@ -129,9 +129,10 @@ escape={ [netfox] general/clear_settings=false -time/tickrate=24 +time/tickrate=16 extras/auto_tile_windows=true -autoconnect/enabled=false +autoconnect/enabled=true +logging/log_level=3 [rendering] From baa241927972bea1df8ea55a0b553712e86dce7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 8 Dec 2025 23:24:13 +0100 Subject: [PATCH 13/95] is fresh --- addons/netfox/rollback/network-rollback.gd | 1 + .../servers/rollback-simulation-server.gd | 42 +++++++++++++++---- .../rollback-synchronization-server.gd | 2 +- .../multiplayer-netfox/characters/player.tscn | 1 - project.godot | 3 +- 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 33bf2285..2b38b0f7 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -399,6 +399,7 @@ func _rollback() -> void: _rollback_stage = _STAGE_AFTER RollbackHistoryServer.restore_tick(display_tick) RollbackHistoryServer.trim_history(history_start) + RollbackSimulationServer.trim_ticks_simulated(history_start) after_loop.emit() # Cleanup diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 826adc9d..473b0ab5 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -7,6 +7,10 @@ var _callbacks := {} # node to input node # TODO: Support multiple input nodes for a single simulated node var _input_for := {} +# node to array of ticks +# used for is_fresh +# TODO: Refactor to ringbuffer containing sets of nodes? +var _simulated_ticks := {} var _predicted_nodes := [] as Array[Node] @@ -22,17 +26,19 @@ func register(callback: Callable) -> void: _callbacks[callback.get_object()] = callback func deregister(callback: Callable) -> void: - if not is_instance_valid(callback.get_object()): - return + if not callback or not callback.is_valid(): return - if _callbacks[callback.get_object()] != callback: - return + var object := callback.get_object() - _callbacks.erase(callback.get_object()) + if not is_instance_valid(object): return + if _callbacks[object] != callback: return + + _callbacks.erase(object) + _input_for.erase(object) + _simulated_ticks.erase(object) func deregister_node(node: Node) -> void: - _callbacks.erase(node) - deregister_input(node) + deregister(_callbacks.get(node)) func register_input_for(node: Node, input: Node) -> void: _input_for[node] = input @@ -73,6 +79,23 @@ func is_predicting(snapshot: Snapshot, node: Node) -> bool: # We own the node and we have data for node's input - we're sure return false +func is_tick_fresh_for(node: Node, tick: int) -> bool: + if not _simulated_ticks.has(node): + return false + var ticks := _simulated_ticks.get(node) as Array[int] + return ticks.has(tick) + +func set_tick_simulated_for(node: Node, tick: int) -> void: + if not _simulated_ticks.has(node): + _simulated_ticks[node] = [tick] as Array[int] + else: + _simulated_ticks[node].append(tick) + +func trim_ticks_simulated(beginning: int) -> void: + for object in _simulated_ticks: + _simulated_ticks[object] = _simulated_ticks[object]\ + .filter(func(tick): return tick >= beginning) + func simulate(delta: float, tick: int) -> void: var snapshot := RollbackHistoryServer.get_snapshot(tick) var nodes := get_nodes_to_simulate(snapshot) @@ -92,9 +115,12 @@ func simulate(delta: float, tick: int) -> void: # Run callbacks and clear group for node in nodes: var callback := _callbacks[node] as Callable - callback.call(delta, tick, false) # TODO: is_fresh + var is_fresh := is_tick_fresh_for(node, tick) + callback.call(delta, tick, is_fresh) node.remove_from_group(_group) + set_tick_simulated_for(node, tick) + # Metrics NetworkPerformance.push_rollback_nodes_simulated(nodes.size()) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 2036b78f..c8d5d8e9 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -131,7 +131,7 @@ func _submit_state(snapshot_data: Variant): var stored_snapshot := RollbackHistoryServer.get_snapshot(snapshot.tick) if stored_snapshot != null: for entry in snapshot.data: - if snapshot.data[entry] != stored_snapshot.data[entry]: + if snapshot.data.get(entry) != stored_snapshot.data.get(entry): pass # _logger.warning("Server reconciliation for @%d/%s: %s -> %s", [snapshot.tick, entry, stored_snapshot.data[entry], snapshot.data[entry]]) diff --git a/examples/multiplayer-netfox/characters/player.tscn b/examples/multiplayer-netfox/characters/player.tscn index 923b9758..4098f0c0 100644 --- a/examples/multiplayer-netfox/characters/player.tscn +++ b/examples/multiplayer-netfox/characters/player.tscn @@ -27,7 +27,6 @@ input_properties = Array[String](["Input:movement"]) [node name="TickInterpolator" type="Node" parent="." node_paths=PackedStringArray("root")] script = ExtResource("3_dkpv5") root = NodePath("..") -enabled = false properties = Array[String]([":position"]) [node name="Input" type="Node" parent="."] diff --git a/project.godot b/project.godot index e52c69d7..bfa7f9e2 100644 --- a/project.godot +++ b/project.godot @@ -129,10 +129,11 @@ escape={ [netfox] general/clear_settings=false -time/tickrate=16 +time/tickrate=24 extras/auto_tile_windows=true autoconnect/enabled=true logging/log_level=3 +autoconnect/simulated_latency_ms=50 [rendering] From 0f5eebef5b79ddd06e0e03a6de943f3d9f3e106b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 20 Dec 2025 22:38:08 +0100 Subject: [PATCH 14/95] RBS.is_predicting --- addons/netfox/rollback/rollback-synchronizer.gd | 9 ++++++++- .../servers/rollback-simulation-server.gd | 14 ++++++++++++++ test/rollback-simulation-server.test.gd | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index c6fdaa6a..b790a180 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -156,6 +156,7 @@ func process_authority(): ## [NodePath] pointing to a node, or an actual [Node] instance. If the given ## property is already tracked, this method does nothing. func add_state(node: Variant, property: String): + # TODO: Rewrite var property_path := PropertyEntry.make_path(root, node, property) if not property_path or state_properties.has(property_path): return @@ -170,6 +171,7 @@ func add_state(node: Variant, property: String): ## [NodePath] pointing to a node, or an actual [Node] instance. If the given ## property is already tracked, this method does nothing. func add_input(node: Variant, property: String) -> void: + # TODO: Rewrite var property_path := PropertyEntry.make_path(root, node, property) if not property_path or input_properties.has(property_path): return @@ -184,6 +186,7 @@ func add_input(node: Variant, property: String) -> void: ## [br][br] ## Returns true if input is available. func has_input() -> bool: + # TODO: Rewrite return false ## Get the age of currently available input in ticks. @@ -193,6 +196,7 @@ func has_input() -> bool: ## [br][br] ## Calling this when [member has_input] is false will yield an error. func get_input_age() -> int: + # TODO: Rewrite return 0 ## Check if the current tick is predicted. @@ -201,13 +205,14 @@ func get_input_age() -> int: ## simulated and recorded, but will not be broadcast, nor considered ## authoritative. func is_predicting() -> bool: - return false + return RollbackSimulationServer.is_predicting_current() ## Ignore a node's prediction for the current rollback tick. ## ## Call this when the input is too old to base predictions on. This call is ## ignored if [member enable_prediction] is false. func ignore_prediction(node: Node) -> void: + # TODO: Rewrite return ## Get the tick of the last known input. @@ -222,6 +227,7 @@ func ignore_prediction(node: Node) -> void: ## [br][br] ## Returns -1 if there's no known input. func get_last_known_input() -> int: + # TODO: Rewrite return -1 ## Get the tick of the last known state. @@ -231,6 +237,7 @@ func get_last_known_input() -> int: ## data may change as new input arrives. For peers that don't own state, this ## will be the tick of the latest state received from the state owner. func get_last_known_state() -> int: + # TODO: Rewrite # If we own state, this will be updated when recording and broadcasting # state, this will be the current tick # If we don't own state, this will be updated when state data is received diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 473b0ab5..ea481d71 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -12,6 +12,10 @@ var _input_for := {} # TODO: Refactor to ringbuffer containing sets of nodes? var _simulated_ticks := {} +# Currently simulated object +var _current_object: Object = null +# Predicted nodes for next simulation +# TODO: _Set? var _predicted_nodes := [] as Array[Node] var _group := StringName("__nf_rollback_sim" + str(get_instance_id())) @@ -79,6 +83,11 @@ func is_predicting(snapshot: Snapshot, node: Node) -> bool: # We own the node and we have data for node's input - we're sure return false +func is_predicting_current() -> bool: + if not _current_object or not is_instance_valid(_current_object): + return false + return _predicted_nodes.has(_current_object) + func is_tick_fresh_for(node: Node, tick: int) -> bool: if not _simulated_ticks.has(node): return false @@ -97,6 +106,8 @@ func trim_ticks_simulated(beginning: int) -> void: .filter(func(tick): return tick >= beginning) func simulate(delta: float, tick: int) -> void: + _current_object = null + var snapshot := RollbackHistoryServer.get_snapshot(tick) var nodes := get_nodes_to_simulate(snapshot) _predicted_nodes.clear() @@ -114,11 +125,14 @@ func simulate(delta: float, tick: int) -> void: # Run callbacks and clear group for node in nodes: + _current_object = node + var callback := _callbacks[node] as Callable var is_fresh := is_tick_fresh_for(node, tick) callback.call(delta, tick, is_fresh) node.remove_from_group(_group) + _current_object = null set_tick_simulated_for(node, tick) # Metrics diff --git a/test/rollback-simulation-server.test.gd b/test/rollback-simulation-server.test.gd index 317d7b6e..0842255a 100644 --- a/test/rollback-simulation-server.test.gd +++ b/test/rollback-simulation-server.test.gd @@ -4,6 +4,23 @@ func get_suite_name() -> String: return "RollbackSimulationServer" func suite() -> void: + define("is_predicting()", func(): + test("should predict non-owned node", func(): todo()) + test("should predict owned node without input", func(): todo()) + test("should predict non-owned inputless", func(): todo()) + test("should not predict owned inputless", func(): todo()) + test("should not predict owned with input", func(): todo()) + ) + + define("is_predicting_current()", func(): + test("should use current node", func(): + # Setup a predicted and non-predicted node + # Should return correct value inside `_rollback_tick()` + # Maybe create a class that takes a rollback tick callback as param + todo() + ) + ) + test("should not simulate without input", func(): var node := RewindableNode.new() var input_node := Node.new() From 0b1497f6baf4d3e5171e0b26cf0ec9a5f5a8ceeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 20 Dec 2025 23:32:46 +0100 Subject: [PATCH 15/95] input prediction methods for RBS --- .../netfox/rollback/rollback-synchronizer.gd | 25 +++++++++++++------ .../netfox/servers/rollback-history-server.gd | 21 +++++++--------- examples/input-prediction/input.gd | 2 +- project.godot | 2 +- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index b790a180..3560de31 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -71,6 +71,8 @@ var visibility_filter := PeerVisibilityFilter.new() var _state_property_config: _PropertyConfig = _PropertyConfig.new() var _input_property_config: _PropertyConfig = _PropertyConfig.new() +var _input_nodes := [] as Array[Node] + var _properties_dirty: bool = false var _property_cache := PropertyCache.new(root) @@ -99,15 +101,15 @@ func process_settings() -> void: nodes = nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it)) nodes.erase(self) - var input_nodes = [] + _input_nodes.clear() for prop in _input_property_config.get_properties(): var input_node := prop.node - if not input_nodes.has(input_node): - input_nodes.append(input_node) + if not _input_nodes.has(input_node): + _input_nodes.append(input_node) for node in nodes: RollbackSimulationServer.register(node._rollback_tick) - for input_node in input_nodes: + for input_node in _input_nodes: RollbackSimulationServer.register_input_for(node, input_node) _registered_nodes.append(node) @@ -186,8 +188,7 @@ func add_input(node: Variant, property: String) -> void: ## [br][br] ## Returns true if input is available. func has_input() -> bool: - # TODO: Rewrite - return false + return get_input_age() >= 0 ## Get the age of currently available input in ticks. ## @@ -196,8 +197,15 @@ func has_input() -> bool: ## [br][br] ## Calling this when [member has_input] is false will yield an error. func get_input_age() -> int: - # TODO: Rewrite - return 0 + # TODO: input-prediction example desyncs + # TODO: Cache these after prepare tick? + var max_age := 0 + for input_node in _input_nodes: + var age := RollbackHistoryServer.get_data_age_for(input_node, NetworkRollback.tick) + if age < 0: + # TODO: Error, somehow + return -1 + return max_age ## Check if the current tick is predicted. ## @@ -213,6 +221,7 @@ func is_predicting() -> bool: ## ignored if [member enable_prediction] is false. func ignore_prediction(node: Node) -> void: # TODO: Rewrite + # TODO: Does this even make sense in its current form? return ## Get the tick of the last known input. diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 05edb735..da9c352a 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -8,18 +8,6 @@ var _snapshots: Dictionary = {} # tick to Snapshot static var _logger := NetfoxLogger._for_netfox("RollbackHistoryServer") -func _ready(): - NetworkTime.on_tick.connect(func(dt, t): - if t != 160: return - - var data := "" - for tick in _snapshots: - var snapshot := _snapshots[tick] as Snapshot - data += "@%d: %s\n" % [tick, snapshot] - - _logger.info("History dump: \n" + data) - ) - func register_property(node: Node, property: NodePath, pool: Array) -> void: var entry := RecordedProperty.key_of(node, property) @@ -100,3 +88,12 @@ func merge_snapshot(snapshot: Snapshot) -> Snapshot: # _snapshots[tick] = stored_snapshot return stored_snapshot + +func get_data_age_for(what: Node, tick: int) -> int: + for i in range(tick, _snapshots.keys.min() - 1, -1): + if not _snapshots.has(i): + continue + var snapshot := get_snapshot(i) + if snapshot.has_node(what, true): + return tick - i + return -1 diff --git a/examples/input-prediction/input.gd b/examples/input-prediction/input.gd index 1f76da97..d43ec274 100644 --- a/examples/input-prediction/input.gd +++ b/examples/input-prediction/input.gd @@ -27,7 +27,7 @@ func _predict(_t): return # Decay input over a short time - var decay_time := NetworkTime.seconds_to_ticks(.15) + var decay_time := NetworkTime.seconds_to_ticks(.05) var input_age := _rollback_synchronizer.get_input_age() # **ALWAYS** cast either side to float, otherwise the integer-integer diff --git a/project.godot b/project.godot index bfa7f9e2..a48a419c 100644 --- a/project.godot +++ b/project.godot @@ -133,7 +133,7 @@ time/tickrate=24 extras/auto_tile_windows=true autoconnect/enabled=true logging/log_level=3 -autoconnect/simulated_latency_ms=50 +autoconnect/simulated_latency_ms=200 [rendering] From c44d5cf4317d01a546408c87dd9beaebd5cb9223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 21 Dec 2025 11:02:46 +0100 Subject: [PATCH 16/95] input age fixes --- addons/netfox/rollback/rollback-synchronizer.gd | 8 +++++++- addons/netfox/servers/rollback-history-server.gd | 5 ++++- addons/netfox/servers/rollback-simulation-server.gd | 3 +++ examples/input-prediction/input.gd | 5 +++++ project.godot | 2 +- test/netfox/rollback-synchronizer.test.gd | 11 +++++++++++ 6 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 test/netfox/rollback-synchronizer.test.gd diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 3560de31..81d473db 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -202,6 +202,7 @@ func get_input_age() -> int: var max_age := 0 for input_node in _input_nodes: var age := RollbackHistoryServer.get_data_age_for(input_node, NetworkRollback.tick) + max_age = maxi(age, max_age) if age < 0: # TODO: Error, somehow return -1 @@ -213,7 +214,12 @@ func get_input_age() -> int: ## simulated and recorded, but will not be broadcast, nor considered ## authoritative. func is_predicting() -> bool: - return RollbackSimulationServer.is_predicting_current() + if RollbackSimulationServer.get_simulated_object() != null: + # An object is being simulated, check if it's predicted + return RollbackSimulationServer.is_predicting_current() + else: + # We're outside of simulation, predicting if we don't have current input + return get_input_age() != 0 ## Ignore a node's prediction for the current rollback tick. ## diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index da9c352a..00fc3f8f 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -90,7 +90,10 @@ func merge_snapshot(snapshot: Snapshot) -> Snapshot: return stored_snapshot func get_data_age_for(what: Node, tick: int) -> int: - for i in range(tick, _snapshots.keys.min() - 1, -1): + if _snapshots.is_empty(): + return -1 + + for i in range(tick, _snapshots.keys().min() - 1, -1): if not _snapshots.has(i): continue var snapshot := get_snapshot(i) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index ea481d71..8545201d 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -88,6 +88,9 @@ func is_predicting_current() -> bool: return false return _predicted_nodes.has(_current_object) +func get_simulated_object() -> Object: + return _current_object + func is_tick_fresh_for(node: Node, tick: int) -> bool: if not _simulated_ticks.has(node): return false diff --git a/examples/input-prediction/input.gd b/examples/input-prediction/input.gd index d43ec274..78811fb2 100644 --- a/examples/input-prediction/input.gd +++ b/examples/input-prediction/input.gd @@ -5,6 +5,8 @@ var confidence: float = 1. @onready var _rollback_synchronizer := $"../RollbackSynchronizer" as RollbackSynchronizer +var logger := NetfoxLogger.new("example", "Input") + func _ready(): super() NetworkRollback.after_prepare_tick.connect(_predict) @@ -19,16 +21,19 @@ func _gather(): func _predict(_t): if not _rollback_synchronizer.is_predicting(): # Not predicting, nothing to do +# logger.info("Input is current") confidence = 1. return if not _rollback_synchronizer.has_input(): + logger.info("No input to predict from") confidence = 0. return # Decay input over a short time var decay_time := NetworkTime.seconds_to_ticks(.05) var input_age := _rollback_synchronizer.get_input_age() + logger.info("Predicting after %d ticks" % [input_age]) # **ALWAYS** cast either side to float, otherwise the integer-integer # division yields either 1 or 0 confidence diff --git a/project.godot b/project.godot index a48a419c..131e0704 100644 --- a/project.godot +++ b/project.godot @@ -133,7 +133,7 @@ time/tickrate=24 extras/auto_tile_windows=true autoconnect/enabled=true logging/log_level=3 -autoconnect/simulated_latency_ms=200 +autoconnect/simulated_latency_ms=250 [rendering] diff --git a/test/netfox/rollback-synchronizer.test.gd b/test/netfox/rollback-synchronizer.test.gd new file mode 100644 index 00000000..c961b078 --- /dev/null +++ b/test/netfox/rollback-synchronizer.test.gd @@ -0,0 +1,11 @@ +extends VestTest + +func get_suite_name() -> String: + return "RollbackSynchronizer" + +func suite(): + define("Input age and predicting", func(): + test("should return -1 on no input", func(): todo()) + test("should return 0 on recent input", func(): todo()) + test("should return positive on old input", func(): todo()) + ) From d021626b3bc5e7a0c77e5cd7cb8499a4a02cdc19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 21 Dec 2025 15:15:00 +0100 Subject: [PATCH 17/95] latest known input and state ticks --- .../netfox/rollback/rollback-synchronizer.gd | 32 ++++++++++++++----- test/netfox/rollback-synchronizer.test.gd | 10 ++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 81d473db..32e20bfd 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -72,6 +72,7 @@ var _state_property_config: _PropertyConfig = _PropertyConfig.new() var _input_property_config: _PropertyConfig = _PropertyConfig.new() var _input_nodes := [] as Array[Node] +var _state_nodes := [] as Array[Node] var _properties_dirty: bool = false var _property_cache := PropertyCache.new(root) @@ -107,6 +108,13 @@ func process_settings() -> void: if not _input_nodes.has(input_node): _input_nodes.append(input_node) + # TODO: Move tracking nodes to property configs? + _state_nodes.clear() + for prop in _state_property_config.get_properties(): + var state_node := prop.node + if not _state_nodes.has(state_node): + _state_nodes.append(state_node) + for node in nodes: RollbackSimulationServer.register(node._rollback_tick) for input_node in _input_nodes: @@ -242,8 +250,14 @@ func ignore_prediction(node: Node) -> void: ## [br][br] ## Returns -1 if there's no known input. func get_last_known_input() -> int: - # TODO: Rewrite - return -1 + # TODO: Is there an easier way? + var max_age := 0 + var latest_tick := NetworkTime.tick + 1 + for input_node in _input_nodes: + var age := RollbackHistoryServer.get_data_age_for(input_node, latest_tick) + if age >= 0: + max_age = maxi(age, max_age) + return latest_tick - max_age ## Get the tick of the last known state. ## [br][br] @@ -252,12 +266,14 @@ func get_last_known_input() -> int: ## data may change as new input arrives. For peers that don't own state, this ## will be the tick of the latest state received from the state owner. func get_last_known_state() -> int: - # TODO: Rewrite - # If we own state, this will be updated when recording and broadcasting - # state, this will be the current tick - # If we don't own state, this will be updated when state data is received -# return _history_transmitter.get_latest_state_tick() - return 0 + # TODO: Is there an easier way? + var max_age := 0 + var latest_tick := NetworkTime.tick + 1 + for state_node in _registered_nodes: + var age := RollbackHistoryServer.get_data_age_for(state_node, latest_tick) + if age >= 0: + max_age = maxi(age, max_age) + return latest_tick - max_age func _ready() -> void: if Engine.is_editor_hint(): diff --git a/test/netfox/rollback-synchronizer.test.gd b/test/netfox/rollback-synchronizer.test.gd index c961b078..9c04c156 100644 --- a/test/netfox/rollback-synchronizer.test.gd +++ b/test/netfox/rollback-synchronizer.test.gd @@ -9,3 +9,13 @@ func suite(): test("should return 0 on recent input", func(): todo()) test("should return positive on old input", func(): todo()) ) + + define("get_last_known_input()", func(): + test("should return -1 for no input", func(): todo()) + test("should return latest", func(): todo()) + ) + + define("get_last_known_state()", func(): + test("should return -1 for no state", func(): todo()) + test("should return latest", func(): todo()) + ) From d48e32f479cf4ac2c4953976a568b9171a77c782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 21 Dec 2025 15:24:49 +0100 Subject: [PATCH 18/95] add some todos --- addons/netfox/servers/rollback-synchronization-server.gd | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index c8d5d8e9..56b8eb98 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -1,6 +1,10 @@ extends Node class_name _RollbackSynchronizationServer +# TODO: Support various encoders +# TODO: Diff states +# TODO: Honor visibility filters + var _input_properties: Array = [] var _state_properties: Array = [] From 82e6a07fc3e404ce33f772aed14871f318cb683c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 7 Jan 2026 00:12:03 +0100 Subject: [PATCH 19/95] diff states --- addons/netfox/servers/data/snapshot.gd | 41 +++++++++++ .../servers/rollback-simulation-server.gd | 1 + .../rollback-synchronization-server.gd | 73 +++++++++++++------ project.godot | 2 +- 4 files changed, 94 insertions(+), 23 deletions(-) diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index 232e6be2..de895345 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -5,6 +5,24 @@ var tick: int var data: Dictionary = {} # RecordedProperty to Variant value var _is_authoritative: Dictionary = {} # Property key to bool, not present means false +static func make_patch(from: Snapshot, to: Snapshot, tick: int = from.tick, include_new: bool = true) -> Snapshot: + var patch := Snapshot.new(to.tick) + + for prop_key in from.data: + # TODO: This works if both props are auth - handle if that differs + if to.data.has(prop_key) and from.data[prop_key] != to.data[prop_key]: + patch.data[prop_key] = to.data[prop_key] + patch._is_authoritative[prop_key] = to._is_authoritative[prop_key] + + if include_new: + for prop_key in to.data: + # TODO: This works if both props are auth - handle if that differs + if from.data.has(prop_key) and from.data[prop_key] != to.data[prop_key]: + patch.data[prop_key] = to.data[prop_key] + patch._is_authoritative[prop_key] = to._is_authoritative[prop_key] + + return patch + func _init(p_tick: int): tick = p_tick @@ -32,6 +50,29 @@ func apply() -> void: var value = data[prop_key] RecordedProperty.apply(prop_key, value) +func filtered_to_properties(prop_keys: Array) -> Snapshot: + var snapshot := Snapshot.new(tick) + + for property in prop_keys: + if not data.has(property): + continue + snapshot.data[property] = data[property] + snapshot._is_authoritative[property] = _is_authoritative[property] + + return snapshot + +func filtered_to_auth() -> Snapshot: + var snapshot := Snapshot.new(tick) + + for property in data: + if not _is_authoritative[property]: + continue + + snapshot.data[property] = data[property] + snapshot._is_authoritative[property] = _is_authoritative[property] + + return snapshot + func has_node(node: Node, require_auth: bool = false) -> bool: for entry in data.keys(): var entry_node := entry[0] as Node diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 8545201d..90fd908f 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -84,6 +84,7 @@ func is_predicting(snapshot: Snapshot, node: Node) -> bool: return false func is_predicting_current() -> bool: + # TODO: Breaks Forest Brawl? if not _current_object or not is_instance_valid(_current_object): return false return _predicted_nodes.has(_current_object) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 56b8eb98..4340824c 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -8,6 +8,10 @@ class_name _RollbackSynchronizationServer var _input_properties: Array = [] var _state_properties: Array = [] +var _full_state_interval := 24 +var _state_ack_interval := 4 +var _ackd_tick := {} # peer id to ack'd tick + static var _logger := NetfoxLogger._for_netfox("RollbackSynchronizationServer") signal on_input(snapshot: Snapshot) @@ -50,10 +54,8 @@ func synchronize_input(tick: int) -> void: input_snapshot._is_authoritative[property] = snapshot._is_authoritative[property] # Transmit - if not input_snapshot.data.is_empty(): - # TODO: Always submit, even if empty -# _logger.debug("Submitting input: %s", [input_snapshot]) - _submit_input.rpc(_serialize_snapshot(input_snapshot)) + # _logger.debug("Submitting input: %s", [input_snapshot]) + _submit_input.rpc(_serialize_snapshot(input_snapshot)) func synchronize_state(tick: int) -> void: # Grab snapshot from RollbackHistoryServer @@ -62,18 +64,44 @@ func synchronize_state(tick: int) -> void: return # Filter to state properties - var state_snapshot := Snapshot.new(tick) - for property in _state_properties: - if not snapshot.data.has(property): - continue - state_snapshot.data[property] = snapshot.data[property] - state_snapshot._is_authoritative[property] = snapshot._is_authoritative[property] + var state_snapshot := snapshot.filtered_to_properties(_state_properties) + + # Figure out whether to send full- or diff state + var is_diff := false + # TODO: Something better than modulo logic? + if _full_state_interval >= 1 and (tick % _full_state_interval) != 0: + is_diff = true # Transmit - if not state_snapshot.data.is_empty(): - # TODO: Always submit, even if empty -# _logger.debug("Submitting state: %s", [state_snapshot]) + if is_diff: + for peer in multiplayer.get_peers(): + if not _ackd_tick.has(peer): + # We don't know any state the peer knows, send full state + _submit_state.rpc_id(peer, _serialize_snapshot(state_snapshot)) + _logger.info("Sent full state for @%d to #%d", [tick, peer]) + continue + + var reference_tick := _ackd_tick[peer] as int + var reference_snapshot := RollbackHistoryServer.get_snapshot(reference_tick) + + if not reference_snapshot: + # Reference snapshot not in history, send full state + _logger.warning("Tick @%d not present in history, can't use it as reference for peer #%d", [reference_tick, peer]) + _submit_state.rpc_id(peer, _serialize_snapshot(state_snapshot)) + _logger.info("Sent full state for @%d to #%d", [tick, peer]) + continue + + # TODO: Optimize, don't create two snapshots + reference_snapshot = reference_snapshot.filtered_to_auth().filtered_to_properties(_state_properties) + + var diff_snapshot := Snapshot.make_patch(state_snapshot, reference_snapshot) + _submit_state.rpc_id(peer, _serialize_snapshot(diff_snapshot)) +# _submit_state.rpc_id(peer, _serialize_snapshot(state_snapshot)) +# _logger.info("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) + else: + # _logger.debug("Submitting state: %s", [state_snapshot]) _submit_state.rpc(_serialize_snapshot(state_snapshot)) + _logger.info("Broadcast full state for @%d", [tick]) func _serialize_snapshot(snapshot: Snapshot) -> Variant: var serialized_properties := [] @@ -129,19 +157,20 @@ func _submit_input(snapshot_data: Variant): @rpc("any_peer", "call_remote", "unreliable") func _submit_state(snapshot_data: Variant): + var sender := multiplayer.get_remote_sender_id() var snapshot := _deserialize_snapshot(snapshot_data) -# _logger.debug("Received state snapshot: %s", [snapshot]) - - var stored_snapshot := RollbackHistoryServer.get_snapshot(snapshot.tick) - if stored_snapshot != null: - for entry in snapshot.data: - if snapshot.data.get(entry) != stored_snapshot.data.get(entry): - pass - # _logger.warning("Server reconciliation for @%d/%s: %s -> %s", [snapshot.tick, entry, stored_snapshot.data[entry], snapshot.data[entry]]) - # TODO: Sanitize +# _logger.debug("Received state snapshot: %s", [snapshot]) var merged := RollbackHistoryServer.merge_snapshot(snapshot) # _logger.debug("Merged state; %s", [merged]) + if _state_ack_interval >= 1 and (snapshot.tick % _state_ack_interval) == 0: + _ack_state.rpc_id(sender, snapshot.tick) + on_state.emit(snapshot) + +@rpc("any_peer", "call_remote", "unreliable") +func _ack_state(tick: int): + var sender := multiplayer.get_remote_sender_id() + _ackd_tick[sender] = maxi(tick, _ackd_tick.get(sender, tick)) diff --git a/project.godot b/project.godot index 131e0704..bfa7f9e2 100644 --- a/project.godot +++ b/project.godot @@ -133,7 +133,7 @@ time/tickrate=24 extras/auto_tile_windows=true autoconnect/enabled=true logging/log_level=3 -autoconnect/simulated_latency_ms=250 +autoconnect/simulated_latency_ms=50 [rendering] From 10dee7a128895eccb4e822aba7a383304924b314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 7 Jan 2026 22:46:40 +0100 Subject: [PATCH 20/95] input redundancy, i guess --- .../rollback-synchronization-server.gd | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 4340824c..380d1121 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -2,16 +2,17 @@ extends Node class_name _RollbackSynchronizationServer # TODO: Support various encoders -# TODO: Diff states # TODO: Honor visibility filters var _input_properties: Array = [] var _state_properties: Array = [] -var _full_state_interval := 24 -var _state_ack_interval := 4 +var _full_state_interval := 24 # TODO: Config +var _state_ack_interval := 4 # TODO: Config var _ackd_tick := {} # peer id to ack'd tick +var _input_redundancy := 3 # TODO: Config + static var _logger := NetfoxLogger._for_netfox("RollbackSynchronizationServer") signal on_input(snapshot: Snapshot) @@ -39,24 +40,27 @@ func register_input(node: Node, property: NodePath) -> void: func deregister_input(node: Node, property: NodePath) -> void: deregister_property(node, property, _input_properties) +# TODO: Make this testable somehow, I beg of you func synchronize_input(tick: int) -> void: - # Grab snapshot from RollbackHistoryServer - var snapshot := RollbackHistoryServer.get_snapshot(tick) - if not snapshot: - return + var encoded_snapshots := [] - # Filter to input properties - var input_snapshot := Snapshot.new(tick) - for property in _input_properties: - if not snapshot.data.has(property): - continue - input_snapshot.data[property] = snapshot.data[property] - input_snapshot._is_authoritative[property] = snapshot._is_authoritative[property] + for offset in _input_redundancy: + # Grab snapshot from RollbackHistoryServer + var snapshot := RollbackHistoryServer.get_snapshot(tick + offset) + if not snapshot: + break - # Transmit - # _logger.debug("Submitting input: %s", [input_snapshot]) - _submit_input.rpc(_serialize_snapshot(input_snapshot)) + # Filter to input properties + # TODO: Send only owned props + var input_snapshot := snapshot.filtered_to_properties(_input_properties) + # Transmit + # _logger.debug("Submitting input: %s", [input_snapshot]) + encoded_snapshots.append(_serialize_snapshot(input_snapshot)) + + _submit_input.rpc(encoded_snapshots) + +# TODO: Make this testable somehow, I beg of you func synchronize_state(tick: int) -> void: # Grab snapshot from RollbackHistoryServer var snapshot := RollbackHistoryServer.get_snapshot(tick) @@ -64,12 +68,15 @@ func synchronize_state(tick: int) -> void: return # Filter to state properties + # TODO: Send only owned props var state_snapshot := snapshot.filtered_to_properties(_state_properties) # Figure out whether to send full- or diff state var is_diff := false - # TODO: Something better than modulo logic? - if _full_state_interval >= 1 and (tick % _full_state_interval) != 0: + if _full_state_interval <= 0: + is_diff = true + elif _full_state_interval >= 1 and (tick % _full_state_interval) != 0: + # TODO: Something better than modulo logic? --^ is_diff = true # Transmit @@ -144,16 +151,17 @@ func _deserialize_snapshot(data: Variant) -> Snapshot: return snapshot @rpc("any_peer", "call_remote", "reliable") -func _submit_input(snapshot_data: Variant): - var snapshot := _deserialize_snapshot(snapshot_data) -# _logger.debug("Received input snapshot: %s", [snapshot]) +func _submit_input(encoded_snapshots: Array): + for snapshot_data in encoded_snapshots: + var snapshot := _deserialize_snapshot(snapshot_data) + # _logger.debug("Received input snapshot: %s", [snapshot]) - # TODO: Sanitize + # TODO: Sanitize - var merged := RollbackHistoryServer.merge_snapshot(snapshot) -# _logger.debug("Merged input; %s", [merged]) + var merged := RollbackHistoryServer.merge_snapshot(snapshot) + # _logger.debug("Merged input; %s", [merged]) - on_input.emit(snapshot) + on_input.emit(snapshot) @rpc("any_peer", "call_remote", "unreliable") func _submit_state(snapshot_data: Variant): From 356e9aa99bd542d6f14c2a2e99de984e3f30ad37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 8 Jan 2026 00:18:02 +0100 Subject: [PATCH 21/95] network identity server for the eventual use --- .../netfox/servers/network-identity-server.gd | 117 ++++++++++++++++++ .../servers/network-identity-server.test.gd | 29 +++++ 2 files changed, 146 insertions(+) create mode 100644 addons/netfox/servers/network-identity-server.gd create mode 100644 test/netfox/servers/network-identity-server.test.gd diff --git a/addons/netfox/servers/network-identity-server.gd b/addons/netfox/servers/network-identity-server.gd new file mode 100644 index 00000000..2d8fba63 --- /dev/null +++ b/addons/netfox/servers/network-identity-server.gd @@ -0,0 +1,117 @@ +extends Node +class_name NetworkIdentityServer + +var _next_id := 0 +var _identifiers := {} # peer to NetworkIdentifier +var _push_queue := [] as Array[IdentityNotification] + +static var _logger := NetfoxLogger._for_netfox("NetworkIdentityServer") + +func register(what: Object, path: String) -> void: + if _identifiers.has(what): + return + + var identifier := NetworkIdentifier.new(what, path, _make_id()) + _identifiers[what] = identifier + +func deregister(what: Object) -> void: + _identifiers.erase(what) + +func clear() -> void: + _identifiers.clear() + _next_id = 0 + +func register_node(node: Node) -> void: + if not node.is_inside_tree(): + _logger.error("Can't register node %s that is not inside tree!", [node]) + return + register(node, node.get_path()) + +func deregister_node(node: Node) -> void: + deregister(node) + +# TODO: Consider specific queries, NetworkIdentifier might be an impl detail +func get_identifier_of(what: Object) -> NetworkIdentifier: + return _identifiers[what] + +func queue_id_for(what: Object, peer: int) -> Error: + var identifier := _identifiers.get(what) as NetworkIdentifier + if not identifier: return ERR_DOES_NOT_EXIST + + _push_queue.append(IdentityNotification.of(peer, identifier)) + return OK + +func flush_queue() -> void: + var ids := {} + + for item in _push_queue: + if not ids.has(item.peer): ids[item.peer] = {} + ids[item.peer][item.identifier.get_full_name()] = item.identifier.get_local_id() + + for peer in ids: + _submit_ids.rpc_id(peer, ids[peer]) + +func _get_identifier_by_name(full_name: String) -> NetworkIdentifier: + # TODO: Optimize, probably by caching + for value in _identifiers.values() as Array: + var identifier := value as NetworkIdentifier + if identifier.get_full_name() == full_name: + return identifier + return null + +func _make_id() -> int: + _next_id += 1 + return _next_id + +@rpc("any_peer", "call_remote", "unreliable") +func _submit_ids(ids: Dictionary) -> void: + var sender := multiplayer.get_remote_sender_id() + + for full_name in ids: + var id := ids[full_name] as int + var identifier := _get_identifier_by_name(full_name) + if not identifier: + # Probably deleted since then + # TODO: Queue in case node was not registered *yet* + _logger.debug("Received identifier for unknown object with full name %s, id #%d", [full_name, id]) + continue + identifier.set_id_for(sender, id) + +# TODO: Consider private +class NetworkIdentifier: + var _subject: Object + var _full_name: String + var _ids: Dictionary = {} # peer to id + var _local_id: int + + func _init(subject: Object, full_name: String, local_id: int): + _subject = subject + _full_name = full_name + _local_id = local_id + + func has_id_for(peer: int) -> bool: + # TODO: Also return true for local peer, just to be correct + return _ids.has(peer) + + func get_id_for(peer: int) -> int: + return _ids.get(peer, -1) + + func set_id_for(peer: int, id: int) -> void: + _ids[peer] = id + + func get_local_id() -> int: + return _local_id + + func get_full_name() -> String: + return _full_name + +# TODO: Private +class IdentityNotification: + var peer: int + var identifier: NetworkIdentifier + + static func of(p_peer: int, p_identifier: NetworkIdentifier) -> IdentityNotification: + var request := IdentityNotification.new() + request.peer = p_peer + request.identifier = p_identifier + return request diff --git a/test/netfox/servers/network-identity-server.test.gd b/test/netfox/servers/network-identity-server.test.gd new file mode 100644 index 00000000..9d01aa98 --- /dev/null +++ b/test/netfox/servers/network-identity-server.test.gd @@ -0,0 +1,29 @@ +extends VestTest + +func get_suite_name() -> String: + return "NetworkIdentityServer" + +func suite() -> void: + define("register_node()", func(): + test("should register", func(): + # Register node + # Assert for identifier + todo() + ) + + test("should fail on node node in tree", func(): todo()) + ) + + define("deregister_node()", func(): + test("should remove known", func(): todo()) + test("should do nothing on unknown", func(): todo()) + ) + + define("queue_id_for()", func(): + test("should return ok", func(): todo()) + test("should fail on unknown", func(): todo()) + ) + + define("flush_queue()", func(): + test("should send ids", func(): todo()) + ) From 0a5b57ea040e95e649016ea31a516c57acd56873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 8 Jan 2026 16:18:32 +0100 Subject: [PATCH 22/95] extra schemas and vague ideas about binary serialization --- addons/netfox/schemas/network-schemas.gd | 57 +++++++++++++++++++ addons/netfox/servers/data/snapshot.gd | 20 +++++++ .../netfox/servers/network-identity-server.gd | 40 +++++++++++++ .../rollback-synchronization-server.gd | 10 ++++ test/netfox/schemas/network-schemas.test.gd | 20 ++++++- 5 files changed, 144 insertions(+), 3 deletions(-) diff --git a/addons/netfox/schemas/network-schemas.gd b/addons/netfox/schemas/network-schemas.gd index a3857cad..b1175b74 100644 --- a/addons/netfox/schemas/network-schemas.gd +++ b/addons/netfox/schemas/network-schemas.gd @@ -55,6 +55,10 @@ static func uint32() -> NetworkSchemaSerializer: static func uint64() -> NetworkSchemaSerializer: return _Uint64Serializer.new() +# TODO: Docs +static func varuint() -> NetworkSchemaSerializer: + return _VaruintSerializer.new() + ## Serialize signed integers as 8 bits. ## [br][br] ## Final size is 1 byte. @@ -456,6 +460,11 @@ static func dictionary(key_serializer: NetworkSchemaSerializer = variant(), size_serializer: NetworkSchemaSerializer = uint16()) -> NetworkSchemaSerializer: return _DictionarySerializer.new(key_serializer, value_serializer, size_serializer) +# TODO: Docs +# TODO: Consider parameterized ID serializer +static func netref() -> NetworkSchemaSerializer: + return _NetworkIdentityReferenceSerializer.new() + # Serializer classes class _VariantSerializer extends NetworkSchemaSerializer: @@ -511,6 +520,33 @@ class _Int64Serializer extends NetworkSchemaSerializer: func encode(v: Variant, b: StreamPeerBuffer) -> void: b.put_64(v) func decode(b: StreamPeerBuffer) -> Variant: return b.get_64() +class _VaruintSerializer extends NetworkSchemaSerializer: + func encode(v: Variant, b: StreamPeerBuffer) -> void: + var value := v as int + for __ in 8: # Bounded while loop + var nominator := value & 0b0111_1111 # Grab the lowest 7 bits + var continuator := value > 0b0111_1111 # Continue if more bits + var cont_bit := 0b1000_0000 if continuator else 0 + var byte := nominator | cont_bit # Combine into 1 byte + value = value >> 7 # Discard lower 7 bits + b.put_u8(byte) # Save byte + + # Stop if no more bytes to write + if not continuator: + break + + func decode(b: StreamPeerBuffer) -> Variant: + var value := 0 + for i in 8: + var byte := b.get_u8() + var nominator := byte & 0b0111_1111 + var continuator := (byte & 0b1000_0000) != 0 + value += nominator << (i * 7) + + if not continuator: + break + return value + class _Float16Serializer extends NetworkSchemaSerializer: func encode(v: Variant, b: StreamPeerBuffer) -> void: if Engine.get_version_info().hex >= 0x040400: @@ -767,3 +803,24 @@ class _DictionarySerializer extends NetworkSchemaSerializer: dictionary[key] = value return dictionary + +class _NetworkIdentityReferenceSerializer extends NetworkSchemaSerializer: + static var varuint := _VaruintSerializer.new() + + func encode(v: Variant, b: StreamPeerBuffer) -> void: + var ref := v as NetworkIdentityServer.NetworkIdentityReference + if ref.has_id(): + varuint.encode(ref.get_id(), b) + else: + b.put_u8(0) + # TODO: Get rid of Godot's prepended 32 bits of string length + # TODO: Write is easy, prefer not manually iterating till \0 on read + b.put_utf8_string(ref.get_full_name()) + + func decode(b: StreamPeerBuffer) -> Variant: + var id := varuint.decode(b) as int + if id == 0: + var full_name := b.get_utf8_string() + return NetworkIdentityServer.NetworkIdentityReference.of_full_name(full_name) + else: + return NetworkIdentityServer.NetworkIdentityReference.of_id(id) diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index de895345..0fc9b06c 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -30,6 +30,9 @@ func set_property(node: Node, property: NodePath, value: Variant, is_authoritati data[RecordedProperty.key_of(node, property)] = value _is_authoritative[RecordedProperty.key_of(node, property)] = is_authoritative +func get_property(node: Node, property: NodePath) -> Variant: + return data[RecordedProperty.key_of(node, property)] + func merge_property(node: Node, property: NodePath, value: Variant, is_authoritative: bool = false) -> bool: var prop_key := RecordedProperty.key_of(node, property) if is_authoritative or not _is_authoritative.get(prop_key, false): @@ -86,6 +89,23 @@ func has_node(node: Node, require_auth: bool = false) -> bool: return true return false +func get_properties_of_node(node: Node) -> Array[NodePath]: + var properties := [] as Array[NodePath] + for entry in data.keys(): + var entry_node := entry[0] as Node + var entry_path := entry[1] as NodePath + if entry_node == node: + properties.append(entry_path) + return properties + +func nodes() -> Array[Node]: + var nodes := [] as Array[Node] + for entry in data.keys(): + var entry_node := entry[0] as Node + if not nodes.has(entry_node): + nodes.append(entry_node) + return nodes + func _to_string() -> String: var result := "Snapshot(#%d" % [tick] for entry in data: diff --git a/addons/netfox/servers/network-identity-server.gd b/addons/netfox/servers/network-identity-server.gd index 2d8fba63..147ce41b 100644 --- a/addons/netfox/servers/network-identity-server.gd +++ b/addons/netfox/servers/network-identity-server.gd @@ -105,6 +105,46 @@ class NetworkIdentifier: func get_full_name() -> String: return _full_name + func reference_for(peer: int) -> NetworkIdentityReference: + if has_id_for(peer): + return NetworkIdentityReference.of_id(get_id_for(peer)) + else: + return NetworkIdentityReference.of_full_name(get_full_name()) + +class NetworkIdentityReference: + var _full_name: String = "" + var _id: int = -1 + + static func of_full_name(full_name: String) -> NetworkIdentityReference: + var reference := NetworkIdentityReference.new() + reference._full_name = full_name + return reference + + static func of_id(id: int) -> NetworkIdentityReference: + var reference := NetworkIdentityReference.new() + reference._id = id + return reference + + func has_id() -> bool: + return _id > 0 + + func get_id() -> int: + return _id + + func get_full_name() -> String: + return _full_name + + func equals(other: Variant) -> bool: + if other is NetworkIdentityReference: + return _full_name == other._full_name and _id == other._id + return false + + func _to_string() -> String: + if has_id(): + return "NetworkIdentityReference#%d" % [_id, _full_name] + else: + return "NetworkIdentityReference(%s)" % [_full_name] + # TODO: Private class IdentityNotification: var peer: int diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 380d1121..261209c9 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -110,6 +110,16 @@ func synchronize_state(tick: int) -> void: _submit_state.rpc(_serialize_snapshot(state_snapshot)) _logger.info("Broadcast full state for @%d", [tick]) +func _serialize_full_state(snapshot: Snapshot, buffer: StreamPeerBuffer) -> void: + # Write tick + buffer.put_u32(snapshot.tick) # TODO: Schemas.varint() + + # For each node + for node in snapshot.nodes(): + # Write identifier + pass + # Write properties as-is + func _serialize_snapshot(snapshot: Snapshot) -> Variant: var serialized_properties := [] diff --git a/test/netfox/schemas/network-schemas.test.gd b/test/netfox/schemas/network-schemas.test.gd index bf0fa972..38f2d2e5 100644 --- a/test/netfox/schemas/network-schemas.test.gd +++ b/test/netfox/schemas/network-schemas.test.gd @@ -22,6 +22,16 @@ func suite() -> void: ["uint32", NetworkSchemas.uint32(), 107, 4], ["uint64", NetworkSchemas.uint64(), 107, 8], + # Test values are max representable values in `n` bytes + ["varuint 8", NetworkSchemas.varuint(), 127, 1], + ["varuint 16", NetworkSchemas.varuint(), 16383, 2], + ["varuint 24", NetworkSchemas.varuint(), 2097151, 3], + ["varuint 32", NetworkSchemas.varuint(), 268435455, 4], + ["varuint 40", NetworkSchemas.varuint(), 34359738367, 5], + ["varuint 48", NetworkSchemas.varuint(), 4398046511103, 6], + ["varuint 56", NetworkSchemas.varuint(), 562949953421311, 7], + ["varuint 64", NetworkSchemas.varuint(), 72057594037927935, 8], + ["sfrac8", NetworkSchemas.sfrac8(), -63. / 255., 1], ["sfrac16", NetworkSchemas.sfrac16(), -63. / 255., 2], ["sfrac32", NetworkSchemas.sfrac32(), -63. / 255., 4], @@ -69,7 +79,11 @@ func suite() -> void: ["transform3f64", NetworkSchemas.transform3f64(), Transform3D.IDENTITY.rotated(Vector3.ONE, 37.), 96], ["array", NetworkSchemas.array_of(NetworkSchemas.uint16()), [1, 2, 3], 8], - ["dictionary", NetworkSchemas.dictionary(NetworkSchemas.uint16(), NetworkSchemas.uint16()), { 1: 32, 2: 48 }, 10] + ["dictionary", NetworkSchemas.dictionary(NetworkSchemas.uint16(), NetworkSchemas.uint16()), { 1: 32, 2: 48 }, 10], + + ["netref id8", NetworkSchemas.netref(), NetworkIdentityServer.NetworkIdentityReference.of_id(12), 1], + ["netref id16", NetworkSchemas.netref(), NetworkIdentityServer.NetworkIdentityReference.of_id(138), 2], + ["netref name", NetworkSchemas.netref(), NetworkIdentityServer.NetworkIdentityReference.of_full_name("path"), 9] ] for case in cases: @@ -84,8 +98,8 @@ func suite() -> void: buffer.seek(0) var decoded = serializer.decode(buffer) - expect_equal(decoded, value) - expect_equal(buffer.data_array.size(), expected_size) + expect_equal(decoded, value, "Value mismatch! Expected %s, got %s" % [value, decoded]) + expect_equal(buffer.data_array.size(), expected_size, "Size mismatch! Expected %d, got %d" % [expected_size, buffer.data_array.size(), expected_size]) ) test("should handle negative degrees", func(): From 60f6594dc260ebac425953f94930f33045cfa3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 8 Jan 2026 22:18:14 +0100 Subject: [PATCH 23/95] binary state + busted input prediction? --- addons/netfox/netfox.gd | 4 + .../netfox/rollback/rollback-synchronizer.gd | 13 ++- .../netfox/servers/data/recorded-property.gd | 6 ++ addons/netfox/servers/data/snapshot.gd | 12 +++ .../netfox/servers/network-identity-server.gd | 31 +++++-- .../rollback-synchronization-server.gd | 85 ++++++++++++++++--- project.godot | 2 +- .../servers/network-identity-server.test.gd | 12 +-- 8 files changed, 136 insertions(+), 29 deletions(-) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 8428c6b2..e8b5b8d7 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -157,6 +157,10 @@ const AUTOLOADS: Array[Dictionary] = [ { "name": "RollbackSynchronizationServer", "path": ROOT + "/servers/rollback-synchronization-server.gd" + }, + { + "name": "NetworkIdentityServer", + "path": ROOT + "/servers/network-identity-server.gd" } ] diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index d78f2f0d..51a7c756 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -121,6 +121,11 @@ func process_settings() -> void: RollbackSimulationServer.register_input_for(node, input_node) _registered_nodes.append(node) + # Register identifiers + # TODO: Somehow deregister on destroy + for node in _state_nodes + _input_nodes: + NetworkIdentityServer.register_node(node) + ## Process settings based on authority. ## [br][br] ## Call this whenever the authority of any of the nodes managed by @@ -134,10 +139,10 @@ func process_authority(): for prop in _get_recorded_input_props(): RollbackHistoryServer.deregister_input(prop.node, prop.property) - for prop in _get_owned_input_props(): + for prop in _input_property_config.get_properties(): RollbackSynchronizationServer.deregister_input(prop.node, prop.property) - for prop in _get_owned_state_props(): + for prop in _state_property_config.get_properties(): RollbackSynchronizationServer.deregister_state(prop.node, prop.property) # Process authority @@ -154,10 +159,10 @@ func process_authority(): for prop in _get_recorded_input_props(): RollbackHistoryServer.register_input(prop.node, prop.property) - for prop in _get_owned_input_props(): + for prop in _input_property_config.get_properties(): RollbackSynchronizationServer.register_input(prop.node, prop.property) - for prop in _get_owned_state_props(): + for prop in _state_property_config.get_properties(): RollbackSynchronizationServer.register_state(prop.node, prop.property) ## Add a state property. diff --git a/addons/netfox/servers/data/recorded-property.gd b/addons/netfox/servers/data/recorded-property.gd index 02fb2380..a9f3d11b 100644 --- a/addons/netfox/servers/data/recorded-property.gd +++ b/addons/netfox/servers/data/recorded-property.gd @@ -4,6 +4,12 @@ class_name RecordedProperty static func key_of(p_node: Node, p_property: NodePath) -> Array: return [p_node, p_property] +static func get_node(key: Array) -> Node: + return key[0] + +static func get_property(key: Array) -> NodePath: + return key[1] + static func extract(key: Array) -> Variant: var node := key[0] as Node var property := key[1] as NodePath diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index 0fc9b06c..f912032a 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -76,6 +76,18 @@ func filtered_to_auth() -> Snapshot: return snapshot +func filtered_to_owned() -> Snapshot: + var snapshot := Snapshot.new(tick) + + for property in data: + if not RecordedProperty.get_node(property).is_multiplayer_authority(): + continue + + snapshot.data[property] = data[property] + snapshot._is_authoritative[property] = _is_authoritative[property] + + return snapshot + func has_node(node: Node, require_auth: bool = false) -> bool: for entry in data.keys(): var entry_node := entry[0] as Node diff --git a/addons/netfox/servers/network-identity-server.gd b/addons/netfox/servers/network-identity-server.gd index 147ce41b..5dc612ae 100644 --- a/addons/netfox/servers/network-identity-server.gd +++ b/addons/netfox/servers/network-identity-server.gd @@ -1,8 +1,7 @@ extends Node -class_name NetworkIdentityServer var _next_id := 0 -var _identifiers := {} # peer to NetworkIdentifier +var _identifiers := {} # object to NetworkIdentifier var _push_queue := [] as Array[IdentityNotification] static var _logger := NetfoxLogger._for_netfox("NetworkIdentityServer") @@ -34,12 +33,17 @@ func deregister_node(node: Node) -> void: func get_identifier_of(what: Object) -> NetworkIdentifier: return _identifiers[what] -func queue_id_for(what: Object, peer: int) -> Error: - var identifier := _identifiers.get(what) as NetworkIdentifier - if not identifier: return ERR_DOES_NOT_EXIST - +func resolve_reference(peer: int, identity_reference: NetworkIdentityReference, allow_queue: bool = true) -> NetworkIdentifier: + if identity_reference.has_id(): + return _get_identifier_by_id(peer, identity_reference.get_id()) + else: + var identifier := _get_identifier_by_name(identity_reference.get_full_name()) + if allow_queue and identifier: + queue_for(identifier, peer) + return identifier + +func queue_for(identifier: NetworkIdentifier, peer: int) -> void: _push_queue.append(IdentityNotification.of(peer, identifier)) - return OK func flush_queue() -> void: var ids := {} @@ -59,6 +63,14 @@ func _get_identifier_by_name(full_name: String) -> NetworkIdentifier: return identifier return null +func _get_identifier_by_id(peer: int, id: int) -> NetworkIdentifier: + # TODO: Optimize, probably by caching + for value in _identifiers.values() as Array: + var identifier := value as NetworkIdentifier + if identifier.get_id_for(peer) == id: + return identifier + return null + func _make_id() -> int: _next_id += 1 return _next_id @@ -104,6 +116,9 @@ class NetworkIdentifier: func get_full_name() -> String: return _full_name + + func get_subject() -> Object: + return _subject func reference_for(peer: int) -> NetworkIdentityReference: if has_id_for(peer): @@ -141,7 +156,7 @@ class NetworkIdentityReference: func _to_string() -> String: if has_id(): - return "NetworkIdentityReference#%d" % [_id, _full_name] + return "NetworkIdentityReference#%d" % [_id] else: return "NetworkIdentityReference(%s)" % [_full_name] diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 261209c9..8b06cd8e 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -40,6 +40,19 @@ func register_input(node: Node, property: NodePath) -> void: func deregister_input(node: Node, property: NodePath) -> void: deregister_property(node, property, _input_properties) +# TODO: Optimize +func get_properties_of(node: Node) -> Array[NodePath]: + var result := [] as Array[NodePath] + + for property in _state_properties + _input_properties: + var prop_node := RecordedProperty.get_node(property) + var prop_path := RecordedProperty.get_property(property) + + if node == prop_node: + result.append(prop_path) + + return result + # TODO: Make this testable somehow, I beg of you func synchronize_input(tick: int) -> void: var encoded_snapshots := [] @@ -51,8 +64,8 @@ func synchronize_input(tick: int) -> void: break # Filter to input properties - # TODO: Send only owned props - var input_snapshot := snapshot.filtered_to_properties(_input_properties) + # TODO: Optimize, avoid making two copies + var input_snapshot := snapshot.filtered_to_properties(_input_properties).filtered_to_owned() # Transmit # _logger.debug("Submitting input: %s", [input_snapshot]) @@ -68,7 +81,6 @@ func synchronize_state(tick: int) -> void: return # Filter to state properties - # TODO: Send only owned props var state_snapshot := snapshot.filtered_to_properties(_state_properties) # Figure out whether to send full- or diff state @@ -78,6 +90,7 @@ func synchronize_state(tick: int) -> void: elif _full_state_interval >= 1 and (tick % _full_state_interval) != 0: # TODO: Something better than modulo logic? --^ is_diff = true + is_diff = false # TODO: Remove once diff states are supported # Transmit if is_diff: @@ -106,19 +119,65 @@ func synchronize_state(tick: int) -> void: # _submit_state.rpc_id(peer, _serialize_snapshot(state_snapshot)) # _logger.info("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) else: - # _logger.debug("Submitting state: %s", [state_snapshot]) - _submit_state.rpc(_serialize_snapshot(state_snapshot)) - _logger.info("Broadcast full state for @%d", [tick]) + for peer in multiplayer.get_peers(): + _submit_state.rpc_id(peer, _serialize_full_state_for(peer, state_snapshot)) + +func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeerBuffer = null) -> PackedByteArray: + if buffer == null: + buffer = StreamPeerBuffer.new() -func _serialize_full_state(snapshot: Snapshot, buffer: StreamPeerBuffer) -> void: + var netref := NetworkSchemas.netref() + # Write tick - buffer.put_u32(snapshot.tick) # TODO: Schemas.varint() + buffer.put_u32(snapshot.tick) # For each node for node in snapshot.nodes(): + if not node.is_multiplayer_authority(): + continue + # Write identifier - pass + var identifier := NetworkIdentityServer.get_identifier_of(node) + if not identifier: + _logger.error("Can't synchronize node %s, identifier missing!", [node]) + continue + var idref := identifier.reference_for(peer) + netref.encode(idref, buffer) + + # TODO: Store some kind of size header, in case `peer` doesn't have the + # node yet + # Write properties as-is + for property in get_properties_of(node): + buffer.put_var(snapshot.get_property(node, property)) # TODO: Schema + + return buffer.data_array + +func _deserialize_full_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: + var netref := NetworkSchemas.netref() + + # Read tick + var tick := buffer.get_u32() + var snapshot := Snapshot.new(tick) + + while buffer.get_available_bytes() > 0: + # Read identity reference + var idref := netref.decode(buffer) as NetworkIdentityServer.NetworkIdentityReference + + # Resolve to identifier + var identifier := NetworkIdentityServer.resolve_reference(peer, idref) + if not identifier: + # TODO: Handle unknown IDs gracefully + _logger.error("Received unknown identity reference %s, discarding rest of the snapshot", [idref]) + break + var node := identifier.get_subject() as Node + + # Read properties + for property in get_properties_of(node): + var value := buffer.get_var() # TODO: Schemas + snapshot.set_property(node, property, value, is_auth) + + return snapshot func _serialize_snapshot(snapshot: Snapshot) -> Variant: var serialized_properties := [] @@ -174,9 +233,13 @@ func _submit_input(encoded_snapshots: Array): on_input.emit(snapshot) @rpc("any_peer", "call_remote", "unreliable") -func _submit_state(snapshot_data: Variant): +func _submit_state(data: PackedByteArray): + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + var sender := multiplayer.get_remote_sender_id() - var snapshot := _deserialize_snapshot(snapshot_data) + var snapshot := _deserialize_full_state_of(sender, buffer) + # TODO: Sanitize # _logger.debug("Received state snapshot: %s", [snapshot]) diff --git a/project.godot b/project.godot index bfa7f9e2..e61fe79f 100644 --- a/project.godot +++ b/project.godot @@ -33,6 +33,7 @@ NetworkSimulator="*res://addons/netfox.extras/network-simulator.gd" RollbackSimulationServer="*res://addons/netfox/servers/rollback-simulation-server.gd" RollbackHistoryServer="*res://addons/netfox/servers/rollback-history-server.gd" RollbackSynchronizationServer="*res://addons/netfox/servers/rollback-synchronization-server.gd" +NetworkIdentityServer="*res://addons/netfox/servers/network-identity-server.gd" [display] @@ -132,7 +133,6 @@ general/clear_settings=false time/tickrate=24 extras/auto_tile_windows=true autoconnect/enabled=true -logging/log_level=3 autoconnect/simulated_latency_ms=50 [rendering] diff --git a/test/netfox/servers/network-identity-server.test.gd b/test/netfox/servers/network-identity-server.test.gd index 9d01aa98..b6135588 100644 --- a/test/netfox/servers/network-identity-server.test.gd +++ b/test/netfox/servers/network-identity-server.test.gd @@ -19,11 +19,13 @@ func suite() -> void: test("should do nothing on unknown", func(): todo()) ) - define("queue_id_for()", func(): - test("should return ok", func(): todo()) - test("should fail on unknown", func(): todo()) - ) - define("flush_queue()", func(): test("should send ids", func(): todo()) ) + + define("resolve_reference()", func(): + test("should return by id", func(): todo()) + test("should return by name", func(): todo()) + test("should return null on unknown", func(): todo()) + test("should queue on name", func(): todo()) + ) From 57724997b30497429faf42b0868107acf5aaf8a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 8 Jan 2026 23:03:56 +0100 Subject: [PATCH 24/95] schemas for state --- .../netfox/rollback/rollback-synchronizer.gd | 19 +++++++++++---- .../rollback-synchronization-server.gd | 24 +++++++++++++++++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 51a7c756..05b4223a 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -73,6 +73,7 @@ var _input_property_config: _PropertyConfig = _PropertyConfig.new() var _input_nodes := [] as Array[Node] var _state_nodes := [] as Array[Node] +var _schema_props := [] as Array[Array] # [node, property path] tuples var _properties_dirty: bool = false var _property_cache := PropertyCache.new(root) @@ -215,11 +216,19 @@ func add_input(node: Variant, property: String) -> void: ## }) ## [/codeblock] func set_schema(schema: Dictionary) -> void: - # TODO: Rewrite - # _schema = _NetworkSchema.new(schema) - # _properties_dirty = true - # _reprocess_settings.call_deferred() - pass + # Remove previous schema + for entry in _schema_props: + var node = entry[0] + var property_path = entry[1] + RollbackSynchronizationServer.deregister_schema(node, property_path) + _schema_props.clear() + + # Register new schema + for prop in schema: + var prop_entry := PropertyEntry.parse(root, prop) + var serializer := schema[prop] as NetworkSchemaSerializer + RollbackSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer) + _schema_props.append([prop_entry.node, prop_entry.property]) ## Check if input is available for the current tick. ## [br][br] diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 8b06cd8e..26b08afb 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -11,6 +11,9 @@ var _full_state_interval := 24 # TODO: Config var _state_ack_interval := 4 # TODO: Config var _ackd_tick := {} # peer id to ack'd tick +var _schemas := {} # RecordedProperty key to NetworkSchemaSerializer +var _fallback_schema := NetworkSchemas.variant() + var _input_redundancy := 3 # TODO: Config static var _logger := NetfoxLogger._for_netfox("RollbackSynchronizationServer") @@ -40,6 +43,14 @@ func register_input(node: Node, property: NodePath) -> void: func deregister_input(node: Node, property: NodePath) -> void: deregister_property(node, property, _input_properties) +func register_schema(node: Node, property: NodePath, serializer: NetworkSchemaSerializer) -> void: + var key := RecordedProperty.key_of(node, property) + _schemas[key] = serializer + +func deregister_schema(node: Node, property: NodePath) -> void: + var key := RecordedProperty.key_of(node, property) + _schemas.erase(key) + # TODO: Optimize func get_properties_of(node: Node) -> Array[NodePath]: var result := [] as Array[NodePath] @@ -149,7 +160,8 @@ func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeer # Write properties as-is for property in get_properties_of(node): - buffer.put_var(snapshot.get_property(node, property)) # TODO: Schema + var value := snapshot.get_property(node, property) + _serialize_property(node, property, value, buffer) return buffer.data_array @@ -174,11 +186,19 @@ func _deserialize_full_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bo # Read properties for property in get_properties_of(node): - var value := buffer.get_var() # TODO: Schemas + var value := _deserialize_property(node, property, buffer) snapshot.set_property(node, property, value, is_auth) return snapshot +func _serialize_property(node: Node, property: NodePath, value: Variant, buffer: StreamPeerBuffer) -> void: + var serializer := _schemas.get(RecordedProperty.key_of(node, property), _fallback_schema) as NetworkSchemaSerializer + serializer.encode(value, buffer) + +func _deserialize_property(node: Node, property: NodePath, buffer: StreamPeerBuffer) -> Variant: + var serializer := _schemas.get(RecordedProperty.key_of(node, property), _fallback_schema) as NetworkSchemaSerializer + return serializer.decode(buffer) + func _serialize_snapshot(snapshot: Snapshot) -> Variant: var serialized_properties := [] From 1b6846d95bccadfff2b15448b669d18a907f8823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 10 Jan 2026 19:43:17 +0100 Subject: [PATCH 25/95] include size in state snapshots --- .../rollback-synchronization-server.gd | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 26b08afb..5b3e0512 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -104,6 +104,7 @@ func synchronize_state(tick: int) -> void: is_diff = false # TODO: Remove once diff states are supported # Transmit + # TODO: Support diff states if is_diff: for peer in multiplayer.get_peers(): if not _ackd_tick.has(peer): @@ -138,6 +139,9 @@ func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeer buffer = StreamPeerBuffer.new() var netref := NetworkSchemas.netref() + var varuint := NetworkSchemas.varuint() + + var node_buffer := StreamPeerBuffer.new() # Write tick buffer.put_u32(snapshot.tick) @@ -159,38 +163,69 @@ func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeer # node yet # Write properties as-is + # First into a buffer, so we can start with the state size + node_buffer.clear() for property in get_properties_of(node): var value := snapshot.get_property(node, property) - _serialize_property(node, property, value, buffer) + _serialize_property(node, property, value, node_buffer) + + # Indicate state size for the node + varuint.encode(node_buffer.data_array.size(), buffer) + + # Write node state + buffer.put_data(node_buffer.data_array) return buffer.data_array func _deserialize_full_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: var netref := NetworkSchemas.netref() + var varuint := NetworkSchemas.varuint() + var node_buffer := StreamPeerBuffer.new() # Read tick var tick := buffer.get_u32() var snapshot := Snapshot.new(tick) while buffer.get_available_bytes() > 0: - # Read identity reference + # Read identity reference, data size, and data + # TODO: Configurable upper limit on how much netfox is allowed to read here? var idref := netref.decode(buffer) as NetworkIdentityServer.NetworkIdentityReference + var node_data_size := varuint.decode(buffer) as int + node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] # Resolve to identifier var identifier := NetworkIdentityServer.resolve_reference(peer, idref) if not identifier: # TODO: Handle unknown IDs gracefully - _logger.error("Received unknown identity reference %s, discarding rest of the snapshot", [idref]) + # TODO: Test that unknown nodes are INDEED SKIPPED + _logger.error("Received unknown identity reference %s, skipping data", [idref]) break var node := identifier.get_subject() as Node # Read properties for property in get_properties_of(node): - var value := _deserialize_property(node, property, buffer) + # TODO: Test if less bytes remain than an entire property ( e.g. 2 bytes ) + if node_buffer.get_available_bytes() == 0: break + + var value := _deserialize_property(node, property, node_buffer) snapshot.set_property(node, property, value, is_auth) return snapshot +func _serialize_input_of(peer: int, snapshots: Array[Snapshot], buffer: StreamPeerBuffer = null) -> PackedByteArray: + # TODO + assert(snapshots.size() < 255, "Can't serialize more than 255 input ticks in a single packet!") + assert(snapshots.size() <= 1 or snapshots.front().tick <= snapshots.back().tick, "Snapshots are not continuous!") + + if buffer == null: + buffer = StreamPeerBuffer.new() + + return buffer.data_array + +func _deserialize_input_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> Array[Snapshot]: + # TODO + return [] + func _serialize_property(node: Node, property: NodePath, value: Variant, buffer: StreamPeerBuffer) -> void: var serializer := _schemas.get(RecordedProperty.key_of(node, property), _fallback_schema) as NetworkSchemaSerializer serializer.encode(value, buffer) From cc1766a59166b696ca6e41cf7e7f4b86da8a3581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 10 Jan 2026 19:56:47 +0100 Subject: [PATCH 26/95] binary input serialization --- .../rollback-synchronization-server.gd | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 5b3e0512..9662c984 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -66,7 +66,7 @@ func get_properties_of(node: Node) -> Array[NodePath]: # TODO: Make this testable somehow, I beg of you func synchronize_input(tick: int) -> void: - var encoded_snapshots := [] + var snapshots := [] as Array[Snapshot] for offset in _input_redundancy: # Grab snapshot from RollbackHistoryServer @@ -80,9 +80,11 @@ func synchronize_input(tick: int) -> void: # Transmit # _logger.debug("Submitting input: %s", [input_snapshot]) - encoded_snapshots.append(_serialize_snapshot(input_snapshot)) + snapshots.append(input_snapshot) - _submit_input.rpc(encoded_snapshots) + # TODO: Option to not broadcast input + for peer in multiplayer.get_peers(): + _submit_input.rpc_id(peer, _serialize_input_for(peer, snapshots)) # TODO: Make this testable somehow, I beg of you func synchronize_state(tick: int) -> void: @@ -212,19 +214,33 @@ func _deserialize_full_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bo return snapshot -func _serialize_input_of(peer: int, snapshots: Array[Snapshot], buffer: StreamPeerBuffer = null) -> PackedByteArray: - # TODO - assert(snapshots.size() < 255, "Can't serialize more than 255 input ticks in a single packet!") - assert(snapshots.size() <= 1 or snapshots.front().tick <= snapshots.back().tick, "Snapshots are not continuous!") +func _serialize_input_for(peer: int, snapshots: Array[Snapshot], buffer: StreamPeerBuffer = null) -> PackedByteArray: + var varuint := NetworkSchemas.varuint() if buffer == null: buffer = StreamPeerBuffer.new() + for snapshot in snapshots: + # TODO: Rename method + var serialized := _serialize_full_state_for(peer, snapshot) + + # Write size and snapshot + varuint.encode(serialized.size(), buffer) + buffer.put_data(serialized) + return buffer.data_array func _deserialize_input_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> Array[Snapshot]: - # TODO - return [] + var varuint := NetworkSchemas.varuint() + + var snapshots := [] as Array[Snapshot] + while buffer.get_available_bytes() > 0: + var snapshot_size := varuint.decode(buffer) + var snapshot_buffer := StreamPeerBuffer.new() + snapshot_buffer.data_array = buffer.get_partial_data(snapshot_size)[1] + + snapshots.append(_deserialize_full_state_of(peer, snapshot_buffer, is_auth)) + return snapshots func _serialize_property(node: Node, property: NodePath, value: Variant, buffer: StreamPeerBuffer) -> void: var serializer := _schemas.get(RecordedProperty.key_of(node, property), _fallback_schema) as NetworkSchemaSerializer @@ -275,9 +291,12 @@ func _deserialize_snapshot(data: Variant) -> Snapshot: return snapshot @rpc("any_peer", "call_remote", "reliable") -func _submit_input(encoded_snapshots: Array): - for snapshot_data in encoded_snapshots: - var snapshot := _deserialize_snapshot(snapshot_data) +func _submit_input(data: PackedByteArray): + var sender := multiplayer.get_remote_sender_id() + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + + for snapshot in _deserialize_input_of(sender, buffer): # _logger.debug("Received input snapshot: %s", [snapshot]) # TODO: Sanitize From 64b79c4be950bbae2a0c645c9ba83b70cc2331b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 10 Jan 2026 21:39:03 +0100 Subject: [PATCH 27/95] we got diff states, now all we need is to make it work --- addons/netfox.internals/bitset.gd | 98 +++++++++++++ addons/netfox/schemas/network-schemas.gd | 53 ++++++- addons/netfox/servers/data/snapshot.gd | 9 ++ .../rollback-synchronization-server.gd | 137 ++++++++++++++++-- test/netfox/schemas/network-schemas.test.gd | 5 +- 5 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 addons/netfox.internals/bitset.gd diff --git a/addons/netfox.internals/bitset.gd b/addons/netfox.internals/bitset.gd new file mode 100644 index 00000000..1165b73a --- /dev/null +++ b/addons/netfox.internals/bitset.gd @@ -0,0 +1,98 @@ +extends RefCounted +class_name _Bitset + +var _data: PackedByteArray +var _bit_count: int + +# TODO: Tests + + +static func of_bools(values: Array) -> _Bitset: + var result := _Bitset.new(values.size()) + for i in values.size(): + if values[i]: + result.set_bit(i) + return result + + +func _init(bit_count: int): + var bytes := bit_count / 8 + if bit_count % 8 > 0: + bytes += 1 + + _data = PackedByteArray() + _data.resize(bytes) + _data.fill(0) # TODO: Test if this is needed + + _bit_count = bit_count + +func bit_count() -> int: + return _bit_count + +func is_empty() -> bool: + return _bit_count == 0 + +func is_not_empty() -> bool: + return _bit_count != 0 + +func get_bit(idx: int) -> bool: + assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count]) + var byte_idx := idx / 8 + var bit_idx := idx % 8 + + return (_data[byte_idx] >> bit_idx) & 0x1 != 0 + +func set_bit(idx: int) -> void: + assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count]) + var byte_idx := idx / 8 + var bit_idx := idx % 8 + + _data[byte_idx] |= 0x1 << bit_idx + +func clear_bit(idx: int) -> void: + assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count]) + var byte_idx := idx / 8 + var bit_idx := idx % 8 + + _data[byte_idx] &= ~(0x1 << bit_idx) + +func toggle_bit(idx: int) -> void: + assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count]) + var byte_idx := idx / 8 + var bit_idx := idx % 8 + + _data[byte_idx] ^= 0x1 << bit_idx + +func get_set_indices() -> Array[int]: + var result := [] as Array[int] + for i in _data.size(): + var byte := _data[i] + + if byte & 0x01: result.append(i * 8 + 0) + if byte & 0x02: result.append(i * 8 + 1) + if byte & 0x04: result.append(i * 8 + 2) + if byte & 0x08: result.append(i * 8 + 3) + + if byte & 0x10: result.append(i * 8 + 4) + if byte & 0x20: result.append(i * 8 + 5) + if byte & 0x40: result.append(i * 8 + 6) + if byte & 0x80: result.append(i * 8 + 7) + return result + +func equals(other) -> bool: + if other is _Bitset: + return other._bit_count == _bit_count and other._data == _data + else: + return false + +func _to_string() -> String: + if is_empty(): + return "Bitset(n=0)" + else: + var body := "" + for i in _bit_count: + if i != 0 and i % 4 == 0: + body += " " + if get_bit(i): body += "1" + else: body += "0" + return "Bitset(n=%d, %s)" % [_bit_count, body] diff --git a/addons/netfox/schemas/network-schemas.gd b/addons/netfox/schemas/network-schemas.gd index b1175b74..277fde24 100644 --- a/addons/netfox/schemas/network-schemas.gd +++ b/addons/netfox/schemas/network-schemas.gd @@ -57,7 +57,10 @@ static func uint64() -> NetworkSchemaSerializer: # TODO: Docs static func varuint() -> NetworkSchemaSerializer: - return _VaruintSerializer.new() + return _VaruintSerializer.instance + +static func _varbits() -> NetworkSchemaSerializer: + return _VariableBitsetSerializer.instance ## Serialize signed integers as 8 bits. ## [br][br] @@ -521,6 +524,8 @@ class _Int64Serializer extends NetworkSchemaSerializer: func decode(b: StreamPeerBuffer) -> Variant: return b.get_64() class _VaruintSerializer extends NetworkSchemaSerializer: + static var instance := _VaruintSerializer.new() + func encode(v: Variant, b: StreamPeerBuffer) -> void: var value := v as int for __ in 8: # Bounded while loop @@ -547,6 +552,52 @@ class _VaruintSerializer extends NetworkSchemaSerializer: break return value +class _VariableBitsetSerializer extends NetworkSchemaSerializer: + static var instance := _VariableBitsetSerializer.new() + + func encode(v: Variant, b: StreamPeerBuffer) -> void: + var bitset := v as _Bitset + if bitset.is_empty(): + b.put_u8(0) + return + + for i in range(0, bitset.bit_count(), 7): + var byte := 0 + + # Set bits + if bitset.bit_count() > i + 0 and bitset.get_bit(i + 0): byte |= 0x01 + if bitset.bit_count() > i + 1 and bitset.get_bit(i + 1): byte |= 0x02 + if bitset.bit_count() > i + 2 and bitset.get_bit(i + 2): byte |= 0x04 + if bitset.bit_count() > i + 3 and bitset.get_bit(i + 3): byte |= 0x08 + if bitset.bit_count() > i + 4 and bitset.get_bit(i + 4): byte |= 0x10 + if bitset.bit_count() > i + 5 and bitset.get_bit(i + 5): byte |= 0x20 + if bitset.bit_count() > i + 6 and bitset.get_bit(i + 6): byte |= 0x40 + + # Set highest bit if there's more bytes to read + if bitset.bit_count() > i + 7: byte |= 80 + + b.put_u8(byte) + + func decode(b: StreamPeerBuffer) -> Variant: + var bools := [] + + while true: + var byte := b.get_u8() + + bools.append(byte & 0x01) + bools.append(byte & 0x02) + bools.append(byte & 0x04) + bools.append(byte & 0x08) + bools.append(byte & 0x10) + bools.append(byte & 0x20) + bools.append(byte & 0x40) + + # Stop if no more data to read + if byte & 0x80 == 0: + break + + return _Bitset.of_bools(bools) + class _Float16Serializer extends NetworkSchemaSerializer: func encode(v: Variant, b: StreamPeerBuffer) -> void: if Engine.get_version_info().hex >= 0x040400: diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index f912032a..c3490aa6 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -26,6 +26,12 @@ static func make_patch(from: Snapshot, to: Snapshot, tick: int = from.tick, incl func _init(p_tick: int): tick = p_tick +func duplicate() -> Snapshot: + var result := Snapshot.new(tick) + result.data = data.duplicate() + result._is_authoritative = _is_authoritative.duplicate() + return result + func set_property(node: Node, property: NodePath, value: Variant, is_authoritative: bool = false) -> void: data[RecordedProperty.key_of(node, property)] = value _is_authoritative[RecordedProperty.key_of(node, property)] = is_authoritative @@ -33,6 +39,9 @@ func set_property(node: Node, property: NodePath, value: Variant, is_authoritati func get_property(node: Node, property: NodePath) -> Variant: return data[RecordedProperty.key_of(node, property)] +func has_property(node: Node, property: NodePath) -> bool: + return data.has(RecordedProperty.key_of(node, property)) + func merge_property(node: Node, property: NodePath, value: Variant, is_authoritative: bool = false) -> bool: var prop_key := RecordedProperty.key_of(node, property) if is_authoritative or not _is_authoritative.get(prop_key, false): diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 9662c984..0c884419 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -8,7 +8,7 @@ var _input_properties: Array = [] var _state_properties: Array = [] var _full_state_interval := 24 # TODO: Config -var _state_ack_interval := 4 # TODO: Config +var _state_ack_interval := 1 # TODO: Config var _ackd_tick := {} # peer id to ack'd tick var _schemas := {} # RecordedProperty key to NetworkSchemaSerializer @@ -103,15 +103,13 @@ func synchronize_state(tick: int) -> void: elif _full_state_interval >= 1 and (tick % _full_state_interval) != 0: # TODO: Something better than modulo logic? --^ is_diff = true - is_diff = false # TODO: Remove once diff states are supported # Transmit - # TODO: Support diff states if is_diff: for peer in multiplayer.get_peers(): if not _ackd_tick.has(peer): # We don't know any state the peer knows, send full state - _submit_state.rpc_id(peer, _serialize_snapshot(state_snapshot)) + _submit_full_state.rpc_id(peer, _serialize_full_state_for(peer, state_snapshot)) _logger.info("Sent full state for @%d to #%d", [tick, peer]) continue @@ -121,7 +119,7 @@ func synchronize_state(tick: int) -> void: if not reference_snapshot: # Reference snapshot not in history, send full state _logger.warning("Tick @%d not present in history, can't use it as reference for peer #%d", [reference_tick, peer]) - _submit_state.rpc_id(peer, _serialize_snapshot(state_snapshot)) + _submit_full_state.rpc_id(peer, _serialize_full_state_for(peer, state_snapshot)) _logger.info("Sent full state for @%d to #%d", [tick, peer]) continue @@ -129,12 +127,12 @@ func synchronize_state(tick: int) -> void: reference_snapshot = reference_snapshot.filtered_to_auth().filtered_to_properties(_state_properties) var diff_snapshot := Snapshot.make_patch(state_snapshot, reference_snapshot) - _submit_state.rpc_id(peer, _serialize_snapshot(diff_snapshot)) + _submit_diff_state.rpc_id(peer, _serialize_diff_state_of(peer, diff_snapshot, reference_tick)) # _submit_state.rpc_id(peer, _serialize_snapshot(state_snapshot)) # _logger.info("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) else: for peer in multiplayer.get_peers(): - _submit_state.rpc_id(peer, _serialize_full_state_for(peer, state_snapshot)) + _submit_full_state.rpc_id(peer, _serialize_full_state_for(peer, state_snapshot)) func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeerBuffer = null) -> PackedByteArray: if buffer == null: @@ -147,6 +145,7 @@ func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeer # Write tick buffer.put_u32(snapshot.tick) + # TODO: Include property config hash to detect mismatches # For each node for node in snapshot.nodes(): @@ -161,9 +160,6 @@ func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeer var idref := identifier.reference_for(peer) netref.encode(idref, buffer) - # TODO: Store some kind of size header, in case `peer` doesn't have the - # node yet - # Write properties as-is # First into a buffer, so we can start with the state size node_buffer.clear() @@ -187,6 +183,7 @@ func _deserialize_full_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bo # Read tick var tick := buffer.get_u32() var snapshot := Snapshot.new(tick) + # TODO: Include property config hash to detect mismatches while buffer.get_available_bytes() > 0: # Read identity reference, data size, and data @@ -214,6 +211,94 @@ func _deserialize_full_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bo return snapshot +func _serialize_diff_state_of(peer: int, snapshot: Snapshot, reference_tick: int, buffer: StreamPeerBuffer = null) -> PackedByteArray: + if buffer == null: + buffer = StreamPeerBuffer.new() + + var netref := NetworkSchemas.netref() + var varuint := NetworkSchemas.varuint() + var varbits := NetworkSchemas._varbits() + + var node_buffer := StreamPeerBuffer.new() + + # Write ticks + buffer.put_u32(snapshot.tick) + buffer.put_u32(reference_tick) + # TODO: Include property config hash to detect mismatches + + # For each node + for node in snapshot.nodes(): + if not node.is_multiplayer_authority(): + continue + + # Write identifier + var identifier := NetworkIdentityServer.get_identifier_of(node) + if not identifier: + _logger.error("Can't synchronize node %s, identifier missing!", [node]) + continue + var idref := identifier.reference_for(peer) + netref.encode(idref, buffer) + + node_buffer.clear() + + # TODO: Create bitset of changed properties + # TODO: Write changed properties into `node_buffer` + var properties := get_properties_of(node) + var changed_bits := _Bitset.new(properties.size()) + + for i in properties.size(): + var property := properties[i] + if not snapshot.has_property(node, property): + continue + + changed_bits.set_bit(i) + var value := snapshot.get_property(node, property) + _serialize_property(node, property, value, node_buffer) + + varuint.encode(node_buffer.data_array.size(), buffer) # Node props len + varbits.encode(changed_bits, buffer) # Changed prop bits + buffer.put_data(node_buffer.data_array) # Changed props + + return buffer.data_array + +func _deserialize_diff_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> DiffSnapshot: + var netref := NetworkSchemas.netref() + var varuint := NetworkSchemas.varuint() + var varbits := NetworkSchemas._varbits() + var node_buffer := StreamPeerBuffer.new() + + # Grab ticks + var tick := buffer.get_u32() + var reference_tick := buffer.get_u32() + # TODO: Include property config hash to detect mismatches + + var snapshot := Snapshot.new(tick) + + while buffer.get_available_bytes() > 0: + # Read header, including identity reference + # TODO: Configurable upper limit on how much netfox is allowed to read here? + var idref := netref.decode(buffer) as NetworkIdentityServer.NetworkIdentityReference + var node_data_size := varuint.decode(buffer) as int + var changed_bits := varbits.decode(buffer) as _Bitset + node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] + + # Resolve to identifier + var identifier := NetworkIdentityServer.resolve_reference(peer, idref) + if not identifier: + # TODO: Handle unknown IDs gracefully + # TODO: Test that unknown nodes are INDEED SKIPPED + _logger.error("Received unknown identity reference %s, skipping data", [idref]) + break + var node := identifier.get_subject() as Node + + # Read changed properties + var properties := get_properties_of(node) + for idx in changed_bits.get_set_indices(): + var property := properties[idx] + var value := _deserialize_property(node, property, node_buffer) + snapshot.set_property(node, property, value, is_auth) + return DiffSnapshot.new(snapshot, reference_tick) + func _serialize_input_for(peer: int, snapshots: Array[Snapshot], buffer: StreamPeerBuffer = null) -> PackedByteArray: var varuint := NetworkSchemas.varuint() @@ -307,13 +392,35 @@ func _submit_input(data: PackedByteArray): on_input.emit(snapshot) @rpc("any_peer", "call_remote", "unreliable") -func _submit_state(data: PackedByteArray): +func _submit_full_state(data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data var sender := multiplayer.get_remote_sender_id() var snapshot := _deserialize_full_state_of(sender, buffer) + + _ingest_state(sender, snapshot) + +@rpc("any_peer", "call_remote", "unreliable") +func _submit_diff_state(data: PackedByteArray): + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + + var sender := multiplayer.get_remote_sender_id() + var diff := _deserialize_diff_state_of(sender, buffer) + var reference_tick := diff.reference_tick + var reference_snapshot := RollbackHistoryServer.get_snapshot(reference_tick) + if not reference_snapshot: + _logger.warning("Reference tick %d missing for #%d, ignoring", [reference_tick, sender]) + return + var snapshot := reference_snapshot.duplicate() + snapshot.merge(diff.snapshot) + snapshot.tick = diff.snapshot.tick + + _ingest_state(sender, snapshot) + +func _ingest_state(sender: int, snapshot: Snapshot) -> void: # TODO: Sanitize # _logger.debug("Received state snapshot: %s", [snapshot]) @@ -329,3 +436,11 @@ func _submit_state(data: PackedByteArray): func _ack_state(tick: int): var sender := multiplayer.get_remote_sender_id() _ackd_tick[sender] = maxi(tick, _ackd_tick.get(sender, tick)) + +class DiffSnapshot: + var snapshot: Snapshot + var reference_tick: int + + func _init(p_snapshot: Snapshot, p_reference_tick: int): + snapshot = p_snapshot + reference_tick = p_reference_tick diff --git a/test/netfox/schemas/network-schemas.test.gd b/test/netfox/schemas/network-schemas.test.gd index 38f2d2e5..439ec377 100644 --- a/test/netfox/schemas/network-schemas.test.gd +++ b/test/netfox/schemas/network-schemas.test.gd @@ -83,7 +83,10 @@ func suite() -> void: ["netref id8", NetworkSchemas.netref(), NetworkIdentityServer.NetworkIdentityReference.of_id(12), 1], ["netref id16", NetworkSchemas.netref(), NetworkIdentityServer.NetworkIdentityReference.of_id(138), 2], - ["netref name", NetworkSchemas.netref(), NetworkIdentityServer.NetworkIdentityReference.of_full_name("path"), 9] + ["netref name", NetworkSchemas.netref(), NetworkIdentityServer.NetworkIdentityReference.of_full_name("path"), 9], + + # Private + ["varbits", NetworkSchemas._varbits(), _Bitset.of_bools([0, 1, 0, 1, 1, 0, 1]), 1] ] for case in cases: From 87b1f74e9b371208b115bad5a51bc49b2579b30f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 10 Jan 2026 23:30:35 +0100 Subject: [PATCH 28/95] man i dont fucking know but here's some tests anyway --- addons/netfox.internals/bitset.gd | 1 - addons/netfox/rollback/network-rollback.gd | 5 +- addons/netfox/servers/data/snapshot.gd | 22 ++++++- .../netfox/servers/rollback-history-server.gd | 2 +- .../rollback-synchronization-server.gd | 59 ++++------------- test/netfox.internals/bitset.test.gd | 27 ++++++++ .../rollback-synchronization-server.test.gd | 32 ++++++++++ test/netfox/servers/snapshot.test.gd | 63 +++++++++++++++++++ 8 files changed, 157 insertions(+), 54 deletions(-) create mode 100644 test/netfox.internals/bitset.test.gd create mode 100644 test/netfox/servers/rollback-synchronization-server.test.gd create mode 100644 test/netfox/servers/snapshot.test.gd diff --git a/addons/netfox.internals/bitset.gd b/addons/netfox.internals/bitset.gd index 1165b73a..98689533 100644 --- a/addons/netfox.internals/bitset.gd +++ b/addons/netfox.internals/bitset.gd @@ -22,7 +22,6 @@ func _init(bit_count: int): _data = PackedByteArray() _data.resize(bytes) - _data.fill(0) # TODO: Test if this is needed _bit_count = bit_count diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 2b38b0f7..31151b52 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -341,8 +341,6 @@ func _rollback() -> void: _resim_from = mini(_resim_from, NetworkTime.tick - 1) _logger.debug("Simulating range @%d>@%d using %s", [_resim_from, NetworkTime.tick, range_source]) - _earliest_input = -1 - _latest_state = -1 # _resim_from = maxi(1, history_start + 1) # Only set _is_rollback *after* emitting before_loop @@ -363,6 +361,9 @@ func _rollback() -> void: ) from = NetworkTime.tick - history_limit + _earliest_input = -1 + _latest_state = -1 + # for tick in from .. to: _rollback_from = from _rollback_to = to diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index c3490aa6..c4e471a1 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -5,8 +5,8 @@ var tick: int var data: Dictionary = {} # RecordedProperty to Variant value var _is_authoritative: Dictionary = {} # Property key to bool, not present means false -static func make_patch(from: Snapshot, to: Snapshot, tick: int = from.tick, include_new: bool = true) -> Snapshot: - var patch := Snapshot.new(to.tick) +static func make_patch(from: Snapshot, to: Snapshot, tick: int = to.tick, include_new: bool = true) -> Snapshot: + var patch := Snapshot.new(tick) for prop_key in from.data: # TODO: This works if both props are auth - handle if that differs @@ -17,7 +17,7 @@ static func make_patch(from: Snapshot, to: Snapshot, tick: int = from.tick, incl if include_new: for prop_key in to.data: # TODO: This works if both props are auth - handle if that differs - if from.data.has(prop_key) and from.data[prop_key] != to.data[prop_key]: + if not from.data.has(prop_key): patch.data[prop_key] = to.data[prop_key] patch._is_authoritative[prop_key] = to._is_authoritative[prop_key] @@ -127,9 +127,25 @@ func nodes() -> Array[Node]: nodes.append(entry_node) return nodes +func is_empty() -> bool: + return data.is_empty() + +func equals(other) -> bool: + if other is Snapshot: + return tick == other.tick and data == other.data and _is_authoritative == other._is_authoritative + else: + return false + func _to_string() -> String: var result := "Snapshot(#%d" % [tick] for entry in data: result += ", %s(%s): %s" % [entry, _is_authoritative.get(entry, false), data[entry]] result += ")" return result + +func _to_vest(): + return { + "tick": tick, + "data": data, + "is_auth": _is_authoritative + } diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 00fc3f8f..7b70b97e 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -74,7 +74,7 @@ func trim_history(earliest_tick: int) -> void: # TODO: Keep snapshots private func get_snapshot(tick: int) -> Snapshot: - return _snapshots.get(tick) + return _snapshots.get(tick) as Snapshot # TODO: Keep snapshots private func merge_snapshot(snapshot: Snapshot) -> Snapshot: diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 0c884419..6d1b1cf1 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -7,8 +7,8 @@ class_name _RollbackSynchronizationServer var _input_properties: Array = [] var _state_properties: Array = [] -var _full_state_interval := 24 # TODO: Config -var _state_ack_interval := 1 # TODO: Config +var _full_state_interval := 0 # TODO: Config +var _state_ack_interval := 2 # TODO: Config var _ackd_tick := {} # peer id to ack'd tick var _schemas := {} # RecordedProperty key to NetworkSchemaSerializer @@ -97,7 +97,7 @@ func synchronize_state(tick: int) -> void: var state_snapshot := snapshot.filtered_to_properties(_state_properties) # Figure out whether to send full- or diff state - var is_diff := false + var is_diff := true # false if _full_state_interval <= 0: is_diff = true elif _full_state_interval >= 1 and (tick % _full_state_interval) != 0: @@ -118,7 +118,7 @@ func synchronize_state(tick: int) -> void: if not reference_snapshot: # Reference snapshot not in history, send full state - _logger.warning("Tick @%d not present in history, can't use it as reference for peer #%d", [reference_tick, peer]) + _logger.warning("Tick @%d not present in history, can't use it as reference for peer #%d ( ack: %s )", [reference_tick, peer, _ackd_tick]) _submit_full_state.rpc_id(peer, _serialize_full_state_for(peer, state_snapshot)) _logger.info("Sent full state for @%d to #%d", [tick, peer]) continue @@ -126,7 +126,9 @@ func synchronize_state(tick: int) -> void: # TODO: Optimize, don't create two snapshots reference_snapshot = reference_snapshot.filtered_to_auth().filtered_to_properties(_state_properties) - var diff_snapshot := Snapshot.make_patch(state_snapshot, reference_snapshot) + _logger.debug("Sending diff state for @%d to #@%d, relative to @%d", [tick, peer, reference_tick]) + + var diff_snapshot := Snapshot.make_patch(reference_snapshot, state_snapshot, tick) _submit_diff_state.rpc_id(peer, _serialize_diff_state_of(peer, diff_snapshot, reference_tick)) # _submit_state.rpc_id(peer, _serialize_snapshot(state_snapshot)) # _logger.info("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) @@ -335,46 +337,6 @@ func _deserialize_property(node: Node, property: NodePath, buffer: StreamPeerBuf var serializer := _schemas.get(RecordedProperty.key_of(node, property), _fallback_schema) as NetworkSchemaSerializer return serializer.decode(buffer) -func _serialize_snapshot(snapshot: Snapshot) -> Variant: - var serialized_properties := [] - - for entry in snapshot.data.keys(): - var node := entry[0] as Node - var property := entry[1] as NodePath - var value = snapshot.data[entry] - var is_auth := snapshot._is_authoritative.get(entry, false) - - if not is_auth: - # Don't broadcast data we're not sure about - continue - - serialized_properties.append([str(node.get_path()), str(property), value, is_auth]) - - serialized_properties.append(snapshot.tick) - return serialized_properties - -func _deserialize_snapshot(data: Variant) -> Snapshot: - var values := data as Array - var tick := values.pop_back() as int - - var snapshot := Snapshot.new(tick) - for entry in values: - var entry_data := entry as Array - - var node_path := entry_data[0] as String - var property := entry_data[1] as String - var value = entry_data[2] - var is_auth := entry_data[3] as bool - - var node := get_tree().root.get_node(node_path) - if not node: - _logger.warning("Can't find node at path %s, ignoring", [node_path]) - continue - - snapshot.set_property(node, property, value, is_auth) - - return snapshot - @rpc("any_peer", "call_remote", "reliable") func _submit_input(data: PackedByteArray): var sender := multiplayer.get_remote_sender_id() @@ -391,7 +353,7 @@ func _submit_input(data: PackedByteArray): on_input.emit(snapshot) -@rpc("any_peer", "call_remote", "unreliable") +@rpc("any_peer", "call_remote", "unreliable_ordered") func _submit_full_state(data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data @@ -401,7 +363,7 @@ func _submit_full_state(data: PackedByteArray): _ingest_state(sender, snapshot) -@rpc("any_peer", "call_remote", "unreliable") +@rpc("any_peer", "call_remote", "unreliable_ordered") func _submit_diff_state(data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data @@ -410,6 +372,7 @@ func _submit_diff_state(data: PackedByteArray): var diff := _deserialize_diff_state_of(sender, buffer) var reference_tick := diff.reference_tick var reference_snapshot := RollbackHistoryServer.get_snapshot(reference_tick) + _logger.debug("Received diff state for @%d, relative to @%d", [diff.snapshot.tick, reference_tick]) if not reference_snapshot: _logger.warning("Reference tick %d missing for #%d, ignoring", [reference_tick, sender]) return @@ -428,6 +391,7 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: # _logger.debug("Merged state; %s", [merged]) if _state_ack_interval >= 1 and (snapshot.tick % _state_ack_interval) == 0: + _logger.debug("ACK'ing state @%d from #%d", [snapshot.tick, sender]) _ack_state.rpc_id(sender, snapshot.tick) on_state.emit(snapshot) @@ -436,6 +400,7 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: func _ack_state(tick: int): var sender := multiplayer.get_remote_sender_id() _ackd_tick[sender] = maxi(tick, _ackd_tick.get(sender, tick)) + _logger.debug("Received ACK for state @%d from #%d, new is %d", [tick, sender, _ackd_tick[sender]]) class DiffSnapshot: var snapshot: Snapshot diff --git a/test/netfox.internals/bitset.test.gd b/test/netfox.internals/bitset.test.gd new file mode 100644 index 00000000..b9cd2366 --- /dev/null +++ b/test/netfox.internals/bitset.test.gd @@ -0,0 +1,27 @@ +extends VestTest + +func get_suite_name() -> String: + return "Bitset" + +func suite(): + test("should be empty on create", func(): + var bits := _Bitset.new(2) + expect_false(bits.get_bit(0)) + expect_false(bits.get_bit(1)) + ) + + define("get_set_indices()", func(): + test("should return expected", func(): + var bits := _Bitset.of_bools([0, 1, 1, 0]) + expect_equal(bits.get_set_indices(), [1, 2]) + ) + ) + + define("set_bit()", func(): + test("should set bit", func(): + var bits := _Bitset.new(8) + bits.set_bit(2) + + expect_true(bits.get_bit(2)) + ) + ) diff --git a/test/netfox/servers/rollback-synchronization-server.test.gd b/test/netfox/servers/rollback-synchronization-server.test.gd new file mode 100644 index 00000000..bbbf73a0 --- /dev/null +++ b/test/netfox/servers/rollback-synchronization-server.test.gd @@ -0,0 +1,32 @@ +extends VestTest + +func get_suite_name() -> String: + return "RollbackSynchronizationServer" + +func suite(): + test("diff state", func(): + var node := Node3D.new() + Vest.get_tree().root.add_child.call_deferred(node) + await node.ready + + NetworkIdentityServer.register_node(node) + RollbackSynchronizationServer.register_state(node, "position") + RollbackSynchronizationServer.register_state(node, "scale") + + var reference_snapshot := Snapshot.new(1) + reference_snapshot.set_property(node, "position", Vector3.ZERO, true) + reference_snapshot.set_property(node, "scale", Vector3.ONE, true) + + var current_snapshot := Snapshot.new(2) + current_snapshot.set_property(node, "position", Vector3.ONE, true) + reference_snapshot.set_property(node, "scale", Vector3.ONE * 2., true) + + var diff_snapshot := Snapshot.make_patch(reference_snapshot, current_snapshot) + + var serialized := RollbackSynchronizationServer._serialize_diff_state_of(1, diff_snapshot, reference_snapshot.tick) + var buffer := StreamPeerBuffer.new(); buffer.data_array = serialized + var deserialized := RollbackSynchronizationServer._deserialize_diff_state_of(1, buffer) + + expect_equal(deserialized.reference_tick, reference_snapshot.tick) + expect_equal(deserialized.snapshot, diff_snapshot) + ) diff --git a/test/netfox/servers/snapshot.test.gd b/test/netfox/servers/snapshot.test.gd new file mode 100644 index 00000000..e574427b --- /dev/null +++ b/test/netfox/servers/snapshot.test.gd @@ -0,0 +1,63 @@ +extends VestTest + +func get_suite_name() -> String: + return "Snapshot" + +func suite() -> void: + var node := Node3D.new() + + define("make_patch()", func(): + test("should return empty on same", func(): + var snapshot := Snapshot.new(1) + snapshot.set_property(node, "position", Vector3.ZERO, true) + snapshot.set_property(node, "scale", Vector3.ONE, true) + + expect_empty(Snapshot.make_patch(snapshot, snapshot)) + ) + + test("should include differing property", func(): + var from := Snapshot.new(1) + from.set_property(node, "position", Vector3.ZERO, true) + from.set_property(node, "scale", Vector3.ONE, true) + + var to := Snapshot.new(2) + to.set_property(node, "position", Vector3.ONE, true) + to.set_property(node, "scale", Vector3.ONE, true) + + var expected := Snapshot.new(2) + expected.set_property(node, "position", Vector3.ONE, true) + + expect_equal(Snapshot.make_patch(from, to), expected) + ) + + test("should include new property", func(): + var from := Snapshot.new(1) + from.set_property(node, "position", Vector3.ZERO, true) + + var to := Snapshot.new(2) + to.set_property(node, "position", Vector3.ZERO, true) + to.set_property(node, "scale", Vector3.ONE, true) + + var expected := Snapshot.new(2) + expected.set_property(node, "scale", Vector3.ONE, true) + + expect_equal(Snapshot.make_patch(from, to), expected) + ) + + test("patch should yield `to` on merge", func(): + var from := Snapshot.new(1) + from.set_property(node, "position", Vector3.ZERO, true) + from.set_property(node, "scale", Vector3.ONE, true) + + var to := Snapshot.new(2) + to.set_property(node, "position", Vector3.ONE, true) + to.set_property(node, "scale", Vector3.ONE, true) + + var patch := Snapshot.make_patch(from, to) + var applied := from.duplicate() + applied.tick = to.tick + applied.merge(patch) + + expect_equal(applied, to) + ) + ) From 18c153dcfee27afee9cd44438423890edcf91000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 12 Jan 2026 12:15:33 +0100 Subject: [PATCH 29/95] temp fix, this will suck to figure out --- addons/netfox/servers/rollback-synchronization-server.gd | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 6d1b1cf1..be71bc82 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -381,7 +381,8 @@ func _submit_diff_state(data: PackedByteArray): snapshot.merge(diff.snapshot) snapshot.tick = diff.snapshot.tick - _ingest_state(sender, snapshot) + # TODO: Using `snapshot` doesn't work + _ingest_state(sender, diff.snapshot) func _ingest_state(sender: int, snapshot: Snapshot) -> void: # TODO: Sanitize From b9ea51c15f1e7a94ac6b0c9df252d34331b01c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 12 Jan 2026 13:42:40 +0100 Subject: [PATCH 30/95] command bus --- addons/netfox/netfox.gd | 4 + .../netfox/servers/data/network-commands.gd | 7 + .../netfox/servers/network-command-server.gd | 122 ++++++++++++++++++ .../rollback-synchronization-server.gd | 47 ++++--- project.godot | 1 + 5 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 addons/netfox/servers/data/network-commands.gd create mode 100644 addons/netfox/servers/network-command-server.gd diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index e8b5b8d7..e2b48ebf 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -161,6 +161,10 @@ const AUTOLOADS: Array[Dictionary] = [ { "name": "NetworkIdentityServer", "path": ROOT + "/servers/network-identity-server.gd" + }, + { + "name": "NetworkCommandServer", + "path": ROOT + "/servers/network-command-server.gd" } ] diff --git a/addons/netfox/servers/data/network-commands.gd b/addons/netfox/servers/data/network-commands.gd new file mode 100644 index 00000000..b36d4d34 --- /dev/null +++ b/addons/netfox/servers/data/network-commands.gd @@ -0,0 +1,7 @@ +extends Object +class_name _NetworkCommands + +const FULL_STATE := 0 +const DIFF_STATE := 1 +const ACKD_STATE := 2 +const INPUT := 3 diff --git a/addons/netfox/servers/network-command-server.gd b/addons/netfox/servers/network-command-server.gd new file mode 100644 index 00000000..a645f1aa --- /dev/null +++ b/addons/netfox/servers/network-command-server.gd @@ -0,0 +1,122 @@ +extends Node +class_name _NetworkCommandServer + +var _next_idx := 0 +var _rpc_transport := RPCTransport.new() +var _packet_transport := PacketTransport.new() +var _commands := {} # id to `Command` + +var _use_rpcs := false # TODO: Config + +static var _logger := NetfoxLogger._for_netfox("NetworkCommandServer") + +# TODO: Update time synchronizer to use commands + +func _ready(): + add_child(_rpc_transport, true) + add_child(_packet_transport, true) + + _rpc_transport.on_receive.connect(_handle_command) + _packet_transport.on_receive.connect(_handle_command) + +func _handle_command(sender: int, idx: int, data: PackedByteArray) -> void: + var command := _commands.get(idx) as Command + if not command: + _logger.error("Received unknown command #%d!", [idx]) + return + command.handle(sender, data) + +func register_command(handler: Callable) -> Command: + var idx := _next_idx + _next_idx += 1 + return register_command_at(idx, handler) + +func register_command_at(idx: int, handler: Callable, mode: MultiplayerPeer.TransferMode = MultiplayerPeer.TRANSFER_MODE_RELIABLE, channel: int = 0) -> Command: + assert(not _commands.has(idx), "Command #%d is already taken!" % idx) + var command := Command.new(idx, handler, mode, channel) + _commands[idx] = command + + _next_idx = maxi(_next_idx, idx + 1) + + return command + +func send_command(idx: int, data: PackedByteArray, target_peer: int = 0, mode: MultiplayerPeer.TransferMode = MultiplayerPeer.TRANSFER_MODE_RELIABLE, channel: int = 0) -> void: + if _use_rpcs: + _rpc_transport.send(idx, data, target_peer, mode, channel) + else: + _packet_transport.send(idx, data, target_peer, mode, channel) + +class Command: + var _idx: int + var _handler: Callable + var _mode: MultiplayerPeer.TransferMode + var _channel: int + + func _init(p_idx: int, p_handler: Callable, p_mode: MultiplayerPeer.TransferMode, p_channel: int): + _idx = p_idx + _handler = p_handler + _mode = p_mode + _channel = p_channel + + func send(data: PackedByteArray, target_peer: int = 0) -> void: + NetworkCommandServer.send_command(_idx, data, target_peer, _mode, _channel) + + func handle(sender: int, data: PackedByteArray) -> void: + _handler.call(sender, data) + +class Transport extends Node: + signal on_receive(idx: int, data: PackedByteArray) + + func send(idx: int, data: PackedByteArray, target_peer: int, mode: MultiplayerPeer.TransferMode, channel: int) -> void: + pass + +class PacketTransport extends Transport: + var _packet_prefix := PackedByteArray([0, 78, 70]) # "\0nf" + + func _ready(): + (multiplayer as SceneMultiplayer).peer_packet.connect(_handle_packet) + + func send(idx: int, data: PackedByteArray, target_peer: int, mode: MultiplayerPeer.TransferMode, channel: int) -> void: + var buffer := StreamPeerBuffer.new() + buffer.put_data(_packet_prefix) + buffer.put_u8(idx) + buffer.put_data(data) + + (multiplayer as SceneMultiplayer).send_bytes(buffer.data_array, target_peer, mode, channel) + + func _handle_packet(peer: int, packet: PackedByteArray) -> void: + var buffer := StreamPeerBuffer.new() + buffer.data_array = packet + + # Check header + for i in _packet_prefix.size(): + if buffer.get_u8() != _packet_prefix[i]: + return + + # Grab data + var idx := buffer.get_u8() + var data := buffer.get_partial_data(buffer.get_available_bytes())[1] as PackedByteArray + + on_receive.emit(peer, idx, data) + +class RPCTransport extends Transport: + func send(idx: int, data: PackedByteArray, target_peer: int, mode: MultiplayerPeer.TransferMode, _channel: int) -> void: + match mode: + MultiplayerPeer.TRANSFER_MODE_UNRELIABLE: _submit_unreliable.rpc_id(target_peer, idx, data) + MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED: _submit_unreliable_ordered.rpc_id(target_peer, idx, data) + MultiplayerPeer.TRANSFER_MODE_RELIABLE: _submit_reliable.rpc_id(target_peer, idx, data) + + @rpc("any_peer", "call_remote", "unreliable") + func _submit_unreliable(idx: int, data: PackedByteArray): + var sender := multiplayer.get_remote_sender_id() + on_receive.emit(sender, idx, data) + + @rpc("any_peer", "call_remote", "unreliable_ordered") + func _submit_unreliable_ordered(idx: int, data: PackedByteArray): + var sender := multiplayer.get_remote_sender_id() + on_receive.emit(sender, idx, data) + + @rpc("any_peer", "call_remote", "reliable") + func _submit_reliable(idx: int, data: PackedByteArray): + var sender := multiplayer.get_remote_sender_id() + on_receive.emit(sender, idx, data) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index be71bc82..85d85eb9 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -16,6 +16,11 @@ var _fallback_schema := NetworkSchemas.variant() var _input_redundancy := 3 # TODO: Config +@onready var _cmd_full_state := NetworkCommandServer.register_command_at(_NetworkCommands.FULL_STATE, _handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) +@onready var _cmd_diff_state := NetworkCommandServer.register_command_at(_NetworkCommands.DIFF_STATE, _handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) +@onready var _cmd_ack_state := NetworkCommandServer.register_command_at(_NetworkCommands.ACKD_STATE, _handle_ack_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) +@onready var _cmd_input := NetworkCommandServer.register_command_at(_NetworkCommands.INPUT, _handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + static var _logger := NetfoxLogger._for_netfox("RollbackSynchronizationServer") signal on_input(snapshot: Snapshot) @@ -84,7 +89,7 @@ func synchronize_input(tick: int) -> void: # TODO: Option to not broadcast input for peer in multiplayer.get_peers(): - _submit_input.rpc_id(peer, _serialize_input_for(peer, snapshots)) + _cmd_input.send(_serialize_input_for(peer, snapshots), peer) # TODO: Make this testable somehow, I beg of you func synchronize_state(tick: int) -> void: @@ -109,7 +114,7 @@ func synchronize_state(tick: int) -> void: for peer in multiplayer.get_peers(): if not _ackd_tick.has(peer): # We don't know any state the peer knows, send full state - _submit_full_state.rpc_id(peer, _serialize_full_state_for(peer, state_snapshot)) + _cmd_full_state.send(_serialize_full_state_for(peer, state_snapshot), peer) _logger.info("Sent full state for @%d to #%d", [tick, peer]) continue @@ -119,22 +124,19 @@ func synchronize_state(tick: int) -> void: if not reference_snapshot: # Reference snapshot not in history, send full state _logger.warning("Tick @%d not present in history, can't use it as reference for peer #%d ( ack: %s )", [reference_tick, peer, _ackd_tick]) - _submit_full_state.rpc_id(peer, _serialize_full_state_for(peer, state_snapshot)) + _cmd_full_state.send(_serialize_full_state_for(peer, state_snapshot), peer) _logger.info("Sent full state for @%d to #%d", [tick, peer]) continue # TODO: Optimize, don't create two snapshots reference_snapshot = reference_snapshot.filtered_to_auth().filtered_to_properties(_state_properties) - _logger.debug("Sending diff state for @%d to #@%d, relative to @%d", [tick, peer, reference_tick]) - var diff_snapshot := Snapshot.make_patch(reference_snapshot, state_snapshot, tick) - _submit_diff_state.rpc_id(peer, _serialize_diff_state_of(peer, diff_snapshot, reference_tick)) -# _submit_state.rpc_id(peer, _serialize_snapshot(state_snapshot)) -# _logger.info("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) + _cmd_diff_state.send(_serialize_diff_state_of(peer, diff_snapshot, reference_tick), peer) + _logger.info("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) else: for peer in multiplayer.get_peers(): - _submit_full_state.rpc_id(peer, _serialize_full_state_for(peer, state_snapshot)) + _cmd_full_state.send(_serialize_full_state_for(peer, state_snapshot), peer) func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeerBuffer = null) -> PackedByteArray: if buffer == null: @@ -199,7 +201,7 @@ func _deserialize_full_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bo if not identifier: # TODO: Handle unknown IDs gracefully # TODO: Test that unknown nodes are INDEED SKIPPED - _logger.error("Received unknown identity reference %s, skipping data", [idref]) + _logger.warning("Received unknown identity reference %s, skipping data", [idref]) break var node := identifier.get_subject() as Node @@ -242,7 +244,7 @@ func _serialize_diff_state_of(peer: int, snapshot: Snapshot, reference_tick: int netref.encode(idref, buffer) node_buffer.clear() - + # TODO: Create bitset of changed properties # TODO: Write changed properties into `node_buffer` var properties := get_properties_of(node) @@ -289,7 +291,7 @@ func _deserialize_diff_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bo if not identifier: # TODO: Handle unknown IDs gracefully # TODO: Test that unknown nodes are INDEED SKIPPED - _logger.error("Received unknown identity reference %s, skipping data", [idref]) + _logger.warning("Received unknown identity reference %s, skipping data", [idref]) break var node := identifier.get_subject() as Node @@ -337,9 +339,7 @@ func _deserialize_property(node: Node, property: NodePath, buffer: StreamPeerBuf var serializer := _schemas.get(RecordedProperty.key_of(node, property), _fallback_schema) as NetworkSchemaSerializer return serializer.decode(buffer) -@rpc("any_peer", "call_remote", "reliable") -func _submit_input(data: PackedByteArray): - var sender := multiplayer.get_remote_sender_id() +func _handle_input(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data @@ -353,22 +353,18 @@ func _submit_input(data: PackedByteArray): on_input.emit(snapshot) -@rpc("any_peer", "call_remote", "unreliable_ordered") -func _submit_full_state(data: PackedByteArray): +func _handle_full_state(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data - var sender := multiplayer.get_remote_sender_id() var snapshot := _deserialize_full_state_of(sender, buffer) _ingest_state(sender, snapshot) -@rpc("any_peer", "call_remote", "unreliable_ordered") -func _submit_diff_state(data: PackedByteArray): +func _handle_diff_state(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data - var sender := multiplayer.get_remote_sender_id() var diff := _deserialize_diff_state_of(sender, buffer) var reference_tick := diff.reference_tick var reference_snapshot := RollbackHistoryServer.get_snapshot(reference_tick) @@ -393,13 +389,14 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: if _state_ack_interval >= 1 and (snapshot.tick % _state_ack_interval) == 0: _logger.debug("ACK'ing state @%d from #%d", [snapshot.tick, sender]) - _ack_state.rpc_id(sender, snapshot.tick) + var buffer := StreamPeerBuffer.new() + buffer.put_u32(snapshot.tick) + _cmd_ack_state.send(buffer.data_array, sender) on_state.emit(snapshot) -@rpc("any_peer", "call_remote", "unreliable") -func _ack_state(tick: int): - var sender := multiplayer.get_remote_sender_id() +func _handle_ack_state(sender: int, data: PackedByteArray): + var tick := data.decode_u32(0) _ackd_tick[sender] = maxi(tick, _ackd_tick.get(sender, tick)) _logger.debug("Received ACK for state @%d from #%d, new is %d", [tick, sender, _ackd_tick[sender]]) diff --git a/project.godot b/project.godot index e61fe79f..842e0817 100644 --- a/project.godot +++ b/project.godot @@ -34,6 +34,7 @@ RollbackSimulationServer="*res://addons/netfox/servers/rollback-simulation-serve RollbackHistoryServer="*res://addons/netfox/servers/rollback-history-server.gd" RollbackSynchronizationServer="*res://addons/netfox/servers/rollback-synchronization-server.gd" NetworkIdentityServer="*res://addons/netfox/servers/network-identity-server.gd" +NetworkCommandServer="*res://addons/netfox/servers/network-command-server.gd" [display] From a22dab08000c8c4e02ad12a958b00694b7929c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 12 Jan 2026 23:07:39 +0100 Subject: [PATCH 31/95] uhh --- .../servers/rollback-synchronization-server.gd | 18 ++++++++++-------- addons/netfox/tick-interpolator.gd | 1 + 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 85d85eb9..7effa1cd 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -7,7 +7,8 @@ class_name _RollbackSynchronizationServer var _input_properties: Array = [] var _state_properties: Array = [] -var _full_state_interval := 0 # TODO: Config +var _enable_diff_states := false # TODO: Config +var _full_state_interval := 24 # TODO: Config var _state_ack_interval := 2 # TODO: Config var _ackd_tick := {} # peer id to ack'd tick @@ -99,15 +100,16 @@ func synchronize_state(tick: int) -> void: return # Filter to state properties - var state_snapshot := snapshot.filtered_to_properties(_state_properties) + var state_snapshot := snapshot.filtered_to_properties(_state_properties).filtered_to_owned() # Figure out whether to send full- or diff state - var is_diff := true # false - if _full_state_interval <= 0: - is_diff = true - elif _full_state_interval >= 1 and (tick % _full_state_interval) != 0: - # TODO: Something better than modulo logic? --^ - is_diff = true + var is_diff := false + if _enable_diff_states: + if _full_state_interval <= 0: + is_diff = true + elif _full_state_interval >= 1 and (tick % _full_state_interval) != 0: + # TODO: Something better than modulo logic? --^ + is_diff = true # Transmit if is_diff: diff --git a/addons/netfox/tick-interpolator.gd b/addons/netfox/tick-interpolator.gd index dfb5666e..37dabd47 100644 --- a/addons/netfox/tick-interpolator.gd +++ b/addons/netfox/tick-interpolator.gd @@ -136,6 +136,7 @@ func _process(_delta: float) -> void: if Engine.is_editor_hint(): return + return # TODO: Remove _interpolate(_state_from, _state_to, NetworkTime.tick_factor) func _reprocess_settings() -> void: From deae15ac14dc1c52fc125ecf4e8a08282e6dc41e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 13 Jan 2026 22:21:53 +0100 Subject: [PATCH 32/95] fix diff state sim fuckery - forgot to `filter_to_auth()` on the snapshot before sending state --- addons/netfox/rollback/network-rollback.gd | 14 ++++++- addons/netfox/servers/data/snapshot.gd | 3 ++ .../netfox/servers/rollback-history-server.gd | 2 +- .../servers/rollback-simulation-server.gd | 23 +++++++++--- .../rollback-synchronization-server.gd | 37 ++++++++++--------- addons/netfox/tick-interpolator.gd | 1 - project.godot | 2 +- test/netfox/servers/snapshot.test.gd | 7 ++++ 8 files changed, 61 insertions(+), 28 deletions(-) diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 31151b52..f0db7bf0 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -304,13 +304,23 @@ func _ready(): ) RollbackSynchronizationServer.on_input.connect(func(snapshot: Snapshot): + if snapshot.is_empty(): + return if _earliest_input < 0 or snapshot.tick < _earliest_input: + _logger.debug("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, snapshot.tick]) _earliest_input = snapshot.tick + else: + _logger.debug("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input]) ) RollbackSynchronizationServer.on_state.connect(func(snapshot: Snapshot): + if snapshot.is_empty(): + return if _latest_state < 0 or snapshot.tick > _latest_state: + _logger.debug("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _latest_state, snapshot.tick]) _latest_state = snapshot.tick + else: + _logger.debug("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _latest_state, _latest_state]) ) func _exit_tree(): @@ -332,10 +342,10 @@ func _rollback() -> void: # TODO: Move to RollbackSimulationServer? var range_source = "notif" - if _earliest_input >= 0: + if _earliest_input >= 0 and _earliest_input <= _resim_from: range_source = "earliest input" _resim_from = mini(_resim_from, _earliest_input) - if _latest_state >= 0: + if _latest_state >= 0 and _latest_state <= _resim_from: range_source = "latest state" _resim_from = mini(_resim_from, _latest_state) _resim_from = mini(_resim_from, NetworkTime.tick - 1) diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index c4e471a1..9f83a464 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -130,6 +130,9 @@ func nodes() -> Array[Node]: func is_empty() -> bool: return data.is_empty() +func is_auth(node: Node, property: NodePath) -> bool: + return _is_authoritative.get(RecordedProperty.key_of(node, property), false) + func equals(other) -> bool: if other is Snapshot: return tick == other.tick and data == other.data and _is_authoritative == other._is_authoritative diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 7b70b97e..0ea59e6b 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -47,7 +47,7 @@ func record_tick(tick: int, properties: Array, predicted_nodes: Array[Node]) -> if snapshot.merge_property(node, property, RecordedProperty.extract(entry), is_auth): updated.append([node, property, RecordedProperty.extract(entry), is_auth]) - _logger.debug("Recorded %d properties: %s; %s", [properties.size(), updated, snapshot]) + _logger.debug("Recorded %d tick @%d: %s", [properties.size(), tick, snapshot]) func record_input(tick: int) -> void: record_tick(tick, _input_properties, []) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 90fd908f..3861087c 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -70,14 +70,22 @@ func get_nodes_to_simulate(snapshot: Snapshot) -> Array[Node]: return result +# TODO: *Thorough* test for node predict rules func is_predicting(snapshot: Snapshot, node: Node) -> bool: - if not node.is_multiplayer_authority(): + var is_owned := node.is_multiplayer_authority() + var is_inputless := not _input_for.has(node) + var has_input := false if is_inputless else snapshot.has_node(_input_for[node], true) + + if not is_owned and has_input: + # We don't own the node, but we own input for it - not (input) predicting + return false + if not is_owned: # We don't own the node, so we can only guess - i.e. predict return true - if not _input_for.has(node): + if is_owned and is_inputless: # We own the node, node doesn't depend on input, we're sure return false - if not snapshot.has_node(_input_for[node], true): + if is_owned and not has_input: # We own the node, node depends on input, we don't have data for input - predict return true # We own the node and we have data for node's input - we're sure @@ -94,9 +102,9 @@ func get_simulated_object() -> Object: func is_tick_fresh_for(node: Node, tick: int) -> bool: if not _simulated_ticks.has(node): - return false + return true var ticks := _simulated_ticks.get(node) as Array[int] - return ticks.has(tick) + return not ticks.has(tick) func set_tick_simulated_for(node: Node, tick: int) -> void: if not _simulated_ticks.has(node): @@ -115,7 +123,7 @@ func simulate(delta: float, tick: int) -> void: var snapshot := RollbackHistoryServer.get_snapshot(tick) var nodes := get_nodes_to_simulate(snapshot) _predicted_nodes.clear() - _logger.debug("Simulating %d nodes: %s", [nodes.size(), nodes]) + _logger.trace("Simulating %d nodes: %s", [nodes.size(), nodes]) # Sort based on SceneTree order for node in nodes: @@ -130,6 +138,9 @@ func simulate(delta: float, tick: int) -> void: # Run callbacks and clear group for node in nodes: _current_object = node + # TODO: Remove after investigation sesh + if is_predicting(snapshot, node): + continue var callback := _callbacks[node] as Callable var is_fresh := is_tick_fresh_for(node, tick) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 7effa1cd..0e1238d9 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -7,7 +7,7 @@ class_name _RollbackSynchronizationServer var _input_properties: Array = [] var _state_properties: Array = [] -var _enable_diff_states := false # TODO: Config +var _enable_diff_states := true # TODO: Config var _full_state_interval := 24 # TODO: Config var _state_ack_interval := 2 # TODO: Config var _ackd_tick := {} # peer id to ack'd tick @@ -76,7 +76,7 @@ func synchronize_input(tick: int) -> void: for offset in _input_redundancy: # Grab snapshot from RollbackHistoryServer - var snapshot := RollbackHistoryServer.get_snapshot(tick + offset) + var snapshot := RollbackHistoryServer.get_snapshot(tick - offset) if not snapshot: break @@ -100,7 +100,7 @@ func synchronize_state(tick: int) -> void: return # Filter to state properties - var state_snapshot := snapshot.filtered_to_properties(_state_properties).filtered_to_owned() + var state_snapshot := snapshot.filtered_to_properties(_state_properties).filtered_to_auth().filtered_to_owned() # Figure out whether to send full- or diff state var is_diff := false @@ -117,7 +117,7 @@ func synchronize_state(tick: int) -> void: if not _ackd_tick.has(peer): # We don't know any state the peer knows, send full state _cmd_full_state.send(_serialize_full_state_for(peer, state_snapshot), peer) - _logger.info("Sent full state for @%d to #%d", [tick, peer]) + _logger.trace("Sent full state for @%d to #%d", [tick, peer]) continue var reference_tick := _ackd_tick[peer] as int @@ -127,15 +127,15 @@ func synchronize_state(tick: int) -> void: # Reference snapshot not in history, send full state _logger.warning("Tick @%d not present in history, can't use it as reference for peer #%d ( ack: %s )", [reference_tick, peer, _ackd_tick]) _cmd_full_state.send(_serialize_full_state_for(peer, state_snapshot), peer) - _logger.info("Sent full state for @%d to #%d", [tick, peer]) + _logger.trace("Sent full state for @%d to #%d", [tick, peer]) continue # TODO: Optimize, don't create two snapshots reference_snapshot = reference_snapshot.filtered_to_auth().filtered_to_properties(_state_properties) var diff_snapshot := Snapshot.make_patch(reference_snapshot, state_snapshot, tick) - _cmd_diff_state.send(_serialize_diff_state_of(peer, diff_snapshot, reference_tick), peer) - _logger.info("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) + _cmd_diff_state.send(_serialize_diff_state_for(peer, diff_snapshot, reference_tick), peer) + _logger.trace("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) else: for peer in multiplayer.get_peers(): _cmd_full_state.send(_serialize_full_state_for(peer, state_snapshot), peer) @@ -155,8 +155,7 @@ func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeer # For each node for node in snapshot.nodes(): - if not node.is_multiplayer_authority(): - continue + assert(node.is_multiplayer_authority(), "Trying to serialize state for non-owned node!") # Write identifier var identifier := NetworkIdentityServer.get_identifier_of(node) @@ -170,6 +169,7 @@ func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeer # First into a buffer, so we can start with the state size node_buffer.clear() for property in get_properties_of(node): + assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") var value := snapshot.get_property(node, property) _serialize_property(node, property, value, node_buffer) @@ -217,7 +217,7 @@ func _deserialize_full_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bo return snapshot -func _serialize_diff_state_of(peer: int, snapshot: Snapshot, reference_tick: int, buffer: StreamPeerBuffer = null) -> PackedByteArray: +func _serialize_diff_state_for(peer: int, snapshot: Snapshot, reference_tick: int, buffer: StreamPeerBuffer = null) -> PackedByteArray: if buffer == null: buffer = StreamPeerBuffer.new() @@ -234,8 +234,7 @@ func _serialize_diff_state_of(peer: int, snapshot: Snapshot, reference_tick: int # For each node for node in snapshot.nodes(): - if not node.is_multiplayer_authority(): - continue + assert(node.is_multiplayer_authority(), "Trying to serialize state for non-owned node!") # Write identifier var identifier := NetworkIdentityServer.get_identifier_of(node) @@ -254,6 +253,8 @@ func _serialize_diff_state_of(peer: int, snapshot: Snapshot, reference_tick: int for i in properties.size(): var property := properties[i] + assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") + if not snapshot.has_property(node, property): continue @@ -345,9 +346,10 @@ func _handle_input(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data - for snapshot in _deserialize_input_of(sender, buffer): - # _logger.debug("Received input snapshot: %s", [snapshot]) + var snapshots := _deserialize_input_of(sender, buffer) + _logger.debug("Received input snapshots: %s", [snapshots]) + for snapshot in snapshots: # TODO: Sanitize var merged := RollbackHistoryServer.merge_snapshot(snapshot) @@ -370,7 +372,7 @@ func _handle_diff_state(sender: int, data: PackedByteArray): var diff := _deserialize_diff_state_of(sender, buffer) var reference_tick := diff.reference_tick var reference_snapshot := RollbackHistoryServer.get_snapshot(reference_tick) - _logger.debug("Received diff state for @%d, relative to @%d", [diff.snapshot.tick, reference_tick]) + _logger.trace("Received diff state for @%d, relative to @%d", [diff.snapshot.tick, reference_tick]) if not reference_snapshot: _logger.warning("Reference tick %d missing for #%d, ignoring", [reference_tick, sender]) return @@ -387,10 +389,11 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: # _logger.debug("Received state snapshot: %s", [snapshot]) var merged := RollbackHistoryServer.merge_snapshot(snapshot) + _logger.debug("Ingested state: %s", [snapshot]) # _logger.debug("Merged state; %s", [merged]) if _state_ack_interval >= 1 and (snapshot.tick % _state_ack_interval) == 0: - _logger.debug("ACK'ing state @%d from #%d", [snapshot.tick, sender]) + _logger.trace("ACK'ing state @%d from #%d", [snapshot.tick, sender]) var buffer := StreamPeerBuffer.new() buffer.put_u32(snapshot.tick) _cmd_ack_state.send(buffer.data_array, sender) @@ -400,7 +403,7 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: func _handle_ack_state(sender: int, data: PackedByteArray): var tick := data.decode_u32(0) _ackd_tick[sender] = maxi(tick, _ackd_tick.get(sender, tick)) - _logger.debug("Received ACK for state @%d from #%d, new is %d", [tick, sender, _ackd_tick[sender]]) + _logger.trace("Received ACK for state @%d from #%d, new is %d", [tick, sender, _ackd_tick[sender]]) class DiffSnapshot: var snapshot: Snapshot diff --git a/addons/netfox/tick-interpolator.gd b/addons/netfox/tick-interpolator.gd index 37dabd47..dfb5666e 100644 --- a/addons/netfox/tick-interpolator.gd +++ b/addons/netfox/tick-interpolator.gd @@ -136,7 +136,6 @@ func _process(_delta: float) -> void: if Engine.is_editor_hint(): return - return # TODO: Remove _interpolate(_state_from, _state_to, NetworkTime.tick_factor) func _reprocess_settings() -> void: diff --git a/project.godot b/project.godot index 842e0817..eb84d99a 100644 --- a/project.godot +++ b/project.godot @@ -134,7 +134,7 @@ general/clear_settings=false time/tickrate=24 extras/auto_tile_windows=true autoconnect/enabled=true -autoconnect/simulated_latency_ms=50 +autoconnect/simulated_latency_ms=100 [rendering] diff --git a/test/netfox/servers/snapshot.test.gd b/test/netfox/servers/snapshot.test.gd index e574427b..ae57d85f 100644 --- a/test/netfox/servers/snapshot.test.gd +++ b/test/netfox/servers/snapshot.test.gd @@ -61,3 +61,10 @@ func suite() -> void: expect_equal(applied, to) ) ) + + define("merge_property()", func(): + test("auth should override non-auth", func(): todo()) + test("auth should update auth", func(): todo()) + test("non-auth should not update auth", func(): todo()) + test("non-auth should update non-auth", func(): todo()) + ) From c112a6a82b712c1e96483df8f49519489a86a926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 13 Jan 2026 22:48:34 +0100 Subject: [PATCH 33/95] chill on eager assert --- addons/netfox/servers/rollback-simulation-server.gd | 3 --- addons/netfox/servers/rollback-synchronization-server.gd | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 3861087c..f8af50b7 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -138,9 +138,6 @@ func simulate(delta: float, tick: int) -> void: # Run callbacks and clear group for node in nodes: _current_object = node - # TODO: Remove after investigation sesh - if is_predicting(snapshot, node): - continue var callback := _callbacks[node] as Callable var is_fresh := is_tick_fresh_for(node, tick) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 0e1238d9..4e8e3beb 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -253,11 +253,11 @@ func _serialize_diff_state_for(peer: int, snapshot: Snapshot, reference_tick: in for i in properties.size(): var property := properties[i] - assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") - if not snapshot.has_property(node, property): continue + assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") + changed_bits.set_bit(i) var value := snapshot.get_property(node, property) _serialize_property(node, property, value, node_buffer) From c78972953f2a90d48c1e891f02d03634bb6d9c7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 13 Jan 2026 23:17:12 +0100 Subject: [PATCH 34/95] notes and fixes --- addons/netfox/servers/rollback-synchronization-server.gd | 5 +++-- examples/multiplayer-fps/scripts/ui/crosshair.gd | 2 +- examples/multiplayer-fps/scripts/ui/window-size-connector.gd | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 4e8e3beb..10e0d961 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -246,8 +246,6 @@ func _serialize_diff_state_for(peer: int, snapshot: Snapshot, reference_tick: in node_buffer.clear() - # TODO: Create bitset of changed properties - # TODO: Write changed properties into `node_buffer` var properties := get_properties_of(node) var changed_bits := _Bitset.new(properties.size()) @@ -352,6 +350,9 @@ func _handle_input(sender: int, data: PackedByteArray): for snapshot in snapshots: # TODO: Sanitize + # TODO: Only merge inputs we don't have yet, so clients don't cheat by + # overriding their earlier choices. Only emit signal for snapshots + # that contain new input. var merged := RollbackHistoryServer.merge_snapshot(snapshot) # _logger.debug("Merged input; %s", [merged]) diff --git a/examples/multiplayer-fps/scripts/ui/crosshair.gd b/examples/multiplayer-fps/scripts/ui/crosshair.gd index a7e2f4e7..94b9ea54 100644 --- a/examples/multiplayer-fps/scripts/ui/crosshair.gd +++ b/examples/multiplayer-fps/scripts/ui/crosshair.gd @@ -12,7 +12,7 @@ func _ready() -> void: if Engine.is_editor_hint(): return - get_tree().get_root().size_changed.connect(_on_window_resized) + get_tree().get_root().size_changed.connect(func(): _on_window_resized(DisplayServer.window_get_size())) _on_window_resized(DisplayServer.window_get_size()) func _enter_tree() -> void: diff --git a/examples/multiplayer-fps/scripts/ui/window-size-connector.gd b/examples/multiplayer-fps/scripts/ui/window-size-connector.gd index 1c1a9e7e..52fdc732 100644 --- a/examples/multiplayer-fps/scripts/ui/window-size-connector.gd +++ b/examples/multiplayer-fps/scripts/ui/window-size-connector.gd @@ -14,7 +14,7 @@ func _ready() -> void: if Engine.is_editor_hint(): return - get_tree().get_root().size_changed.connect(_on_window_resized) + get_tree().get_root().size_changed.connect(func(): _on_window_resized(DisplayServer.window_get_size())) _on_window_resized(DisplayServer.window_get_size()) func _on_window_resized(new_size: Vector2) -> void: From 6294710fc9b41ecdd5dab4474134880d30b8fdb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 14 Jan 2026 23:42:30 +0100 Subject: [PATCH 35/95] jfc once again I forgot about nodes with state but no sim --- addons/netfox/rollback/network-rollback.gd | 8 ++++---- addons/netfox/rollback/rollback-synchronizer.gd | 7 ++++++- addons/netfox/servers/rollback-history-server.gd | 9 ++++++++- .../netfox/servers/rollback-simulation-server.gd | 1 + .../servers/rollback-synchronization-server.gd | 14 ++++++++++---- 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index f0db7bf0..97935481 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -307,20 +307,20 @@ func _ready(): if snapshot.is_empty(): return if _earliest_input < 0 or snapshot.tick < _earliest_input: - _logger.debug("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, snapshot.tick]) + _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, snapshot.tick]) _earliest_input = snapshot.tick else: - _logger.debug("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input]) + _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input]) ) RollbackSynchronizationServer.on_state.connect(func(snapshot: Snapshot): if snapshot.is_empty(): return if _latest_state < 0 or snapshot.tick > _latest_state: - _logger.debug("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _latest_state, snapshot.tick]) + _logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _latest_state, snapshot.tick]) _latest_state = snapshot.tick else: - _logger.debug("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _latest_state, _latest_state]) + _logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _latest_state, _latest_state]) ) func _exit_tree(): diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 05b4223a..d2f795fc 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -116,11 +116,16 @@ func process_settings() -> void: if not _state_nodes.has(state_node): _state_nodes.append(state_node) + # Register simulation callbacks for node in nodes: RollbackSimulationServer.register(node._rollback_tick) + _registered_nodes.append(node) + + # Both simulated and state nodes depend on all inputs + # TODO: Write tests for setups where a node is synchronized but not simulated + for node in nodes + _state_nodes: for input_node in _input_nodes: RollbackSimulationServer.register_input_for(node, input_node) - _registered_nodes.append(node) # Register identifiers # TODO: Somehow deregister on destroy diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 0ea59e6b..d131fd43 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -44,10 +44,17 @@ func record_tick(tick: int, properties: Array, predicted_nodes: Array[Node]) -> var property := entry[1] as NodePath var is_auth := node.is_multiplayer_authority() and not predicted_nodes.has(node) + # HACK: Figure out proper API + # Passing in `RollbackSimulationServer.get_predicted_nodes()` accounts + # for *simulated* nodes, but not for nodes with *just* state + if properties == _state_properties: + if RollbackSimulationServer.is_predicting(snapshot, node): + is_auth = false + if snapshot.merge_property(node, property, RecordedProperty.extract(entry), is_auth): updated.append([node, property, RecordedProperty.extract(entry), is_auth]) - _logger.debug("Recorded %d tick @%d: %s", [properties.size(), tick, snapshot]) + _logger.debug("Recorded %d props for tick @%d: %s", [properties.size(), tick, snapshot]) func record_input(tick: int) -> void: record_tick(tick, _input_properties, []) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index f8af50b7..d4938750 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -45,6 +45,7 @@ func deregister_node(node: Node) -> void: deregister(_callbacks.get(node)) func register_input_for(node: Node, input: Node) -> void: + # TODO: Support multiple input nodes per node _input_for[node] = input func deregister_input(node: Node) -> void: diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 10e0d961..d74b86be 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -85,7 +85,7 @@ func synchronize_input(tick: int) -> void: var input_snapshot := snapshot.filtered_to_properties(_input_properties).filtered_to_owned() # Transmit - # _logger.debug("Submitting input: %s", [input_snapshot]) + _logger.trace("Submitting input: %s", [input_snapshot]) snapshots.append(input_snapshot) # TODO: Option to not broadcast input @@ -345,7 +345,7 @@ func _handle_input(sender: int, data: PackedByteArray): buffer.data_array = data var snapshots := _deserialize_input_of(sender, buffer) - _logger.debug("Received input snapshots: %s", [snapshots]) + _logger.trace("Received input snapshots: %s", [snapshots]) for snapshot in snapshots: # TODO: Sanitize @@ -354,7 +354,7 @@ func _handle_input(sender: int, data: PackedByteArray): # overriding their earlier choices. Only emit signal for snapshots # that contain new input. var merged := RollbackHistoryServer.merge_snapshot(snapshot) - # _logger.debug("Merged input; %s", [merged]) + _logger.trace("Ingested input: %s", [snapshot]) on_input.emit(snapshot) @@ -389,8 +389,14 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: # TODO: Sanitize # _logger.debug("Received state snapshot: %s", [snapshot]) + var stored_snapshot := RollbackHistoryServer.get_snapshot(snapshot.tick) + if stored_snapshot: + var diff := Snapshot.make_patch(stored_snapshot, snapshot, snapshot.tick, false) + if not diff.is_empty(): + _logger.debug("Reconciled state diff: %s", [diff]) + var merged := RollbackHistoryServer.merge_snapshot(snapshot) - _logger.debug("Ingested state: %s", [snapshot]) + _logger.trace("Ingested state: %s", [snapshot]) # _logger.debug("Merged state; %s", [merged]) if _state_ack_interval >= 1 and (snapshot.tick % _state_ack_interval) == 0: From 2f94adff62955475d006c2ad741971024075d704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 15 Jan 2026 19:42:15 +0100 Subject: [PATCH 36/95] visibility filtering --- .../netfox/rollback/rollback-synchronizer.gd | 7 ++ addons/netfox/servers/data/snapshot.gd | 14 ++++ .../netfox/servers/rollback-history-server.gd | 4 +- .../rollback-synchronization-server.gd | 79 ++++++++++++------- test/netfox/servers/snapshot.test.gd | 6 ++ 5 files changed, 81 insertions(+), 29 deletions(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index d2f795fc..4cc693e5 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -103,12 +103,14 @@ func process_settings() -> void: nodes = nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it)) nodes.erase(self) + # Gather nodes with input props _input_nodes.clear() for prop in _input_property_config.get_properties(): var input_node := prop.node if not _input_nodes.has(input_node): _input_nodes.append(input_node) + # Gather nodes with state props # TODO: Move tracking nodes to property configs? _state_nodes.clear() for prop in _state_property_config.get_properties(): @@ -132,6 +134,11 @@ func process_settings() -> void: for node in _state_nodes + _input_nodes: NetworkIdentityServer.register_node(node) + # Register visibility filter + # TODO: Somehow deregister on destroy + for node in _state_nodes: + RollbackSynchronizationServer.register_visibility_filter(node, visibility_filter) + ## Process settings based on authority. ## [br][br] ## Call this whenever the authority of any of the nodes managed by diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index 9f83a464..fa80f669 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -97,6 +97,20 @@ func filtered_to_owned() -> Snapshot: return snapshot +func filtered(filter: Callable) -> Snapshot: + var snapshot := Snapshot.new(tick) + + for property in data: + var node := RecordedProperty.get_node(property) + var prop := RecordedProperty.get_property(property) + if not filter.call(node, prop): + continue + + snapshot.data[property] = data[property] + snapshot._is_authoritative[property] = _is_authoritative[property] + + return snapshot + func has_node(node: Node, require_auth: bool = false) -> bool: for entry in data.keys(): var entry_node := entry[0] as Node diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index d131fd43..02b6f776 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -54,7 +54,7 @@ func record_tick(tick: int, properties: Array, predicted_nodes: Array[Node]) -> if snapshot.merge_property(node, property, RecordedProperty.extract(entry), is_auth): updated.append([node, property, RecordedProperty.extract(entry), is_auth]) - _logger.debug("Recorded %d props for tick @%d: %s", [properties.size(), tick, snapshot]) + _logger.trace("Recorded %d props for tick @%d: %s", [properties.size(), tick, snapshot]) func record_input(tick: int) -> void: record_tick(tick, _input_properties, []) @@ -68,7 +68,7 @@ func restore_tick(tick: int) -> bool: return false var snapshot := _snapshots[tick] as Snapshot - _logger.debug("Restoring snapshot: %s", [snapshot]) + _logger.trace("Restoring snapshot: %s", [snapshot]) snapshot.apply() return true diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index d74b86be..c73dedd8 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -7,6 +7,8 @@ class_name _RollbackSynchronizationServer var _input_properties: Array = [] var _state_properties: Array = [] +var _visibility_filters := {} # Node to PeerVisibilityFilter + var _enable_diff_states := true # TODO: Config var _full_state_interval := 24 # TODO: Config var _state_ack_interval := 2 # TODO: Config @@ -57,6 +59,20 @@ func deregister_schema(node: Node, property: NodePath) -> void: var key := RecordedProperty.key_of(node, property) _schemas.erase(key) +func register_visibility_filter(node: Node, filter: PeerVisibilityFilter) -> void: + _visibility_filters[node] = filter + +func deregister_visibility_filter(node: Node) -> void: + _visibility_filters.erase(node) + +func is_property_visible_to(peer: int, node: Node, property: NodePath) -> bool: + # TODO: Cache visibilities + var filter := _visibility_filters.get(node) as PeerVisibilityFilter + if not filter: + return true + else: + return filter.get_visibility_for(peer) + # TODO: Optimize func get_properties_of(node: Node) -> Array[NodePath]: var result := [] as Array[NodePath] @@ -111,34 +127,43 @@ func synchronize_state(tick: int) -> void: # TODO: Something better than modulo logic? --^ is_diff = true - # Transmit + var full_state_peers := [] as Array[int] + var diff_state_peers := [] as Array[int] + if is_diff: - for peer in multiplayer.get_peers(): - if not _ackd_tick.has(peer): - # We don't know any state the peer knows, send full state - _cmd_full_state.send(_serialize_full_state_for(peer, state_snapshot), peer) - _logger.trace("Sent full state for @%d to #%d", [tick, peer]) - continue + diff_state_peers.assign(multiplayer.get_peers()) + else: + full_state_peers.assign(multiplayer.get_peers()) - var reference_tick := _ackd_tick[peer] as int - var reference_snapshot := RollbackHistoryServer.get_snapshot(reference_tick) - - if not reference_snapshot: - # Reference snapshot not in history, send full state - _logger.warning("Tick @%d not present in history, can't use it as reference for peer #%d ( ack: %s )", [reference_tick, peer, _ackd_tick]) - _cmd_full_state.send(_serialize_full_state_for(peer, state_snapshot), peer) - _logger.trace("Sent full state for @%d to #%d", [tick, peer]) - continue + # Send diff states + for peer in diff_state_peers: + if not _ackd_tick.has(peer): + # We don't know any state the peer knows, send full state + full_state_peers.append(peer) + continue - # TODO: Optimize, don't create two snapshots - reference_snapshot = reference_snapshot.filtered_to_auth().filtered_to_properties(_state_properties) - - var diff_snapshot := Snapshot.make_patch(reference_snapshot, state_snapshot, tick) - _cmd_diff_state.send(_serialize_diff_state_for(peer, diff_snapshot, reference_tick), peer) - _logger.trace("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) - else: - for peer in multiplayer.get_peers(): - _cmd_full_state.send(_serialize_full_state_for(peer, state_snapshot), peer) + var reference_tick := _ackd_tick[peer] as int + var reference_snapshot := RollbackHistoryServer.get_snapshot(reference_tick) + + if not reference_snapshot: + # Reference snapshot not in history, send full state + _logger.warning("Tick @%d not present in history, can't use it as reference for peer #%d ( ack: %s )", [reference_tick, peer, _ackd_tick]) + full_state_peers.append(peer) + continue + + # TODO: Optimize, don't create two snapshots + reference_snapshot = reference_snapshot.filtered_to_auth().filtered_to_properties(_state_properties) + + var diff_snapshot := Snapshot.make_patch(reference_snapshot, state_snapshot, tick) + diff_snapshot = diff_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + + _cmd_diff_state.send(_serialize_diff_state_for(peer, diff_snapshot, reference_tick), peer) + _logger.trace("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) + + # Send full states + for peer in full_state_peers: + var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + _cmd_full_state.send(_serialize_full_state_for(peer, peer_snapshot), peer) func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeerBuffer = null) -> PackedByteArray: if buffer == null: @@ -393,10 +418,10 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: if stored_snapshot: var diff := Snapshot.make_patch(stored_snapshot, snapshot, snapshot.tick, false) if not diff.is_empty(): - _logger.debug("Reconciled state diff: %s", [diff]) + _logger.trace("Reconciled state diff: %s", [diff]) var merged := RollbackHistoryServer.merge_snapshot(snapshot) - _logger.trace("Ingested state: %s", [snapshot]) + _logger.debug("Ingested state: %s", [snapshot]) # _logger.debug("Merged state; %s", [merged]) if _state_ack_interval >= 1 and (snapshot.tick % _state_ack_interval) == 0: diff --git a/test/netfox/servers/snapshot.test.gd b/test/netfox/servers/snapshot.test.gd index ae57d85f..a4dc84e7 100644 --- a/test/netfox/servers/snapshot.test.gd +++ b/test/netfox/servers/snapshot.test.gd @@ -68,3 +68,9 @@ func suite() -> void: test("non-auth should not update auth", func(): todo()) test("non-auth should update non-auth", func(): todo()) ) + + define("filtered()", func(): + test("should return empty", func(): todo()) + test("should return identical", func(): todo()) + test("should remove property", func(): todo()) + ) From 1aa8c92d17d96176f6960442e41ebb954635e566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 17 Jan 2026 01:35:54 +0100 Subject: [PATCH 37/95] hacked in state synchronizer without diff states --- addons/netfox/network-time.gd | 6 + addons/netfox/rollback/network-rollback.gd | 7 +- .../netfox/servers/data/network-commands.gd | 2 + .../netfox/servers/rollback-history-server.gd | 111 ++++++++--- .../servers/rollback-simulation-server.gd | 17 +- .../rollback-synchronization-server.gd | 70 +++++-- addons/netfox/state-synchronizer.gd | 188 +++--------------- .../scenes/wanderer.tscn | 2 +- .../state-synchronizer-npc.tscn | 26 ++- 9 files changed, 213 insertions(+), 216 deletions(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index af6ef343..605fd5d0 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -559,12 +559,18 @@ func _loop() -> void: before_tick_loop.emit() before_tick.emit(ticktime, tick) + on_tick.emit(ticktime, tick) + + RollbackHistoryServer.record_sync_state(tick + 1) + RollbackSynchronizationServer.synchronize_sync_state(tick + 1) after_tick.emit(ticktime, tick) _tick += 1 ticks_in_loop += 1 _next_tick_time += ticktime + + RollbackHistoryServer.restore_synchronizer_state(tick) if ticks_in_loop > 0: after_tick_loop.emit() diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 97935481..c54b1191 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -349,7 +349,7 @@ func _rollback() -> void: range_source = "latest state" _resim_from = mini(_resim_from, _latest_state) _resim_from = mini(_resim_from, NetworkTime.tick - 1) - _logger.debug("Simulating range @%d>@%d using %s", [_resim_from, NetworkTime.tick, range_source]) + _logger.trace("Simulating range @%d>@%d using %s", [_resim_from, NetworkTime.tick, range_source]) # _resim_from = maxi(1, history_start + 1) @@ -385,7 +385,8 @@ func _rollback() -> void: # Done individually by Rewindables ( usually Rollback Synchronizers ) # Restore input and state for tick _rollback_stage = _STAGE_PREPARE - RollbackHistoryServer.restore_tick(tick) + RollbackHistoryServer.restore_rollback_input(tick) + RollbackHistoryServer.restore_rollback_state(tick) on_prepare_tick.emit(tick) after_prepare_tick.emit(tick) @@ -408,7 +409,7 @@ func _rollback() -> void: # Restore display state _rollback_stage = _STAGE_AFTER - RollbackHistoryServer.restore_tick(display_tick) + RollbackHistoryServer.restore_rollback_state(display_tick) RollbackHistoryServer.trim_history(history_start) RollbackSimulationServer.trim_ticks_simulated(history_start) after_loop.emit() diff --git a/addons/netfox/servers/data/network-commands.gd b/addons/netfox/servers/data/network-commands.gd index b36d4d34..b690fa24 100644 --- a/addons/netfox/servers/data/network-commands.gd +++ b/addons/netfox/servers/data/network-commands.gd @@ -5,3 +5,5 @@ const FULL_STATE := 0 const DIFF_STATE := 1 const ACKD_STATE := 2 const INPUT := 3 +const FULL_SYNC := 4 # TODO: Find a better name than SYNC +const DIFF_SYNC := 5 # TODO: Find a better name than SYNC diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 02b6f776..2c82aff5 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -1,10 +1,14 @@ extends Node class_name _RollbackHistoryServer +# TODO: Rename to Network(ed?)HistoryServer var _input_properties: Array = [] var _state_properties: Array = [] +var _sync_state_properties: Array = [] # [node, property] tuples -var _snapshots: Dictionary = {} # tick to Snapshot +var _rollback_input_snapshots: Dictionary = {} # tick to Snapshot +var _rollback_state_snapshots: Dictionary = {} # tick to Snapshot +var _sync_state_snapshots: Dictionary = {} # tick to Snapshot static var _logger := NetfoxLogger._for_netfox("RollbackHistoryServer") @@ -30,12 +34,20 @@ func register_input(node: Node, property: NodePath) -> void: func deregister_input(node: Node, property: NodePath) -> void: deregister_property(node, property, _input_properties) -func record_tick(tick: int, properties: Array, predicted_nodes: Array[Node]) -> void: +func register_sync_state(node: Node, property: NodePath) -> void: + register_property(node, property, _sync_state_properties) + +func deregister_sync_state(node: Node, property: NodePath) -> void: + deregister_property(node, property, _sync_state_properties) + +# TODO: Private +# TODO: Replace `predicted_nodes` with a filter callable +func record_tick(tick: int, snapshots: Dictionary, properties: Array, predicted_nodes: Array[Node]) -> void: # Ensure snapshot - var snapshot := _snapshots.get(tick) as Snapshot + var snapshot := snapshots.get(tick) as Snapshot if snapshot == null: snapshot = Snapshot.new(tick) - _snapshots[tick] = snapshot + snapshots[tick] = snapshot # Record values var updated := [] @@ -57,53 +69,94 @@ func record_tick(tick: int, properties: Array, predicted_nodes: Array[Node]) -> _logger.trace("Recorded %d props for tick @%d: %s", [properties.size(), tick, snapshot]) func record_input(tick: int) -> void: - record_tick(tick, _input_properties, []) + record_tick(tick, _rollback_input_snapshots, _input_properties, []) func record_state(tick: int) -> void: - # TODO: Servers preferably shouldn't depend on eachother - record_tick(tick, _state_properties, RollbackSimulationServer.get_predicted_nodes()) + record_tick(tick, _rollback_state_snapshots, _state_properties, RollbackSimulationServer.get_predicted_nodes()) -func restore_tick(tick: int) -> bool: - if not _snapshots.has(tick): +func record_sync_state(tick: int) -> void: + record_tick(tick, _sync_state_snapshots, _sync_state_properties, []) + +# TODO: Private +func restore_tick(tick: int, snapshots: Dictionary) -> bool: + # TODO: Prettier recreation of HistoryBuffer logic and / or reuse HistoryBuffer + if snapshots.is_empty() or tick < snapshots.keys().min(): return false + while not snapshots.has(tick) and tick >= snapshots.keys().min(): + tick -= 1 - var snapshot := _snapshots[tick] as Snapshot - _logger.trace("Restoring snapshot: %s", [snapshot]) + var snapshot := snapshots[tick] as Snapshot + if snapshots == _sync_state_snapshots: + _logger.debug("Restoring snapshot: %s", [snapshot]) snapshot.apply() return true +func restore_rollback_input(tick: int) -> bool: + return restore_tick(tick, _rollback_input_snapshots) + +func restore_rollback_state(tick: int) -> bool: + return restore_tick(tick, _rollback_state_snapshots) + +func restore_synchronizer_state(tick: int) -> bool: + return restore_tick(tick, _sync_state_snapshots) + func trim_history(earliest_tick: int) -> void: - while not _snapshots.is_empty(): - var earliest_stored_tick := _snapshots.keys().min() - if earliest_stored_tick >= earliest_tick: - break - _snapshots.erase(earliest_stored_tick) + var snapshot_pools := [_rollback_input_snapshots, _rollback_state_snapshots, _sync_state_snapshots] as Array[Dictionary] + + for snapshots in snapshot_pools: + while not snapshots.is_empty(): + var earliest_stored_tick := snapshots.keys().min() + if earliest_stored_tick >= earliest_tick: + break + snapshots.erase(earliest_stored_tick) # TODO: Keep snapshots private -func get_snapshot(tick: int) -> Snapshot: - return _snapshots.get(tick) as Snapshot +# TODO: Private +func get_snapshot(tick: int, snapshots: Dictionary) -> Snapshot: + return snapshots.get(tick) as Snapshot + +func get_rollback_input_snapshot(tick: int) -> Snapshot: + return get_snapshot(tick, _rollback_input_snapshots) + +func get_rollback_state_snapshot(tick: int) -> Snapshot: + return get_snapshot(tick, _rollback_state_snapshots) + +func get_synchronizer_state_snapshot(tick: int) -> Snapshot: + return get_snapshot(tick, _sync_state_snapshots) # TODO: Keep snapshots private -func merge_snapshot(snapshot: Snapshot) -> Snapshot: +func merge_snapshot(snapshot: Snapshot, snapshots: Dictionary) -> Snapshot: var tick := snapshot.tick - if not _snapshots.has(snapshot.tick): - _snapshots[tick] = snapshot + if not snapshots.has(snapshot.tick): + snapshots[tick] = snapshot return snapshot - var stored_snapshot := _snapshots[tick] as Snapshot + var stored_snapshot := snapshots[tick] as Snapshot stored_snapshot.merge(snapshot) -# _snapshots[tick] = stored_snapshot return stored_snapshot +func merge_rollback_input(snapshot: Snapshot) -> Snapshot: + return merge_snapshot(snapshot, _rollback_input_snapshots) + +func merge_rollback_state(snapshot: Snapshot) -> Snapshot: + return merge_snapshot(snapshot, _rollback_state_snapshots) + +func merge_synchronizer_state(snapshot: Snapshot) -> Snapshot: + return merge_snapshot(snapshot, _sync_state_snapshots) + func get_data_age_for(what: Node, tick: int) -> int: - if _snapshots.is_empty(): + if _rollback_state_snapshots.is_empty() and _rollback_input_snapshots.is_empty(): return -1 - for i in range(tick, _snapshots.keys().min() - 1, -1): - if not _snapshots.has(i): - continue - var snapshot := get_snapshot(i) - if snapshot.has_node(what, true): + var earliest_tick := mini(_rollback_state_snapshots.keys().min(), _rollback_input_snapshots.keys().min()) + for i in range(tick, earliest_tick - 1, -1): + var input_snapshot := get_rollback_input_snapshot(i) + var state_snapshot := get_rollback_state_snapshot(i) + + var has_input := input_snapshot != null and input_snapshot.has_node(what, true) + var has_state := state_snapshot != null and state_snapshot.has_node(what, true) + + if has_input or has_state: return tick - i return -1 diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index d4938750..6d00f2f4 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -51,9 +51,9 @@ func register_input_for(node: Node, input: Node) -> void: func deregister_input(node: Node) -> void: _input_for.erase(node) -func get_nodes_to_simulate(snapshot: Snapshot) -> Array[Node]: +func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: var result: Array[Node] = [] - if not snapshot: + if not input_snapshot: return [] for node in _callbacks.keys(): @@ -63,7 +63,7 @@ func get_nodes_to_simulate(snapshot: Snapshot) -> Array[Node]: continue var input := _input_for[node] as Node - if not snapshot.has_node(input, true): + if not input_snapshot.has_node(input, true): # We don't have input for node, don't simulate continue @@ -72,10 +72,10 @@ func get_nodes_to_simulate(snapshot: Snapshot) -> Array[Node]: return result # TODO: *Thorough* test for node predict rules -func is_predicting(snapshot: Snapshot, node: Node) -> bool: +func is_predicting(input_snapshot: Snapshot, node: Node) -> bool: var is_owned := node.is_multiplayer_authority() var is_inputless := not _input_for.has(node) - var has_input := false if is_inputless else snapshot.has_node(_input_for[node], true) + var has_input := false if is_inputless else input_snapshot.has_node(_input_for[node], true) if not is_owned and has_input: # We don't own the node, but we own input for it - not (input) predicting @@ -121,8 +121,9 @@ func trim_ticks_simulated(beginning: int) -> void: func simulate(delta: float, tick: int) -> void: _current_object = null - var snapshot := RollbackHistoryServer.get_snapshot(tick) - var nodes := get_nodes_to_simulate(snapshot) + var input_snapshot := RollbackHistoryServer.get_rollback_input_snapshot(tick) + var state_snapshot := RollbackHistoryServer.get_rollback_state_snapshot(tick) + var nodes := get_nodes_to_simulate(input_snapshot) _predicted_nodes.clear() _logger.trace("Simulating %d nodes: %s", [nodes.size(), nodes]) @@ -133,7 +134,7 @@ func simulate(delta: float, tick: int) -> void: # Determine predicted nodes for node in _callbacks.keys(): - if is_predicting(snapshot, node): + if is_predicting(input_snapshot, node): _predicted_nodes.append(node) # Run callbacks and clear group diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index c73dedd8..3fe22369 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -6,6 +6,7 @@ class_name _RollbackSynchronizationServer var _input_properties: Array = [] var _state_properties: Array = [] +var _sync_state_properties: Array = [] as Array[Array] var _visibility_filters := {} # Node to PeerVisibilityFilter @@ -24,6 +25,8 @@ var _input_redundancy := 3 # TODO: Config @onready var _cmd_ack_state := NetworkCommandServer.register_command_at(_NetworkCommands.ACKD_STATE, _handle_ack_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) @onready var _cmd_input := NetworkCommandServer.register_command_at(_NetworkCommands.INPUT, _handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) +@onready var _cmd_full_sync := NetworkCommandServer.register_command_at(_NetworkCommands.FULL_SYNC, _handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) + static var _logger := NetfoxLogger._for_netfox("RollbackSynchronizationServer") signal on_input(snapshot: Snapshot) @@ -51,6 +54,12 @@ func register_input(node: Node, property: NodePath) -> void: func deregister_input(node: Node, property: NodePath) -> void: deregister_property(node, property, _input_properties) +func register_sync_state(node: Node, property: NodePath) -> void: + register_property(node, property, _sync_state_properties) + +func deregister_sync_state(node: Node, property: NodePath) -> void: + deregister_property(node, property, _sync_state_properties) + func register_schema(node: Node, property: NodePath, serializer: NetworkSchemaSerializer) -> void: var key := RecordedProperty.key_of(node, property) _schemas[key] = serializer @@ -77,10 +86,11 @@ func is_property_visible_to(peer: int, node: Node, property: NodePath) -> bool: func get_properties_of(node: Node) -> Array[NodePath]: var result := [] as Array[NodePath] - for property in _state_properties + _input_properties: + # TODO: Split method? Or somehow avoid this merge + for property in _state_properties + _input_properties + _sync_state_properties: var prop_node := RecordedProperty.get_node(property) var prop_path := RecordedProperty.get_property(property) - + if node == prop_node: result.append(prop_path) @@ -92,13 +102,13 @@ func synchronize_input(tick: int) -> void: for offset in _input_redundancy: # Grab snapshot from RollbackHistoryServer - var snapshot := RollbackHistoryServer.get_snapshot(tick - offset) + var snapshot := RollbackHistoryServer.get_rollback_input_snapshot(tick - offset) if not snapshot: break # Filter to input properties # TODO: Optimize, avoid making two copies - var input_snapshot := snapshot.filtered_to_properties(_input_properties).filtered_to_owned() + var input_snapshot := snapshot.filtered_to_owned() # Transmit _logger.trace("Submitting input: %s", [input_snapshot]) @@ -111,12 +121,12 @@ func synchronize_input(tick: int) -> void: # TODO: Make this testable somehow, I beg of you func synchronize_state(tick: int) -> void: # Grab snapshot from RollbackHistoryServer - var snapshot := RollbackHistoryServer.get_snapshot(tick) + var snapshot := RollbackHistoryServer.get_rollback_state_snapshot(tick) if not snapshot: return # Filter to state properties - var state_snapshot := snapshot.filtered_to_properties(_state_properties).filtered_to_auth().filtered_to_owned() + var state_snapshot := snapshot.filtered_to_auth().filtered_to_owned() # Figure out whether to send full- or diff state var is_diff := false @@ -143,7 +153,7 @@ func synchronize_state(tick: int) -> void: continue var reference_tick := _ackd_tick[peer] as int - var reference_snapshot := RollbackHistoryServer.get_snapshot(reference_tick) + var reference_snapshot := RollbackHistoryServer.get_rollback_state_snapshot(reference_tick) if not reference_snapshot: # Reference snapshot not in history, send full state @@ -152,7 +162,7 @@ func synchronize_state(tick: int) -> void: continue # TODO: Optimize, don't create two snapshots - reference_snapshot = reference_snapshot.filtered_to_auth().filtered_to_properties(_state_properties) + reference_snapshot = reference_snapshot.filtered_to_auth() var diff_snapshot := Snapshot.make_patch(reference_snapshot, state_snapshot, tick) diff_snapshot = diff_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) @@ -165,6 +175,21 @@ func synchronize_state(tick: int) -> void: var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) _cmd_full_state.send(_serialize_full_state_for(peer, peer_snapshot), peer) +func synchronize_sync_state(tick: int) -> void: + # TODO: Reduce copy-paste + # Grab snapshot from RollbackHistoryServer + var snapshot := RollbackHistoryServer.get_synchronizer_state_snapshot(tick) + if not snapshot: + return + + # Filter to state properties + var state_snapshot := snapshot.filtered_to_auth().filtered_to_owned() + + # Send full states + for peer in multiplayer.get_peers(): + var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + _cmd_full_sync.send(_serialize_full_state_for(peer, peer_snapshot), peer) + func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeerBuffer = null) -> PackedByteArray: if buffer == null: buffer = StreamPeerBuffer.new() @@ -378,7 +403,7 @@ func _handle_input(sender: int, data: PackedByteArray): # TODO: Only merge inputs we don't have yet, so clients don't cheat by # overriding their earlier choices. Only emit signal for snapshots # that contain new input. - var merged := RollbackHistoryServer.merge_snapshot(snapshot) + var merged := RollbackHistoryServer.merge_rollback_input(snapshot) _logger.trace("Ingested input: %s", [snapshot]) on_input.emit(snapshot) @@ -391,13 +416,32 @@ func _handle_full_state(sender: int, data: PackedByteArray): _ingest_state(sender, snapshot) +func _handle_full_sync(sender: int, data: PackedByteArray): + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + + var snapshot := _deserialize_full_state_of(sender, buffer) + + # TODO: Reduce copy-paste + var original_tick := snapshot.tick + + # Merge received tick onto every tick from received to current PLUS ONE + # We need to override the next tick too with the snapshot data, so on the + # next loop, the received data will be used as the starting point. + # Without doing so, the client will record guessed values and display those, + # hiding state received from the server. + for tick in range(original_tick, NetworkTime.tick + 2): + snapshot.tick = tick + RollbackHistoryServer.merge_synchronizer_state(snapshot) + _logger.debug("Ingested sync state: %s", [snapshot]) + func _handle_diff_state(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data var diff := _deserialize_diff_state_of(sender, buffer) var reference_tick := diff.reference_tick - var reference_snapshot := RollbackHistoryServer.get_snapshot(reference_tick) + var reference_snapshot := RollbackHistoryServer.get_rollback_state_snapshot(reference_tick) _logger.trace("Received diff state for @%d, relative to @%d", [diff.snapshot.tick, reference_tick]) if not reference_snapshot: _logger.warning("Reference tick %d missing for #%d, ignoring", [reference_tick, sender]) @@ -414,14 +458,14 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: # TODO: Sanitize # _logger.debug("Received state snapshot: %s", [snapshot]) - var stored_snapshot := RollbackHistoryServer.get_snapshot(snapshot.tick) + var stored_snapshot := RollbackHistoryServer.get_rollback_state_snapshot(snapshot.tick) if stored_snapshot: var diff := Snapshot.make_patch(stored_snapshot, snapshot, snapshot.tick, false) if not diff.is_empty(): _logger.trace("Reconciled state diff: %s", [diff]) - var merged := RollbackHistoryServer.merge_snapshot(snapshot) - _logger.debug("Ingested state: %s", [snapshot]) + var merged := RollbackHistoryServer.merge_rollback_state(snapshot) + _logger.trace("Ingested state: %s", [snapshot]) # _logger.debug("Merged state; %s", [merged]) if _state_ack_interval >= 1 and (snapshot.tick % _state_ack_interval) == 0: diff --git a/addons/netfox/state-synchronizer.gd b/addons/netfox/state-synchronizer.gd index a750a07e..c6dd2e99 100644 --- a/addons/netfox/state-synchronizer.gd +++ b/addons/netfox/state-synchronizer.gd @@ -45,22 +45,9 @@ var diff_ack_interval: int = 0 # TODO: Don't tie to a network rollback setting? ## Decides which peers will receive updates var visibility_filter := PeerVisibilityFilter.new() -var _property_cache: PropertyCache -var _property_config: _PropertyConfig = _PropertyConfig.new() var _properties_dirty: bool = false - -var _schema: _NetworkSchema - -var _state_history := _PropertyHistoryBuffer.new() - -# Collaborators -var _full_state_encoder: _SnapshotHistoryEncoder -var _diff_state_encoder: _DiffHistoryEncoder - -# State -var _ackd_state: Dictionary = {} -var _next_full_state_tick: int -var _next_diff_ack_tick: int +var _registered_properties := [] as Array[PropertyEntry] +var _schema_props := [] as Array[PropertyEntry] var _is_initialized: bool = false @@ -70,24 +57,20 @@ static var _logger := NetfoxLogger._for_netfox("StateSynchronizer") ## [br][br] ## Call this after any change to configuration. func process_settings() -> void: - _property_cache = PropertyCache.new(root) - _property_config.set_properties_from_paths(properties, _property_cache) - - _full_state_encoder = _SnapshotHistoryEncoder.new(_state_history, _property_cache, _schema) - _diff_state_encoder = _DiffHistoryEncoder.new(_state_history, _property_cache, _schema) - - _diff_state_encoder.add_properties(_property_config.get_properties()) - - _next_full_state_tick = NetworkTime.tick - _next_diff_ack_tick = NetworkTime.tick - - # Scatter full state sends, so not all nodes send at the same tick - if is_inside_tree(): - _next_full_state_tick += hash(root.get_path()) % maxi(1, full_state_interval) - _next_diff_ack_tick += hash(root.get_path()) % maxi(1, diff_ack_interval) - else: - _next_full_state_tick += hash(root.name) % maxi(1, full_state_interval) - _next_diff_ack_tick += hash(root.name) % maxi(1, diff_ack_interval) + # Remove old configuration + for property in _registered_properties: + RollbackHistoryServer.deregister_sync_state(property.node, property.property) + RollbackSynchronizationServer.deregister_sync_state(property.node, property.property) + + # Register new configuration + _registered_properties.clear() + for property_spec in properties: + var property := PropertyEntry.parse(root, property_spec) + _registered_properties.append(property) + RollbackHistoryServer.register_sync_state(property.node, property.property) + RollbackSynchronizationServer.register_sync_state(property.node, property.property) + # TODO: Somehow deregister on destroy + NetworkIdentityServer.register_node(property.node) _is_initialized = true @@ -123,9 +106,17 @@ func add_state(node: Variant, property: String) -> void: ## }) ## [/codeblock] func set_schema(schema: Dictionary) -> void: - _schema = _NetworkSchema.new(schema) - _properties_dirty = true - _reprocess_settings.call_deferred() + # Remove previous schema + for entry in _schema_props: + RollbackSynchronizationServer.deregister_schema(entry.node, entry.property) + _schema_props.clear() + + # Register new schema + for prop in schema: + var prop_entry := PropertyEntry.parse(root, prop) + var serializer := schema[prop] as NetworkSchemaSerializer + RollbackSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer) + _schema_props.append(prop_entry) func _notification(what) -> void: if what == NOTIFICATION_EDITOR_PRE_SAVE: @@ -144,14 +135,6 @@ func _get_configuration_warnings() -> PackedStringArray: add_state(node, prop) ) -func _connect_signals() -> void: - NetworkTime.after_tick.connect(_after_tick) - NetworkTime.after_tick_loop.connect(_after_loop) - -func _disconnect_signals() -> void: - NetworkTime.after_tick.disconnect(_after_tick) - NetworkTime.after_tick_loop.disconnect(_after_loop) - func _enter_tree() -> void: if Engine.is_editor_hint(): return @@ -161,128 +144,11 @@ func _enter_tree() -> void: if not visibility_filter.get_parent(): add_child(visibility_filter) - _connect_signals.call_deferred() process_settings.call_deferred() -func _exit_tree() -> void: - if Engine.is_editor_hint(): - return - - _disconnect_signals() - -func _after_tick(_dt: float, tick: int) -> void: - if is_multiplayer_authority(): - # Submit snapshot - var state := _PropertySnapshot.extract(_property_config.get_properties()) - _state_history.set_snapshot(tick, state) - _broadcast_state(tick, state) - elif not _state_history.is_empty(): - var state := _state_history.get_history(tick) - state.apply(_property_cache) - -func _after_loop() -> void: - _state_history.trim(NetworkTime.tick - NetworkRollback.history_limit) # TODO: Don't tie to rollback? - func _reprocess_settings() -> void: if not _properties_dirty: return _properties_dirty = false process_settings() - -func _broadcast_state(tick: int, state: _PropertySnapshot) -> void: - var is_sending_diffs := NetworkRollback.enable_diff_states # TODO: Don't tie to a rollback setting? - var is_full_state_tick := not is_sending_diffs or (full_state_interval > 0 and tick > _next_full_state_tick) - - if is_full_state_tick: - # Broadcast new full state - for peer in visibility_filter.get_rpc_target_peers(): - _send_full_state(tick, peer) - - # Adjust next full state if sending diffs - if is_sending_diffs: - _next_full_state_tick = tick + full_state_interval - else: - # Send diffs to each peer - for peer in visibility_filter.get_visible_peers(): - var reference_tick := _ackd_state.get(peer, -1) as int - if reference_tick < 0 or not _state_history.has(reference_tick): - # Peer hasn't ack'd any tick, or we don't have the ack'd tick - # Send full state - _logger.trace("Reference tick @%d not found for peer #%s, sending full tick", [reference_tick, peer]) - _send_full_state(tick, peer) - continue - - # Prepare diff - var diff_state_data := _diff_state_encoder.encode(tick, reference_tick, _property_config.get_properties()) - - if _diff_state_encoder.get_full_snapshot().size() == _diff_state_encoder.get_encoded_snapshot().size(): - # State is completely different, send full state - _send_full_state(tick, peer) - else: - # Send only diff - _submit_diff_state.rpc_id(peer, diff_state_data, tick, reference_tick) - - # Push metrics - NetworkPerformance.push_full_state(_diff_state_encoder.get_full_snapshot()) - NetworkPerformance.push_sent_state(_diff_state_encoder.get_encoded_snapshot()) - -func _send_full_state(tick: int, peer: int = 0) -> void: - var full_state_snapshot := _state_history.get_snapshot(tick).as_dictionary() - var full_state_data := _full_state_encoder.encode(tick, _property_config.get_properties()) - - _submit_full_state.rpc_id(peer, full_state_data, tick) - - if peer <= 0: - NetworkPerformance.push_full_state_broadcast(full_state_snapshot) - NetworkPerformance.push_sent_state_broadcast(full_state_snapshot) - else: - NetworkPerformance.push_full_state(full_state_snapshot) - NetworkPerformance.push_sent_state(full_state_snapshot) - -# `serialized_state` is a serialized _PropertySnapshot -@rpc("any_peer", "unreliable_ordered", "call_remote") -func _submit_full_state(data: PackedByteArray, tick: int) -> void: - if not _is_initialized: return - - var sender := multiplayer.get_remote_sender_id() - var snapshot := _full_state_encoder.decode(data, _property_config.get_properties_owned_by(sender)) - - if not _full_state_encoder.apply(tick, snapshot, sender): - # Invalid data - return - - if NetworkRollback.enable_diff_states: - _ack_full_state.rpc_id(sender, tick) - -# State is a serialized _PropertySnapshot (Dictionary[String, Variant]) -@rpc("any_peer", "unreliable_ordered", "call_remote") -func _submit_diff_state(data: PackedByteArray, tick: int, reference_tick: int) -> void: - if not _is_initialized: return - - var sender = multiplayer.get_remote_sender_id() - var diff_snapshot := _diff_state_encoder.decode(data, _property_config.get_properties_owned_by(sender)) - if not _diff_state_encoder.apply(tick, diff_snapshot, reference_tick, sender): - # Invalid data - return - - if NetworkRollback.enable_diff_states: - if diff_ack_interval > 0 and tick > _next_diff_ack_tick: - _ack_diff_state.rpc_id(sender, tick) - _next_diff_ack_tick = tick + diff_ack_interval - -@rpc("any_peer", "reliable", "call_remote") -func _ack_full_state(tick: int) -> void: - var sender_id := multiplayer.get_remote_sender_id() - _ackd_state[sender_id] = tick - - _logger.trace("Peer %d ack'd full state for tick %d", [sender_id, tick]) - -@rpc("any_peer", "unreliable_ordered", "call_remote") -func _ack_diff_state(tick: int) -> void: - if not _is_initialized: return - - var sender_id := multiplayer.get_remote_sender_id() - _ackd_state[sender_id] = tick - - _logger.trace("Peer %d ack'd diff state for tick %d", [sender_id, tick]) diff --git a/examples/state-synchronizer-npc/scenes/wanderer.tscn b/examples/state-synchronizer-npc/scenes/wanderer.tscn index 76e4bf1e..ad3dca9b 100644 --- a/examples/state-synchronizer-npc/scenes/wanderer.tscn +++ b/examples/state-synchronizer-npc/scenes/wanderer.tscn @@ -28,7 +28,7 @@ mesh = SubResource("SphereMesh_lew78") [node name="StateSynchronizer" type="Node" parent="." node_paths=PackedStringArray("root")] script = ExtResource("1_dmbj5") root = NodePath("..") -properties = Array[String]([":position", ":rotation", ":scale"]) +properties = Array[String]([":position", ":quaternion"]) [node name="TickInterpolator" type="Node" parent="." node_paths=PackedStringArray("root")] script = ExtResource("3_nyirf") diff --git a/examples/state-synchronizer-npc/state-synchronizer-npc.tscn b/examples/state-synchronizer-npc/state-synchronizer-npc.tscn index 07ce05c2..fc271635 100644 --- a/examples/state-synchronizer-npc/state-synchronizer-npc.tscn +++ b/examples/state-synchronizer-npc/state-synchronizer-npc.tscn @@ -1,10 +1,21 @@ -[gd_scene load_steps=5 format=3 uid="uid://cl3qmgo4c2nfh"] +[gd_scene load_steps=7 format=3 uid="uid://cl3qmgo4c2nfh"] [ext_resource type="PackedScene" uid="uid://cngy6hs8ohodj" path="res://examples/shared/scenes/map-square.tscn" id="1_mm3em"] [ext_resource type="PackedScene" uid="uid://cncdbq72u50j3" path="res://examples/shared/scenes/environment.tscn" id="2_ykbn6"] [ext_resource type="PackedScene" uid="uid://badtpsxn5lago" path="res://examples/shared/ui/network-popup.tscn" id="3_mhiy8"] +[ext_resource type="Script" path="res://examples/shared/scripts/simple-time-display.gd" id="4_4w44e"] [ext_resource type="PackedScene" uid="uid://ru7kvgw7wjew" path="res://examples/state-synchronizer-npc/scenes/wanderer.tscn" id="4_47wiq"] +[sub_resource type="GDScript" id="GDScript_hpcpy"] +script/source = "extends Label + +func _process(_dt): + if multiplayer.is_server(): + text = \"Server\" + else: + text = \"Client\" +" + [node name="state-synchronizer-npc" type="Node3D"] [node name="Square Map" parent="." instance=ExtResource("1_mm3em")] @@ -26,6 +37,19 @@ offset_top = -120.0 offset_right = 180.0 offset_bottom = 120.0 +[node name="VBoxContainer" type="VBoxContainer" parent="UI"] +layout_mode = 0 +offset_right = 40.0 +offset_bottom = 40.0 + +[node name="Time Display" type="Label" parent="UI/VBoxContainer"] +layout_mode = 2 +script = ExtResource("4_4w44e") + +[node name="Label" type="Label" parent="UI/VBoxContainer"] +layout_mode = 2 +script = SubResource("GDScript_hpcpy") + [node name="NPCs" type="Node" parent="."] [node name="Wanderer" parent="NPCs" instance=ExtResource("4_47wiq")] From d251bd169bf14a1d33c68a853f74b41932a44da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 17 Jan 2026 12:13:06 +0100 Subject: [PATCH 38/95] diff states for state synchronizer --- addons/netfox/network-time.gd | 4 +- .../netfox/servers/rollback-history-server.gd | 21 +++++++- .../rollback-synchronization-server.gd | 52 +++++++++++++------ 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 605fd5d0..aeebe554 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -556,6 +556,7 @@ func _loop() -> void: _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: + RollbackHistoryServer.restore_synchronizer_state(tick) before_tick_loop.emit() before_tick.emit(ticktime, tick) @@ -569,10 +570,9 @@ func _loop() -> void: _tick += 1 ticks_in_loop += 1 _next_tick_time += ticktime - - RollbackHistoryServer.restore_synchronizer_state(tick) if ticks_in_loop > 0: + RollbackHistoryServer.restore_synchronizer_state(tick) after_tick_loop.emit() func _process(delta: float) -> void: diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 2c82aff5..32655114 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -75,7 +75,26 @@ func record_state(tick: int) -> void: record_tick(tick, _rollback_state_snapshots, _state_properties, RollbackSimulationServer.get_predicted_nodes()) func record_sync_state(tick: int) -> void: - record_tick(tick, _sync_state_snapshots, _sync_state_properties, []) + # TODO: Reduce duplication + # Record values + var snapshot := Snapshot.new(tick) + for entry in _sync_state_properties: + var node := entry[0] as Node + var property := entry[1] as NodePath + var is_auth := node.is_multiplayer_authority() + if not is_auth: + continue + + snapshot.merge_property(node, property, RecordedProperty.extract(entry), is_auth) + + if snapshot.is_empty(): + # No auth data in snapshot, nothing to do + return + + if not _sync_state_snapshots.has(tick): + _sync_state_snapshots[tick] = snapshot + else: + (_sync_state_snapshots[tick] as Snapshot).merge(snapshot) # TODO: Private func restore_tick(tick: int, snapshots: Dictionary) -> bool: diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 3fe22369..4e97d6b0 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -15,6 +15,12 @@ var _full_state_interval := 24 # TODO: Config var _state_ack_interval := 2 # TODO: Config var _ackd_tick := {} # peer id to ack'd tick +var _last_sync_state_sent := Snapshot.new(0) +var _last_sync_tick_recvd := -1 +var _sync_enable_diffs := true # TODO: Config +var _sync_full_interval := 24 # TODO: Config +var _sync_full_next := 0 + var _schemas := {} # RecordedProperty key to NetworkSchemaSerializer var _fallback_schema := NetworkSchemas.variant() @@ -26,6 +32,7 @@ var _input_redundancy := 3 # TODO: Config @onready var _cmd_input := NetworkCommandServer.register_command_at(_NetworkCommands.INPUT, _handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) @onready var _cmd_full_sync := NetworkCommandServer.register_command_at(_NetworkCommands.FULL_SYNC, _handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) +@onready var _cmd_diff_sync := NetworkCommandServer.register_command_at(_NetworkCommands.DIFF_SYNC, _handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) static var _logger := NetfoxLogger._for_netfox("RollbackSynchronizationServer") @@ -185,10 +192,24 @@ func synchronize_sync_state(tick: int) -> void: # Filter to state properties var state_snapshot := snapshot.filtered_to_auth().filtered_to_owned() - # Send full states - for peer in multiplayer.get_peers(): - var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) - _cmd_full_sync.send(_serialize_full_state_for(peer, peer_snapshot), peer) + if false: # not _sync_enable_diffs or _sync_full_next <= 0: + _sync_full_next = _sync_full_interval + + # Send full states + for peer in multiplayer.get_peers(): + var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + _cmd_full_sync.send(_serialize_full_state_for(peer, peer_snapshot), peer) + else: + _sync_full_next -= 1 + var diff := Snapshot.make_patch(_last_sync_state_sent, state_snapshot) + + # Send diffs + for peer in multiplayer.get_peers(): + var peer_snapshot := diff.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + _cmd_diff_sync.send(_serialize_diff_state_for(peer, peer_snapshot, 0), peer) + + # Remember last sent state for diffing + _last_sync_state_sent = state_snapshot func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeerBuffer = null) -> PackedByteArray: if buffer == null: @@ -423,17 +444,18 @@ func _handle_full_sync(sender: int, data: PackedByteArray): var snapshot := _deserialize_full_state_of(sender, buffer) # TODO: Reduce copy-paste - var original_tick := snapshot.tick - - # Merge received tick onto every tick from received to current PLUS ONE - # We need to override the next tick too with the snapshot data, so on the - # next loop, the received data will be used as the starting point. - # Without doing so, the client will record guessed values and display those, - # hiding state received from the server. - for tick in range(original_tick, NetworkTime.tick + 2): - snapshot.tick = tick - RollbackHistoryServer.merge_synchronizer_state(snapshot) - _logger.debug("Ingested sync state: %s", [snapshot]) + RollbackHistoryServer.merge_synchronizer_state(snapshot) + _logger.debug("Ingested sync state: %s", [snapshot]) + +func _handle_diff_sync(sender: int, data: PackedByteArray): + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + + var snapshot := _deserialize_diff_state_of(sender, buffer).snapshot + + # TODO: Reduce copy-paste + RollbackHistoryServer.merge_synchronizer_state(snapshot) + _logger.debug("Ingested sync state diff: %s", [snapshot]) func _handle_diff_state(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() From b7859a87243435c84975189d2db918bd651df0d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 17 Jan 2026 12:15:54 +0100 Subject: [PATCH 39/95] fix interval scheduling --- addons/netfox/servers/rollback-synchronization-server.gd | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 4e97d6b0..447574cf 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -18,8 +18,8 @@ var _ackd_tick := {} # peer id to ack'd tick var _last_sync_state_sent := Snapshot.new(0) var _last_sync_tick_recvd := -1 var _sync_enable_diffs := true # TODO: Config -var _sync_full_interval := 24 # TODO: Config -var _sync_full_next := 0 +var _sync_full_interval := 4 # TODO: Config +var _sync_full_next := -1 var _schemas := {} # RecordedProperty key to NetworkSchemaSerializer var _fallback_schema := NetworkSchemas.variant() @@ -192,7 +192,7 @@ func synchronize_sync_state(tick: int) -> void: # Filter to state properties var state_snapshot := snapshot.filtered_to_auth().filtered_to_owned() - if false: # not _sync_enable_diffs or _sync_full_next <= 0: + if not _sync_enable_diffs or _sync_full_next <= 0: _sync_full_next = _sync_full_interval # Send full states From 9066be546404e01691e39a205846602d35b05076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 17 Jan 2026 15:48:19 +0100 Subject: [PATCH 40/95] restore traffic metrics and fix funky issue --- .../netfox/servers/rollback-history-server.gd | 3 +- .../servers/rollback-simulation-server.gd | 8 ++- .../rollback-synchronization-server.gd | 64 ++++++++++++------- examples/forest-brawl/scenes/brawler.tscn | 1 + 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 32655114..c1d05b07 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -60,7 +60,8 @@ func record_tick(tick: int, snapshots: Dictionary, properties: Array, predicted_ # Passing in `RollbackSimulationServer.get_predicted_nodes()` accounts # for *simulated* nodes, but not for nodes with *just* state if properties == _state_properties: - if RollbackSimulationServer.is_predicting(snapshot, node): + var input_snapshot := _rollback_input_snapshots.get(tick) as Snapshot + if RollbackSimulationServer.is_predicting(input_snapshot, node): is_auth = false if snapshot.merge_property(node, property, RecordedProperty.extract(entry), is_auth): diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 6d00f2f4..8aae9b38 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -75,8 +75,12 @@ func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: func is_predicting(input_snapshot: Snapshot, node: Node) -> bool: var is_owned := node.is_multiplayer_authority() var is_inputless := not _input_for.has(node) - var has_input := false if is_inputless else input_snapshot.has_node(_input_for[node], true) - + var has_input := false if is_inputless else true + + # TODO: Avoid supporting null snapshots if possible + if not is_inputless and input_snapshot: + has_input = input_snapshot.has_node(_input_for[node], true) + if not is_owned and has_input: # We don't own the node, but we own input for it - not (input) predicting return false diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 447574cf..9498d39b 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -18,7 +18,7 @@ var _ackd_tick := {} # peer id to ack'd tick var _last_sync_state_sent := Snapshot.new(0) var _last_sync_tick_recvd := -1 var _sync_enable_diffs := true # TODO: Config -var _sync_full_interval := 4 # TODO: Config +var _sync_full_interval := 24 # TODO: Config var _sync_full_next := -1 var _schemas := {} # RecordedProperty key to NetworkSchemaSerializer @@ -135,6 +135,11 @@ func synchronize_state(tick: int) -> void: # Filter to state properties var state_snapshot := snapshot.filtered_to_auth().filtered_to_owned() + # TODO: Early exit if we don't have auth state props + if state_snapshot.is_empty(): + # Nothing to send + return + # Figure out whether to send full- or diff state var is_diff := false if _enable_diff_states: @@ -175,12 +180,21 @@ func synchronize_state(tick: int) -> void: diff_snapshot = diff_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) _cmd_diff_state.send(_serialize_diff_state_for(peer, diff_snapshot, reference_tick), peer) - _logger.trace("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) + _logger.debug("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) + + NetworkPerformance.push_full_state(state_snapshot.data) # TODO: Ugh... + NetworkPerformance.push_sent_state(diff_snapshot.data) # TODO: Ugh... + _logger.debug("Pushed diff state metrics: %d sent, %d full", [diff_snapshot.data.size(), state_snapshot.data.size()]) # Send full states for peer in full_state_peers: var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) _cmd_full_state.send(_serialize_full_state_for(peer, peer_snapshot), peer) + _logger.debug("Sent full state to #%d: %s", [peer, peer_snapshot]) + + NetworkPerformance.push_full_state(peer_snapshot.data) # TODO: Ugh... + NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... + _logger.debug("Pushed full state metrics: %d sent, %d full", [peer_snapshot.data.size(), peer_snapshot.data.size()]) func synchronize_sync_state(tick: int) -> void: # TODO: Reduce copy-paste @@ -199,6 +213,9 @@ func synchronize_sync_state(tick: int) -> void: for peer in multiplayer.get_peers(): var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) _cmd_full_sync.send(_serialize_full_state_for(peer, peer_snapshot), peer) + + NetworkPerformance.push_full_state(peer_snapshot.data) # TODO: Ugh... + NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... else: _sync_full_next -= 1 var diff := Snapshot.make_patch(_last_sync_state_sent, state_snapshot) @@ -207,6 +224,9 @@ func synchronize_sync_state(tick: int) -> void: for peer in multiplayer.get_peers(): var peer_snapshot := diff.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) _cmd_diff_sync.send(_serialize_diff_state_for(peer, peer_snapshot, 0), peer) + + NetworkPerformance.push_full_state(state_snapshot.data) # TODO: Ugh... + NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... # Remember last sent state for diffing _last_sync_state_sent = state_snapshot @@ -437,6 +457,25 @@ func _handle_full_state(sender: int, data: PackedByteArray): _ingest_state(sender, snapshot) +func _handle_diff_state(sender: int, data: PackedByteArray): + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + + var diff := _deserialize_diff_state_of(sender, buffer) + var reference_tick := diff.reference_tick + var reference_snapshot := RollbackHistoryServer.get_rollback_state_snapshot(reference_tick) + _logger.trace("Received diff state for @%d, relative to @%d", [diff.snapshot.tick, reference_tick]) + if not reference_snapshot: + _logger.warning("Reference tick %d missing for #%d, ignoring", [reference_tick, sender]) + return + + var snapshot := reference_snapshot.duplicate() + snapshot.merge(diff.snapshot) + snapshot.tick = diff.snapshot.tick + + # TODO: Using `snapshot` doesn't work + _ingest_state(sender, diff.snapshot) + func _handle_full_sync(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data @@ -457,25 +496,6 @@ func _handle_diff_sync(sender: int, data: PackedByteArray): RollbackHistoryServer.merge_synchronizer_state(snapshot) _logger.debug("Ingested sync state diff: %s", [snapshot]) -func _handle_diff_state(sender: int, data: PackedByteArray): - var buffer := StreamPeerBuffer.new() - buffer.data_array = data - - var diff := _deserialize_diff_state_of(sender, buffer) - var reference_tick := diff.reference_tick - var reference_snapshot := RollbackHistoryServer.get_rollback_state_snapshot(reference_tick) - _logger.trace("Received diff state for @%d, relative to @%d", [diff.snapshot.tick, reference_tick]) - if not reference_snapshot: - _logger.warning("Reference tick %d missing for #%d, ignoring", [reference_tick, sender]) - return - - var snapshot := reference_snapshot.duplicate() - snapshot.merge(diff.snapshot) - snapshot.tick = diff.snapshot.tick - - # TODO: Using `snapshot` doesn't work - _ingest_state(sender, diff.snapshot) - func _ingest_state(sender: int, snapshot: Snapshot) -> void: # TODO: Sanitize # _logger.debug("Received state snapshot: %s", [snapshot]) @@ -487,7 +507,7 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: _logger.trace("Reconciled state diff: %s", [diff]) var merged := RollbackHistoryServer.merge_rollback_state(snapshot) - _logger.trace("Ingested state: %s", [snapshot]) + _logger.debug("Ingested state: %s", [snapshot]) # _logger.debug("Merged state; %s", [merged]) if _state_ack_interval >= 1 and (snapshot.tick % _state_ack_interval) == 0: diff --git a/examples/forest-brawl/scenes/brawler.tscn b/examples/forest-brawl/scenes/brawler.tscn index 63073744..f44089a6 100644 --- a/examples/forest-brawl/scenes/brawler.tscn +++ b/examples/forest-brawl/scenes/brawler.tscn @@ -148,6 +148,7 @@ script = ExtResource("7_cmfmx") root = NodePath("..") enable_prediction = true state_properties = Array[String]([":transform", ":velocity", ":speed", ":mass"]) +full_state_interval = 24 diff_ack_interval = 4 input_properties = Array[String](["Input:movement", "Input:aim"]) enable_input_broadcast = false From 99b982dd6359aff8d2b393cc5eab73c87a4fb91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 17 Jan 2026 16:11:32 +0100 Subject: [PATCH 41/95] simplify diff states --- .../netfox/servers/data/network-commands.gd | 7 +- .../rollback-synchronization-server.gd | 158 ++++++++---------- 2 files changed, 69 insertions(+), 96 deletions(-) diff --git a/addons/netfox/servers/data/network-commands.gd b/addons/netfox/servers/data/network-commands.gd index b690fa24..bf8aa97f 100644 --- a/addons/netfox/servers/data/network-commands.gd +++ b/addons/netfox/servers/data/network-commands.gd @@ -3,7 +3,6 @@ class_name _NetworkCommands const FULL_STATE := 0 const DIFF_STATE := 1 -const ACKD_STATE := 2 -const INPUT := 3 -const FULL_SYNC := 4 # TODO: Find a better name than SYNC -const DIFF_SYNC := 5 # TODO: Find a better name than SYNC +const INPUT := 2 +const FULL_SYNC := 3 # TODO: Find a better name than SYNC +const DIFF_SYNC := 4 # TODO: Find a better name than SYNC diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 9498d39b..c77d01b5 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -1,9 +1,6 @@ extends Node class_name _RollbackSynchronizationServer -# TODO: Support various encoders -# TODO: Honor visibility filters - var _input_properties: Array = [] var _state_properties: Array = [] var _sync_state_properties: Array = [] as Array[Array] @@ -11,12 +8,11 @@ var _sync_state_properties: Array = [] as Array[Array] var _visibility_filters := {} # Node to PeerVisibilityFilter var _enable_diff_states := true # TODO: Config -var _full_state_interval := 24 # TODO: Config -var _state_ack_interval := 2 # TODO: Config -var _ackd_tick := {} # peer id to ack'd tick +var _rb_enable_diffs := true # TODO: Config +var _rb_full_interval := 24 # TODO: Config +var _rb_full_next := -1 var _last_sync_state_sent := Snapshot.new(0) -var _last_sync_tick_recvd := -1 var _sync_enable_diffs := true # TODO: Config var _sync_full_interval := 24 # TODO: Config var _sync_full_next := -1 @@ -28,7 +24,6 @@ var _input_redundancy := 3 # TODO: Config @onready var _cmd_full_state := NetworkCommandServer.register_command_at(_NetworkCommands.FULL_STATE, _handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) @onready var _cmd_diff_state := NetworkCommandServer.register_command_at(_NetworkCommands.DIFF_STATE, _handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) -@onready var _cmd_ack_state := NetworkCommandServer.register_command_at(_NetworkCommands.ACKD_STATE, _handle_ack_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) @onready var _cmd_input := NetworkCommandServer.register_command_at(_NetworkCommands.INPUT, _handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) @onready var _cmd_full_sync := NetworkCommandServer.register_command_at(_NetworkCommands.FULL_SYNC, _handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) @@ -130,6 +125,7 @@ func synchronize_state(tick: int) -> void: # Grab snapshot from RollbackHistoryServer var snapshot := RollbackHistoryServer.get_rollback_state_snapshot(tick) if not snapshot: + # No data for tick return # Filter to state properties @@ -142,59 +138,50 @@ func synchronize_state(tick: int) -> void: # Figure out whether to send full- or diff state var is_diff := false - if _enable_diff_states: - if _full_state_interval <= 0: + if _rb_enable_diffs: + if _rb_full_interval <= 0: is_diff = true - elif _full_state_interval >= 1 and (tick % _full_state_interval) != 0: - # TODO: Something better than modulo logic? --^ + elif _rb_full_next < 0: is_diff = true - var full_state_peers := [] as Array[int] - var diff_state_peers := [] as Array[int] + # Check if we have history to diff to + var reference_snapshot := RollbackHistoryServer.get_rollback_state_snapshot(tick - 1) + if not reference_snapshot: + is_diff = false if is_diff: - diff_state_peers.assign(multiplayer.get_peers()) - else: - full_state_peers.assign(multiplayer.get_peers()) - - # Send diff states - for peer in diff_state_peers: - if not _ackd_tick.has(peer): - # We don't know any state the peer knows, send full state - full_state_peers.append(peer) - continue - - var reference_tick := _ackd_tick[peer] as int - var reference_snapshot := RollbackHistoryServer.get_rollback_state_snapshot(reference_tick) - - if not reference_snapshot: - # Reference snapshot not in history, send full state - _logger.warning("Tick @%d not present in history, can't use it as reference for peer #%d ( ack: %s )", [reference_tick, peer, _ackd_tick]) - full_state_peers.append(peer) - continue - - # TODO: Optimize, don't create two snapshots - reference_snapshot = reference_snapshot.filtered_to_auth() + _rb_full_next -= 1 + reference_snapshot = reference_snapshot.filtered_to_auth().filtered_to_owned() + var diff := Snapshot.make_patch(reference_snapshot, state_snapshot) + if diff.is_empty(): + # Nothing changed, don't send anything + return + + # Send diff states + for peer in multiplayer.get_peers(): + var peer_diff := diff.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + if peer_diff.is_empty(): + # Peer can't see any changes, send nothing + continue - var diff_snapshot := Snapshot.make_patch(reference_snapshot, state_snapshot, tick) - diff_snapshot = diff_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + _cmd_diff_state.send(_serialize_diff_state_for(peer, peer_diff), peer) - _cmd_diff_state.send(_serialize_diff_state_for(peer, diff_snapshot, reference_tick), peer) - _logger.debug("Sent diff state for @%d <- @%d to #%d", [tick, reference_tick, peer]) - - NetworkPerformance.push_full_state(state_snapshot.data) # TODO: Ugh... - NetworkPerformance.push_sent_state(diff_snapshot.data) # TODO: Ugh... - _logger.debug("Pushed diff state metrics: %d sent, %d full", [diff_snapshot.data.size(), state_snapshot.data.size()]) + NetworkPerformance.push_full_state(state_snapshot.data) # TODO: Ugh... + NetworkPerformance.push_sent_state(diff.data) # TODO: Ugh... + else: + # Send full states + for peer in multiplayer.get_peers(): + var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + if peer_snapshot.is_empty(): + # Peer can't see anything, send nothing + continue - # Send full states - for peer in full_state_peers: - var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) - _cmd_full_state.send(_serialize_full_state_for(peer, peer_snapshot), peer) - _logger.debug("Sent full state to #%d: %s", [peer, peer_snapshot]) + _cmd_full_state.send(_serialize_full_state_for(peer, peer_snapshot), peer) + _logger.trace("Sent full state to #%d: %s", [peer, peer_snapshot]) - NetworkPerformance.push_full_state(peer_snapshot.data) # TODO: Ugh... - NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... - _logger.debug("Pushed full state metrics: %d sent, %d full", [peer_snapshot.data.size(), peer_snapshot.data.size()]) + NetworkPerformance.push_full_state(peer_snapshot.data) # TODO: Ugh... + NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... + _logger.debug("Pushed full state metrics: %d sent, %d full", [peer_snapshot.data.size(), peer_snapshot.data.size()]) func synchronize_sync_state(tick: int) -> void: # TODO: Reduce copy-paste @@ -205,13 +192,26 @@ func synchronize_sync_state(tick: int) -> void: # Filter to state properties var state_snapshot := snapshot.filtered_to_auth().filtered_to_owned() - - if not _sync_enable_diffs or _sync_full_next <= 0: + + # Figure out whether to send full- or diff state + var is_diff := false + if _sync_enable_diffs: + if _sync_full_interval <= 0: + is_diff = true + elif _sync_full_next < 0: + is_diff = true + + if not is_diff: _sync_full_next = _sync_full_interval # Send full states for peer in multiplayer.get_peers(): var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + + if peer_snapshot.is_empty(): + # Peer can't see anything, send nothing + continue + _cmd_full_sync.send(_serialize_full_state_for(peer, peer_snapshot), peer) NetworkPerformance.push_full_state(peer_snapshot.data) # TODO: Ugh... @@ -223,7 +223,12 @@ func synchronize_sync_state(tick: int) -> void: # Send diffs for peer in multiplayer.get_peers(): var peer_snapshot := diff.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) - _cmd_diff_sync.send(_serialize_diff_state_for(peer, peer_snapshot, 0), peer) + + if peer_snapshot.is_empty(): + # Nothing changed, don't send anything + continue + + _cmd_diff_sync.send(_serialize_diff_state_for(peer, peer_snapshot), peer) NetworkPerformance.push_full_state(state_snapshot.data) # TODO: Ugh... NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... @@ -308,7 +313,7 @@ func _deserialize_full_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bo return snapshot -func _serialize_diff_state_for(peer: int, snapshot: Snapshot, reference_tick: int, buffer: StreamPeerBuffer = null) -> PackedByteArray: +func _serialize_diff_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeerBuffer = null) -> PackedByteArray: if buffer == null: buffer = StreamPeerBuffer.new() @@ -320,7 +325,6 @@ func _serialize_diff_state_for(peer: int, snapshot: Snapshot, reference_tick: in # Write ticks buffer.put_u32(snapshot.tick) - buffer.put_u32(reference_tick) # TODO: Include property config hash to detect mismatches # For each node @@ -357,7 +361,7 @@ func _serialize_diff_state_for(peer: int, snapshot: Snapshot, reference_tick: in return buffer.data_array -func _deserialize_diff_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> DiffSnapshot: +func _deserialize_diff_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: var netref := NetworkSchemas.netref() var varuint := NetworkSchemas.varuint() var varbits := NetworkSchemas._varbits() @@ -365,7 +369,6 @@ func _deserialize_diff_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bo # Grab ticks var tick := buffer.get_u32() - var reference_tick := buffer.get_u32() # TODO: Include property config hash to detect mismatches var snapshot := Snapshot.new(tick) @@ -393,7 +396,7 @@ func _deserialize_diff_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bo var property := properties[idx] var value := _deserialize_property(node, property, node_buffer) snapshot.set_property(node, property, value, is_auth) - return DiffSnapshot.new(snapshot, reference_tick) + return snapshot func _serialize_input_for(peer: int, snapshots: Array[Snapshot], buffer: StreamPeerBuffer = null) -> PackedByteArray: var varuint := NetworkSchemas.varuint() @@ -462,19 +465,10 @@ func _handle_diff_state(sender: int, data: PackedByteArray): buffer.data_array = data var diff := _deserialize_diff_state_of(sender, buffer) - var reference_tick := diff.reference_tick - var reference_snapshot := RollbackHistoryServer.get_rollback_state_snapshot(reference_tick) - _logger.trace("Received diff state for @%d, relative to @%d", [diff.snapshot.tick, reference_tick]) - if not reference_snapshot: - _logger.warning("Reference tick %d missing for #%d, ignoring", [reference_tick, sender]) - return - - var snapshot := reference_snapshot.duplicate() - snapshot.merge(diff.snapshot) - snapshot.tick = diff.snapshot.tick + _logger.trace("Received diff state for @%d", [diff.tick]) # TODO: Using `snapshot` doesn't work - _ingest_state(sender, diff.snapshot) + _ingest_state(sender, diff) func _handle_full_sync(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() @@ -490,7 +484,7 @@ func _handle_diff_sync(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data - var snapshot := _deserialize_diff_state_of(sender, buffer).snapshot + var snapshot := _deserialize_diff_state_of(sender, buffer) # TODO: Reduce copy-paste RollbackHistoryServer.merge_synchronizer_state(snapshot) @@ -508,25 +502,5 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: var merged := RollbackHistoryServer.merge_rollback_state(snapshot) _logger.debug("Ingested state: %s", [snapshot]) -# _logger.debug("Merged state; %s", [merged]) - - if _state_ack_interval >= 1 and (snapshot.tick % _state_ack_interval) == 0: - _logger.trace("ACK'ing state @%d from #%d", [snapshot.tick, sender]) - var buffer := StreamPeerBuffer.new() - buffer.put_u32(snapshot.tick) - _cmd_ack_state.send(buffer.data_array, sender) on_state.emit(snapshot) - -func _handle_ack_state(sender: int, data: PackedByteArray): - var tick := data.decode_u32(0) - _ackd_tick[sender] = maxi(tick, _ackd_tick.get(sender, tick)) - _logger.trace("Received ACK for state @%d from #%d, new is %d", [tick, sender, _ackd_tick[sender]]) - -class DiffSnapshot: - var snapshot: Snapshot - var reference_tick: int - - func _init(p_snapshot: Snapshot, p_reference_tick: int): - snapshot = p_snapshot - reference_tick = p_reference_tick From ef7c1403d22437d616cdf20143ea48c4d0edd630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 17 Jan 2026 20:41:31 +0100 Subject: [PATCH 42/95] use commands for netids --- .../netfox/servers/data/network-commands.gd | 13 ++++--- .../netfox/servers/network-identity-server.gd | 35 ++++++++++++++++--- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/addons/netfox/servers/data/network-commands.gd b/addons/netfox/servers/data/network-commands.gd index bf8aa97f..8c0e2a08 100644 --- a/addons/netfox/servers/data/network-commands.gd +++ b/addons/netfox/servers/data/network-commands.gd @@ -1,8 +1,11 @@ extends Object class_name _NetworkCommands -const FULL_STATE := 0 -const DIFF_STATE := 1 -const INPUT := 2 -const FULL_SYNC := 3 # TODO: Find a better name than SYNC -const DIFF_SYNC := 4 # TODO: Find a better name than SYNC +const IDS := 0 + +const FULL_STATE := 1 +const DIFF_STATE := 2 +const INPUT := 3 + +const FULL_SYNC := 4 # TODO: Find a better name than SYNC +const DIFF_SYNC := 5 # TODO: Find a better name than SYNC diff --git a/addons/netfox/servers/network-identity-server.gd b/addons/netfox/servers/network-identity-server.gd index 5dc612ae..409efd64 100644 --- a/addons/netfox/servers/network-identity-server.gd +++ b/addons/netfox/servers/network-identity-server.gd @@ -4,6 +4,8 @@ var _next_id := 0 var _identifiers := {} # object to NetworkIdentifier var _push_queue := [] as Array[IdentityNotification] +@onready var _cmd_ids := NetworkCommandServer.register_command_at(_NetworkCommands.IDS, _handle_ids) + static var _logger := NetfoxLogger._for_netfox("NetworkIdentityServer") func register(what: Object, path: String) -> void: @@ -53,7 +55,7 @@ func flush_queue() -> void: ids[item.peer][item.identifier.get_full_name()] = item.identifier.get_local_id() for peer in ids: - _submit_ids.rpc_id(peer, ids[peer]) + _cmd_ids.send(_serialize_ids(ids[peer]), peer) func _get_identifier_by_name(full_name: String) -> NetworkIdentifier: # TODO: Optimize, probably by caching @@ -75,9 +77,8 @@ func _make_id() -> int: _next_id += 1 return _next_id -@rpc("any_peer", "call_remote", "unreliable") -func _submit_ids(ids: Dictionary) -> void: - var sender := multiplayer.get_remote_sender_id() +func _handle_ids(sender: int, data: PackedByteArray) -> void: + var ids := _deserialize_ids(data) for full_name in ids: var id := ids[full_name] as int @@ -89,6 +90,32 @@ func _submit_ids(ids: Dictionary) -> void: continue identifier.set_id_for(sender, id) +func _serialize_ids(ids: Dictionary) -> PackedByteArray: + var buffer := StreamPeerBuffer.new() + var varuint := NetworkSchemas.varuint() + + for full_name in ids.keys(): + var id := ids[full_name] as int + + buffer.put_utf8_string(full_name) + varuint.encode(ids[full_name], buffer) + + return buffer.data_array + +func _deserialize_ids(data: PackedByteArray) -> Dictionary: + var ids := {} + var varuint := NetworkSchemas.varuint() + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + + while buffer.get_available_bytes() > 0: + var full_name := buffer.get_utf8_string() + var id := varuint.decode(buffer) as int + + ids[full_name] = id + + return ids + # TODO: Consider private class NetworkIdentifier: var _subject: Object From 7707165280778b293452cba75a6d8f289fd80cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 17 Jan 2026 21:38:40 +0100 Subject: [PATCH 43/95] quick graph impl to support ( at least in theory ) multiple inputs per state node --- addons/netfox.internals/graph.gd | 60 +++++++++++++++++++ .../netfox/rollback/rollback-synchronizer.gd | 1 + addons/netfox/servers/data/snapshot.gd | 15 ++++- .../servers/rollback-simulation-server.gd | 29 +++++---- .../rollback-synchronization-server.gd | 15 +++++ test/netfox.internals/graph.test.gd | 40 +++++++++++++ 6 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 addons/netfox.internals/graph.gd create mode 100644 test/netfox.internals/graph.test.gd diff --git a/addons/netfox.internals/graph.gd b/addons/netfox.internals/graph.gd new file mode 100644 index 00000000..030ba570 --- /dev/null +++ b/addons/netfox.internals/graph.gd @@ -0,0 +1,60 @@ +extends RefCounted +class_name _Graph + +# TODO: Optimize with caches +var _links := [] as Array[Link] + +func link(from: Variant, to: Variant) -> void: + if has_link(from, to): + return + _links.append(Link.new(from, to)) + +func unlink(from: Variant, to: Variant) -> void: + for i in _links.size(): + var link := _links[i] + if link.from == from and link.to == to: + _links.remove_at(i) + break + +func erase(node: Variant) -> void: + # TODO: Measure if recreating the links is faster or if it's faster in-place + var filtered_links := [] as Array[Link] + + for link in _links: + if link.from == node or link.to == node: + continue + filtered_links.append(link) + + _links = filtered_links + +func get_linked_from(from: Variant) -> Array: + var result := [] + + for link in _links: + if link.from == from: + result.append(link.to) + + return result + +func get_linked_to(to: Variant) -> Array: + var result := [] + + for link in _links: + if link.to == to: + result.append(link.from) + + return result + +func has_link(from: Variant, to: Variant) -> bool: + for link in _links: + if link.from == from and link.to == to: + return true + return false + +class Link: + var from: Variant + var to: Variant + + func _init(p_from: Variant, p_to: Variant): + from = p_from + to = p_to diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 4cc693e5..3d0b27f8 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -125,6 +125,7 @@ func process_settings() -> void: # Both simulated and state nodes depend on all inputs # TODO: Write tests for setups where a node is synchronized but not simulated + # TODO: Deregister eventually for node in nodes + _state_nodes: for input_node in _input_nodes: RollbackSimulationServer.register_input_for(node, input_node) diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index fa80f669..b6e986ec 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -116,7 +116,20 @@ func has_node(node: Node, require_auth: bool = false) -> bool: var entry_node := entry[0] as Node if entry_node != node: continue - + + var is_auth := _is_authoritative.get(entry, false) as bool + if require_auth and not is_auth: + continue + + return true + return false + +func has_nodes(nodes: Array[Node], require_auth: bool = false) -> bool: + for entry in data.keys(): + var entry_node := entry[0] as Node + if not nodes.has(entry_node): + continue + var is_auth := _is_authoritative.get(entry, false) as bool if require_auth and not is_auth: continue diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 8aae9b38..be5bd229 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -4,14 +4,13 @@ class_name _RollbackSimulationServer # node to callback # TODO: Consider allowing any Object, not just nodes var _callbacks := {} -# node to input node -# TODO: Support multiple input nodes for a single simulated node -var _input_for := {} # node to array of ticks # used for is_fresh # TODO: Refactor to ringbuffer containing sets of nodes? var _simulated_ticks := {} +var _input_graph := _Graph.new() # Links inputs to objects controlled by them + # Currently simulated object var _current_object: Object = null # Predicted nodes for next simulation @@ -38,18 +37,17 @@ func deregister(callback: Callable) -> void: if _callbacks[object] != callback: return _callbacks.erase(object) - _input_for.erase(object) + _input_graph.erase(object) _simulated_ticks.erase(object) func deregister_node(node: Node) -> void: deregister(_callbacks.get(node)) func register_input_for(node: Node, input: Node) -> void: - # TODO: Support multiple input nodes per node - _input_for[node] = input + _input_graph.link(input, node) -func deregister_input(node: Node) -> void: - _input_for.erase(node) +func deregister_input(node: Node, input: Node) -> void: + _input_graph.unlink(input, node) func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: var result: Array[Node] = [] @@ -57,13 +55,15 @@ func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: return [] for node in _callbacks.keys(): - if not _input_for.has(node): + var inputs := [] as Array[Node] + inputs.assign(_input_graph.get_linked_to(node)) + + if inputs.is_empty(): # Node has no input, simulate it result.append(node) continue - var input := _input_for[node] as Node - if not input_snapshot.has_node(input, true): + if not input_snapshot.has_nodes(inputs, true): # We don't have input for node, don't simulate continue @@ -73,13 +73,16 @@ func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: # TODO: *Thorough* test for node predict rules func is_predicting(input_snapshot: Snapshot, node: Node) -> bool: + var input_nodes := [] as Array[Node] + input_nodes.assign(_input_graph.get_linked_to(node)) + var is_owned := node.is_multiplayer_authority() - var is_inputless := not _input_for.has(node) + var is_inputless := input_nodes.is_empty() var has_input := false if is_inputless else true # TODO: Avoid supporting null snapshots if possible if not is_inputless and input_snapshot: - has_input = input_snapshot.has_node(_input_for[node], true) + has_input = input_snapshot.has_nodes(input_nodes, true) if not is_owned and has_input: # We don't own the node, but we own input for it - not (input) predicting diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index c77d01b5..dc37a46b 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -102,6 +102,21 @@ func get_properties_of(node: Node) -> Array[NodePath]: func synchronize_input(tick: int) -> void: var snapshots := [] as Array[Snapshot] + if false: + # Grab owned input objects + var input_objects := _Set.new() + for prop in _input_properties: + var node := RecordedProperty.get_node(prop) + input_objects.add(node) + + var notified_peers := _Set.new() + # for each input object + for input_object in input_objects: + pass + # Grab state objects controlled by input + # Create set with peers owning state objects + # Only send input to peers in set + for offset in _input_redundancy: # Grab snapshot from RollbackHistoryServer var snapshot := RollbackHistoryServer.get_rollback_input_snapshot(tick - offset) diff --git a/test/netfox.internals/graph.test.gd b/test/netfox.internals/graph.test.gd new file mode 100644 index 00000000..63f245e0 --- /dev/null +++ b/test/netfox.internals/graph.test.gd @@ -0,0 +1,40 @@ +extends VestTest + +func get_suite_name() -> String: + return "Graph" + +func suite(): + test("should add link", func(): + var graph := _Graph.new() + graph.link("foo", "bar") + + expect(graph.has_link("foo", "bar")) + expect_equal(graph.get_linked_from("foo"), ["bar"]) + expect_equal(graph.get_linked_to("bar"), ["foo"]) + ) + + test("should remove link", func(): + var graph := _Graph.new() + graph.link("foo", "bar") + graph.link("quix", "baz") + + graph.unlink("foo", "bar") + + expect_not(graph.has_link("foo", "bar")) + expect(graph.has_link("quix", "baz")) + expect_empty(graph.get_linked_from("foo")) + expect_empty(graph.get_linked_to("bar")) + ) + + test("should erase", func(): + var graph := _Graph.new() + graph.link("foo", "bar") + graph.link("foo", "baz") + graph.link("quix", "baz") + + graph.erase("foo") + + expect_not(graph.has_link("foo", "bar"), "Link was not erased!") + expect_not(graph.has_link("foo", "baz"), "Link was not erased!") + expect(graph.has_link("quix", "baz"), "Link was erased!") + ) From 49148290b6c8f6a365ccbac8be2e84c484adb5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 17 Jan 2026 21:53:54 +0100 Subject: [PATCH 44/95] input broadcast toggle --- addons/netfox/rollback/network-rollback.gd | 4 +++ .../servers/rollback-simulation-server.gd | 5 +++ .../rollback-synchronization-server.gd | 36 ++++++++++++------- .../visibility-filtering.tscn | 25 ++++++++++++- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index c54b1191..85299024 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -271,6 +271,7 @@ func is_just_mutated(target: Object, p_tick: int = tick) -> bool: return false ## Register that a node has submitted its input for a specific tick +# TODO: Make sure this works func register_input_submission(root_node: Node, tick: int) -> void: if not _input_submissions.has(root_node): _input_submissions[root_node] = tick @@ -280,18 +281,21 @@ func register_input_submission(root_node: Node, tick: int) -> void: ## Get the latest input tick submitted by a specific root node ## [br][br] ## Returns [code]-1[/code] if no input was submitted for the node, ever. +# TODO: Make sure this works func get_latest_input_tick(root_node: Node) -> int: if _input_submissions.has(root_node): return _input_submissions[root_node] return -1 ## Check if a node has submitted input for a specific tick (or later) +# TODO: Make sure this works func has_input_for_tick(root_node: Node, tick: int) -> bool: return _input_submissions.has(root_node) and _input_submissions[root_node] >= tick ## Free all input submission data for a node ## [br][br] ## Use this once the node is freed. +# TODO: Make sure this works func free_input_submission_data_for(root_node: Node) -> void: _input_submissions.erase(root_node) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index be5bd229..e0fb0c75 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -161,3 +161,8 @@ func simulate(delta: float, tick: int) -> void: func get_predicted_nodes() -> Array[Node]: return _predicted_nodes + +func get_controlled_by(input: Node) -> Array[Node]: + var result := [] as Array[Node] + result.assign(_input_graph.get_linked_from(input)) + return result diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index dc37a46b..ee1138f4 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -7,9 +7,9 @@ var _sync_state_properties: Array = [] as Array[Array] var _visibility_filters := {} # Node to PeerVisibilityFilter -var _enable_diff_states := true # TODO: Config -var _rb_enable_diffs := true # TODO: Config -var _rb_full_interval := 24 # TODO: Config +var _rb_enable_input_broadcast := false # TODO: Config +var _rb_enable_diffs := true # TODO: Config +var _rb_full_interval := 24 # TODO: Config var _rb_full_next := -1 var _last_sync_state_sent := Snapshot.new(0) @@ -101,21 +101,29 @@ func get_properties_of(node: Node) -> Array[NodePath]: # TODO: Make this testable somehow, I beg of you func synchronize_input(tick: int) -> void: var snapshots := [] as Array[Snapshot] + var notified_peers := _Set.new() - if false: + if not _rb_enable_input_broadcast: # Grab owned input objects var input_objects := _Set.new() for prop in _input_properties: var node := RecordedProperty.get_node(prop) input_objects.add(node) - var notified_peers := _Set.new() # for each input object for input_object in input_objects: - pass # Grab state objects controlled by input - # Create set with peers owning state objects - # Only send input to peers in set + var controlled_nodes := RollbackSimulationServer.get_controlled_by(input_object) + + # Notify peers owning nodes about the input + for node in controlled_nodes: + notified_peers.add(node.get_multiplayer_authority()) + else: + for peer in multiplayer.get_peers(): + notified_peers.add(peer) + + notified_peers.erase(multiplayer.get_unique_id()) + # Only send input to peers in set for offset in _input_redundancy: # Grab snapshot from RollbackHistoryServer @@ -131,8 +139,8 @@ func synchronize_input(tick: int) -> void: _logger.trace("Submitting input: %s", [input_snapshot]) snapshots.append(input_snapshot) - # TODO: Option to not broadcast input - for peer in multiplayer.get_peers(): + _logger.trace("Submitting input to peers: %s", [notified_peers]) + for peer in notified_peers: _cmd_input.send(_serialize_input_for(peer, snapshots), peer) # TODO: Make this testable somehow, I beg of you @@ -184,6 +192,8 @@ func synchronize_state(tick: int) -> void: NetworkPerformance.push_full_state(state_snapshot.data) # TODO: Ugh... NetworkPerformance.push_sent_state(diff.data) # TODO: Ugh... else: + _rb_full_next = _rb_full_interval + # Send full states for peer in multiplayer.get_peers(): var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) @@ -493,7 +503,7 @@ func _handle_full_sync(sender: int, data: PackedByteArray): # TODO: Reduce copy-paste RollbackHistoryServer.merge_synchronizer_state(snapshot) - _logger.debug("Ingested sync state: %s", [snapshot]) + _logger.trace("Ingested sync state: %s", [snapshot]) func _handle_diff_sync(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() @@ -503,7 +513,7 @@ func _handle_diff_sync(sender: int, data: PackedByteArray): # TODO: Reduce copy-paste RollbackHistoryServer.merge_synchronizer_state(snapshot) - _logger.debug("Ingested sync state diff: %s", [snapshot]) + _logger.trace("Ingested sync state diff: %s", [snapshot]) func _ingest_state(sender: int, snapshot: Snapshot) -> void: # TODO: Sanitize @@ -516,6 +526,6 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: _logger.trace("Reconciled state diff: %s", [diff]) var merged := RollbackHistoryServer.merge_rollback_state(snapshot) - _logger.debug("Ingested state: %s", [snapshot]) + _logger.trace("Ingested state: %s", [snapshot]) on_state.emit(snapshot) diff --git a/examples/visibility-filtering/visibility-filtering.tscn b/examples/visibility-filtering/visibility-filtering.tscn index 2461c112..71716034 100644 --- a/examples/visibility-filtering/visibility-filtering.tscn +++ b/examples/visibility-filtering/visibility-filtering.tscn @@ -1,10 +1,21 @@ -[gd_scene load_steps=6 format=3 uid="uid://dliw6lntsr5ce"] +[gd_scene load_steps=8 format=3 uid="uid://dliw6lntsr5ce"] [ext_resource type="PackedScene" uid="uid://cncdbq72u50j3" path="res://examples/shared/scenes/environment.tscn" id="1_vhod1"] [ext_resource type="Script" path="res://examples/shared/scripts/player-spawner.gd" id="2_7yqbt"] [ext_resource type="PackedScene" uid="uid://bd40plic1m6fb" path="res://examples/visibility-filtering/scenes/player.tscn" id="3_ohgyy"] [ext_resource type="Script" path="res://examples/visibility-filtering/scripts/visibility-manager.gd" id="4_jies5"] [ext_resource type="PackedScene" uid="uid://badtpsxn5lago" path="res://examples/shared/ui/network-popup.tscn" id="4_ujnwk"] +[ext_resource type="Script" path="res://examples/shared/scripts/simple-time-display.gd" id="6_lqh40"] + +[sub_resource type="GDScript" id="GDScript_fdk23"] +script/source = "extends Label + +func _process(_dt): + if multiplayer.is_server(): + text = \"Server\" + else: + text = \"Client\" +" [node name="Visibility Filtering Example" type="Node3D"] @@ -85,3 +96,15 @@ offset_left = -180.0 offset_top = -120.0 offset_right = 180.0 offset_bottom = 120.0 + +[node name="VBoxContainer" type="VBoxContainer" parent="UI"] +offset_right = 40.0 +offset_bottom = 40.0 + +[node name="Time Display" type="Label" parent="UI/VBoxContainer"] +layout_mode = 2 +script = ExtResource("6_lqh40") + +[node name="Label" type="Label" parent="UI/VBoxContainer"] +layout_mode = 2 +script = SubResource("GDScript_fdk23") From 3f3497f0963480563032d75fd2eef149c363d33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sat, 17 Jan 2026 23:43:19 +0100 Subject: [PATCH 45/95] blazing fast graph queries --- addons/netfox.internals/graph.gd | 72 +++++++++++++---------------- test/netfox.internals/graph.perf.gd | 36 +++++++++++++++ test/netfox.internals/graph.test.gd | 20 +++++--- 3 files changed, 83 insertions(+), 45 deletions(-) create mode 100644 test/netfox.internals/graph.perf.gd diff --git a/addons/netfox.internals/graph.gd b/addons/netfox.internals/graph.gd index 030ba570..cb452c27 100644 --- a/addons/netfox.internals/graph.gd +++ b/addons/netfox.internals/graph.gd @@ -1,60 +1,54 @@ extends RefCounted class_name _Graph -# TODO: Optimize with caches -var _links := [] as Array[Link] +var _links_from := {} # `from` to `to[]` +var _links_to := {} # `to` to `from[]` func link(from: Variant, to: Variant) -> void: if has_link(from, to): return - _links.append(Link.new(from, to)) + + _append(_links_from, from, to) + _append(_links_to, to, from) func unlink(from: Variant, to: Variant) -> void: - for i in _links.size(): - var link := _links[i] - if link.from == from and link.to == to: - _links.remove_at(i) - break + _erase(_links_from, from, to) + _erase(_links_to, to, from) func erase(node: Variant) -> void: - # TODO: Measure if recreating the links is faster or if it's faster in-place - var filtered_links := [] as Array[Link] + var links_to := _links_from.get(node, []) + var links_from := _links_to.get(node, []) + + _links_from.erase(node) + _links_to.erase(node) - for link in _links: - if link.from == node or link.to == node: - continue - filtered_links.append(link) + for link in links_to: + _erase(_links_to, link, node) - _links = filtered_links + for link in links_from: + _erase(_links_from, link, node) func get_linked_from(from: Variant) -> Array: - var result := [] + return _links_from.get(from, []) - for link in _links: - if link.from == from: - result.append(link.to) +func get_linked_to(to: Variant) -> Array: + return _links_to.get(to, []) - return result +func has_link(from: Variant, to: Variant) -> bool: + return get_linked_from(from).has(to) -func get_linked_to(to: Variant) -> Array: - var result := [] +func _append(pool: Dictionary, key: Variant, value: Variant) -> void: + if not pool.has(key): + pool[key] = [value] + else: + pool[key].append(value) - for link in _links: - if link.to == to: - result.append(link.from) +func _erase(pool: Dictionary, key: Variant, value: Variant) -> void: + if not pool.has(key): + return - return result + var values := pool[key] as Array + values.erase(value) -func has_link(from: Variant, to: Variant) -> bool: - for link in _links: - if link.from == from and link.to == to: - return true - return false - -class Link: - var from: Variant - var to: Variant - - func _init(p_from: Variant, p_to: Variant): - from = p_from - to = p_to + if values.is_empty(): + pool.erase(key) diff --git a/test/netfox.internals/graph.perf.gd b/test/netfox.internals/graph.perf.gd new file mode 100644 index 00000000..e3907580 --- /dev/null +++ b/test/netfox.internals/graph.perf.gd @@ -0,0 +1,36 @@ +extends VestTest + +func get_suite_name() -> String: + return "Graph" + +func suite(): + var cases := [ + ["tiny", 16, 2, 2048], + ["medium", 128, 4, 2048], + ["large", 1024, 16, 2048] + ] + + for case in cases: + var name := case[0] as String + var items := case[1] as int + var depth := case[2] as int + var batch := case[3] as int + + test("queries - %s graph, %d / %d" % [name, items, depth], func(): + var graph := _Graph.new() + for i in items: + for j in depth: + graph.link(i, i + j) + + benchmark("get_linked_from()", func(__): + var nodes := graph.get_linked_from(randi() % items) + ).with_duration(1.).with_batch_size(batch).run() + + benchmark("get_linked_to()", func(__): + var nodes := graph.get_linked_to(randi() % items) + ).with_duration(1.).with_batch_size(batch).run() + + benchmark("has_link()", func(__): + var nodes := graph.has_link(randi() % items, randi() % items) + ).with_duration(1.).with_batch_size(batch).run() + ) diff --git a/test/netfox.internals/graph.test.gd b/test/netfox.internals/graph.test.gd index 63f245e0..ff5b54a8 100644 --- a/test/netfox.internals/graph.test.gd +++ b/test/netfox.internals/graph.test.gd @@ -8,7 +8,7 @@ func suite(): var graph := _Graph.new() graph.link("foo", "bar") - expect(graph.has_link("foo", "bar")) + expect_linked(graph, "foo", "bar") expect_equal(graph.get_linked_from("foo"), ["bar"]) expect_equal(graph.get_linked_to("bar"), ["foo"]) ) @@ -20,8 +20,8 @@ func suite(): graph.unlink("foo", "bar") - expect_not(graph.has_link("foo", "bar")) - expect(graph.has_link("quix", "baz")) + expect_unlinked(graph, "foo", "bar") + expect_linked(graph, "quix", "baz") expect_empty(graph.get_linked_from("foo")) expect_empty(graph.get_linked_to("bar")) ) @@ -31,10 +31,18 @@ func suite(): graph.link("foo", "bar") graph.link("foo", "baz") graph.link("quix", "baz") + graph.link("oof", "foo") graph.erase("foo") - expect_not(graph.has_link("foo", "bar"), "Link was not erased!") - expect_not(graph.has_link("foo", "baz"), "Link was not erased!") - expect(graph.has_link("quix", "baz"), "Link was erased!") + expect_unlinked(graph, "foo", "bar") + expect_unlinked(graph, "foo", "baz") + expect_unlinked(graph, "oof", "foo") + expect_linked(graph, "quix", "baz") ) + +func expect_linked(graph: _Graph, from: Variant, to: Variant): + expect(graph.has_link(from, to), "Link %s -> %s was not present!") + +func expect_unlinked(graph: _Graph, from: Variant, to: Variant): + expect_not(graph.has_link(from, to), "Link %s -> %s was present!" % [from, to]) From c8d961d4d3d9473bbe7aba047e3a8a91d19e07a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 18 Jan 2026 00:00:26 +0100 Subject: [PATCH 46/95] small fxs --- addons/netfox/servers/rollback-history-server.gd | 2 +- addons/netfox/servers/rollback-simulation-server.gd | 1 - examples/input-prediction/input.gd | 5 ----- examples/multiplayer-netfox/scripts/player.gd | 8 ++------ 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index c1d05b07..74e07405 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -166,7 +166,7 @@ func merge_synchronizer_state(snapshot: Snapshot) -> Snapshot: return merge_snapshot(snapshot, _sync_state_snapshots) func get_data_age_for(what: Node, tick: int) -> int: - if _rollback_state_snapshots.is_empty() and _rollback_input_snapshots.is_empty(): + if _rollback_state_snapshots.is_empty() or _rollback_input_snapshots.is_empty(): return -1 var earliest_tick := mini(_rollback_state_snapshots.keys().min(), _rollback_input_snapshots.keys().min()) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index e0fb0c75..d4474b33 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -100,7 +100,6 @@ func is_predicting(input_snapshot: Snapshot, node: Node) -> bool: return false func is_predicting_current() -> bool: - # TODO: Breaks Forest Brawl? if not _current_object or not is_instance_valid(_current_object): return false return _predicted_nodes.has(_current_object) diff --git a/examples/input-prediction/input.gd b/examples/input-prediction/input.gd index 78811fb2..d43ec274 100644 --- a/examples/input-prediction/input.gd +++ b/examples/input-prediction/input.gd @@ -5,8 +5,6 @@ var confidence: float = 1. @onready var _rollback_synchronizer := $"../RollbackSynchronizer" as RollbackSynchronizer -var logger := NetfoxLogger.new("example", "Input") - func _ready(): super() NetworkRollback.after_prepare_tick.connect(_predict) @@ -21,19 +19,16 @@ func _gather(): func _predict(_t): if not _rollback_synchronizer.is_predicting(): # Not predicting, nothing to do -# logger.info("Input is current") confidence = 1. return if not _rollback_synchronizer.has_input(): - logger.info("No input to predict from") confidence = 0. return # Decay input over a short time var decay_time := NetworkTime.seconds_to_ticks(.05) var input_age := _rollback_synchronizer.get_input_age() - logger.info("Predicting after %d ticks" % [input_age]) # **ALWAYS** cast either side to float, otherwise the integer-integer # division yields either 1 or 0 confidence diff --git a/examples/multiplayer-netfox/scripts/player.gd b/examples/multiplayer-netfox/scripts/player.gd index 5b1ba400..76834b30 100644 --- a/examples/multiplayer-netfox/scripts/player.gd +++ b/examples/multiplayer-netfox/scripts/player.gd @@ -19,12 +19,8 @@ func _rollback_tick(delta, _tick, _is_fresh): var input_dir = input.movement var direction = (transform.basis * Vector3(input_dir.x, 0, input_dir.z)).normalized() - if direction: - velocity.x = direction.x * speed - velocity.z = direction.z * speed - else: - velocity.x = 0. * move_toward(velocity.x, 0, speed) - velocity.z = 0. * move_toward(velocity.z, 0, speed) + velocity.x = direction.x * speed + velocity.z = direction.z * speed # move_and_slide assumes physics delta # multiplying velocity by NetworkTime.physics_factor compensates for it From 09d77733ad2284595a58a716f6785112f06df0d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 18 Jan 2026 01:24:35 +0100 Subject: [PATCH 47/95] network identity server tests --- .../netfox/servers/network-command-server.gd | 9 +- .../netfox/servers/network-identity-server.gd | 24 +++- .../servers/network-identity-server.test.gd | 121 ++++++++++++++++-- 3 files changed, 138 insertions(+), 16 deletions(-) diff --git a/addons/netfox/servers/network-command-server.gd b/addons/netfox/servers/network-command-server.gd index a645f1aa..e833599e 100644 --- a/addons/netfox/servers/network-command-server.gd +++ b/addons/netfox/servers/network-command-server.gd @@ -33,7 +33,7 @@ func register_command(handler: Callable) -> Command: func register_command_at(idx: int, handler: Callable, mode: MultiplayerPeer.TransferMode = MultiplayerPeer.TRANSFER_MODE_RELIABLE, channel: int = 0) -> Command: assert(not _commands.has(idx), "Command #%d is already taken!" % idx) - var command := Command.new(idx, handler, mode, channel) + var command := Command.new(self, idx, handler, mode, channel) _commands[idx] = command _next_idx = maxi(_next_idx, idx + 1) @@ -47,19 +47,22 @@ func send_command(idx: int, data: PackedByteArray, target_peer: int = 0, mode: M _packet_transport.send(idx, data, target_peer, mode, channel) class Command: + var _command_server: _NetworkCommandServer + var _idx: int var _handler: Callable var _mode: MultiplayerPeer.TransferMode var _channel: int - func _init(p_idx: int, p_handler: Callable, p_mode: MultiplayerPeer.TransferMode, p_channel: int): + func _init(p_command_server: _NetworkCommandServer, p_idx: int, p_handler: Callable, p_mode: MultiplayerPeer.TransferMode, p_channel: int): + _command_server = p_command_server _idx = p_idx _handler = p_handler _mode = p_mode _channel = p_channel func send(data: PackedByteArray, target_peer: int = 0) -> void: - NetworkCommandServer.send_command(_idx, data, target_peer, _mode, _channel) + _command_server.send_command(_idx, data, target_peer, _mode, _channel) func handle(sender: int, data: PackedByteArray) -> void: _handler.call(sender, data) diff --git a/addons/netfox/servers/network-identity-server.gd b/addons/netfox/servers/network-identity-server.gd index 409efd64..74a245db 100644 --- a/addons/netfox/servers/network-identity-server.gd +++ b/addons/netfox/servers/network-identity-server.gd @@ -1,18 +1,30 @@ extends Node +class_name _NetworkIdentityServer + +var _command_server: _NetworkCommandServer var _next_id := 0 var _identifiers := {} # object to NetworkIdentifier var _push_queue := [] as Array[IdentityNotification] -@onready var _cmd_ids := NetworkCommandServer.register_command_at(_NetworkCommands.IDS, _handle_ids) +var _cmd_ids: _NetworkCommandServer.Command static var _logger := NetfoxLogger._for_netfox("NetworkIdentityServer") +func _init(p_command_server: _NetworkCommandServer = null): + _command_server = p_command_server + +func _ready(): + if not _command_server: + _command_server = NetworkCommandServer + + _cmd_ids = _command_server.register_command_at(_NetworkCommands.IDS, _handle_ids) + func register(what: Object, path: String) -> void: if _identifiers.has(what): return - var identifier := NetworkIdentifier.new(what, path, _make_id()) + var identifier := NetworkIdentifier.new(what, path, _make_id(), multiplayer.get_unique_id()) _identifiers[what] = identifier func deregister(what: Object) -> void: @@ -33,7 +45,7 @@ func deregister_node(node: Node) -> void: # TODO: Consider specific queries, NetworkIdentifier might be an impl detail func get_identifier_of(what: Object) -> NetworkIdentifier: - return _identifiers[what] + return _identifiers.get(what) func resolve_reference(peer: int, identity_reference: NetworkIdentityReference, allow_queue: bool = true) -> NetworkIdentifier: if identity_reference.has_id(): @@ -123,10 +135,11 @@ class NetworkIdentifier: var _ids: Dictionary = {} # peer to id var _local_id: int - func _init(subject: Object, full_name: String, local_id: int): + func _init(subject: Object, full_name: String, local_id: int, local_peer: int): _subject = subject _full_name = full_name _local_id = local_id + _ids[local_peer] = local_id func has_id_for(peer: int) -> bool: # TODO: Also return true for local peer, just to be correct @@ -153,6 +166,9 @@ class NetworkIdentifier: else: return NetworkIdentityReference.of_full_name(get_full_name()) + func _to_string() -> String: + return "NetworkIdentifier(%s)" % [_full_name] + class NetworkIdentityReference: var _full_name: String = "" var _id: int = -1 diff --git a/test/netfox/servers/network-identity-server.test.gd b/test/netfox/servers/network-identity-server.test.gd index b6135588..ebcb66a4 100644 --- a/test/netfox/servers/network-identity-server.test.gd +++ b/test/netfox/servers/network-identity-server.test.gd @@ -3,29 +3,132 @@ extends VestTest func get_suite_name() -> String: return "NetworkIdentityServer" +var command_server: TestingCommandServer +var identity_server: _NetworkIdentityServer + +var node: Node +var orphan_node: Node + +func before_case(__): + # Makes sure local peer is 1, otherwise identifiers get random local IDs + Vest.get_tree().root.multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() + + command_server = TestingCommandServer.new() + identity_server = _NetworkIdentityServer.new(command_server) + + node = Node.new() + node.name = "Node" + + orphan_node = Node.new() + + Vest.get_tree().root.add_child.call_deferred(node) + Vest.get_tree().root.add_child.call_deferred(command_server) + Vest.get_tree().root.add_child.call_deferred(identity_server) + + await node.ready + await command_server.ready + await identity_server.ready + +func after_case(__): + identity_server.queue_free() + command_server.queue_free() + node.queue_free() + orphan_node.queue_free() + func suite() -> void: define("register_node()", func(): test("should register", func(): # Register node + identity_server.register_node(node) + # Assert for identifier - todo() + var identifier := identity_server.get_identifier_of(node) + expect_not_null(identifier) + expect_equal(identifier.get_full_name(), "/root/Node") ) - test("should fail on node node in tree", func(): todo()) + test("should fail on node node in tree", func(): + # Register node + identity_server.register_node(orphan_node) + + # Assert for identifier + var identifier := identity_server.get_identifier_of(orphan_node) + expect_null(identifier) + ) ) define("deregister_node()", func(): - test("should remove known", func(): todo()) - test("should do nothing on unknown", func(): todo()) + test("should remove known", func(): + # Register node + identity_server.register_node(node) + expect_not_null(identity_server.get_identifier_of(node)) + + # Deregister + identity_server.deregister_node(node) + expect_null(identity_server.get_identifier_of(node)) + ) + + test("should do nothing on unknown", func(): + identity_server.deregister_node(orphan_node) + expect_null(identity_server.get_identifier_of(orphan_node)) + ) ) define("flush_queue()", func(): - test("should send ids", func(): todo()) + test("should send ids", func(): + # Register node + identity_server.register_node(node) + + var identifier := identity_server.get_identifier_of(node) + var full_name := identifier.get_full_name() + + # Try and resolve some unknown identities + identity_server.resolve_reference(2, identifier.reference_for(2)) + identity_server.resolve_reference(3, identifier.reference_for(3)) + + # Flush queue + identity_server.flush_queue() + + # Check commands sent + expect_equal(command_server.commands_sent.size(), 2) + for i in command_server.commands_sent.size(): + var command := command_server.commands_sent[i] + + expect_equal(command[0], _NetworkCommands.IDS) # Command id + expect_equal(command[2], 2 + i) # Peer + expect_equal(command[3], MultiplayerPeer.TRANSFER_MODE_RELIABLE) + ) ) define("resolve_reference()", func(): - test("should return by id", func(): todo()) - test("should return by name", func(): todo()) - test("should return null on unknown", func(): todo()) - test("should queue on name", func(): todo()) + test("should return by id", func(): + # Register node + identity_server.register_node(node) + var identifier := identity_server.get_identifier_of(node) + + # Resolve + var reference := _NetworkIdentityServer.NetworkIdentityReference.of_id(identifier.get_local_id()) + expect_equal(identity_server.resolve_reference(1, reference), identifier) + ) + + test("should return by name", func(): + # Register node + identity_server.register_node(node) + var identifier := identity_server.get_identifier_of(node) + + # Resolve + var reference := _NetworkIdentityServer.NetworkIdentityReference.of_full_name(identifier.get_full_name()) + expect_equal(identity_server.resolve_reference(1, reference), identifier) + ) + + test("should return null on unknown", func(): + var reference := _NetworkIdentityServer.NetworkIdentityReference.of_full_name("Unknown Node") + expect_null(identity_server.resolve_reference(1, reference)) + ) ) + +class TestingCommandServer extends _NetworkCommandServer: + var commands_sent := [] as Array[Array] + + func send_command(idx: int, data: PackedByteArray, target_peer: int = 0, mode: MultiplayerPeer.TransferMode = MultiplayerPeer.TRANSFER_MODE_RELIABLE, channel: int = 0) -> void: + commands_sent.append([idx, data, target_peer, mode, channel]) From d54674da33684f127dcb23395c99fd74ffed3f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 18 Jan 2026 01:43:14 +0100 Subject: [PATCH 48/95] perf test for network identity server resolve --- .../servers/network-identity-server.perf.gd | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 test/netfox/servers/network-identity-server.perf.gd diff --git a/test/netfox/servers/network-identity-server.perf.gd b/test/netfox/servers/network-identity-server.perf.gd new file mode 100644 index 00000000..80b78fd0 --- /dev/null +++ b/test/netfox/servers/network-identity-server.perf.gd @@ -0,0 +1,64 @@ +extends VestTest + +func get_suite_name() -> String: + return "NetworkIdentityServer" + +var command_server: _NetworkCommandServer +var identity_server: _NetworkIdentityServer + +func before_case(__): + # Makes sure local peer is 1, otherwise identifiers get random local IDs + Vest.get_tree().root.multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() + + command_server = _NetworkCommandServer.new() + identity_server = _NetworkIdentityServer.new(command_server) + + Vest.get_tree().root.add_child.call_deferred(command_server) + Vest.get_tree().root.add_child.call_deferred(identity_server) + + await command_server.ready + await identity_server.ready + +func after_case(__): + identity_server.queue_free() + command_server.queue_free() + +func suite() -> void: + define("resolve_reference()", func(): + # 6.04us/7.08us, 26.75us/32.29us, 211.05us/240.66us, 4.55ms/4.35ms + for case in [[16, 256], [128, 256], [1024, 64], [16384, 64]]: + var count := case[0] as int + var batch := case[1] as int + + # Run benchmarks + test("resolve %d nodes" % count, func(): + # Setup nodes + var nodes := [] as Array[Node] + for i in count: + var node := Node.new() + node.name = "Node %d" % i + nodes.append(node) + Vest.get_tree().root.add_child.call_deferred(node) + + # Wait for nodes to be in tree and register + for node in nodes: + await node.ready + identity_server.register_node(node) + + benchmark("resolve by ID", func(__): + var id := (randi() % count) as int + var reference := _NetworkIdentityServer.NetworkIdentityReference.of_id(id) + identity_server.resolve_reference(1, reference, false) + ).with_batch_size(batch).with_duration(1.).run() + + benchmark("resolve by name", func(__): + var id := (randi() % count) as int + var reference := _NetworkIdentityServer.NetworkIdentityReference.of_full_name("/root/Node %d" % id) + identity_server.resolve_reference(1, reference, false) + ).with_batch_size(batch).with_duration(1.).run() + + # Free nodes + for node in nodes: + node.queue_free() + ) + ) From bd9d0107494529d2bc60da640a70430e6cffa2f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 18 Jan 2026 10:47:38 +0100 Subject: [PATCH 49/95] optimize network identity resolution --- .../netfox/servers/network-identity-server.gd | 59 ++++++++++++++----- .../servers/network-identity-server.perf.gd | 4 +- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/addons/netfox/servers/network-identity-server.gd b/addons/netfox/servers/network-identity-server.gd index 74a245db..d5955386 100644 --- a/addons/netfox/servers/network-identity-server.gd +++ b/addons/netfox/servers/network-identity-server.gd @@ -7,6 +7,9 @@ var _next_id := 0 var _identifiers := {} # object to NetworkIdentifier var _push_queue := [] as Array[IdentityNotification] +var _identifier_by_name := {} # full name to NetworkIdentifier +var _identifier_by_id := {} # peer to (id to NetworkIdentifier) + var _cmd_ids: _NetworkCommandServer.Command static var _logger := NetfoxLogger._for_netfox("NetworkIdentityServer") @@ -26,12 +29,24 @@ func register(what: Object, path: String) -> void: var identifier := NetworkIdentifier.new(what, path, _make_id(), multiplayer.get_unique_id()) _identifiers[what] = identifier + _identifier_by_name[identifier.get_full_name()] = identifier + + identifier.on_id.connect(func(peer: int, id: int): _update_id_cache(identifier, peer, id)) + _update_id_cache(identifier, multiplayer.get_unique_id(), identifier.get_local_id()) func deregister(what: Object) -> void: + if not _identifiers.has(what): + return + + var identifier := _identifiers[what] as NetworkIdentifier _identifiers.erase(what) + _identifier_by_name.erase(identifier.get_full_name()) + _erase_from_id_cache(identifier) func clear() -> void: _identifiers.clear() + _identifier_by_name.clear() + _identifier_by_id.clear() _next_id = 0 func register_node(node: Node) -> void: @@ -70,25 +85,32 @@ func flush_queue() -> void: _cmd_ids.send(_serialize_ids(ids[peer]), peer) func _get_identifier_by_name(full_name: String) -> NetworkIdentifier: - # TODO: Optimize, probably by caching - for value in _identifiers.values() as Array: - var identifier := value as NetworkIdentifier - if identifier.get_full_name() == full_name: - return identifier - return null + return _identifier_by_name.get(full_name) func _get_identifier_by_id(peer: int, id: int) -> NetworkIdentifier: - # TODO: Optimize, probably by caching - for value in _identifiers.values() as Array: - var identifier := value as NetworkIdentifier - if identifier.get_id_for(peer) == id: - return identifier - return null + return _identifier_by_id.get(peer, {}).get(id, null) func _make_id() -> int: _next_id += 1 return _next_id +func _update_id_cache(identifier: NetworkIdentifier, peer: int, id: int) -> void: + if not _identifier_by_id.has(peer): + _identifier_by_id[peer] = { id: identifier } + else: + _identifier_by_id[peer][id] = identifier + +func _erase_from_id_cache(identifier: NetworkIdentifier) -> void: + for peer in identifier.get_known_peers(): + if not _identifier_by_id.has(peer): + # nani? + continue + + var cache := _identifier_by_id.get(peer) as Dictionary + cache.erase(identifier.get_id_for(peer)) + if cache.is_empty(): + _identifier_by_id.erase(peer) + func _handle_ids(sender: int, data: PackedByteArray) -> void: var ids := _deserialize_ids(data) @@ -135,6 +157,8 @@ class NetworkIdentifier: var _ids: Dictionary = {} # peer to id var _local_id: int + signal on_id(peer: int, id: int) + func _init(subject: Object, full_name: String, local_id: int, local_peer: int): _subject = subject _full_name = full_name @@ -149,17 +173,24 @@ class NetworkIdentifier: return _ids.get(peer, -1) func set_id_for(peer: int, id: int) -> void: + assert(not _ids.has(peer), "ID for peer #%d is already set!" % [peer]) _ids[peer] = id + on_id.emit(peer, id) func get_local_id() -> int: return _local_id - + func get_full_name() -> String: return _full_name - + func get_subject() -> Object: return _subject + func get_known_peers() -> Array[int]: + var result := [] as Array[int] + result.assign(_ids.keys()) + return result + func reference_for(peer: int) -> NetworkIdentityReference: if has_id_for(peer): return NetworkIdentityReference.of_id(get_id_for(peer)) diff --git a/test/netfox/servers/network-identity-server.perf.gd b/test/netfox/servers/network-identity-server.perf.gd index 80b78fd0..f41c4d61 100644 --- a/test/netfox/servers/network-identity-server.perf.gd +++ b/test/netfox/servers/network-identity-server.perf.gd @@ -26,7 +26,9 @@ func after_case(__): func suite() -> void: define("resolve_reference()", func(): # 6.04us/7.08us, 26.75us/32.29us, 211.05us/240.66us, 4.55ms/4.35ms - for case in [[16, 256], [128, 256], [1024, 64], [16384, 64]]: + # ??/3.69us, ??/3.74us, ??/3.81us, ??/3.99us + # 2.73us/3.63us, 2.73us/3.72us, 2.68us/3.68us, 2.91us/3.95us + for case in [[16, 512], [128, 512], [1024, 512], [16384, 512]]: var count := case[0] as int var batch := case[1] as int From e86e8a3ee58ee66bf90f7f43a96040abd36483fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 18 Jan 2026 10:56:57 +0100 Subject: [PATCH 50/95] some cleanup --- addons/netfox/encoder/diff-history-encoder.gd | 141 --------- .../encoder/diff-history-encoder.gd.uid | 1 - .../encoder/redundant-history-encoder.gd | 125 -------- .../encoder/redundant-history-encoder.gd.uid | 1 - .../encoder/snapshot-history-encoder.gd | 78 ----- .../encoder/snapshot-history-encoder.gd.uid | 1 - .../composite/rollback-history-recorder.gd | 111 ------- .../rollback-history-recorder.gd.uid | 1 - .../composite/rollback-history-transmitter.gd | 285 ------------------ .../rollback-history-transmitter.gd.uid | 1 - .../rollback/rollback-freshness-store.gd | 34 --- .../netfox/servers/network-identity-server.gd | 2 + .../encoder/diff-history-encoder.test.gd | 122 -------- .../encoder/diff-history-encoder.test.gd.uid | 1 - .../encoder/redundant-history-encoder.test.gd | 137 --------- .../redundant-history-encoder.test.gd.uid | 1 - .../encoder/snapshot-history-encoder.test.gd | 118 -------- .../snapshot-history-encoder.test.gd.uid | 1 - 18 files changed, 2 insertions(+), 1159 deletions(-) delete mode 100644 addons/netfox/encoder/diff-history-encoder.gd delete mode 100644 addons/netfox/encoder/diff-history-encoder.gd.uid delete mode 100644 addons/netfox/encoder/redundant-history-encoder.gd delete mode 100644 addons/netfox/encoder/redundant-history-encoder.gd.uid delete mode 100644 addons/netfox/encoder/snapshot-history-encoder.gd delete mode 100644 addons/netfox/encoder/snapshot-history-encoder.gd.uid delete mode 100644 addons/netfox/rollback/composite/rollback-history-recorder.gd delete mode 100644 addons/netfox/rollback/composite/rollback-history-recorder.gd.uid delete mode 100644 addons/netfox/rollback/composite/rollback-history-transmitter.gd delete mode 100644 addons/netfox/rollback/composite/rollback-history-transmitter.gd.uid delete mode 100644 addons/netfox/rollback/rollback-freshness-store.gd delete mode 100644 test/netfox/encoder/diff-history-encoder.test.gd delete mode 100644 test/netfox/encoder/diff-history-encoder.test.gd.uid delete mode 100644 test/netfox/encoder/redundant-history-encoder.test.gd delete mode 100644 test/netfox/encoder/redundant-history-encoder.test.gd.uid delete mode 100644 test/netfox/encoder/snapshot-history-encoder.test.gd delete mode 100644 test/netfox/encoder/snapshot-history-encoder.test.gd.uid diff --git a/addons/netfox/encoder/diff-history-encoder.gd b/addons/netfox/encoder/diff-history-encoder.gd deleted file mode 100644 index 384b66ab..00000000 --- a/addons/netfox/encoder/diff-history-encoder.gd +++ /dev/null @@ -1,141 +0,0 @@ -extends RefCounted -class_name _DiffHistoryEncoder - -var _history: _PropertyHistoryBuffer -var _property_cache: PropertyCache -var _schema_handler: _NetworkSchema - -var _full_snapshot := {} -var _encoded_snapshot := {} - -var _property_indexes := _BiMap.new() - -var _version := 0 -var _has_received := false - -static var _logger := NetfoxLogger._for_netfox("DiffHistoryEncoder") - -func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache, p_schema_handler: _NetworkSchema) -> void: - _history = p_history - _property_cache = p_property_cache - _schema_handler = p_schema_handler - -func add_properties(properties: Array[PropertyEntry]) -> void: - var has_new_properties := false - - for property_entry in properties: - var is_new := _ensure_property_idx(property_entry.to_string()) - has_new_properties = has_new_properties or is_new - - # If we added any new properties, increment version - if has_new_properties: - _version = (_version + 1) % 256 - -func encode(tick: int, reference_tick: int, properties: Array[PropertyEntry]) -> PackedByteArray: - assert(properties.size() <= 255, "Property indices may not fit into bytes; too many properties!") - - var snapshot := _history.get_snapshot(tick) - var property_strings := properties.map(func(it): return it.to_string()) - - var reference_snapshot := _history.get_history(reference_tick) - var diff_snapshot := reference_snapshot.make_patch(snapshot) - - _full_snapshot = snapshot.as_dictionary() - _encoded_snapshot = diff_snapshot.as_dictionary() - - if diff_snapshot.is_empty(): - return PackedByteArray() - - var buffer := StreamPeerBuffer.new() - buffer.put_u8(_version) - - for property_path in diff_snapshot.properties(): - var property_idx := _property_indexes.get_by_value(property_path) as int - buffer.put_u8(property_idx) - - var value := diff_snapshot.get_value(property_path) - _schema_handler.encode(property_path, value, buffer) - - return buffer.data_array - -func decode(data: PackedByteArray, properties: Array[PropertyEntry]) -> _PropertySnapshot: - var result := _PropertySnapshot.new() - - if data.is_empty(): - return result - - var buffer := StreamPeerBuffer.new() - buffer.data_array = data - - var packet_version := buffer.get_u8() - # TODO: Extract schema versioning into shared code to avoid duplication - if packet_version != _version: - if not _has_received: - # This is the first time we receive data - # Assume the version is OK - _version = packet_version - else: - # Since we don't remove entries, only add, we can still parse what - # we can - _logger.warning("Property config version mismatch - own %d != received %d", [_version, packet_version]) - - _has_received = true - - while buffer.get_available_bytes() > 0: - var property_idx := buffer.get_u8() - - if not _property_indexes.has_key(property_idx): - _logger.warning("Received unknown property index %d, ignoring!", [property_idx]) - break - - var property_path := _property_indexes.get_by_key(property_idx) - var value := _schema_handler.decode(property_path, buffer) - - result.set_value(property_path, value) - - return result - -func apply(tick: int, snapshot: _PropertySnapshot, reference_tick: int, sender: int = -1) -> bool: - if tick < NetworkRollback.history_start: - # State too old! - _logger.error( - "Received diff snapshot for @%d, rejecting because older than %s frames", - [tick, NetworkRollback.history_limit] - ) - return false - - if snapshot.is_empty(): - return true - - if sender > 0: - snapshot.sanitize(sender, _property_cache) - if snapshot.is_empty(): - _logger.warning("Received invalid diff from #%s for @%s", [sender, tick]) - return false - - if not _history.has(reference_tick): - # Reference tick missing, hope for the best - _logger.warning("Reference tick %d missing for #%s applying %d", [reference_tick, sender, tick]) - - var reference_snapshot := _history.get_snapshot(reference_tick) - _history.set_snapshot(tick, reference_snapshot.merge(snapshot)) - return true - -# TODO: Rework metrics so these are not needed -func get_encoded_snapshot() -> Dictionary: - return _encoded_snapshot - -func get_full_snapshot() -> Dictionary: - return _full_snapshot - -func _ensure_property_idx(property: String) -> bool: - if _property_indexes.has_value(property): - return false - - assert(_property_indexes.size() < 256, "Property index map is full, can't add new property!") - var idx := hash(property) % 256 - while _property_indexes.has_key(idx): - idx = hash(idx + 1) % 256 - _property_indexes.put(idx, property) - - return true diff --git a/addons/netfox/encoder/diff-history-encoder.gd.uid b/addons/netfox/encoder/diff-history-encoder.gd.uid deleted file mode 100644 index 67ae0a91..00000000 --- a/addons/netfox/encoder/diff-history-encoder.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dc73evbedbmvs diff --git a/addons/netfox/encoder/redundant-history-encoder.gd b/addons/netfox/encoder/redundant-history-encoder.gd deleted file mode 100644 index 5da2e0a0..00000000 --- a/addons/netfox/encoder/redundant-history-encoder.gd +++ /dev/null @@ -1,125 +0,0 @@ -extends RefCounted -class_name _RedundantHistoryEncoder - -var redundancy: int = 4: - get = get_redundancy, - set = set_redundancy - -var _history: _PropertyHistoryBuffer -var _properties: Array[PropertyEntry] -var _property_cache: PropertyCache -var _schema_handler: _NetworkSchema - -var _version := 0 -var _has_received := false - -var _logger := NetfoxLogger._for_netfox("RedundantHistoryEncoder") - -func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache, p_schema_handler: _NetworkSchema): - _history = p_history - _property_cache = p_property_cache - _schema_handler = p_schema_handler - -func get_redundancy() -> int: - return redundancy - -func set_redundancy(p_redundancy: int): - if p_redundancy <= 0: - _logger.warning("Attempting to set redundancy to %d, which would send no data!", [p_redundancy]) - return - - redundancy = p_redundancy - -func set_properties(properties: Array[PropertyEntry]) -> void: - if _properties != properties: - _version = (_version + 1) % 256 - _properties = properties.duplicate() - -func encode(tick: int, properties: Array[PropertyEntry]) -> PackedByteArray: - if _history.is_empty(): - return PackedByteArray() - - var buffer := StreamPeerBuffer.new() - buffer.put_u8(_version) - - for i in range(mini(redundancy, _history.size())): - var offset_tick := tick - i - if offset_tick < _history.get_earliest_tick(): - break - - var snapshot := _history.get_snapshot(offset_tick) - for property in properties: - var path := property.to_string() - var value := snapshot.get_value(path) - _schema_handler.encode(path, value, buffer) - - return buffer.data_array - -func decode(data: PackedByteArray, properties: Array[PropertyEntry]) -> Array[_PropertySnapshot]: - var result: Array[_PropertySnapshot] = [] - - if data.is_empty(): - return result - - var buffer := StreamPeerBuffer.new() - buffer.data_array = data - var packet_version := buffer.get_u8() - - if packet_version != _version: - if not _has_received: - # First packet, assume version is OK - _version = packet_version - else: - # Version mismatch, can't parse - _logger.warning("Version mismatch! own: %d, received: %s", [_version, packet_version]) - return result - - _has_received = true - - while buffer.get_available_bytes() > 0: - var snapshot = _PropertySnapshot.new() - - for property in properties: - # Stop if we run out of data mid-snapshot - if buffer.get_available_bytes() == 0: - break - - var path := property.to_string() - var value := _schema_handler.decode(path, buffer) - - snapshot.set_value(path, value) - - result.append(snapshot) - - return result - -# Returns earliest new tick as int, or -1 if no new ticks applied -func apply(tick: int, snapshots: Array[_PropertySnapshot], sender: int = 0) -> int: - var earliest_new_tick = -1 - - for i in range(snapshots.size()): - var offset_tick := tick - i - var snapshot := snapshots[i] - - if offset_tick < NetworkRollback.history_start: - # Data too old - _logger.warning( - "Received data for %s, rejecting because older than %s frames", - [offset_tick, NetworkRollback.history_limit] - ) - continue - - if sender > 0: - snapshot.sanitize(sender, _property_cache) - if snapshot.is_empty(): - # No valid properties ( probably after sanitize ) - _logger.warning("Received invalid data from %d for tick %d", [sender, tick]) - continue - - var known_snapshot := _history.get_snapshot(offset_tick) - if not known_snapshot.equals(snapshot): - # Received a new snapshot, store and emit signal - _history.set_snapshot(offset_tick, snapshot) - earliest_new_tick = offset_tick - - return earliest_new_tick diff --git a/addons/netfox/encoder/redundant-history-encoder.gd.uid b/addons/netfox/encoder/redundant-history-encoder.gd.uid deleted file mode 100644 index fd830c32..00000000 --- a/addons/netfox/encoder/redundant-history-encoder.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ytn07qahpatv diff --git a/addons/netfox/encoder/snapshot-history-encoder.gd b/addons/netfox/encoder/snapshot-history-encoder.gd deleted file mode 100644 index db796758..00000000 --- a/addons/netfox/encoder/snapshot-history-encoder.gd +++ /dev/null @@ -1,78 +0,0 @@ -extends RefCounted -class_name _SnapshotHistoryEncoder - -var _history: _PropertyHistoryBuffer -var _property_cache: PropertyCache -var _properties: Array[PropertyEntry] -var _schema_handler: _NetworkSchema - -var _version := -1 -var _has_received := false - -static var _logger := NetfoxLogger._for_netfox("_SnapshotHistoryEncoder") - -func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache, p_schema_handler: _NetworkSchema) -> void: - _history = p_history - _property_cache = p_property_cache - _schema_handler = p_schema_handler - -func set_properties(properties: Array[PropertyEntry]) -> void: - if _properties != properties: - _version = (_version + 1) % 256 - _properties = properties.duplicate() - -func encode(tick: int, properties: Array[PropertyEntry]) -> PackedByteArray: - var snapshot := _history.get_snapshot(tick) - var buffer := StreamPeerBuffer.new() - - buffer.put_u8(_version) - - for property in properties: - var path: String = property.to_string() - var value = snapshot.get_value(path) - _schema_handler.encode(path, value, buffer) - - return buffer.data_array - -func decode(data: PackedByteArray, properties: Array[PropertyEntry]) -> _PropertySnapshot: - var result := _PropertySnapshot.new() - - if data.is_empty(): - return result - - var buffer := StreamPeerBuffer.new() - buffer.data_array = data - var packet_version: int = buffer.get_u8() - - if packet_version != _version: - if not _has_received: - _version = packet_version - else: - _logger.warning("Version mismatch! own: %d, received: %s", [_version, packet_version]) - return result - - _has_received = true - - for property in properties: - if buffer.get_available_bytes() == 0: - _logger.warning("Received snapshot with %d properties, expected %d!", [result.size(), properties.size()]) - break - - var path := property.to_string() - var value := _schema_handler.decode(path, buffer) - result.set_value(path, value) - - return result - -func apply(tick: int, snapshot: _PropertySnapshot, sender: int = -1) -> bool: - if tick < NetworkRollback.history_start: - # State too old! - _logger.error("Received full snapshot for %s, rejecting because older than %s frames", [tick, NetworkRollback.history_limit]) - return false - - if sender > 0: - snapshot.sanitize(sender, _property_cache) - if snapshot.is_empty(): return false - - _history.set_snapshot(tick, snapshot) - return true diff --git a/addons/netfox/encoder/snapshot-history-encoder.gd.uid b/addons/netfox/encoder/snapshot-history-encoder.gd.uid deleted file mode 100644 index e1d588fb..00000000 --- a/addons/netfox/encoder/snapshot-history-encoder.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dwhlghjsgdy4o diff --git a/addons/netfox/rollback/composite/rollback-history-recorder.gd b/addons/netfox/rollback/composite/rollback-history-recorder.gd deleted file mode 100644 index bf2edfeb..00000000 --- a/addons/netfox/rollback/composite/rollback-history-recorder.gd +++ /dev/null @@ -1,111 +0,0 @@ -extends RefCounted -class_name _RollbackHistoryRecorder - -# Provided externally by RBS -var _state_history: _PropertyHistoryBuffer -var _input_history: _PropertyHistoryBuffer - -var _state_property_config: _PropertyConfig -var _input_property_config: _PropertyConfig - -var _property_cache: PropertyCache - -var _latest_state_tick: int -var _skipset: _Set - -func configure( - p_state_history: _PropertyHistoryBuffer, p_input_history: _PropertyHistoryBuffer, - p_state_property_config: _PropertyConfig, p_input_property_config: _PropertyConfig, - p_property_cache: PropertyCache, - p_skipset: _Set - ) -> void: - _state_history = p_state_history - _input_history = p_input_history - _state_property_config = p_state_property_config - _input_property_config = p_input_property_config - _property_cache = p_property_cache - _skipset = p_skipset - -func set_latest_state_tick(p_latest_state_tick: int) -> void: - _latest_state_tick = p_latest_state_tick - -func apply_state(tick: int) -> void: - # Apply state for tick - var state = _state_history.get_history(tick) - state.apply(_property_cache) - -func apply_display_state() -> void: - apply_state(NetworkRollback.display_tick) - -func apply_tick(tick: int) -> void: - var state := _state_history.get_history(tick) - var input := _input_history.get_history(tick) - - state.apply(_property_cache) - input.apply(_property_cache) - -func trim_history() -> void: - # Trim history - _state_history.trim() - _input_history.trim() - -func record_input(tick: int) -> void: - # Record input - if not _get_recorded_input_props().is_empty(): - var input = _PropertySnapshot.extract(_get_recorded_input_props()) - var input_tick: int = tick + NetworkRollback.input_delay - _input_history.set_snapshot(input_tick, input) - -func record_state(tick: int) -> void: - # Record state for specified tick ( current + 1 ) - # Check if any of the managed nodes were mutated - var is_mutated := _get_recorded_state_props().any(func(pe): - return NetworkRollback.is_mutated(pe.node, tick - 1)) - - var record_state := _PropertySnapshot.extract(_get_state_props_to_record(tick)) - if record_state.size(): - var merge_state := _state_history.get_history(tick - 1) - _state_history.set_snapshot(tick, merge_state.merge(record_state)) - -func _should_record_tick(tick: int) -> bool: - if _get_recorded_state_props().is_empty(): - # Don't record tick if there's no props to record - return false - - if _get_recorded_state_props().any(func(pe): - return NetworkRollback.is_mutated(pe.node, tick - 1)): - # If there's any node that was mutated, there's something to record - return true - - # Otherwise, record only if we don't have authoritative state for the tick - return tick > _latest_state_tick - -func _get_state_props_to_record(tick: int) -> Array[PropertyEntry]: - if not _should_record_tick(tick): - return [] - if _skipset.is_empty(): - return _get_recorded_state_props() - - return _get_recorded_state_props().filter(func(pe): return _should_record_property(pe, tick)) - -func _should_record_property(property_entry: PropertyEntry, tick: int) -> bool: - if NetworkRollback.is_mutated(property_entry.node, tick - 1): - return true - if _skipset.has(property_entry.node): - return false - return true - -# ============================================================================= -# Shared utils, extract later - -func _get_recorded_state_props() -> Array[PropertyEntry]: - return _state_property_config.get_properties() - -func _get_owned_state_props() -> Array[PropertyEntry]: - return _state_property_config.get_owned_properties() - -func _get_recorded_input_props() -> Array[PropertyEntry]: - return _input_property_config.get_owned_properties() - -func _get_owned_input_props() -> Array[PropertyEntry]: - return _input_property_config.get_owned_properties() diff --git a/addons/netfox/rollback/composite/rollback-history-recorder.gd.uid b/addons/netfox/rollback/composite/rollback-history-recorder.gd.uid deleted file mode 100644 index fcaad353..00000000 --- a/addons/netfox/rollback/composite/rollback-history-recorder.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bn6fsqxbfhihk diff --git a/addons/netfox/rollback/composite/rollback-history-transmitter.gd b/addons/netfox/rollback/composite/rollback-history-transmitter.gd deleted file mode 100644 index 5d1a1fca..00000000 --- a/addons/netfox/rollback/composite/rollback-history-transmitter.gd +++ /dev/null @@ -1,285 +0,0 @@ -extends Node -class_name _RollbackHistoryTransmitter - -var root: Node -var enable_input_broadcast: bool = true -var full_state_interval: int -var diff_ack_interval: int - -# Provided externally by RBS -var _state_history: _PropertyHistoryBuffer -var _input_history: _PropertyHistoryBuffer -var _visibility_filter: PeerVisibilityFilter - -var _state_property_config: _PropertyConfig -var _input_property_config: _PropertyConfig - -var _property_cache: PropertyCache -var _skipset: _Set - -var _schema_handler: _NetworkSchema - -# Collaborators -var _input_encoder: _RedundantHistoryEncoder -var _full_state_encoder: _SnapshotHistoryEncoder -var _diff_state_encoder: _DiffHistoryEncoder - -# State -var _ackd_state: Dictionary = {} -var _next_full_state_tick: int -var _next_diff_ack_tick: int - -var _earliest_input_tick: int -var _latest_state_tick: int - -var _is_predicted_tick: bool -var _is_initialized: bool - -# Signals -signal _on_transmit_state(state: Dictionary, tick: int) - -static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("RollbackHistoryTransmitter") - -func get_earliest_input_tick() -> int: - return _earliest_input_tick - -func get_latest_state_tick() -> int: - return _latest_state_tick - -func set_predicted_tick(p_is_predicted_tick) -> void: - _is_predicted_tick = p_is_predicted_tick - -func sync_settings(p_root: Node, p_enable_input_broadcast: bool, p_full_state_interval: int, p_diff_ack_interval: int) -> void: - root = p_root - enable_input_broadcast = p_enable_input_broadcast - full_state_interval = p_full_state_interval - diff_ack_interval = p_diff_ack_interval - -func configure( - p_state_history: _PropertyHistoryBuffer, p_input_history: _PropertyHistoryBuffer, - p_state_property_config: _PropertyConfig, p_input_property_config: _PropertyConfig, - p_visibility_filter: PeerVisibilityFilter, - p_property_cache: PropertyCache, - p_skipset: _Set, - p_schema_handler: _NetworkSchema, - ) -> void: - _state_history = p_state_history - _input_history = p_input_history - _state_property_config = p_state_property_config - _input_property_config = p_input_property_config - _visibility_filter = p_visibility_filter - _property_cache = p_property_cache - _skipset = p_skipset - _schema_handler = p_schema_handler - - _input_encoder = _RedundantHistoryEncoder.new(_input_history, _property_cache, _schema_handler) - _full_state_encoder = _SnapshotHistoryEncoder.new(_state_history, _property_cache, _schema_handler) - _diff_state_encoder = _DiffHistoryEncoder.new(_state_history, _property_cache, _schema_handler) - - _is_initialized = true - reset() - -func reset() -> void: - _ackd_state.clear() - _latest_state_tick = NetworkTime.tick - 1 - _earliest_input_tick = NetworkTime.tick - _next_full_state_tick = NetworkTime.tick - _next_diff_ack_tick = NetworkTime.tick - - # Scatter full state sends, so not all nodes send at the same tick - if is_inside_tree(): - _next_full_state_tick += hash(root.get_path()) % maxi(1, full_state_interval) - _next_diff_ack_tick += hash(root.get_path()) % maxi(1, diff_ack_interval) - else: - _next_full_state_tick += hash(root.name) % maxi(1, full_state_interval) - _next_diff_ack_tick += hash(root.name) % maxi(1, diff_ack_interval) - - _diff_state_encoder.add_properties(_state_property_config.get_properties()) - _full_state_encoder.set_properties(_get_owned_state_props()) - _input_encoder.set_properties(_get_owned_input_props()) - -func conclude_tick_loop() -> void: - _earliest_input_tick = NetworkTime.tick - -func transmit_input(tick: int) -> void: - if not _get_owned_input_props().is_empty(): - var input_tick: int = tick + NetworkRollback.input_delay - var input_data := _input_encoder.encode(input_tick, _get_owned_input_props()) - var state_owning_peer := root.get_multiplayer_authority() - NetworkRollback.register_input_submission(root, tick) - - if enable_input_broadcast: - for peer in _visibility_filter.get_rpc_target_peers(): - _submit_input.rpc_id(peer, input_tick, input_data) - elif state_owning_peer != multiplayer.get_unique_id(): - _submit_input.rpc_id(state_owning_peer, input_tick, input_data) - -func transmit_state(tick: int) -> void: - if _get_owned_state_props().is_empty(): - # We don't own state, don't transmit anything - return - - if _is_predicted_tick and not _input_property_config.get_properties().is_empty(): - # Don't transmit anything if we're predicting - # EXCEPT when we're running inputless - return - - # Include properties we own - var full_state := _PropertySnapshot.new() - - for property in _get_owned_state_props(): - if _should_broadcast(property, tick): - full_state.set_value(property.to_string(), property.get_value()) - - _on_transmit_state.emit(full_state, tick) - - # No properties to send? - if full_state.is_empty(): - return - - _latest_state_tick = max(_latest_state_tick, tick) - - var is_sending_diffs := NetworkRollback.enable_diff_states - var is_full_state_tick := not is_sending_diffs or (full_state_interval > 0 and tick > _next_full_state_tick) - - if is_full_state_tick: - # Broadcast new full state - for peer in _visibility_filter.get_rpc_target_peers(): - _send_full_state(tick, peer) - - # Adjust next full state if sending diffs - if is_sending_diffs: - _next_full_state_tick = tick + full_state_interval - else: - # Send diffs to each peer - for peer in _visibility_filter.get_visible_peers(): - var reference_tick := _ackd_state.get(peer, -1) as int - if reference_tick < 0 or not _state_history.has(reference_tick): - # Peer hasn't ack'd any tick, or we don't have the ack'd tick - # Send full state - _send_full_state(tick, peer) - continue - - # Prepare diff - var diff_state_data := _diff_state_encoder.encode(tick, reference_tick, _get_owned_state_props()) - - if _diff_state_encoder.get_full_snapshot().size() == _diff_state_encoder.get_encoded_snapshot().size(): - # State is completely different, send full state - _send_full_state(tick, peer) - else: - # Send only diff - _submit_diff_state.rpc_id(peer, diff_state_data, tick, reference_tick) - - # Push metrics - NetworkPerformance.push_full_state(_diff_state_encoder.get_full_snapshot()) - NetworkPerformance.push_sent_state(_diff_state_encoder.get_encoded_snapshot()) - -func _should_broadcast(property: PropertyEntry, tick: int) -> bool: - # Only broadcast if we've simulated the node - # NOTE: _can_simulate checks mutations, but to override _skipset - # we check first - if NetworkRollback.is_mutated(property.node, tick - 1): - return true - if _skipset.has(property.node): - return false - if NetworkRollback.is_rollback_aware(property.node): - return NetworkRollback.is_simulated(property.node) - - # Node is not rollback-aware, broadcast updates only if we own it - return property.node.is_multiplayer_authority() - -func _send_full_state(tick: int, peer: int = 0) -> void: - var full_state_snapshot := _state_history.get_snapshot(tick).as_dictionary() - var full_state_data := _full_state_encoder.encode(tick, _get_owned_state_props()) - - _submit_full_state.rpc_id(peer, full_state_data, tick) - - if peer <= 0: - NetworkPerformance.push_full_state_broadcast(full_state_snapshot) - NetworkPerformance.push_sent_state_broadcast(full_state_snapshot) - else: - NetworkPerformance.push_full_state(full_state_snapshot) - NetworkPerformance.push_sent_state(full_state_snapshot) - -func _notification(what): - if what == NOTIFICATION_PREDELETE: - NetworkRollback.free_input_submission_data_for(root) - -@rpc("any_peer", "unreliable", "call_remote") -func _submit_input(tick: int, data: PackedByteArray) -> void: - if not _is_initialized: - # Settings not processed yet - return - - var sender := multiplayer.get_remote_sender_id() - var snapshots := _input_encoder.decode(data, _input_property_config.get_properties_owned_by(sender)) - var earliest_received_input = _input_encoder.apply(tick, snapshots, sender) - if earliest_received_input >= 0: - _earliest_input_tick = mini(_earliest_input_tick, earliest_received_input) - NetworkRollback.register_input_submission(root, tick) - -# `serialized_state` is a serialized _PropertySnapshot -@rpc("any_peer", "unreliable_ordered", "call_remote") -func _submit_full_state(data: PackedByteArray, tick: int) -> void: - if not _is_initialized: - # Settings not processed yet - return - - var sender := multiplayer.get_remote_sender_id() - var snapshot := _full_state_encoder.decode(data, _state_property_config.get_properties_owned_by(sender)) - if not _full_state_encoder.apply(tick, snapshot, sender): - # Invalid data - return - - _latest_state_tick = tick - if NetworkRollback.enable_diff_states: - _ack_full_state.rpc_id(sender, tick) - -# State is a serialized _PropertySnapshot (Dictionary[String, Variant]) -@rpc("any_peer", "unreliable_ordered", "call_remote") -func _submit_diff_state(data: PackedByteArray, tick: int, reference_tick: int) -> void: - if not _is_initialized: - # Settings not processed yet - return - - var sender = multiplayer.get_remote_sender_id() - var diff_snapshot := _diff_state_encoder.decode(data, _state_property_config.get_properties_owned_by(sender)) - if not _diff_state_encoder.apply(tick, diff_snapshot, reference_tick, sender): - # Invalid data - return - - _latest_state_tick = tick - - if NetworkRollback.enable_diff_states: - if diff_ack_interval > 0 and tick > _next_diff_ack_tick: - _ack_diff_state.rpc_id(sender, tick) - _next_diff_ack_tick = tick + diff_ack_interval - -@rpc("any_peer", "reliable", "call_remote") -func _ack_full_state(tick: int) -> void: - var sender_id := multiplayer.get_remote_sender_id() - _ackd_state[sender_id] = tick - - _logger.trace("Peer %d ack'd full state for tick %d", [sender_id, tick]) - -@rpc("any_peer", "unreliable_ordered", "call_remote") -func _ack_diff_state(tick: int) -> void: - var sender_id := multiplayer.get_remote_sender_id() - _ackd_state[sender_id] = tick - - _logger.trace("Peer %d ack'd diff state for tick %d", [sender_id, tick]) - -# ============================================================================= -# Shared utils, extract later - -func _get_recorded_state_props() -> Array[PropertyEntry]: - return _state_property_config.get_properties() - -func _get_owned_state_props() -> Array[PropertyEntry]: - return _state_property_config.get_owned_properties() - -func _get_recorded_input_props() -> Array[PropertyEntry]: - return _input_property_config.get_owned_properties() - -func _get_owned_input_props() -> Array[PropertyEntry]: - return _input_property_config.get_owned_properties() diff --git a/addons/netfox/rollback/composite/rollback-history-transmitter.gd.uid b/addons/netfox/rollback/composite/rollback-history-transmitter.gd.uid deleted file mode 100644 index a9ccefe9..00000000 --- a/addons/netfox/rollback/composite/rollback-history-transmitter.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ofadsxmvd3e3 diff --git a/addons/netfox/rollback/rollback-freshness-store.gd b/addons/netfox/rollback/rollback-freshness-store.gd deleted file mode 100644 index 1250c7ff..00000000 --- a/addons/netfox/rollback/rollback-freshness-store.gd +++ /dev/null @@ -1,34 +0,0 @@ -extends RefCounted -class_name RollbackFreshnessStore - -## This class tracks nodes and whether they have processed any given tick during -## a rollback. - -# TODO: _Set -var _data: Dictionary = {} - -func is_fresh(node: Node, tick: int) -> bool: - if not _data.has(tick): - return true - - if not _data[tick].has(node): - return true - - return false - -func notify_processed(node: Node, tick: int) -> void: - if not _data.has(tick): - _data[tick] = {} - - _data[tick][node] = true - -func trim() -> void: - while not _data.is_empty(): - var earliest_tick := _data.keys().min() - if earliest_tick < NetworkRollback.history_start: - _data.erase(earliest_tick) - else: - break - -func clear(): - _data.clear() diff --git a/addons/netfox/servers/network-identity-server.gd b/addons/netfox/servers/network-identity-server.gd index d5955386..b0aeb398 100644 --- a/addons/netfox/servers/network-identity-server.gd +++ b/addons/netfox/servers/network-identity-server.gd @@ -49,6 +49,8 @@ func clear() -> void: _identifier_by_id.clear() _next_id = 0 +# TODO: Handle peer disconnect by clearing up data + func register_node(node: Node) -> void: if not node.is_inside_tree(): _logger.error("Can't register node %s that is not inside tree!", [node]) diff --git a/test/netfox/encoder/diff-history-encoder.test.gd b/test/netfox/encoder/diff-history-encoder.test.gd deleted file mode 100644 index 8bf6e47c..00000000 --- a/test/netfox/encoder/diff-history-encoder.test.gd +++ /dev/null @@ -1,122 +0,0 @@ -extends VestTest - -func get_suite_name() -> String: - return "DiffHistoryEncoder" - -const TICK := 1 -const REFERENCE_TICK := 0 -const UNAUTHORIZED_SENDER := 2 - -var source_history: _PropertyHistoryBuffer -var target_history: _PropertyHistoryBuffer -var property_cache: PropertyCache - -var property_entries: Array[PropertyEntry] -var source_encoder: _DiffHistoryEncoder -var target_encoder: _DiffHistoryEncoder - -var root_node: SnapshotFixtures.StateNode - -func before_case(__): - # Setup - root_node = SnapshotFixtures.state_node() - property_entries = SnapshotFixtures.state_propery_entries(root_node) - - source_history = _PropertyHistoryBuffer.new() - target_history = _PropertyHistoryBuffer.new() - property_cache = PropertyCache.new(root_node) - - var schema := _NetworkSchema.new({}) - source_encoder = _DiffHistoryEncoder.new(source_history, property_cache, schema) - target_encoder = _DiffHistoryEncoder.new(target_history, property_cache, schema) - - source_encoder.add_properties(property_entries) - target_encoder.add_properties(property_entries) - - # Set history - source_history.set_snapshot(0, SnapshotFixtures.state_snapshot(Vector3(1, 1, 1))) - source_history.set_snapshot(1, SnapshotFixtures.state_snapshot(Vector3(2, 1, 1))) - source_history.set_snapshot(2, SnapshotFixtures.state_snapshot(Vector3(2, 1, 2))) - source_history.set_snapshot(2, SnapshotFixtures.state_snapshot(Vector3(1, 1, 2))) - - target_history.set_snapshot(0, SnapshotFixtures.state_snapshot(Vector3(1, 1, 1))) - -func after_case(__): - NetworkTime._tick = 0 - -func test_apply_should_sync_history(): - var data := source_encoder.encode(TICK, REFERENCE_TICK, property_entries) - var snapshot := target_encoder.decode(data, property_entries) - var success := target_encoder.apply(TICK, snapshot, REFERENCE_TICK) - - expect(success, "Snapshot should have been applied!") - expect_equal( - target_history.get_snapshot(TICK).as_dictionary(), - source_history.get_snapshot(TICK).as_dictionary() - ) - -func test_apply_should_fail_on_old_data(): - var data := source_encoder.encode(TICK, REFERENCE_TICK, property_entries) - var snapshot := target_encoder.decode(data, property_entries) - - NetworkTime._tick = TICK + NetworkRollback.history_limit + 2 - - expect_false( - target_encoder.apply(TICK, snapshot, REFERENCE_TICK), - "Snapshot should be rejected!" - ) - -func test_apply_should_fail_on_unauthorized_data(): - var data := source_encoder.encode(TICK, REFERENCE_TICK, property_entries) - var snapshot := target_encoder.decode(data, property_entries) - - NetworkTime._tick = TICK + NetworkRollback.history_limit + 2 - - expect_false( - target_encoder.apply(TICK, snapshot, REFERENCE_TICK, UNAUTHORIZED_SENDER), - "Snapshot should be rejected!" - ) - -func test_apply_should_continue_without_reference_tick(): - var data := source_encoder.encode(2, 1, property_entries) - var snapshot := target_encoder.decode(data, property_entries) - var success := target_encoder.apply(2, snapshot, 1) - - expect(success, "Snapshot should have been applied!") - -func test_decode_known_on_version_mismatch(): - var new_properties := property_entries.duplicate() - new_properties.append(PropertyEntry.parse(root_node, ":quaternion")) - - # Transmit first tick to match versions - target_encoder.decode(source_encoder.encode(2, 1, property_entries), property_entries) - - # Change property config for second transmit - source_encoder.add_properties(new_properties) - var encoded := source_encoder.encode(2, 1, new_properties) - var decoded := target_encoder.decode(encoded, property_entries) - - expect_not_empty(decoded) - -func test_bandwidth_on_no_change(): - # Set first two ticks to equal - source_history.set_snapshot(TICK, source_history.get_snapshot(REFERENCE_TICK)) - - var data := source_encoder.encode(TICK, REFERENCE_TICK, property_entries) - var bytes_per_snapshot := var_to_bytes(data).size() - - # Went from 8 to 8 - Vest.message("Empty diff size: %d bytes" % [bytes_per_snapshot]) - - ok() - -func test_bandwidth_on_partial_change(): - # Partial diff already set up in before_case() - - var data := source_encoder.encode(TICK, REFERENCE_TICK, property_entries) - var bytes_per_snapshot := var_to_bytes(data).size() - - # Went from 44 to 32 - Vest.message("Partial diff size: %d bytes" % [bytes_per_snapshot]) - - ok() diff --git a/test/netfox/encoder/diff-history-encoder.test.gd.uid b/test/netfox/encoder/diff-history-encoder.test.gd.uid deleted file mode 100644 index 09273f2a..00000000 --- a/test/netfox/encoder/diff-history-encoder.test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bx4ffuqyha3xt diff --git a/test/netfox/encoder/redundant-history-encoder.test.gd b/test/netfox/encoder/redundant-history-encoder.test.gd deleted file mode 100644 index 2d81dd9b..00000000 --- a/test/netfox/encoder/redundant-history-encoder.test.gd +++ /dev/null @@ -1,137 +0,0 @@ -extends VestTest - -func get_suite_name() -> String: - return "RedundantHistoryEncoder" - -const REDUNDANCY := 3 -const TICK := REDUNDANCY - 1 - -var source_history: _PropertyHistoryBuffer -var target_history: _PropertyHistoryBuffer -var property_cache: PropertyCache - -var property_entries: Array[PropertyEntry] -var source_encoder: _RedundantHistoryEncoder -var target_encoder: _RedundantHistoryEncoder - -func before_case(__): - # Setup - var root_node := Node3D.new() - var input_node := SnapshotFixtures.input_node() - root_node.add_child(input_node) - property_entries = SnapshotFixtures.input_property_entries(root_node) - - source_history = _PropertyHistoryBuffer.new() - target_history = _PropertyHistoryBuffer.new() - property_cache = PropertyCache.new(root_node) - - var schema := _NetworkSchema.new({}) - source_encoder = _RedundantHistoryEncoder.new(source_history, property_cache, schema) - target_encoder = _RedundantHistoryEncoder.new(target_history, property_cache, schema) - - # By setting different redundancies, we also test for the encoders - # recognizing redundancy in incoming data - source_encoder.redundancy = REDUNDANCY - target_encoder.redundancy = 1 - - source_encoder.set_properties(property_entries) - target_encoder.set_properties(property_entries) - - # Set some base data - for tick in range(REDUNDANCY): - source_history.set_snapshot(tick, SnapshotFixtures.input_snapshot(Vector3(tick, 0, 0))) - - target_history.set_snapshot(1, SnapshotFixtures.input_snapshot(Vector3.RIGHT)) - -func after_case(__): - NetworkTime._tick = 0 - -func test_encode_should_decode_to_same(): - # Source encodes a snapshot, and the target decodes it. - # The two snapshots should match. - - var data := source_encoder.encode(TICK, property_entries) - var snapshots := target_encoder.decode(data, property_entries) - - for i in range(REDUNDANCY): - expect_equal( - snapshots[i].as_dictionary(), - source_history.get_snapshot(TICK - i).as_dictionary(), - "Snapshot %d should equal source!" % [i] - ) - -func test_decode_should_fail_on_version_mismatch(): - var tick := 0 - var new_properties := property_entries.slice(0, 1) - - # Transmit first tick to match versions - target_encoder.decode(source_encoder.encode(tick, property_entries), property_entries) - - # Change property config for second transmit - source_encoder.set_properties(new_properties) - var encoded := source_encoder.encode(tick, new_properties) - var decoded := target_encoder.decode(encoded, property_entries) - - expect_empty(decoded) - -func test_encode_should_skip_unavailable_ticks(): - # Encoded data should not contain ticks before the first tick in history - - var data := source_encoder.encode(0, property_entries) - var snapshots := target_encoder.decode(data, property_entries) - - var actual := snapshots.map(func(s): return s.as_dictionary()) - var expected := [SnapshotFixtures.input_snapshot(Vector3.ZERO).as_dictionary()] - - expect_equal(actual, expected) - -func test_encode_should_return_empty_on_empty_history(): - # Encoder should not break on empty history - - source_history.clear() - var data := source_encoder.encode(0, property_entries) - var snapshots := target_encoder.decode(data, property_entries) - - expect_empty(snapshots) - -func test_apply_should_return_earliest_new_tick(): - # Apply should merge incoming data into history. - # Incoming history: [x][x][x] - # Known history: [ ][x][ ] - # Hence the earliest new tick should be 0 - - var data := source_encoder.encode(TICK, property_entries) - var snapshots := target_encoder.decode(data, property_entries) - var earliest_new_tick = target_encoder.apply(TICK, snapshots) - - expect_equal(earliest_new_tick, 0) - -func test_apply_should_ignore_unauthorized_data(): - # Apply should sanitize data and ignore all properties not owned by sender - - var data := source_encoder.encode(TICK, property_entries) - var snapshots := target_encoder.decode(data, property_entries) - var earliest_new_tick = target_encoder.apply(TICK, snapshots, 2) - - expect_equal(earliest_new_tick, -1) - -func test_apply_should_ignore_old_data(): - # Apply should sanitize data and ignore all properties not owned by sender - - var data := source_encoder.encode(TICK, property_entries) - var snapshots := target_encoder.decode(data, property_entries) - - NetworkTime._tick = TICK + NetworkRollback.history_limit - 2 - - var earliest_new_tick = target_encoder.apply(TICK, snapshots) - - expect_equal(earliest_new_tick, TICK - 2) - -func test_bandwidth(): - var data := source_encoder.encode(TICK, property_entries) - var bytes_per_snapshot := var_to_bytes(data).size() - - # 248 to 104 to 80 - Vest.message("Snapshot size with %d redundancy: %d bytes" % [source_encoder.redundancy, bytes_per_snapshot]) - - ok() diff --git a/test/netfox/encoder/redundant-history-encoder.test.gd.uid b/test/netfox/encoder/redundant-history-encoder.test.gd.uid deleted file mode 100644 index 679b060b..00000000 --- a/test/netfox/encoder/redundant-history-encoder.test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ddsrxoh313ffk diff --git a/test/netfox/encoder/snapshot-history-encoder.test.gd b/test/netfox/encoder/snapshot-history-encoder.test.gd deleted file mode 100644 index 3c6e074c..00000000 --- a/test/netfox/encoder/snapshot-history-encoder.test.gd +++ /dev/null @@ -1,118 +0,0 @@ -extends VestTest - -func get_suite_name() -> String: - return "SnapshotHistoryEncoder" - -var source_history: _PropertyHistoryBuffer -var target_history: _PropertyHistoryBuffer -var property_cache: PropertyCache - -var property_entries: Array[PropertyEntry] -var source_encoder: _SnapshotHistoryEncoder -var target_encoder: _SnapshotHistoryEncoder - -func before_case(__): - # Setup - var root_node := SnapshotFixtures.state_node() - property_entries = SnapshotFixtures.state_propery_entries(root_node) - - source_history = _PropertyHistoryBuffer.new() - target_history = _PropertyHistoryBuffer.new() - property_cache = PropertyCache.new(root_node) - - var schema := _NetworkSchema.new({}) - source_encoder = _SnapshotHistoryEncoder.new(source_history, property_cache, schema) - target_encoder = _SnapshotHistoryEncoder.new(target_history, property_cache, schema) - - source_encoder.set_properties(property_entries) - target_encoder.set_properties(property_entries) - - # Set history - source_history.set_snapshot(0, SnapshotFixtures.state_snapshot(Vector3(1, 1, 0))) - target_history.set_snapshot(1, SnapshotFixtures.state_snapshot(Vector3(0, 1, 0))) - -func after_case(__): - NetworkTime._tick = 0 - -func test_encode_should_decode_to_same(): - # Source encodes a snapshot, and the target decodes it. - # The two snapshots should match. - - var tick := 0 - var data := source_encoder.encode(tick, property_entries) - var snapshot := target_encoder.decode(data, property_entries) - - expect_equal( - snapshot.as_dictionary(), - source_history.get_snapshot(tick).as_dictionary() - ) - -func test_decode_should_fail_on_version_mismatch(): - var tick := 0 - var new_properties := property_entries.slice(0, 1) - - # Transmit first tick to match versions - target_encoder.decode(source_encoder.encode(tick, property_entries), property_entries) - - # Change property config for second transmit - source_encoder.set_properties(new_properties) - var encoded := source_encoder.encode(tick, new_properties) - var decoded := target_encoder.decode(encoded, property_entries) - - expect_empty(decoded) - -func test_apply_should_update_history(): - # Source encodes a snapshot, the target decodes and applies it. - # Histories should be in sync for the affected tick. - - var tick := 0 - var data := source_encoder.encode(tick, property_entries) - var snapshot := target_encoder.decode(data, property_entries) - - var success := target_encoder.apply(tick, snapshot) - - expect(success, "Snapshot should have been applied!") - expect_equal( - target_history.get_snapshot(tick).as_dictionary(), - source_history.get_snapshot(tick).as_dictionary() - ) - -func test_apply_should_fail_on_old_data(): - # Source encodes a snapshot, the target decodes and tries to apply it. - # Apply fails because the snapshot is too old. - - var tick := 0 - var data := source_encoder.encode(tick, property_entries) - var snapshot := target_encoder.decode(data, property_entries) - - NetworkTime._tick = tick + NetworkRollback.history_limit + 2 - - expect_false( - target_encoder.apply(tick, snapshot), - "Snapshot should be rejected!" - ) - -func test_apply_should_fail_on_unauthorized_data(): - # Source encodes a snapshot, the target decodes and tries to apply it. - # Apply fails because none of the properties are owned by the sender - - var tick := 0 - var data := source_encoder.encode(tick, property_entries) - var snapshot := target_encoder.decode(data, property_entries) - - NetworkTime._tick = tick - - expect_false( - target_encoder.apply(tick, snapshot, 2), - "Snapshot should be rejected!" - ) - -func test_bandwidth(): - # TODO(vest): Attach custom data to test results and benchmarks - var data := source_encoder.encode(0, property_entries) - var bytes_per_snapshot := var_to_bytes(data).size() - - # Went from 104 to 48 - Vest.message("Snapshot size: %d bytes" % [bytes_per_snapshot]) - - ok() diff --git a/test/netfox/encoder/snapshot-history-encoder.test.gd.uid b/test/netfox/encoder/snapshot-history-encoder.test.gd.uid deleted file mode 100644 index d44b153b..00000000 --- a/test/netfox/encoder/snapshot-history-encoder.test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dixhv0lquqtyi From 443b8f8807b3deedfe179f2d7d4589f34a44e986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 18 Jan 2026 11:53:40 +0100 Subject: [PATCH 51/95] netid fxs --- addons/netfox/schemas/network-schemas.gd | 11 +- .../netfox/servers/data/network-identifier.gd | 49 +++++++ .../data/network-identity-reference.gd | 35 +++++ .../netfox/servers/network-identity-server.gd | 131 +++--------------- .../rollback-synchronization-server.gd | 12 +- test/netfox/schemas/network-schemas.test.gd | 6 +- .../servers/network-identity-server.perf.gd | 4 +- .../servers/network-identity-server.test.gd | 6 +- 8 files changed, 120 insertions(+), 134 deletions(-) create mode 100644 addons/netfox/servers/data/network-identifier.gd create mode 100644 addons/netfox/servers/data/network-identity-reference.gd diff --git a/addons/netfox/schemas/network-schemas.gd b/addons/netfox/schemas/network-schemas.gd index 277fde24..4a7d395f 100644 --- a/addons/netfox/schemas/network-schemas.gd +++ b/addons/netfox/schemas/network-schemas.gd @@ -463,9 +463,8 @@ static func dictionary(key_serializer: NetworkSchemaSerializer = variant(), size_serializer: NetworkSchemaSerializer = uint16()) -> NetworkSchemaSerializer: return _DictionarySerializer.new(key_serializer, value_serializer, size_serializer) -# TODO: Docs -# TODO: Consider parameterized ID serializer -static func netref() -> NetworkSchemaSerializer: +# Serializes [_NetworkIdentityReference] objects +static func _netref() -> NetworkSchemaSerializer: return _NetworkIdentityReferenceSerializer.new() # Serializer classes @@ -859,7 +858,7 @@ class _NetworkIdentityReferenceSerializer extends NetworkSchemaSerializer: static var varuint := _VaruintSerializer.new() func encode(v: Variant, b: StreamPeerBuffer) -> void: - var ref := v as NetworkIdentityServer.NetworkIdentityReference + var ref := v as _NetworkIdentityReference if ref.has_id(): varuint.encode(ref.get_id(), b) else: @@ -872,6 +871,6 @@ class _NetworkIdentityReferenceSerializer extends NetworkSchemaSerializer: var id := varuint.decode(b) as int if id == 0: var full_name := b.get_utf8_string() - return NetworkIdentityServer.NetworkIdentityReference.of_full_name(full_name) + return _NetworkIdentityReference.of_full_name(full_name) else: - return NetworkIdentityServer.NetworkIdentityReference.of_id(id) + return _NetworkIdentityReference.of_id(id) diff --git a/addons/netfox/servers/data/network-identifier.gd b/addons/netfox/servers/data/network-identifier.gd new file mode 100644 index 00000000..d861b80c --- /dev/null +++ b/addons/netfox/servers/data/network-identifier.gd @@ -0,0 +1,49 @@ +class_name _NetworkIdentifier +extends RefCounted + +var _subject: Object +var _full_name: String +var _ids: Dictionary = {} # peer to id +var _local_id: int + +signal on_id(peer: int, id: int) + +func _init(subject: Object, full_name: String, local_id: int, local_peer: int): + _subject = subject + _full_name = full_name + _local_id = local_id + _ids[local_peer] = local_id + +func has_id_for(peer: int) -> bool: + return _ids.has(peer) + +func get_id_for(peer: int) -> int: + return _ids.get(peer, -1) + +func set_id_for(peer: int, id: int) -> void: + assert(not _ids.has(peer), "ID for peer #%d is already set!" % [peer]) + _ids[peer] = id + on_id.emit(peer, id) + +func get_local_id() -> int: + return _local_id + +func get_full_name() -> String: + return _full_name + +func get_subject() -> Object: + return _subject + +func get_known_peers() -> Array[int]: + var result := [] as Array[int] + result.assign(_ids.keys()) + return result + +func reference_for(peer: int) -> _NetworkIdentityReference: + if has_id_for(peer): + return _NetworkIdentityReference.of_id(get_id_for(peer)) + else: + return _NetworkIdentityReference.of_full_name(get_full_name()) + +func _to_string() -> String: + return "NetworkIdentifier(%s)" % [_full_name] diff --git a/addons/netfox/servers/data/network-identity-reference.gd b/addons/netfox/servers/data/network-identity-reference.gd new file mode 100644 index 00000000..954cf0ac --- /dev/null +++ b/addons/netfox/servers/data/network-identity-reference.gd @@ -0,0 +1,35 @@ +class_name _NetworkIdentityReference +extends RefCounted + +var _full_name: String = "" +var _id: int = -1 + +static func of_full_name(full_name: String) -> _NetworkIdentityReference: + var reference := _NetworkIdentityReference.new() + reference._full_name = full_name + return reference + +static func of_id(id: int) -> _NetworkIdentityReference: + var reference := _NetworkIdentityReference.new() + reference._id = id + return reference + +func has_id() -> bool: + return _id >= 0 + +func get_id() -> int: + return _id + +func get_full_name() -> String: + return _full_name + +func equals(other: Variant) -> bool: + if other is _NetworkIdentityReference: + return _full_name == other._full_name and _id == other._id + return false + +func _to_string() -> String: + if has_id(): + return "NetworkIdentityReference#%d" % [_id] + else: + return "NetworkIdentityReference(%s)" % [_full_name] diff --git a/addons/netfox/servers/network-identity-server.gd b/addons/netfox/servers/network-identity-server.gd index b0aeb398..cce02e66 100644 --- a/addons/netfox/servers/network-identity-server.gd +++ b/addons/netfox/servers/network-identity-server.gd @@ -5,7 +5,7 @@ var _command_server: _NetworkCommandServer var _next_id := 0 var _identifiers := {} # object to NetworkIdentifier -var _push_queue := [] as Array[IdentityNotification] +var _push_queue := {} # peer to (full name to id) var _identifier_by_name := {} # full name to NetworkIdentifier var _identifier_by_id := {} # peer to (id to NetworkIdentifier) @@ -27,7 +27,7 @@ func register(what: Object, path: String) -> void: if _identifiers.has(what): return - var identifier := NetworkIdentifier.new(what, path, _make_id(), multiplayer.get_unique_id()) + var identifier := _NetworkIdentifier.new(what, path, _make_id(), multiplayer.get_unique_id()) _identifiers[what] = identifier _identifier_by_name[identifier.get_full_name()] = identifier @@ -38,7 +38,7 @@ func deregister(what: Object) -> void: if not _identifiers.has(what): return - var identifier := _identifiers[what] as NetworkIdentifier + var identifier := _identifiers[what] as _NetworkIdentifier _identifiers.erase(what) _identifier_by_name.erase(identifier.get_full_name()) _erase_from_id_cache(identifier) @@ -61,10 +61,10 @@ func deregister_node(node: Node) -> void: deregister(node) # TODO: Consider specific queries, NetworkIdentifier might be an impl detail -func get_identifier_of(what: Object) -> NetworkIdentifier: +func get_identifier_of(what: Object) -> _NetworkIdentifier: return _identifiers.get(what) -func resolve_reference(peer: int, identity_reference: NetworkIdentityReference, allow_queue: bool = true) -> NetworkIdentifier: +func resolve_reference(peer: int, identity_reference: _NetworkIdentityReference, allow_queue: bool = true) -> _NetworkIdentifier: if identity_reference.has_id(): return _get_identifier_by_id(peer, identity_reference.get_id()) else: @@ -73,36 +73,34 @@ func resolve_reference(peer: int, identity_reference: NetworkIdentityReference, queue_for(identifier, peer) return identifier -func queue_for(identifier: NetworkIdentifier, peer: int) -> void: - _push_queue.append(IdentityNotification.of(peer, identifier)) +func queue_for(identifier: _NetworkIdentifier, peer: int) -> void: + if not _push_queue.has(peer): + _push_queue[peer] = { identifier.get_full_name(): identifier.get_id_for(peer) } + else: + _push_queue[peer][identifier.get_full_name()] = identifier.get_id_for(peer) func flush_queue() -> void: - var ids := {} - - for item in _push_queue: - if not ids.has(item.peer): ids[item.peer] = {} - ids[item.peer][item.identifier.get_full_name()] = item.identifier.get_local_id() - - for peer in ids: - _cmd_ids.send(_serialize_ids(ids[peer]), peer) + for peer in _push_queue: + _cmd_ids.send(_serialize_ids(_push_queue[peer]), peer) + _push_queue.clear() -func _get_identifier_by_name(full_name: String) -> NetworkIdentifier: +func _get_identifier_by_name(full_name: String) -> _NetworkIdentifier: return _identifier_by_name.get(full_name) -func _get_identifier_by_id(peer: int, id: int) -> NetworkIdentifier: +func _get_identifier_by_id(peer: int, id: int) -> _NetworkIdentifier: return _identifier_by_id.get(peer, {}).get(id, null) func _make_id() -> int: _next_id += 1 return _next_id -func _update_id_cache(identifier: NetworkIdentifier, peer: int, id: int) -> void: +func _update_id_cache(identifier: _NetworkIdentifier, peer: int, id: int) -> void: if not _identifier_by_id.has(peer): _identifier_by_id[peer] = { id: identifier } else: _identifier_by_id[peer][id] = identifier -func _erase_from_id_cache(identifier: NetworkIdentifier) -> void: +func _erase_from_id_cache(identifier: _NetworkIdentifier) -> void: for peer in identifier.get_known_peers(): if not _identifier_by_id.has(peer): # nani? @@ -151,98 +149,3 @@ func _deserialize_ids(data: PackedByteArray) -> Dictionary: ids[full_name] = id return ids - -# TODO: Consider private -class NetworkIdentifier: - var _subject: Object - var _full_name: String - var _ids: Dictionary = {} # peer to id - var _local_id: int - - signal on_id(peer: int, id: int) - - func _init(subject: Object, full_name: String, local_id: int, local_peer: int): - _subject = subject - _full_name = full_name - _local_id = local_id - _ids[local_peer] = local_id - - func has_id_for(peer: int) -> bool: - # TODO: Also return true for local peer, just to be correct - return _ids.has(peer) - - func get_id_for(peer: int) -> int: - return _ids.get(peer, -1) - - func set_id_for(peer: int, id: int) -> void: - assert(not _ids.has(peer), "ID for peer #%d is already set!" % [peer]) - _ids[peer] = id - on_id.emit(peer, id) - - func get_local_id() -> int: - return _local_id - - func get_full_name() -> String: - return _full_name - - func get_subject() -> Object: - return _subject - - func get_known_peers() -> Array[int]: - var result := [] as Array[int] - result.assign(_ids.keys()) - return result - - func reference_for(peer: int) -> NetworkIdentityReference: - if has_id_for(peer): - return NetworkIdentityReference.of_id(get_id_for(peer)) - else: - return NetworkIdentityReference.of_full_name(get_full_name()) - - func _to_string() -> String: - return "NetworkIdentifier(%s)" % [_full_name] - -class NetworkIdentityReference: - var _full_name: String = "" - var _id: int = -1 - - static func of_full_name(full_name: String) -> NetworkIdentityReference: - var reference := NetworkIdentityReference.new() - reference._full_name = full_name - return reference - - static func of_id(id: int) -> NetworkIdentityReference: - var reference := NetworkIdentityReference.new() - reference._id = id - return reference - - func has_id() -> bool: - return _id > 0 - - func get_id() -> int: - return _id - - func get_full_name() -> String: - return _full_name - - func equals(other: Variant) -> bool: - if other is NetworkIdentityReference: - return _full_name == other._full_name and _id == other._id - return false - - func _to_string() -> String: - if has_id(): - return "NetworkIdentityReference#%d" % [_id] - else: - return "NetworkIdentityReference(%s)" % [_full_name] - -# TODO: Private -class IdentityNotification: - var peer: int - var identifier: NetworkIdentifier - - static func of(p_peer: int, p_identifier: NetworkIdentifier) -> IdentityNotification: - var request := IdentityNotification.new() - request.peer = p_peer - request.identifier = p_identifier - return request diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index ee1138f4..75f1e376 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -265,7 +265,7 @@ func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeer if buffer == null: buffer = StreamPeerBuffer.new() - var netref := NetworkSchemas.netref() + var netref := NetworkSchemas._netref() var varuint := NetworkSchemas.varuint() var node_buffer := StreamPeerBuffer.new() @@ -303,7 +303,7 @@ func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeer return buffer.data_array func _deserialize_full_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: - var netref := NetworkSchemas.netref() + var netref := NetworkSchemas._netref() var varuint := NetworkSchemas.varuint() var node_buffer := StreamPeerBuffer.new() @@ -315,7 +315,7 @@ func _deserialize_full_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bo while buffer.get_available_bytes() > 0: # Read identity reference, data size, and data # TODO: Configurable upper limit on how much netfox is allowed to read here? - var idref := netref.decode(buffer) as NetworkIdentityServer.NetworkIdentityReference + var idref := netref.decode(buffer) as _NetworkIdentityReference var node_data_size := varuint.decode(buffer) as int node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] @@ -342,7 +342,7 @@ func _serialize_diff_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeer if buffer == null: buffer = StreamPeerBuffer.new() - var netref := NetworkSchemas.netref() + var netref := NetworkSchemas._netref() var varuint := NetworkSchemas.varuint() var varbits := NetworkSchemas._varbits() @@ -387,7 +387,7 @@ func _serialize_diff_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeer return buffer.data_array func _deserialize_diff_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: - var netref := NetworkSchemas.netref() + var netref := NetworkSchemas._netref() var varuint := NetworkSchemas.varuint() var varbits := NetworkSchemas._varbits() var node_buffer := StreamPeerBuffer.new() @@ -401,7 +401,7 @@ func _deserialize_diff_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bo while buffer.get_available_bytes() > 0: # Read header, including identity reference # TODO: Configurable upper limit on how much netfox is allowed to read here? - var idref := netref.decode(buffer) as NetworkIdentityServer.NetworkIdentityReference + var idref := netref.decode(buffer) as _NetworkIdentityReference var node_data_size := varuint.decode(buffer) as int var changed_bits := varbits.decode(buffer) as _Bitset node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] diff --git a/test/netfox/schemas/network-schemas.test.gd b/test/netfox/schemas/network-schemas.test.gd index 439ec377..0e0cb12f 100644 --- a/test/netfox/schemas/network-schemas.test.gd +++ b/test/netfox/schemas/network-schemas.test.gd @@ -81,9 +81,9 @@ func suite() -> void: ["array", NetworkSchemas.array_of(NetworkSchemas.uint16()), [1, 2, 3], 8], ["dictionary", NetworkSchemas.dictionary(NetworkSchemas.uint16(), NetworkSchemas.uint16()), { 1: 32, 2: 48 }, 10], - ["netref id8", NetworkSchemas.netref(), NetworkIdentityServer.NetworkIdentityReference.of_id(12), 1], - ["netref id16", NetworkSchemas.netref(), NetworkIdentityServer.NetworkIdentityReference.of_id(138), 2], - ["netref name", NetworkSchemas.netref(), NetworkIdentityServer.NetworkIdentityReference.of_full_name("path"), 9], + ["netref id8", NetworkSchemas._netref(), _NetworkIdentityReference.of_id(12), 1], + ["netref id16", NetworkSchemas._netref(), _NetworkIdentityReference.of_id(138), 2], + ["netref name", NetworkSchemas._netref(), _NetworkIdentityReference.of_full_name("path"), 9], # Private ["varbits", NetworkSchemas._varbits(), _Bitset.of_bools([0, 1, 0, 1, 1, 0, 1]), 1] diff --git a/test/netfox/servers/network-identity-server.perf.gd b/test/netfox/servers/network-identity-server.perf.gd index f41c4d61..7fd640e8 100644 --- a/test/netfox/servers/network-identity-server.perf.gd +++ b/test/netfox/servers/network-identity-server.perf.gd @@ -49,13 +49,13 @@ func suite() -> void: benchmark("resolve by ID", func(__): var id := (randi() % count) as int - var reference := _NetworkIdentityServer.NetworkIdentityReference.of_id(id) + var reference := _NetworkIdentityReference.of_id(id) identity_server.resolve_reference(1, reference, false) ).with_batch_size(batch).with_duration(1.).run() benchmark("resolve by name", func(__): var id := (randi() % count) as int - var reference := _NetworkIdentityServer.NetworkIdentityReference.of_full_name("/root/Node %d" % id) + var reference := _NetworkIdentityReference.of_full_name("/root/Node %d" % id) identity_server.resolve_reference(1, reference, false) ).with_batch_size(batch).with_duration(1.).run() diff --git a/test/netfox/servers/network-identity-server.test.gd b/test/netfox/servers/network-identity-server.test.gd index ebcb66a4..7a8d9b8a 100644 --- a/test/netfox/servers/network-identity-server.test.gd +++ b/test/netfox/servers/network-identity-server.test.gd @@ -107,7 +107,7 @@ func suite() -> void: var identifier := identity_server.get_identifier_of(node) # Resolve - var reference := _NetworkIdentityServer.NetworkIdentityReference.of_id(identifier.get_local_id()) + var reference := _NetworkIdentityReference.of_id(identifier.get_local_id()) expect_equal(identity_server.resolve_reference(1, reference), identifier) ) @@ -117,12 +117,12 @@ func suite() -> void: var identifier := identity_server.get_identifier_of(node) # Resolve - var reference := _NetworkIdentityServer.NetworkIdentityReference.of_full_name(identifier.get_full_name()) + var reference := _NetworkIdentityReference.of_full_name(identifier.get_full_name()) expect_equal(identity_server.resolve_reference(1, reference), identifier) ) test("should return null on unknown", func(): - var reference := _NetworkIdentityServer.NetworkIdentityReference.of_full_name("Unknown Node") + var reference := _NetworkIdentityReference.of_full_name("Unknown Node") expect_null(identity_server.resolve_reference(1, reference)) ) ) From 902daeed65c3954b134c6239cc1147275a4f69e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 18 Jan 2026 14:17:03 +0100 Subject: [PATCH 52/95] reusable property pool --- .../serializers/dense-snapshot-serializer.gd | 79 ++++++++++ addons/netfox/servers/data/property-pool.gd | 36 +++++ .../netfox/servers/rollback-history-server.gd | 136 +++++++++--------- .../servers/rollback-simulation-server.gd | 2 +- .../rollback-synchronization-server.gd | 48 +++---- project.godot | 2 +- test/rollback-history-server.perf.gd | 61 ++++++++ 7 files changed, 265 insertions(+), 99 deletions(-) create mode 100644 addons/netfox/serializers/dense-snapshot-serializer.gd create mode 100644 addons/netfox/servers/data/property-pool.gd create mode 100644 test/rollback-history-server.perf.gd diff --git a/addons/netfox/serializers/dense-snapshot-serializer.gd b/addons/netfox/serializers/dense-snapshot-serializer.gd new file mode 100644 index 00000000..a2ba00b1 --- /dev/null +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd @@ -0,0 +1,79 @@ +extends RefCounted +class_name DenseSnapshotSerializer + +#func serialize_for(peer: int, snapshot: Snapshot, buffer: StreamPeerBuffer = null) -> PackedByteArray: +# if buffer == null: +# buffer = StreamPeerBuffer.new() +# +# var netref := NetworkSchemas._netref() +# var varuint := NetworkSchemas.varuint() +# +# var node_buffer := StreamPeerBuffer.new() +# +# # Write tick +# buffer.put_u32(snapshot.tick) +# # TODO: Include property config hash to detect mismatches +# +# # For each node +# for node in snapshot.nodes(): +# assert(node.is_multiplayer_authority(), "Trying to serialize state for non-owned node!") +# +# # Write identifier +# var identifier := NetworkIdentityServer.get_identifier_of(node) +# if not identifier: +# _logger.error("Can't synchronize node %s, identifier missing!", [node]) +# continue +# var idref := identifier.reference_for(peer) +# netref.encode(idref, buffer) +# +# # Write properties as-is +# # First into a buffer, so we can start with the state size +# node_buffer.clear() +# for property in get_properties_of(node): +# assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") +# var value := snapshot.get_property(node, property) +# _serialize_property(node, property, value, node_buffer) +# +# # Indicate state size for the node +# varuint.encode(node_buffer.data_array.size(), buffer) +# +# # Write node state +# buffer.put_data(node_buffer.data_array) +# +# return buffer.data_array +# +#func deserialize_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: +# var netref := NetworkSchemas._netref() +# var varuint := NetworkSchemas.varuint() +# var node_buffer := StreamPeerBuffer.new() +# +# # Read tick +# var tick := buffer.get_u32() +# var snapshot := Snapshot.new(tick) +# # TODO: Include property config hash to detect mismatches +# +# while buffer.get_available_bytes() > 0: +# # Read identity reference, data size, and data +# # TODO: Configurable upper limit on how much netfox is allowed to read here? +# var idref := netref.decode(buffer) as _NetworkIdentityReference +# var node_data_size := varuint.decode(buffer) as int +# node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] +# +# # Resolve to identifier +# var identifier := NetworkIdentityServer.resolve_reference(peer, idref) +# if not identifier: +# # TODO: Handle unknown IDs gracefully +# # TODO: Test that unknown nodes are INDEED SKIPPED +# _logger.warning("Received unknown identity reference %s, skipping data", [idref]) +# break +# var node := identifier.get_subject() as Node +# +# # Read properties +# for property in get_properties_of(node): +# # TODO: Test if less bytes remain than an entire property ( e.g. 2 bytes ) +# if node_buffer.get_available_bytes() == 0: break +# +# var value := _deserialize_property(node, property, node_buffer) +# snapshot.set_property(node, property, value, is_auth) +# +# return snapshot diff --git a/addons/netfox/servers/data/property-pool.gd b/addons/netfox/servers/data/property-pool.gd new file mode 100644 index 00000000..d87d6353 --- /dev/null +++ b/addons/netfox/servers/data/property-pool.gd @@ -0,0 +1,36 @@ +extends RefCounted +class_name _PropertyPool + +var _properties_by_subject := {} # object to property array + +func add(subject: Object, property: NodePath) -> void: + if has(subject, property): + return + + if not _properties_by_subject.has(subject): + _properties_by_subject[subject] = [property] + else: + _properties_by_subject[subject].append(property) + +func has(subject: Object, property: NodePath) -> bool: + return (_properties_by_subject.get(subject, []) as Array).has(property) + +func erase(subject: Object, property: NodePath) -> void: + if not _properties_by_subject.has(subject): + return + + var props := _properties_by_subject[subject] as Array + props.erase(property) + + if props.is_empty(): + _properties_by_subject.erase(subject) + +func get_properties_of(subject: Object) -> Array[NodePath]: + var properties := [] as Array[NodePath] + properties.assign(_properties_by_subject.get(subject, [])) + return properties + +func get_subjects() -> Array[Object]: + var subjects := [] as Array[Object] + subjects.assign(_properties_by_subject.keys()) + return subjects diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 74e07405..014dc380 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -2,12 +2,12 @@ extends Node class_name _RollbackHistoryServer # TODO: Rename to Network(ed?)HistoryServer -var _input_properties: Array = [] -var _state_properties: Array = [] -var _sync_state_properties: Array = [] # [node, property] tuples +var _rb_input_properties := _PropertyPool.new() +var _rb_state_properties := _PropertyPool.new() +var _sync_state_properties := _PropertyPool.new() -var _rollback_input_snapshots: Dictionary = {} # tick to Snapshot -var _rollback_state_snapshots: Dictionary = {} # tick to Snapshot +var _rb_input_snapshots: Dictionary = {} # tick to Snapshot +var _rb_state_snapshots: Dictionary = {} # tick to Snapshot var _sync_state_snapshots: Dictionary = {} # tick to Snapshot static var _logger := NetfoxLogger._for_netfox("RollbackHistoryServer") @@ -23,82 +23,55 @@ func deregister_property(node: Node, property: NodePath, pool: Array) -> void: pool.erase([node, property]) func register_state(node: Node, property: NodePath) -> void: - register_property(node, property, _state_properties) + _rb_state_properties.add(node, property) func deregister_state(node: Node, property: NodePath) -> void: - deregister_property(node, property, _state_properties) + _rb_state_properties.erase(node, property) func register_input(node: Node, property: NodePath) -> void: - register_property(node, property, _input_properties) + _rb_input_properties.add(node, property) func deregister_input(node: Node, property: NodePath) -> void: - deregister_property(node, property, _input_properties) + _rb_input_properties.erase(node, property) func register_sync_state(node: Node, property: NodePath) -> void: - register_property(node, property, _sync_state_properties) + _sync_state_properties.add(node, property) func deregister_sync_state(node: Node, property: NodePath) -> void: - deregister_property(node, property, _sync_state_properties) + _sync_state_properties.erase(node, property) -# TODO: Private -# TODO: Replace `predicted_nodes` with a filter callable -func record_tick(tick: int, snapshots: Dictionary, properties: Array, predicted_nodes: Array[Node]) -> void: +func _record(tick: int, snapshots: Dictionary, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: # Ensure snapshot var snapshot := snapshots.get(tick) as Snapshot + var is_new := false if snapshot == null: snapshot = Snapshot.new(tick) snapshots[tick] = snapshot + is_new = true # Record values - var updated := [] - for entry in properties: - var node := entry[0] as Node - var property := entry[1] as NodePath - var is_auth := node.is_multiplayer_authority() and not predicted_nodes.has(node) - - # HACK: Figure out proper API - # Passing in `RollbackSimulationServer.get_predicted_nodes()` accounts - # for *simulated* nodes, but not for nodes with *just* state - if properties == _state_properties: - var input_snapshot := _rollback_input_snapshots.get(tick) as Snapshot - if RollbackSimulationServer.is_predicting(input_snapshot, node): - is_auth = false - - if snapshot.merge_property(node, property, RecordedProperty.extract(entry), is_auth): - updated.append([node, property, RecordedProperty.extract(entry), is_auth]) - - _logger.trace("Recorded %d props for tick @%d: %s", [properties.size(), tick, snapshot]) - -func record_input(tick: int) -> void: - record_tick(tick, _rollback_input_snapshots, _input_properties, []) - -func record_state(tick: int) -> void: - record_tick(tick, _rollback_state_snapshots, _state_properties, RollbackSimulationServer.get_predicted_nodes()) + var updates := [] + for subject in property_pool.get_subjects(): + assert(subject is Node, "Only nodes supported for now!") -func record_sync_state(tick: int) -> void: - # TODO: Reduce duplication - # Record values - var snapshot := Snapshot.new(tick) - for entry in _sync_state_properties: - var node := entry[0] as Node - var property := entry[1] as NodePath - var is_auth := node.is_multiplayer_authority() - if not is_auth: + var is_auth := auth_filter.call(subject) + if only_auth and not is_auth: continue - snapshot.merge_property(node, property, RecordedProperty.extract(entry), is_auth) - - if snapshot.is_empty(): - # No auth data in snapshot, nothing to do - return + for property in property_pool.get_properties_of(subject): + var value := subject.get_indexed(property) + if snapshot.merge_property(subject, property, value, is_auth): + updates.append([subject, property, value, is_auth]) - if not _sync_state_snapshots.has(tick): - _sync_state_snapshots[tick] = snapshot - else: - (_sync_state_snapshots[tick] as Snapshot).merge(snapshot) + match snapshots: + _rb_input_snapshots: + _logger.debug("Updates to%s input @%d: %s" % [" new" if is_new else "", tick, updates]) + _logger.debug("Recorded input @%d: %s", [tick, snapshot]) + _rb_state_snapshots: + _logger.debug("Updates to%s state @%d: %s" % [" new" if is_new else "", tick, updates]) + _logger.debug("Recorded state @%d: %s", [tick, snapshot]) -# TODO: Private -func restore_tick(tick: int, snapshots: Dictionary) -> bool: +func _restore(tick: int, snapshots: Dictionary) -> bool: # TODO: Prettier recreation of HistoryBuffer logic and / or reuse HistoryBuffer if snapshots.is_empty() or tick < snapshots.keys().min(): return false @@ -106,22 +79,45 @@ func restore_tick(tick: int, snapshots: Dictionary) -> bool: tick -= 1 var snapshot := snapshots[tick] as Snapshot - if snapshots == _sync_state_snapshots: - _logger.debug("Restoring snapshot: %s", [snapshot]) snapshot.apply() + + match snapshots: + _rb_input_snapshots: _logger.debug("Restored input @%d: %s", [tick, snapshot]) + _rb_state_snapshots: _logger.debug("Restored state @%d: %s", [tick, snapshot]) + return true +func record_input(tick: int) -> void: + _record(tick, _rb_input_snapshots, _rb_input_properties, false, func(subject: Node): + return subject.is_multiplayer_authority() + ) + +func record_state(tick: int) -> void: + var input_snapshot := get_rollback_input_snapshot(tick - 1) + _record(tick, _rb_state_snapshots, _rb_state_properties, false, func(subject: Node): + if not subject.is_multiplayer_authority(): + return false + if RollbackSimulationServer.is_predicting(input_snapshot, subject): + return false + return true + ) + +func record_sync_state(tick: int) -> void: + _record(tick, _sync_state_snapshots, _sync_state_properties, true, func(subject: Node): + return subject.is_multiplayer_authority() + ) + func restore_rollback_input(tick: int) -> bool: - return restore_tick(tick, _rollback_input_snapshots) + return _restore(tick, _rb_input_snapshots) func restore_rollback_state(tick: int) -> bool: - return restore_tick(tick, _rollback_state_snapshots) + return _restore(tick, _rb_state_snapshots) func restore_synchronizer_state(tick: int) -> bool: - return restore_tick(tick, _sync_state_snapshots) + return _restore(tick, _sync_state_snapshots) func trim_history(earliest_tick: int) -> void: - var snapshot_pools := [_rollback_input_snapshots, _rollback_state_snapshots, _sync_state_snapshots] as Array[Dictionary] + var snapshot_pools := [_rb_input_snapshots, _rb_state_snapshots, _sync_state_snapshots] as Array[Dictionary] for snapshots in snapshot_pools: while not snapshots.is_empty(): @@ -136,10 +132,10 @@ func get_snapshot(tick: int, snapshots: Dictionary) -> Snapshot: return snapshots.get(tick) as Snapshot func get_rollback_input_snapshot(tick: int) -> Snapshot: - return get_snapshot(tick, _rollback_input_snapshots) + return get_snapshot(tick, _rb_input_snapshots) func get_rollback_state_snapshot(tick: int) -> Snapshot: - return get_snapshot(tick, _rollback_state_snapshots) + return get_snapshot(tick, _rb_state_snapshots) func get_synchronizer_state_snapshot(tick: int) -> Snapshot: return get_snapshot(tick, _sync_state_snapshots) @@ -157,19 +153,19 @@ func merge_snapshot(snapshot: Snapshot, snapshots: Dictionary) -> Snapshot: return stored_snapshot func merge_rollback_input(snapshot: Snapshot) -> Snapshot: - return merge_snapshot(snapshot, _rollback_input_snapshots) + return merge_snapshot(snapshot, _rb_input_snapshots) func merge_rollback_state(snapshot: Snapshot) -> Snapshot: - return merge_snapshot(snapshot, _rollback_state_snapshots) + return merge_snapshot(snapshot, _rb_state_snapshots) func merge_synchronizer_state(snapshot: Snapshot) -> Snapshot: return merge_snapshot(snapshot, _sync_state_snapshots) func get_data_age_for(what: Node, tick: int) -> int: - if _rollback_state_snapshots.is_empty() or _rollback_input_snapshots.is_empty(): + if _rb_state_snapshots.is_empty() or _rb_input_snapshots.is_empty(): return -1 - var earliest_tick := mini(_rollback_state_snapshots.keys().min(), _rollback_input_snapshots.keys().min()) + var earliest_tick := mini(_rb_state_snapshots.keys().min(), _rb_input_snapshots.keys().min()) for i in range(tick, earliest_tick - 1, -1): var input_snapshot := get_rollback_input_snapshot(i) var state_snapshot := get_rollback_state_snapshot(i) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index d4474b33..76f8f287 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -78,7 +78,7 @@ func is_predicting(input_snapshot: Snapshot, node: Node) -> bool: var is_owned := node.is_multiplayer_authority() var is_inputless := input_nodes.is_empty() - var has_input := false if is_inputless else true + var has_input := false # TODO: Avoid supporting null snapshots if possible if not is_inputless and input_snapshot: diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 75f1e376..8942ebe1 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -1,9 +1,9 @@ extends Node class_name _RollbackSynchronizationServer -var _input_properties: Array = [] -var _state_properties: Array = [] -var _sync_state_properties: Array = [] as Array[Array] +var _rb_input_properties := _PropertyPool.new() +var _rb_state_properties := _PropertyPool.new() +var _sync_state_properties := _PropertyPool.new() var _visibility_filters := {} # Node to PeerVisibilityFilter @@ -45,22 +45,22 @@ func deregister_property(node: Node, property: NodePath, pool: Array) -> void: pool.erase([node, property]) func register_state(node: Node, property: NodePath) -> void: - register_property(node, property, _state_properties) + _rb_state_properties.add(node, property) func deregister_state(node: Node, property: NodePath) -> void: - deregister_property(node, property, _state_properties) + _rb_state_properties.erase(node, property) func register_input(node: Node, property: NodePath) -> void: - register_property(node, property, _input_properties) + _rb_input_properties.add(node, property) func deregister_input(node: Node, property: NodePath) -> void: - deregister_property(node, property, _input_properties) + _rb_input_properties.erase(node, property) func register_sync_state(node: Node, property: NodePath) -> void: - register_property(node, property, _sync_state_properties) + _sync_state_properties.add(node, property) func deregister_sync_state(node: Node, property: NodePath) -> void: - deregister_property(node, property, _sync_state_properties) + _sync_state_properties.erase(node, property) func register_schema(node: Node, property: NodePath, serializer: NetworkSchemaSerializer) -> void: var key := RecordedProperty.key_of(node, property) @@ -84,18 +84,14 @@ func is_property_visible_to(peer: int, node: Node, property: NodePath) -> bool: else: return filter.get_visibility_for(peer) -# TODO: Optimize func get_properties_of(node: Node) -> Array[NodePath]: var result := [] as Array[NodePath] - + # TODO: Split method? Or somehow avoid this merge - for property in _state_properties + _input_properties + _sync_state_properties: - var prop_node := RecordedProperty.get_node(property) - var prop_path := RecordedProperty.get_property(property) + result.append_array(_rb_input_properties.get_properties_of(node)) + result.append_array(_rb_state_properties.get_properties_of(node)) + result.append_array(_sync_state_properties.get_properties_of(node)) - if node == prop_node: - result.append(prop_path) - return result # TODO: Make this testable somehow, I beg of you @@ -105,15 +101,11 @@ func synchronize_input(tick: int) -> void: if not _rb_enable_input_broadcast: # Grab owned input objects - var input_objects := _Set.new() - for prop in _input_properties: - var node := RecordedProperty.get_node(prop) - input_objects.add(node) - - # for each input object - for input_object in input_objects: + for input_subject in _rb_input_properties.get_subjects(): + assert(input_subject is Node, "Only nodes for now!") + # Grab state objects controlled by input - var controlled_nodes := RollbackSimulationServer.get_controlled_by(input_object) + var controlled_nodes := RollbackSimulationServer.get_controlled_by(input_subject) # Notify peers owning nodes about the input for node in controlled_nodes: @@ -133,6 +125,7 @@ func synchronize_input(tick: int) -> void: # Filter to input properties # TODO: Optimize, avoid making two copies + # TODO: Filter to only synchronized props var input_snapshot := snapshot.filtered_to_owned() # Transmit @@ -152,6 +145,7 @@ func synchronize_state(tick: int) -> void: return # Filter to state properties + # TODO: Filter to only synchronized props var state_snapshot := snapshot.filtered_to_auth().filtered_to_owned() # TODO: Early exit if we don't have auth state props @@ -473,7 +467,7 @@ func _handle_input(sender: int, data: PackedByteArray): # overriding their earlier choices. Only emit signal for snapshots # that contain new input. var merged := RollbackHistoryServer.merge_rollback_input(snapshot) - _logger.trace("Ingested input: %s", [snapshot]) + _logger.debug("Ingested input: %s", [snapshot]) on_input.emit(snapshot) @@ -526,6 +520,6 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: _logger.trace("Reconciled state diff: %s", [diff]) var merged := RollbackHistoryServer.merge_rollback_state(snapshot) - _logger.trace("Ingested state: %s", [snapshot]) + _logger.debug("Ingested state: %s", [snapshot]) on_state.emit(snapshot) diff --git a/project.godot b/project.godot index eb84d99a..198f35f8 100644 --- a/project.godot +++ b/project.godot @@ -134,7 +134,6 @@ general/clear_settings=false time/tickrate=24 extras/auto_tile_windows=true autoconnect/enabled=true -autoconnect/simulated_latency_ms=100 [rendering] @@ -146,3 +145,4 @@ anti_aliasing/quality/screen_space_aa=1 tests_root="res://test/" new_test_location=2 +test_name_patterns=PackedStringArray("*.test.gd", "*.perf.gd") diff --git a/test/rollback-history-server.perf.gd b/test/rollback-history-server.perf.gd new file mode 100644 index 00000000..240bb161 --- /dev/null +++ b/test/rollback-history-server.perf.gd @@ -0,0 +1,61 @@ +extends VestTest + +func get_suite_name() -> String: + return "RollbackHistoryServer" + +var idx := 0 + +func suite(): + for count in [16, 1024, 16384]: + # register / record / restore / deregister + # *2.06us/235.37us/*46.01us/*1.37us, 14.05us/14.73ms/1.08ms/6.56us, 207.84us/234.73ms/23.18ms/88.84us + # *2.19us/77.97us/*23.46us/*3.07us, 1.73us/ 4.55ms/1.83ms/1.48us, 2.15us/ 81.64ms/45.45ms/ 1.57us + test("%d nodes" % count, func(): + # Create nodes + var nodes := await get_nodes(count) + + # Register nodes + idx = 0 + benchmark("register()", func(__): + RollbackHistoryServer.register_state(nodes[idx], "name") + idx += 1 + ).with_iterations(count).with_batch_size(count).run() + + # Record + benchmark("record()", func(__): + RollbackHistoryServer.record_state(0) + ).with_duration(1.).with_batch_size(16).run() + + # Restore + benchmark("restore()", func(__): + RollbackHistoryServer.restore_rollback_state(0) + ).with_duration(1.).with_batch_size(16).run() + + # Deregister nodes + idx = 0 + benchmark("deregister()", func(__): + RollbackHistoryServer.deregister_state(nodes[idx], "name") + idx += 1 + ).with_iterations(count).with_batch_size(count).run() + + # Free nodes + free_nodes(nodes) + ) + +func get_nodes(count: int) -> Array[Node]: + var nodes := [] as Array[Node] + + for i in count: + var node := Node.new() + node.name = "Node %d" % i + nodes.append(node) + Vest.get_tree().root.add_child.call_deferred(node) + + for node in nodes: + await node.ready + + return nodes + +func free_nodes(nodes: Array[Node]) -> void: + for node in nodes: + node.queue_free() From 15b585de338ccb66753ab713cc96da9d923ccfcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 18 Jan 2026 21:50:28 +0100 Subject: [PATCH 53/95] extract serializer logic --- .../serializers/base-snapshot-serializer.gd | 33 +++ .../serializers/dense-snapshot-serializer.gd | 157 ++++++------ .../redundant-snapshot-serializer.gd | 40 +++ .../serializers/sparse-snapshot-serializer.gd | 86 +++++++ addons/netfox/servers/data/snapshot.gd | 11 - .../rollback-synchronization-server.gd | 232 ++---------------- .../visibility-filtering.tscn | 1 + 7 files changed, 260 insertions(+), 300 deletions(-) create mode 100644 addons/netfox/serializers/base-snapshot-serializer.gd create mode 100644 addons/netfox/serializers/redundant-snapshot-serializer.gd create mode 100644 addons/netfox/serializers/sparse-snapshot-serializer.gd diff --git a/addons/netfox/serializers/base-snapshot-serializer.gd b/addons/netfox/serializers/base-snapshot-serializer.gd new file mode 100644 index 00000000..4742515d --- /dev/null +++ b/addons/netfox/serializers/base-snapshot-serializer.gd @@ -0,0 +1,33 @@ +extends RefCounted +class_name _BaseSnapshotSerializer + +# TODO: Swap to (object to (property to NetworkSchemaSerializer)) +var _schemas := {} # RecordedProperty key to NetworkSchemaSerializer +var _fallback_schema := NetworkSchemas.variant() + +static var _logger := NetfoxLogger._for_netfox("DenseSnapshotSerializer") + +func _init(p_schemas: Dictionary): + assert(p_schemas != null, "Missing schemas!") + # Intentionally storing reference, so it can be modified from the outside + # e.g. RollbackSynchronizerServer adds a property + _schemas = p_schemas + +func _write_property(node: Node, property: NodePath, value: Variant, buffer: StreamPeerBuffer) -> void: + var serializer := _schemas.get(RecordedProperty.key_of(node, property), _fallback_schema) as NetworkSchemaSerializer + serializer.encode(value, buffer) + +func _read_property(node: Node, property: NodePath, buffer: StreamPeerBuffer) -> Variant: + var serializer := _schemas.get(RecordedProperty.key_of(node, property), _fallback_schema) as NetworkSchemaSerializer + return serializer.decode(buffer) + +func _write_identifier(subject: Object, peer: int, buffer: StreamPeerBuffer) -> Error: + var netref := NetworkSchemas._netref() + var identifier := NetworkIdentityServer.get_identifier_of(subject) + if not identifier: + _logger.error("Can't synchronize %s, identifier missing!", [subject]) + return ERR_DOES_NOT_EXIST + + var idref := identifier.reference_for(peer) + netref.encode(idref, buffer) + return OK diff --git a/addons/netfox/serializers/dense-snapshot-serializer.gd b/addons/netfox/serializers/dense-snapshot-serializer.gd index a2ba00b1..d6de58a9 100644 --- a/addons/netfox/serializers/dense-snapshot-serializer.gd +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd @@ -1,79 +1,78 @@ -extends RefCounted -class_name DenseSnapshotSerializer - -#func serialize_for(peer: int, snapshot: Snapshot, buffer: StreamPeerBuffer = null) -> PackedByteArray: -# if buffer == null: -# buffer = StreamPeerBuffer.new() -# -# var netref := NetworkSchemas._netref() -# var varuint := NetworkSchemas.varuint() -# -# var node_buffer := StreamPeerBuffer.new() -# -# # Write tick -# buffer.put_u32(snapshot.tick) -# # TODO: Include property config hash to detect mismatches -# -# # For each node -# for node in snapshot.nodes(): -# assert(node.is_multiplayer_authority(), "Trying to serialize state for non-owned node!") -# -# # Write identifier -# var identifier := NetworkIdentityServer.get_identifier_of(node) -# if not identifier: -# _logger.error("Can't synchronize node %s, identifier missing!", [node]) -# continue -# var idref := identifier.reference_for(peer) -# netref.encode(idref, buffer) -# -# # Write properties as-is -# # First into a buffer, so we can start with the state size -# node_buffer.clear() -# for property in get_properties_of(node): -# assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") -# var value := snapshot.get_property(node, property) -# _serialize_property(node, property, value, node_buffer) -# -# # Indicate state size for the node -# varuint.encode(node_buffer.data_array.size(), buffer) -# -# # Write node state -# buffer.put_data(node_buffer.data_array) -# -# return buffer.data_array -# -#func deserialize_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: -# var netref := NetworkSchemas._netref() -# var varuint := NetworkSchemas.varuint() -# var node_buffer := StreamPeerBuffer.new() -# -# # Read tick -# var tick := buffer.get_u32() -# var snapshot := Snapshot.new(tick) -# # TODO: Include property config hash to detect mismatches -# -# while buffer.get_available_bytes() > 0: -# # Read identity reference, data size, and data -# # TODO: Configurable upper limit on how much netfox is allowed to read here? -# var idref := netref.decode(buffer) as _NetworkIdentityReference -# var node_data_size := varuint.decode(buffer) as int -# node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] -# -# # Resolve to identifier -# var identifier := NetworkIdentityServer.resolve_reference(peer, idref) -# if not identifier: -# # TODO: Handle unknown IDs gracefully -# # TODO: Test that unknown nodes are INDEED SKIPPED -# _logger.warning("Received unknown identity reference %s, skipping data", [idref]) -# break -# var node := identifier.get_subject() as Node -# -# # Read properties -# for property in get_properties_of(node): -# # TODO: Test if less bytes remain than an entire property ( e.g. 2 bytes ) -# if node_buffer.get_available_bytes() == 0: break -# -# var value := _deserialize_property(node, property, node_buffer) -# snapshot.set_property(node, property, value, is_auth) -# -# return snapshot +extends _BaseSnapshotSerializer +class_name _DenseSnapshotSerializer + +static func _static_init(): + _logger = NetfoxLogger._for_netfox("DenseSnapshotSerializer") + +func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: StreamPeerBuffer = null) -> PackedByteArray: + if buffer == null: + buffer = StreamPeerBuffer.new() + + var netref := NetworkSchemas._netref() + var varuint := NetworkSchemas.varuint() + + var node_buffer := StreamPeerBuffer.new() + + # Write tick + buffer.put_u32(snapshot.tick) + # TODO: Include property config hash to detect mismatches + + # For each node + for node in snapshot.nodes(): + assert(node.is_multiplayer_authority(), "Trying to serialize state for non-owned node!") + + # Write identifier + if _write_identifier(node, peer, buffer) != OK: + continue + + # Write properties as-is + # First into a buffer, so we can start with the state size + node_buffer.clear() + for property in properties.get_properties_of(node): + assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") + var value := snapshot.get_property(node, property) + _write_property(node, property, value, node_buffer) + + # Indicate state size for the node + varuint.encode(node_buffer.data_array.size(), buffer) + + # Write node state + buffer.put_data(node_buffer.data_array) + + return buffer.data_array + +func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: + var netref := NetworkSchemas._netref() + var varuint := NetworkSchemas.varuint() + var node_buffer := StreamPeerBuffer.new() + + # Read tick + var tick := buffer.get_u32() + var snapshot := Snapshot.new(tick) + # TODO: Include property config hash to detect mismatches + + while buffer.get_available_bytes() > 0: + # Read identity reference, data size, and data + # TODO: Configurable upper limit on how much netfox is allowed to read here? + var idref := netref.decode(buffer) as _NetworkIdentityReference + var node_data_size := varuint.decode(buffer) as int + node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] + + # Resolve to identifier + var identifier := NetworkIdentityServer.resolve_reference(peer, idref) + if not identifier: + # TODO: Handle unknown IDs gracefully + # TODO: Test that unknown nodes are INDEED SKIPPED + _logger.warning("Received unknown identity reference %s, skipping data", [idref]) + continue + var node := identifier.get_subject() as Node + + # Read properties + for property in properties.get_properties_of(node): + # TODO: Test if less bytes remain than an entire property ( e.g. 2 bytes ) + if node_buffer.get_available_bytes() == 0: break + + var value := _read_property(node, property, node_buffer) + snapshot.set_property(node, property, value, is_auth) + + return snapshot diff --git a/addons/netfox/serializers/redundant-snapshot-serializer.gd b/addons/netfox/serializers/redundant-snapshot-serializer.gd new file mode 100644 index 00000000..a12fdee7 --- /dev/null +++ b/addons/netfox/serializers/redundant-snapshot-serializer.gd @@ -0,0 +1,40 @@ +extends _BaseSnapshotSerializer +class_name _RedundantSnapshotSerializer + +var _dense_serializer: _DenseSnapshotSerializer + +static func _static_init(): + _logger = NetfoxLogger._for_netfox("RedundantSnapshotSerializer") + +func _init(p_schemas: Dictionary): + super(p_schemas) + _dense_serializer = _DenseSnapshotSerializer.new(_schemas) + +func write_for(peer: int, snapshots: Array[Snapshot], properties: _PropertyPool, buffer: StreamPeerBuffer = null) -> PackedByteArray: + var varuint := NetworkSchemas.varuint() + + if buffer == null: + buffer = StreamPeerBuffer.new() + + # TODO: How about encoding the first snapshot as-is, and then the rest as diffs + for snapshot in snapshots: + var serialized := _dense_serializer.write_for(peer, snapshot, properties) + + # Write size and snapshot + varuint.encode(serialized.size(), buffer) + buffer.put_data(serialized) + + return buffer.data_array + +func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, is_auth: bool = true) -> Array[Snapshot]: + var varuint := NetworkSchemas.varuint() + + var snapshots := [] as Array[Snapshot] + while buffer.get_available_bytes() > 0: + var snapshot_size := varuint.decode(buffer) + var snapshot_buffer := StreamPeerBuffer.new() + snapshot_buffer.data_array = buffer.get_partial_data(snapshot_size)[1] + + var snapshot := _dense_serializer.read_from(peer, properties, snapshot_buffer, is_auth) + snapshots.append(snapshot) + return snapshots diff --git a/addons/netfox/serializers/sparse-snapshot-serializer.gd b/addons/netfox/serializers/sparse-snapshot-serializer.gd new file mode 100644 index 00000000..57f6757c --- /dev/null +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd @@ -0,0 +1,86 @@ +extends _BaseSnapshotSerializer +class_name _SparseSnapshotSerializer + +static func _static_init(): + _logger = NetfoxLogger._for_netfox("SparseSnapshotSerializer") + +func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: StreamPeerBuffer = null) -> PackedByteArray: + if buffer == null: + buffer = StreamPeerBuffer.new() + + var netref := NetworkSchemas._netref() + var varuint := NetworkSchemas.varuint() + var varbits := NetworkSchemas._varbits() + + var node_buffer := StreamPeerBuffer.new() + + # Write ticks + buffer.put_u32(snapshot.tick) + # TODO: Include property config hash to detect mismatches + + # For each node + for node in snapshot.nodes(): + assert(node.is_multiplayer_authority(), "Trying to serialize state for non-owned node!") + + # Write identifier + if _write_identifier(node, peer, buffer) != OK: + continue + + node_buffer.clear() + + var node_props := properties.get_properties_of(node) + var changed_bits := _Bitset.new(properties.size()) + + for i in node_props.size(): + var property := node_props[i] + if not snapshot.has_property(node, property): + continue + + assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") + + changed_bits.set_bit(i) + var value := snapshot.get_property(node, property) + _write_property(node, property, value, node_buffer) + + varuint.encode(node_buffer.data_array.size(), buffer) # Node props len + varbits.encode(changed_bits, buffer) # Changed prop bits + buffer.put_data(node_buffer.data_array) # Changed props + + return buffer.data_array + +func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: + var netref := NetworkSchemas._netref() + var varuint := NetworkSchemas.varuint() + var varbits := NetworkSchemas._varbits() + var node_buffer := StreamPeerBuffer.new() + + # Grab ticks + var tick := buffer.get_u32() + # TODO: Include property config hash to detect mismatches + + var snapshot := Snapshot.new(tick) + + while buffer.get_available_bytes() > 0: + # Read header, including identity reference + # TODO: Configurable upper limit on how much netfox is allowed to read here? + var idref := netref.decode(buffer) as _NetworkIdentityReference + var node_data_size := varuint.decode(buffer) as int + var changed_bits := varbits.decode(buffer) as _Bitset + node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] + + # Resolve to identifier + var identifier := NetworkIdentityServer.resolve_reference(peer, idref) + if not identifier: + # TODO: Handle unknown IDs gracefully + # TODO: Test that unknown nodes are INDEED SKIPPED + _logger.warning("Received unknown identity reference %s, skipping data", [idref]) + break + var node := identifier.get_subject() as Node + + # Read changed properties + var node_props := properties.get_properties_of(node) + for idx in changed_bits.get_set_indices(): + var property := node_props[idx] + var value := _read_property(node, property, node_buffer) + snapshot.set_property(node, property, value, is_auth) + return snapshot diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index b6e986ec..5306b1b7 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -62,17 +62,6 @@ func apply() -> void: var value = data[prop_key] RecordedProperty.apply(prop_key, value) -func filtered_to_properties(prop_keys: Array) -> Snapshot: - var snapshot := Snapshot.new(tick) - - for property in prop_keys: - if not data.has(property): - continue - snapshot.data[property] = data[property] - snapshot._is_authoritative[property] = _is_authoritative[property] - - return snapshot - func filtered_to_auth() -> Snapshot: var snapshot := Snapshot.new(tick) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 8942ebe1..5fa0dd70 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -17,11 +17,16 @@ var _sync_enable_diffs := true # TODO: Config var _sync_full_interval := 24 # TODO: Config var _sync_full_next := -1 +# TODO: Swap to (object to (property to NetworkSchemaSerializer)) var _schemas := {} # RecordedProperty key to NetworkSchemaSerializer var _fallback_schema := NetworkSchemas.variant() var _input_redundancy := 3 # TODO: Config +var _dense_serializer := _DenseSnapshotSerializer.new(_schemas) +var _sparse_serializer := _SparseSnapshotSerializer.new(_schemas) +var _redundant_serializer := _RedundantSnapshotSerializer.new(_schemas) + @onready var _cmd_full_state := NetworkCommandServer.register_command_at(_NetworkCommands.FULL_STATE, _handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) @onready var _cmd_diff_state := NetworkCommandServer.register_command_at(_NetworkCommands.DIFF_STATE, _handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) @onready var _cmd_input := NetworkCommandServer.register_command_at(_NetworkCommands.INPUT, _handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) @@ -134,7 +139,8 @@ func synchronize_input(tick: int) -> void: _logger.trace("Submitting input to peers: %s", [notified_peers]) for peer in notified_peers: - _cmd_input.send(_serialize_input_for(peer, snapshots), peer) + var data := _redundant_serializer.write_for(peer, snapshots, _rb_input_properties) + _cmd_input.send(data, peer) # TODO: Make this testable somehow, I beg of you func synchronize_state(tick: int) -> void: @@ -181,7 +187,8 @@ func synchronize_state(tick: int) -> void: # Peer can't see any changes, send nothing continue - _cmd_diff_state.send(_serialize_diff_state_for(peer, peer_diff), peer) + var data := _sparse_serializer.write_for(peer, peer_diff, _rb_state_properties) + _cmd_diff_state.send(data, peer) NetworkPerformance.push_full_state(state_snapshot.data) # TODO: Ugh... NetworkPerformance.push_sent_state(diff.data) # TODO: Ugh... @@ -195,7 +202,8 @@ func synchronize_state(tick: int) -> void: # Peer can't see anything, send nothing continue - _cmd_full_state.send(_serialize_full_state_for(peer, peer_snapshot), peer) + var data := _dense_serializer.write_for(peer, peer_snapshot, _rb_state_properties) + _cmd_full_state.send(data, peer) _logger.trace("Sent full state to #%d: %s", [peer, peer_snapshot]) NetworkPerformance.push_full_state(peer_snapshot.data) # TODO: Ugh... @@ -231,8 +239,9 @@ func synchronize_sync_state(tick: int) -> void: # Peer can't see anything, send nothing continue - _cmd_full_sync.send(_serialize_full_state_for(peer, peer_snapshot), peer) - + var data := _dense_serializer.write_for(peer, peer_snapshot, _sync_state_properties) + _cmd_full_sync.send(data, peer) + NetworkPerformance.push_full_state(peer_snapshot.data) # TODO: Ugh... NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... else: @@ -247,217 +256,20 @@ func synchronize_sync_state(tick: int) -> void: # Nothing changed, don't send anything continue - _cmd_diff_sync.send(_serialize_diff_state_for(peer, peer_snapshot), peer) - + var data := _sparse_serializer.write_for(peer, peer_snapshot, _sync_state_properties) + _cmd_diff_sync.send(data, peer) + NetworkPerformance.push_full_state(state_snapshot.data) # TODO: Ugh... NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... # Remember last sent state for diffing _last_sync_state_sent = state_snapshot -func _serialize_full_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeerBuffer = null) -> PackedByteArray: - if buffer == null: - buffer = StreamPeerBuffer.new() - - var netref := NetworkSchemas._netref() - var varuint := NetworkSchemas.varuint() - - var node_buffer := StreamPeerBuffer.new() - - # Write tick - buffer.put_u32(snapshot.tick) - # TODO: Include property config hash to detect mismatches - - # For each node - for node in snapshot.nodes(): - assert(node.is_multiplayer_authority(), "Trying to serialize state for non-owned node!") - - # Write identifier - var identifier := NetworkIdentityServer.get_identifier_of(node) - if not identifier: - _logger.error("Can't synchronize node %s, identifier missing!", [node]) - continue - var idref := identifier.reference_for(peer) - netref.encode(idref, buffer) - - # Write properties as-is - # First into a buffer, so we can start with the state size - node_buffer.clear() - for property in get_properties_of(node): - assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") - var value := snapshot.get_property(node, property) - _serialize_property(node, property, value, node_buffer) - - # Indicate state size for the node - varuint.encode(node_buffer.data_array.size(), buffer) - - # Write node state - buffer.put_data(node_buffer.data_array) - - return buffer.data_array - -func _deserialize_full_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: - var netref := NetworkSchemas._netref() - var varuint := NetworkSchemas.varuint() - var node_buffer := StreamPeerBuffer.new() - - # Read tick - var tick := buffer.get_u32() - var snapshot := Snapshot.new(tick) - # TODO: Include property config hash to detect mismatches - - while buffer.get_available_bytes() > 0: - # Read identity reference, data size, and data - # TODO: Configurable upper limit on how much netfox is allowed to read here? - var idref := netref.decode(buffer) as _NetworkIdentityReference - var node_data_size := varuint.decode(buffer) as int - node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] - - # Resolve to identifier - var identifier := NetworkIdentityServer.resolve_reference(peer, idref) - if not identifier: - # TODO: Handle unknown IDs gracefully - # TODO: Test that unknown nodes are INDEED SKIPPED - _logger.warning("Received unknown identity reference %s, skipping data", [idref]) - break - var node := identifier.get_subject() as Node - - # Read properties - for property in get_properties_of(node): - # TODO: Test if less bytes remain than an entire property ( e.g. 2 bytes ) - if node_buffer.get_available_bytes() == 0: break - - var value := _deserialize_property(node, property, node_buffer) - snapshot.set_property(node, property, value, is_auth) - - return snapshot - -func _serialize_diff_state_for(peer: int, snapshot: Snapshot, buffer: StreamPeerBuffer = null) -> PackedByteArray: - if buffer == null: - buffer = StreamPeerBuffer.new() - - var netref := NetworkSchemas._netref() - var varuint := NetworkSchemas.varuint() - var varbits := NetworkSchemas._varbits() - - var node_buffer := StreamPeerBuffer.new() - - # Write ticks - buffer.put_u32(snapshot.tick) - # TODO: Include property config hash to detect mismatches - - # For each node - for node in snapshot.nodes(): - assert(node.is_multiplayer_authority(), "Trying to serialize state for non-owned node!") - - # Write identifier - var identifier := NetworkIdentityServer.get_identifier_of(node) - if not identifier: - _logger.error("Can't synchronize node %s, identifier missing!", [node]) - continue - var idref := identifier.reference_for(peer) - netref.encode(idref, buffer) - - node_buffer.clear() - - var properties := get_properties_of(node) - var changed_bits := _Bitset.new(properties.size()) - - for i in properties.size(): - var property := properties[i] - if not snapshot.has_property(node, property): - continue - - assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") - - changed_bits.set_bit(i) - var value := snapshot.get_property(node, property) - _serialize_property(node, property, value, node_buffer) - - varuint.encode(node_buffer.data_array.size(), buffer) # Node props len - varbits.encode(changed_bits, buffer) # Changed prop bits - buffer.put_data(node_buffer.data_array) # Changed props - - return buffer.data_array - -func _deserialize_diff_state_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: - var netref := NetworkSchemas._netref() - var varuint := NetworkSchemas.varuint() - var varbits := NetworkSchemas._varbits() - var node_buffer := StreamPeerBuffer.new() - - # Grab ticks - var tick := buffer.get_u32() - # TODO: Include property config hash to detect mismatches - - var snapshot := Snapshot.new(tick) - - while buffer.get_available_bytes() > 0: - # Read header, including identity reference - # TODO: Configurable upper limit on how much netfox is allowed to read here? - var idref := netref.decode(buffer) as _NetworkIdentityReference - var node_data_size := varuint.decode(buffer) as int - var changed_bits := varbits.decode(buffer) as _Bitset - node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] - - # Resolve to identifier - var identifier := NetworkIdentityServer.resolve_reference(peer, idref) - if not identifier: - # TODO: Handle unknown IDs gracefully - # TODO: Test that unknown nodes are INDEED SKIPPED - _logger.warning("Received unknown identity reference %s, skipping data", [idref]) - break - var node := identifier.get_subject() as Node - - # Read changed properties - var properties := get_properties_of(node) - for idx in changed_bits.get_set_indices(): - var property := properties[idx] - var value := _deserialize_property(node, property, node_buffer) - snapshot.set_property(node, property, value, is_auth) - return snapshot - -func _serialize_input_for(peer: int, snapshots: Array[Snapshot], buffer: StreamPeerBuffer = null) -> PackedByteArray: - var varuint := NetworkSchemas.varuint() - - if buffer == null: - buffer = StreamPeerBuffer.new() - - for snapshot in snapshots: - # TODO: Rename method - var serialized := _serialize_full_state_for(peer, snapshot) - - # Write size and snapshot - varuint.encode(serialized.size(), buffer) - buffer.put_data(serialized) - - return buffer.data_array - -func _deserialize_input_of(peer: int, buffer: StreamPeerBuffer, is_auth: bool = true) -> Array[Snapshot]: - var varuint := NetworkSchemas.varuint() - - var snapshots := [] as Array[Snapshot] - while buffer.get_available_bytes() > 0: - var snapshot_size := varuint.decode(buffer) - var snapshot_buffer := StreamPeerBuffer.new() - snapshot_buffer.data_array = buffer.get_partial_data(snapshot_size)[1] - - snapshots.append(_deserialize_full_state_of(peer, snapshot_buffer, is_auth)) - return snapshots - -func _serialize_property(node: Node, property: NodePath, value: Variant, buffer: StreamPeerBuffer) -> void: - var serializer := _schemas.get(RecordedProperty.key_of(node, property), _fallback_schema) as NetworkSchemaSerializer - serializer.encode(value, buffer) - -func _deserialize_property(node: Node, property: NodePath, buffer: StreamPeerBuffer) -> Variant: - var serializer := _schemas.get(RecordedProperty.key_of(node, property), _fallback_schema) as NetworkSchemaSerializer - return serializer.decode(buffer) - func _handle_input(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data - var snapshots := _deserialize_input_of(sender, buffer) + var snapshots := _redundant_serializer.read_from(sender, _rb_input_properties, buffer, true) _logger.trace("Received input snapshots: %s", [snapshots]) for snapshot in snapshots: @@ -475,7 +287,7 @@ func _handle_full_state(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data - var snapshot := _deserialize_full_state_of(sender, buffer) + var snapshot := _dense_serializer.read_from(sender, _rb_state_properties, buffer, true) _ingest_state(sender, snapshot) @@ -483,7 +295,7 @@ func _handle_diff_state(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data - var diff := _deserialize_diff_state_of(sender, buffer) + var diff := _sparse_serializer.read_from(sender, _rb_state_properties, buffer) _logger.trace("Received diff state for @%d", [diff.tick]) # TODO: Using `snapshot` doesn't work @@ -493,7 +305,7 @@ func _handle_full_sync(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data - var snapshot := _deserialize_full_state_of(sender, buffer) + var snapshot := _dense_serializer.read_from(sender, _sync_state_properties, buffer, true) # TODO: Reduce copy-paste RollbackHistoryServer.merge_synchronizer_state(snapshot) @@ -503,7 +315,7 @@ func _handle_diff_sync(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data - var snapshot := _deserialize_diff_state_of(sender, buffer) + var snapshot := _sparse_serializer.read_from(sender, _sync_state_properties, buffer) # TODO: Reduce copy-paste RollbackHistoryServer.merge_synchronizer_state(snapshot) diff --git a/examples/visibility-filtering/visibility-filtering.tscn b/examples/visibility-filtering/visibility-filtering.tscn index 71716034..5cc9cab7 100644 --- a/examples/visibility-filtering/visibility-filtering.tscn +++ b/examples/visibility-filtering/visibility-filtering.tscn @@ -98,6 +98,7 @@ offset_right = 180.0 offset_bottom = 120.0 [node name="VBoxContainer" type="VBoxContainer" parent="UI"] +layout_mode = 0 offset_right = 40.0 offset_bottom = 40.0 From cc62eb3bc857fc4618b1d12d4b5053469be548d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 19 Jan 2026 00:59:11 +0100 Subject: [PATCH 54/95] avoid snapshot filtering unless really necessary --- .../netfox/rollback/rollback-synchronizer.gd | 1 + .../serializers/dense-snapshot-serializer.gd | 8 +- .../serializers/sparse-snapshot-serializer.gd | 8 +- addons/netfox/servers/data/property-pool.gd | 6 + .../rollback-synchronization-server.gd | 105 +++++++----------- addons/netfox/state-synchronizer.gd | 6 + project.godot | 2 +- 7 files changed, 68 insertions(+), 68 deletions(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 3d0b27f8..799ecbb3 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -337,6 +337,7 @@ func _ready() -> void: await NetworkTime.after_sync process_settings.call_deferred() + multiplayer.connected_to_server.connect(process_settings) func _notification(what: int) -> void: if what == NOTIFICATION_EDITOR_PRE_SAVE: diff --git a/addons/netfox/serializers/dense-snapshot-serializer.gd b/addons/netfox/serializers/dense-snapshot-serializer.gd index d6de58a9..7e7b36db 100644 --- a/addons/netfox/serializers/dense-snapshot-serializer.gd +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd @@ -18,7 +18,8 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: # TODO: Include property config hash to detect mismatches # For each node - for node in snapshot.nodes(): + for subject in properties.get_subjects(): + var node := subject as Node assert(node.is_multiplayer_authority(), "Trying to serialize state for non-owned node!") # Write identifier @@ -29,7 +30,12 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: # First into a buffer, so we can start with the state size node_buffer.clear() for property in properties.get_properties_of(node): + # TODO: Subject-level auth tracking so we don't have to do this check for every property + if not snapshot.is_auth(node, property): continue + + assert(snapshot.has_property(node, property), "Trying to serialize missing property %s on subject %s!" % [property, node]) assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") + var value := snapshot.get_property(node, property) _write_property(node, property, value, node_buffer) diff --git a/addons/netfox/serializers/sparse-snapshot-serializer.gd b/addons/netfox/serializers/sparse-snapshot-serializer.gd index 57f6757c..73fc7f50 100644 --- a/addons/netfox/serializers/sparse-snapshot-serializer.gd +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd @@ -19,7 +19,8 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: # TODO: Include property config hash to detect mismatches # For each node - for node in snapshot.nodes(): + for subject in properties.get_subjects(): + var node := subject as Node assert(node.is_multiplayer_authority(), "Trying to serialize state for non-owned node!") # Write identifier @@ -29,11 +30,12 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: node_buffer.clear() var node_props := properties.get_properties_of(node) - var changed_bits := _Bitset.new(properties.size()) + var changed_bits := _Bitset.new(node_props.size()) for i in node_props.size(): var property := node_props[i] - if not snapshot.has_property(node, property): + # TODO: Node-level auth tracking + if not snapshot.has_property(node, property) or not snapshot.is_auth(node, property): continue assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") diff --git a/addons/netfox/servers/data/property-pool.gd b/addons/netfox/servers/data/property-pool.gd index d87d6353..5cd5f236 100644 --- a/addons/netfox/servers/data/property-pool.gd +++ b/addons/netfox/servers/data/property-pool.gd @@ -25,6 +25,9 @@ func erase(subject: Object, property: NodePath) -> void: if props.is_empty(): _properties_by_subject.erase(subject) +func erase_subject(subject: Object) -> void: + _properties_by_subject.erase(subject) + func get_properties_of(subject: Object) -> Array[NodePath]: var properties := [] as Array[NodePath] properties.assign(_properties_by_subject.get(subject, [])) @@ -34,3 +37,6 @@ func get_subjects() -> Array[Object]: var subjects := [] as Array[Object] subjects.assign(_properties_by_subject.keys()) return subjects + +func is_empty() -> bool: + return _properties_by_subject.is_empty() diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 5fa0dd70..0f80cfff 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -3,7 +3,10 @@ class_name _RollbackSynchronizationServer var _rb_input_properties := _PropertyPool.new() var _rb_state_properties := _PropertyPool.new() +var _rb_owned_input_properties := _PropertyPool.new() +var _rb_owned_state_properties := _PropertyPool.new() var _sync_state_properties := _PropertyPool.new() +var _sync_owned_state_properties := _PropertyPool.new() var _visibility_filters := {} # Node to PeerVisibilityFilter @@ -39,33 +42,32 @@ static var _logger := NetfoxLogger._for_netfox("RollbackSynchronizationServer") signal on_input(snapshot: Snapshot) signal on_state(snapshot: Snapshot) -func register_property(node: Node, property: NodePath, pool: Array) -> void: - var entry := RecordedProperty.key_of(node, property) - - # TODO: Accelerate this check, maybe with _Set - if not pool.has(entry): - pool.append(entry) - -func deregister_property(node: Node, property: NodePath, pool: Array) -> void: - pool.erase([node, property]) - func register_state(node: Node, property: NodePath) -> void: _rb_state_properties.add(node, property) + if node.is_multiplayer_authority(): + _rb_owned_state_properties.add(node, property) func deregister_state(node: Node, property: NodePath) -> void: _rb_state_properties.erase(node, property) + _rb_owned_state_properties.erase(node, property) func register_input(node: Node, property: NodePath) -> void: _rb_input_properties.add(node, property) + if node.is_multiplayer_authority(): + _rb_owned_input_properties.add(node, property) func deregister_input(node: Node, property: NodePath) -> void: _rb_input_properties.erase(node, property) + _rb_owned_input_properties.erase(node, property) func register_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.add(node, property) + if node.is_multiplayer_authority(): + _sync_owned_state_properties.add(node, property) func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.erase(node, property) + _sync_owned_state_properties.erase(node, property) func register_schema(node: Node, property: NodePath, serializer: NetworkSchemaSerializer) -> void: var key := RecordedProperty.key_of(node, property) @@ -89,26 +91,18 @@ func is_property_visible_to(peer: int, node: Node, property: NodePath) -> bool: else: return filter.get_visibility_for(peer) -func get_properties_of(node: Node) -> Array[NodePath]: - var result := [] as Array[NodePath] - - # TODO: Split method? Or somehow avoid this merge - result.append_array(_rb_input_properties.get_properties_of(node)) - result.append_array(_rb_state_properties.get_properties_of(node)) - result.append_array(_sync_state_properties.get_properties_of(node)) - - return result - # TODO: Make this testable somehow, I beg of you func synchronize_input(tick: int) -> void: + # We don't own inputs, nothing to synchronize + if _rb_owned_input_properties.is_empty(): + return + var snapshots := [] as Array[Snapshot] var notified_peers := _Set.new() if not _rb_enable_input_broadcast: # Grab owned input objects - for input_subject in _rb_input_properties.get_subjects(): - assert(input_subject is Node, "Only nodes for now!") - + for input_subject in _rb_owned_input_properties.get_subjects(): # Grab state objects controlled by input var controlled_nodes := RollbackSimulationServer.get_controlled_by(input_subject) @@ -128,34 +122,28 @@ func synchronize_input(tick: int) -> void: if not snapshot: break - # Filter to input properties - # TODO: Optimize, avoid making two copies - # TODO: Filter to only synchronized props - var input_snapshot := snapshot.filtered_to_owned() - # Transmit - _logger.trace("Submitting input: %s", [input_snapshot]) - snapshots.append(input_snapshot) + _logger.trace("Submitting input: %s", [snapshot]) + snapshots.append(snapshot) _logger.trace("Submitting input to peers: %s", [notified_peers]) for peer in notified_peers: - var data := _redundant_serializer.write_for(peer, snapshots, _rb_input_properties) + var data := _redundant_serializer.write_for(peer, snapshots, _rb_owned_input_properties) _cmd_input.send(data, peer) # TODO: Make this testable somehow, I beg of you func synchronize_state(tick: int) -> void: + # We don't own state, nothing to synchronize + if _rb_owned_state_properties.is_empty(): + return + # Grab snapshot from RollbackHistoryServer var snapshot := RollbackHistoryServer.get_rollback_state_snapshot(tick) if not snapshot: # No data for tick return - # Filter to state properties - # TODO: Filter to only synchronized props - var state_snapshot := snapshot.filtered_to_auth().filtered_to_owned() - - # TODO: Early exit if we don't have auth state props - if state_snapshot.is_empty(): + if snapshot.is_empty(): # Nothing to send return @@ -174,35 +162,36 @@ func synchronize_state(tick: int) -> void: if is_diff: _rb_full_next -= 1 - reference_snapshot = reference_snapshot.filtered_to_auth().filtered_to_owned() - var diff := Snapshot.make_patch(reference_snapshot, state_snapshot) + var diff := Snapshot.make_patch(reference_snapshot, snapshot) if diff.is_empty(): # Nothing changed, don't send anything return # Send diff states for peer in multiplayer.get_peers(): + # TODO: Filter property set instead? var peer_diff := diff.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) if peer_diff.is_empty(): # Peer can't see any changes, send nothing continue - var data := _sparse_serializer.write_for(peer, peer_diff, _rb_state_properties) + var data := _sparse_serializer.write_for(peer, peer_diff, _rb_owned_state_properties) _cmd_diff_state.send(data, peer) - NetworkPerformance.push_full_state(state_snapshot.data) # TODO: Ugh... + NetworkPerformance.push_full_state(snapshot.data) # TODO: Ugh... NetworkPerformance.push_sent_state(diff.data) # TODO: Ugh... else: _rb_full_next = _rb_full_interval # Send full states for peer in multiplayer.get_peers(): - var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + # TODO: Filter property set instead? + var peer_snapshot := snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) if peer_snapshot.is_empty(): # Peer can't see anything, send nothing continue - var data := _dense_serializer.write_for(peer, peer_snapshot, _rb_state_properties) + var data := _dense_serializer.write_for(peer, peer_snapshot, _rb_owned_state_properties) _cmd_full_state.send(data, peer) _logger.trace("Sent full state to #%d: %s", [peer, peer_snapshot]) @@ -217,9 +206,6 @@ func synchronize_sync_state(tick: int) -> void: if not snapshot: return - # Filter to state properties - var state_snapshot := snapshot.filtered_to_auth().filtered_to_owned() - # Figure out whether to send full- or diff state var is_diff := false if _sync_enable_diffs: @@ -233,20 +219,20 @@ func synchronize_sync_state(tick: int) -> void: # Send full states for peer in multiplayer.get_peers(): - var peer_snapshot := state_snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + var peer_snapshot := snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) if peer_snapshot.is_empty(): # Peer can't see anything, send nothing continue - var data := _dense_serializer.write_for(peer, peer_snapshot, _sync_state_properties) + var data := _dense_serializer.write_for(peer, peer_snapshot, _sync_owned_state_properties) _cmd_full_sync.send(data, peer) NetworkPerformance.push_full_state(peer_snapshot.data) # TODO: Ugh... NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... else: _sync_full_next -= 1 - var diff := Snapshot.make_patch(_last_sync_state_sent, state_snapshot) + var diff := Snapshot.make_patch(_last_sync_state_sent, snapshot) # Send diffs for peer in multiplayer.get_peers(): @@ -256,14 +242,16 @@ func synchronize_sync_state(tick: int) -> void: # Nothing changed, don't send anything continue - var data := _sparse_serializer.write_for(peer, peer_snapshot, _sync_state_properties) + var me := multiplayer.get_unique_id() + var data := _sparse_serializer.write_for(peer, peer_snapshot, _sync_owned_state_properties) _cmd_diff_sync.send(data, peer) - NetworkPerformance.push_full_state(state_snapshot.data) # TODO: Ugh... + NetworkPerformance.push_full_state(snapshot.data) # TODO: Ugh... NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... # Remember last sent state for diffing - _last_sync_state_sent = state_snapshot + # NOTE: This is a shared instance, theoretically shouldn't screw things up + _last_sync_state_sent = snapshot func _handle_input(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() @@ -297,8 +285,7 @@ func _handle_diff_state(sender: int, data: PackedByteArray): var diff := _sparse_serializer.read_from(sender, _rb_state_properties, buffer) _logger.trace("Received diff state for @%d", [diff.tick]) - - # TODO: Using `snapshot` doesn't work + _ingest_state(sender, diff) func _handle_full_sync(sender: int, data: PackedByteArray): @@ -307,7 +294,6 @@ func _handle_full_sync(sender: int, data: PackedByteArray): var snapshot := _dense_serializer.read_from(sender, _sync_state_properties, buffer, true) - # TODO: Reduce copy-paste RollbackHistoryServer.merge_synchronizer_state(snapshot) _logger.trace("Ingested sync state: %s", [snapshot]) @@ -317,20 +303,13 @@ func _handle_diff_sync(sender: int, data: PackedByteArray): var snapshot := _sparse_serializer.read_from(sender, _sync_state_properties, buffer) - # TODO: Reduce copy-paste RollbackHistoryServer.merge_synchronizer_state(snapshot) - _logger.trace("Ingested sync state diff: %s", [snapshot]) + _logger.trace("Ingested sync diff: %s", [snapshot]) func _ingest_state(sender: int, snapshot: Snapshot) -> void: # TODO: Sanitize # _logger.debug("Received state snapshot: %s", [snapshot]) - var stored_snapshot := RollbackHistoryServer.get_rollback_state_snapshot(snapshot.tick) - if stored_snapshot: - var diff := Snapshot.make_patch(stored_snapshot, snapshot, snapshot.tick, false) - if not diff.is_empty(): - _logger.trace("Reconciled state diff: %s", [diff]) - var merged := RollbackHistoryServer.merge_rollback_state(snapshot) _logger.debug("Ingested state: %s", [snapshot]) diff --git a/addons/netfox/state-synchronizer.gd b/addons/netfox/state-synchronizer.gd index c6dd2e99..08c93c82 100644 --- a/addons/netfox/state-synchronizer.gd +++ b/addons/netfox/state-synchronizer.gd @@ -146,6 +146,12 @@ func _enter_tree() -> void: process_settings.call_deferred() +func _ready(): + # Reprocess authority + # Important if nodes are pre-placed in the scene - node starts as owned by + # us ( offline peer is 1 ), but once we connect, we no longer own the node + multiplayer.connected_to_server.connect(process_settings) + func _reprocess_settings() -> void: if not _properties_dirty: return diff --git a/project.godot b/project.godot index 198f35f8..6ff30012 100644 --- a/project.godot +++ b/project.godot @@ -133,7 +133,7 @@ escape={ general/clear_settings=false time/tickrate=24 extras/auto_tile_windows=true -autoconnect/enabled=true +autoconnect/enabled=false [rendering] From eceb53353969143aa1735380e45001676f3c27e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 19 Jan 2026 01:08:13 +0100 Subject: [PATCH 55/95] cleanup --- .../netfox/servers/rollback-history-server.gd | 116 ++++++++---------- .../servers/rollback-simulation-server.gd | 8 +- 2 files changed, 52 insertions(+), 72 deletions(-) diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 014dc380..17785f26 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -12,16 +12,6 @@ var _sync_state_snapshots: Dictionary = {} # tick to Snapshot static var _logger := NetfoxLogger._for_netfox("RollbackHistoryServer") -func register_property(node: Node, property: NodePath, pool: Array) -> void: - var entry := RecordedProperty.key_of(node, property) - - # TODO: Accelerate this check, maybe with _Set - if not pool.has(entry): - pool.append(entry) - -func deregister_property(node: Node, property: NodePath, pool: Array) -> void: - pool.erase([node, property]) - func register_state(node: Node, property: NodePath) -> void: _rb_state_properties.add(node, property) @@ -40,53 +30,6 @@ func register_sync_state(node: Node, property: NodePath) -> void: func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.erase(node, property) -func _record(tick: int, snapshots: Dictionary, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: - # Ensure snapshot - var snapshot := snapshots.get(tick) as Snapshot - var is_new := false - if snapshot == null: - snapshot = Snapshot.new(tick) - snapshots[tick] = snapshot - is_new = true - - # Record values - var updates := [] - for subject in property_pool.get_subjects(): - assert(subject is Node, "Only nodes supported for now!") - - var is_auth := auth_filter.call(subject) - if only_auth and not is_auth: - continue - - for property in property_pool.get_properties_of(subject): - var value := subject.get_indexed(property) - if snapshot.merge_property(subject, property, value, is_auth): - updates.append([subject, property, value, is_auth]) - - match snapshots: - _rb_input_snapshots: - _logger.debug("Updates to%s input @%d: %s" % [" new" if is_new else "", tick, updates]) - _logger.debug("Recorded input @%d: %s", [tick, snapshot]) - _rb_state_snapshots: - _logger.debug("Updates to%s state @%d: %s" % [" new" if is_new else "", tick, updates]) - _logger.debug("Recorded state @%d: %s", [tick, snapshot]) - -func _restore(tick: int, snapshots: Dictionary) -> bool: - # TODO: Prettier recreation of HistoryBuffer logic and / or reuse HistoryBuffer - if snapshots.is_empty() or tick < snapshots.keys().min(): - return false - while not snapshots.has(tick) and tick >= snapshots.keys().min(): - tick -= 1 - - var snapshot := snapshots[tick] as Snapshot - snapshot.apply() - - match snapshots: - _rb_input_snapshots: _logger.debug("Restored input @%d: %s", [tick, snapshot]) - _rb_state_snapshots: _logger.debug("Restored state @%d: %s", [tick, snapshot]) - - return true - func record_input(tick: int) -> void: _record(tick, _rb_input_snapshots, _rb_input_properties, false, func(subject: Node): return subject.is_multiplayer_authority() @@ -126,21 +69,15 @@ func trim_history(earliest_tick: int) -> void: break snapshots.erase(earliest_stored_tick) -# TODO: Keep snapshots private -# TODO: Private -func get_snapshot(tick: int, snapshots: Dictionary) -> Snapshot: - return snapshots.get(tick) as Snapshot - func get_rollback_input_snapshot(tick: int) -> Snapshot: - return get_snapshot(tick, _rb_input_snapshots) + return _rb_input_snapshots.get(tick) func get_rollback_state_snapshot(tick: int) -> Snapshot: - return get_snapshot(tick, _rb_state_snapshots) + return _rb_state_snapshots.get(tick) func get_synchronizer_state_snapshot(tick: int) -> Snapshot: - return get_snapshot(tick, _sync_state_snapshots) + return _sync_state_snapshots.get(tick) -# TODO: Keep snapshots private func merge_snapshot(snapshot: Snapshot, snapshots: Dictionary) -> Snapshot: var tick := snapshot.tick if not snapshots.has(snapshot.tick): @@ -176,3 +113,50 @@ func get_data_age_for(what: Node, tick: int) -> int: if has_input or has_state: return tick - i return -1 + +func _record(tick: int, snapshots: Dictionary, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: + # Ensure snapshot + var snapshot := snapshots.get(tick) as Snapshot + var is_new := false + if snapshot == null: + snapshot = Snapshot.new(tick) + snapshots[tick] = snapshot + is_new = true + + # Record values + var updates := [] + for subject in property_pool.get_subjects(): + assert(subject is Node, "Only nodes supported for now!") + + var is_auth := auth_filter.call(subject) + if only_auth and not is_auth: + continue + + for property in property_pool.get_properties_of(subject): + var value := subject.get_indexed(property) + if snapshot.merge_property(subject, property, value, is_auth): + updates.append([subject, property, value, is_auth]) + + match snapshots: + _rb_input_snapshots: + _logger.debug("Updates to%s input @%d: %s" % [" new" if is_new else "", tick, updates]) + _logger.debug("Recorded input @%d: %s", [tick, snapshot]) + _rb_state_snapshots: + _logger.debug("Updates to%s state @%d: %s" % [" new" if is_new else "", tick, updates]) + _logger.debug("Recorded state @%d: %s", [tick, snapshot]) + +func _restore(tick: int, snapshots: Dictionary) -> bool: + # TODO: Prettier recreation of HistoryBuffer logic and / or reuse HistoryBuffer + if snapshots.is_empty() or tick < snapshots.keys().min(): + return false + while not snapshots.has(tick) and tick >= snapshots.keys().min(): + tick -= 1 + + var snapshot := snapshots[tick] as Snapshot + snapshot.apply() + + match snapshots: + _rb_input_snapshots: _logger.debug("Restored input @%d: %s", [tick, snapshot]) + _rb_state_snapshots: _logger.debug("Restored state @%d: %s", [tick, snapshot]) + + return true diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 76f8f287..e2cfbf61 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -14,8 +14,7 @@ var _input_graph := _Graph.new() # Links inputs to objects controlled by them # Currently simulated object var _current_object: Object = null # Predicted nodes for next simulation -# TODO: _Set? -var _predicted_nodes := [] as Array[Node] +var _predicted_nodes := _Set.new() var _group := StringName("__nf_rollback_sim" + str(get_instance_id())) @@ -141,7 +140,7 @@ func simulate(delta: float, tick: int) -> void: # Determine predicted nodes for node in _callbacks.keys(): if is_predicting(input_snapshot, node): - _predicted_nodes.append(node) + _predicted_nodes.add(node) # Run callbacks and clear group for node in nodes: @@ -158,9 +157,6 @@ func simulate(delta: float, tick: int) -> void: # Metrics NetworkPerformance.push_rollback_nodes_simulated(nodes.size()) -func get_predicted_nodes() -> Array[Node]: - return _predicted_nodes - func get_controlled_by(input: Node) -> Array[Node]: var result := [] as Array[Node] result.assign(_input_graph.get_linked_from(input)) From 24ad6b48197e90fd9ddb7c69cf186e9421a5a014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 19 Jan 2026 01:20:47 +0100 Subject: [PATCH 56/95] cleanups --- addons/netfox/network-performance.gd | 6 ++++ .../netfox/rollback/rollback-synchronizer.gd | 4 --- .../netfox/servers/data/network-commands.gd | 8 ++--- addons/netfox/servers/data/snapshot.gd | 3 ++ .../netfox/servers/network-identity-server.gd | 1 - .../servers/rollback-simulation-server.gd | 10 ++----- .../rollback-synchronization-server.gd | 29 ++++++++++--------- addons/netfox/state-synchronizer.gd | 4 +-- 8 files changed, 33 insertions(+), 32 deletions(-) diff --git a/addons/netfox/network-performance.gd b/addons/netfox/network-performance.gd index 7de8a0be..1fa9195a 100644 --- a/addons/netfox/network-performance.gd +++ b/addons/netfox/network-performance.gd @@ -106,12 +106,18 @@ func get_sent_state_props_ratio() -> float: func push_full_state(state: Dictionary) -> void: _full_state_props_accum += state.size() +func push_full_state_props(count: int) -> void: + _full_state_props_accum += count + func push_full_state_broadcast(state: Dictionary) -> void: _full_state_props_accum += state.size() * (multiplayer.get_peers().size() - 1) func push_sent_state(state: Dictionary) -> void: _sent_state_props_accum += state.size() +func push_sent_state_props(count: int) -> void: + _sent_state_props_accum += count + func push_sent_state_broadcast(state: Dictionary) -> void: _sent_state_props_accum += state.size() * (multiplayer.get_peers().size() - 1) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 799ecbb3..c69f9066 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -185,7 +185,6 @@ func process_authority(): ## [NodePath] pointing to a node, or an actual [Node] instance. If the given ## property is already tracked, this method does nothing. func add_state(node: Variant, property: String): - # TODO: Rewrite var property_path := PropertyEntry.make_path(root, node, property) if not property_path or state_properties.has(property_path): return @@ -200,7 +199,6 @@ func add_state(node: Variant, property: String): ## [NodePath] pointing to a node, or an actual [Node] instance. If the given ## property is already tracked, this method does nothing. func add_input(node: Variant, property: String) -> void: - # TODO: Rewrite var property_path := PropertyEntry.make_path(root, node, property) if not property_path or input_properties.has(property_path): return @@ -258,7 +256,6 @@ func has_input() -> bool: ## [br][br] ## Calling this when [member has_input] is false will yield an error. func get_input_age() -> int: - # TODO: input-prediction example desyncs # TODO: Cache these after prepare tick? var max_age := 0 for input_node in _input_nodes: @@ -287,7 +284,6 @@ func is_predicting() -> bool: ## Call this when the input is too old to base predictions on. This call is ## ignored if [member enable_prediction] is false. func ignore_prediction(node: Node) -> void: - # TODO: Rewrite # TODO: Does this even make sense in its current form? return diff --git a/addons/netfox/servers/data/network-commands.gd b/addons/netfox/servers/data/network-commands.gd index 8c0e2a08..a2509416 100644 --- a/addons/netfox/servers/data/network-commands.gd +++ b/addons/netfox/servers/data/network-commands.gd @@ -3,9 +3,9 @@ class_name _NetworkCommands const IDS := 0 -const FULL_STATE := 1 -const DIFF_STATE := 2 +const RB_FULL_STATE := 1 +const RB_DIFF_STATE := 2 const INPUT := 3 -const FULL_SYNC := 4 # TODO: Find a better name than SYNC -const DIFF_SYNC := 5 # TODO: Find a better name than SYNC +const SYNC_FULL := 4 +const SYNC_DIFF := 5 diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index 5306b1b7..c7bde9d5 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -146,6 +146,9 @@ func nodes() -> Array[Node]: func is_empty() -> bool: return data.is_empty() +func size() -> int: + return data.size() + func is_auth(node: Node, property: NodePath) -> bool: return _is_authoritative.get(RecordedProperty.key_of(node, property), false) diff --git a/addons/netfox/servers/network-identity-server.gd b/addons/netfox/servers/network-identity-server.gd index cce02e66..fbea1230 100644 --- a/addons/netfox/servers/network-identity-server.gd +++ b/addons/netfox/servers/network-identity-server.gd @@ -60,7 +60,6 @@ func register_node(node: Node) -> void: func deregister_node(node: Node) -> void: deregister(node) -# TODO: Consider specific queries, NetworkIdentifier might be an impl detail func get_identifier_of(what: Object) -> _NetworkIdentifier: return _identifiers.get(what) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index e2cfbf61..ec5ce1ca 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -1,13 +1,8 @@ extends Node class_name _RollbackSimulationServer -# node to callback -# TODO: Consider allowing any Object, not just nodes -var _callbacks := {} -# node to array of ticks -# used for is_fresh -# TODO: Refactor to ringbuffer containing sets of nodes? -var _simulated_ticks := {} +var _callbacks := {} # node to callback +var _simulated_ticks := {} # node to array of ticks var _input_graph := _Graph.new() # Links inputs to objects controlled by them @@ -79,7 +74,6 @@ func is_predicting(input_snapshot: Snapshot, node: Node) -> bool: var is_inputless := input_nodes.is_empty() var has_input := false - # TODO: Avoid supporting null snapshots if possible if not is_inputless and input_snapshot: has_input = input_snapshot.has_nodes(input_nodes, true) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index 0f80cfff..c8c4daa2 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -30,12 +30,12 @@ var _dense_serializer := _DenseSnapshotSerializer.new(_schemas) var _sparse_serializer := _SparseSnapshotSerializer.new(_schemas) var _redundant_serializer := _RedundantSnapshotSerializer.new(_schemas) -@onready var _cmd_full_state := NetworkCommandServer.register_command_at(_NetworkCommands.FULL_STATE, _handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) -@onready var _cmd_diff_state := NetworkCommandServer.register_command_at(_NetworkCommands.DIFF_STATE, _handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) +@onready var _cmd_full_state := NetworkCommandServer.register_command_at(_NetworkCommands.RB_FULL_STATE, _handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) +@onready var _cmd_diff_state := NetworkCommandServer.register_command_at(_NetworkCommands.RB_DIFF_STATE, _handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) @onready var _cmd_input := NetworkCommandServer.register_command_at(_NetworkCommands.INPUT, _handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) -@onready var _cmd_full_sync := NetworkCommandServer.register_command_at(_NetworkCommands.FULL_SYNC, _handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) -@onready var _cmd_diff_sync := NetworkCommandServer.register_command_at(_NetworkCommands.DIFF_SYNC, _handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) +@onready var _cmd_full_sync := NetworkCommandServer.register_command_at(_NetworkCommands.SYNC_FULL, _handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) +@onready var _cmd_diff_sync := NetworkCommandServer.register_command_at(_NetworkCommands.SYNC_DIFF, _handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) static var _logger := NetfoxLogger._for_netfox("RollbackSynchronizationServer") @@ -178,8 +178,8 @@ func synchronize_state(tick: int) -> void: var data := _sparse_serializer.write_for(peer, peer_diff, _rb_owned_state_properties) _cmd_diff_state.send(data, peer) - NetworkPerformance.push_full_state(snapshot.data) # TODO: Ugh... - NetworkPerformance.push_sent_state(diff.data) # TODO: Ugh... + NetworkPerformance.push_full_state_props(snapshot.size()) + NetworkPerformance.push_sent_state_props(diff.size()) else: _rb_full_next = _rb_full_interval @@ -195,12 +195,15 @@ func synchronize_state(tick: int) -> void: _cmd_full_state.send(data, peer) _logger.trace("Sent full state to #%d: %s", [peer, peer_snapshot]) - NetworkPerformance.push_full_state(peer_snapshot.data) # TODO: Ugh... - NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... + NetworkPerformance.push_full_state_props(peer_snapshot.size()) + NetworkPerformance.push_sent_state_props(peer_snapshot.size()) _logger.debug("Pushed full state metrics: %d sent, %d full", [peer_snapshot.data.size(), peer_snapshot.data.size()]) func synchronize_sync_state(tick: int) -> void: - # TODO: Reduce copy-paste + # We don't own sync state, nothing to synchronize + if _sync_owned_state_properties.is_empty(): + return + # Grab snapshot from RollbackHistoryServer var snapshot := RollbackHistoryServer.get_synchronizer_state_snapshot(tick) if not snapshot: @@ -228,8 +231,8 @@ func synchronize_sync_state(tick: int) -> void: var data := _dense_serializer.write_for(peer, peer_snapshot, _sync_owned_state_properties) _cmd_full_sync.send(data, peer) - NetworkPerformance.push_full_state(peer_snapshot.data) # TODO: Ugh... - NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... + NetworkPerformance.push_full_state_props(peer_snapshot.size()) + NetworkPerformance.push_sent_state_props(peer_snapshot.size()) else: _sync_full_next -= 1 var diff := Snapshot.make_patch(_last_sync_state_sent, snapshot) @@ -246,8 +249,8 @@ func synchronize_sync_state(tick: int) -> void: var data := _sparse_serializer.write_for(peer, peer_snapshot, _sync_owned_state_properties) _cmd_diff_sync.send(data, peer) - NetworkPerformance.push_full_state(snapshot.data) # TODO: Ugh... - NetworkPerformance.push_sent_state(peer_snapshot.data) # TODO: Ugh... + NetworkPerformance.push_full_state_props(snapshot.size()) + NetworkPerformance.push_sent_state_props(peer_snapshot.size()) # Remember last sent state for diffing # NOTE: This is a shared instance, theoretically shouldn't screw things up diff --git a/addons/netfox/state-synchronizer.gd b/addons/netfox/state-synchronizer.gd index 08c93c82..8d81a790 100644 --- a/addons/netfox/state-synchronizer.gd +++ b/addons/netfox/state-synchronizer.gd @@ -23,7 +23,7 @@ class_name StateSynchronizer ## [br][br] ## Only considered if [member _NetworkRollback.enable_diff_states] is true. @export_range(0, 128, 1, "or_greater") -var full_state_interval: int = 24 # TODO: Don't tie to a network rollback setting? +var full_state_interval: int = 24 ## Ticks to wait between unreliably acknowledging diff states. ## [br][br] @@ -40,7 +40,7 @@ var full_state_interval: int = 24 # TODO: Don't tie to a network rollback settin ## [br][br] ## Only considered if [member _NetworkRollback.enable_diff_states] is true. @export_range(0, 128, 1, "or_greater") -var diff_ack_interval: int = 0 # TODO: Don't tie to a network rollback setting? +var diff_ack_interval: int = 0 ## Decides which peers will receive updates var visibility_filter := PeerVisibilityFilter.new() From 51f0b70aca4056e2386ae8709da99a37d944ee20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 19 Jan 2026 15:28:13 +0100 Subject: [PATCH 57/95] mid fuckery --- addons/netfox.internals/bitset.gd | 4 - addons/netfox/schemas/network-schema.gd | 32 ++- addons/netfox/schemas/network-schemas.gd | 8 +- .../serializers/base-snapshot-serializer.gd | 13 +- .../serializers/dense-snapshot-serializer.gd | 22 +- .../redundant-snapshot-serializer.gd | 2 +- .../serializers/sparse-snapshot-serializer.gd | 28 ++- .../netfox/servers/data/recorded-property.gd | 40 ---- addons/netfox/servers/data/snapshot.gd | 200 +++++++----------- .../netfox/servers/rollback-history-server.gd | 13 +- .../servers/rollback-simulation-server.gd | 4 +- .../rollback-synchronization-server.gd | 63 +++--- project.godot | 2 +- test/netfox.internals/bitset.test.gd | 39 ++-- test/netfox/servers/data/snapshot.perf.gd | 77 +++++++ .../servers/{ => data}/snapshot.test.gd | 0 .../servers}/rollback-history-server.perf.gd | 0 17 files changed, 294 insertions(+), 253 deletions(-) delete mode 100644 addons/netfox/servers/data/recorded-property.gd create mode 100644 test/netfox/servers/data/snapshot.perf.gd rename test/netfox/servers/{ => data}/snapshot.test.gd (100%) rename test/{ => netfox/servers}/rollback-history-server.perf.gd (100%) diff --git a/addons/netfox.internals/bitset.gd b/addons/netfox.internals/bitset.gd index 98689533..054e6d79 100644 --- a/addons/netfox.internals/bitset.gd +++ b/addons/netfox.internals/bitset.gd @@ -4,9 +4,6 @@ class_name _Bitset var _data: PackedByteArray var _bit_count: int -# TODO: Tests - - static func of_bools(values: Array) -> _Bitset: var result := _Bitset.new(values.size()) for i in values.size(): @@ -14,7 +11,6 @@ static func of_bools(values: Array) -> _Bitset: result.set_bit(i) return result - func _init(bit_count: int): var bytes := bit_count / 8 if bit_count % 8 > 0: diff --git a/addons/netfox/schemas/network-schema.gd b/addons/netfox/schemas/network-schema.gd index f5fa24a5..c82a2655 100644 --- a/addons/netfox/schemas/network-schema.gd +++ b/addons/netfox/schemas/network-schema.gd @@ -1,15 +1,33 @@ extends RefCounted class_name _NetworkSchema -var _serializers: Dictionary +var _serializers := {} # subject to (property to NetworkSchemaSerializer) var _fallback: NetworkSchemaSerializer -func _init(serializers: Dictionary, fallback: NetworkSchemaSerializer = NetworkSchemas.variant()) -> void: - _serializers = serializers +func _init(fallback: NetworkSchemaSerializer = NetworkSchemas.variant()) -> void: _fallback = fallback -func encode(path: String, value: Variant, buffer: StreamPeerBuffer) -> void: - (_serializers.get(path, _fallback) as NetworkSchemaSerializer).encode(value, buffer) +func add(subject: Object, property: NodePath, serializer: NetworkSchemaSerializer) -> void: + if not _serializers.has(subject): + _serializers[subject] = { property: serializer } + else: + _serializers[subject][property] = serializer -func decode(path: String, buffer: StreamPeerBuffer) -> Variant: - return (_serializers.get(path, _fallback) as NetworkSchemaSerializer).decode(buffer) +func erase(subject: Object, property: NodePath) -> void: + if not _serializers.has(subject): + return + + var subject_schema := _serializers[subject] as Dictionary + subject_schema.erase(property) + + if subject_schema.is_empty(): + _serializers.erase(subject) + +func encode(subject: Object, property: NodePath, value: Variant, buffer: StreamPeerBuffer) -> void: + _get_serializer(subject, property).encode(value, buffer) + +func decode(subject: Object, property: NodePath, buffer: StreamPeerBuffer) -> Variant: + return _get_serializer(subject, property).decode(buffer) + +func _get_serializer(subject: Object, property: NodePath) -> NetworkSchemaSerializer: + return _serializers.get(subject, {}).get(property, _fallback) diff --git a/addons/netfox/schemas/network-schemas.gd b/addons/netfox/schemas/network-schemas.gd index 4a7d395f..0b149405 100644 --- a/addons/netfox/schemas/network-schemas.gd +++ b/addons/netfox/schemas/network-schemas.gd @@ -55,7 +55,13 @@ static func uint32() -> NetworkSchemaSerializer: static func uint64() -> NetworkSchemaSerializer: return _Uint64Serializer.new() -# TODO: Docs +## Serialize an unsigned integer as a variable amount of bytes. +## [br][br] +## Each byte contains 7 bits of data. The 8th bit indicates whether there are +## more bytes left. Thus, small numbers fitting into 7 bits will be encoded as +## a single byte, while larger numbers take more space as they increase. +## [br][br] +## Final size is 1 byte for every 7 bits of numeric data. static func varuint() -> NetworkSchemaSerializer: return _VaruintSerializer.instance diff --git a/addons/netfox/serializers/base-snapshot-serializer.gd b/addons/netfox/serializers/base-snapshot-serializer.gd index 4742515d..0b273af3 100644 --- a/addons/netfox/serializers/base-snapshot-serializer.gd +++ b/addons/netfox/serializers/base-snapshot-serializer.gd @@ -1,25 +1,22 @@ extends RefCounted class_name _BaseSnapshotSerializer -# TODO: Swap to (object to (property to NetworkSchemaSerializer)) -var _schemas := {} # RecordedProperty key to NetworkSchemaSerializer -var _fallback_schema := NetworkSchemas.variant() +var _schemas: _NetworkSchema +static var _default_filter := func(subject: Object): return true static var _logger := NetfoxLogger._for_netfox("DenseSnapshotSerializer") -func _init(p_schemas: Dictionary): +func _init(p_schemas: _NetworkSchema): assert(p_schemas != null, "Missing schemas!") # Intentionally storing reference, so it can be modified from the outside # e.g. RollbackSynchronizerServer adds a property _schemas = p_schemas func _write_property(node: Node, property: NodePath, value: Variant, buffer: StreamPeerBuffer) -> void: - var serializer := _schemas.get(RecordedProperty.key_of(node, property), _fallback_schema) as NetworkSchemaSerializer - serializer.encode(value, buffer) + _schemas.encode(node, property, value, buffer) func _read_property(node: Node, property: NodePath, buffer: StreamPeerBuffer) -> Variant: - var serializer := _schemas.get(RecordedProperty.key_of(node, property), _fallback_schema) as NetworkSchemaSerializer - return serializer.decode(buffer) + return _schemas.decode(node, property, buffer) func _write_identifier(subject: Object, peer: int, buffer: StreamPeerBuffer) -> Error: var netref := NetworkSchemas._netref() diff --git a/addons/netfox/serializers/dense-snapshot-serializer.gd b/addons/netfox/serializers/dense-snapshot-serializer.gd index 7e7b36db..fa7695fb 100644 --- a/addons/netfox/serializers/dense-snapshot-serializer.gd +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd @@ -4,7 +4,7 @@ class_name _DenseSnapshotSerializer static func _static_init(): _logger = NetfoxLogger._for_netfox("DenseSnapshotSerializer") -func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: StreamPeerBuffer = null) -> PackedByteArray: +func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, filter: Callable = _default_filter, buffer: StreamPeerBuffer = null) -> PackedByteArray: if buffer == null: buffer = StreamPeerBuffer.new() @@ -13,14 +13,20 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: var node_buffer := StreamPeerBuffer.new() + var has_data := false + # Write tick buffer.put_u32(snapshot.tick) # TODO: Include property config hash to detect mismatches # For each node for subject in properties.get_subjects(): + if not filter.call(subject): continue + if not snapshot.is_auth(subject): continue + var node := subject as Node assert(node.is_multiplayer_authority(), "Trying to serialize state for non-owned node!") + assert(snapshot.is_auth(node), "Trying to serialize non-auth state node!") # Write identifier if _write_identifier(node, peer, buffer) != OK: @@ -30,11 +36,7 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: # First into a buffer, so we can start with the state size node_buffer.clear() for property in properties.get_properties_of(node): - # TODO: Subject-level auth tracking so we don't have to do this check for every property - if not snapshot.is_auth(node, property): continue - assert(snapshot.has_property(node, property), "Trying to serialize missing property %s on subject %s!" % [property, node]) - assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") var value := snapshot.get_property(node, property) _write_property(node, property, value, node_buffer) @@ -45,7 +47,12 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: # Write node state buffer.put_data(node_buffer.data_array) - return buffer.data_array + has_data = true + + if has_data: + return buffer.data_array + else: + return PackedByteArray() func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: var netref := NetworkSchemas._netref() @@ -79,6 +86,7 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i if node_buffer.get_available_bytes() == 0: break var value := _read_property(node, property, node_buffer) - snapshot.set_property(node, property, value, is_auth) + snapshot.set_property(node, property, value) + snapshot.set_auth(node, is_auth) return snapshot diff --git a/addons/netfox/serializers/redundant-snapshot-serializer.gd b/addons/netfox/serializers/redundant-snapshot-serializer.gd index a12fdee7..0b85a9ac 100644 --- a/addons/netfox/serializers/redundant-snapshot-serializer.gd +++ b/addons/netfox/serializers/redundant-snapshot-serializer.gd @@ -6,7 +6,7 @@ var _dense_serializer: _DenseSnapshotSerializer static func _static_init(): _logger = NetfoxLogger._for_netfox("RedundantSnapshotSerializer") -func _init(p_schemas: Dictionary): +func _init(p_schemas: _NetworkSchema): super(p_schemas) _dense_serializer = _DenseSnapshotSerializer.new(_schemas) diff --git a/addons/netfox/serializers/sparse-snapshot-serializer.gd b/addons/netfox/serializers/sparse-snapshot-serializer.gd index 73fc7f50..0f4ace0f 100644 --- a/addons/netfox/serializers/sparse-snapshot-serializer.gd +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd @@ -4,24 +4,30 @@ class_name _SparseSnapshotSerializer static func _static_init(): _logger = NetfoxLogger._for_netfox("SparseSnapshotSerializer") -func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: StreamPeerBuffer = null) -> PackedByteArray: +func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, filter: Callable = _default_filter, buffer: StreamPeerBuffer = null) -> PackedByteArray: if buffer == null: buffer = StreamPeerBuffer.new() var netref := NetworkSchemas._netref() var varuint := NetworkSchemas.varuint() var varbits := NetworkSchemas._varbits() - + var node_buffer := StreamPeerBuffer.new() - + + var has_data := false + # Write ticks buffer.put_u32(snapshot.tick) # TODO: Include property config hash to detect mismatches # For each node for subject in properties.get_subjects(): + if not filter.call(subject): continue + if not snapshot.is_auth(subject): continue + var node := subject as Node assert(node.is_multiplayer_authority(), "Trying to serialize state for non-owned node!") + assert(snapshot.is_auth(node), "Trying to serialize non-auth state node!") # Write identifier if _write_identifier(node, peer, buffer) != OK: @@ -35,11 +41,9 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: for i in node_props.size(): var property := node_props[i] # TODO: Node-level auth tracking - if not snapshot.has_property(node, property) or not snapshot.is_auth(node, property): + if not snapshot.has_property(node, property): continue - assert(snapshot.is_auth(node, property), "Trying to serialize non-auth state property!") - changed_bits.set_bit(i) var value := snapshot.get_property(node, property) _write_property(node, property, value, node_buffer) @@ -47,8 +51,13 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, buffer: varuint.encode(node_buffer.data_array.size(), buffer) # Node props len varbits.encode(changed_bits, buffer) # Changed prop bits buffer.put_data(node_buffer.data_array) # Changed props - - return buffer.data_array + has_data = true + + if has_data: + return buffer.data_array + else: + # Return an empty buffer if we ended up not serializing anything + return PackedByteArray() func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: var netref := NetworkSchemas._netref() @@ -84,5 +93,6 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i for idx in changed_bits.get_set_indices(): var property := node_props[idx] var value := _read_property(node, property, node_buffer) - snapshot.set_property(node, property, value, is_auth) + snapshot.set_property(node, property, value) + snapshot.set_auth(node, is_auth) return snapshot diff --git a/addons/netfox/servers/data/recorded-property.gd b/addons/netfox/servers/data/recorded-property.gd deleted file mode 100644 index a9f3d11b..00000000 --- a/addons/netfox/servers/data/recorded-property.gd +++ /dev/null @@ -1,40 +0,0 @@ -extends RefCounted -class_name RecordedProperty - -static func key_of(p_node: Node, p_property: NodePath) -> Array: - return [p_node, p_property] - -static func get_node(key: Array) -> Node: - return key[0] - -static func get_property(key: Array) -> NodePath: - return key[1] - -static func extract(key: Array) -> Variant: - var node := key[0] as Node - var property := key[1] as NodePath - return node.get_indexed(property) - -static func apply(key: Array, value: Variant): - var node := key[0] as Node - var property := key[1] as NodePath - node.set_indexed(property, value) - -var node: Node -var property: NodePath - -func _init(p_node: Node, p_property: NodePath): - node = p_node - property = p_property - -func extract_value() -> Variant: - return node.get_indexed(property) - -func apply_value(value: Variant) -> void: - node.set_indexed(property, value) - -func equals(other: RecordedProperty) -> bool: - return node == other.node and property == other.property - -func _to_string() -> String: - return "$(%s:%s)" % [node, property] diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index c7bde9d5..b882f419 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -2,25 +2,26 @@ extends RefCounted class_name Snapshot var tick: int -var data: Dictionary = {} # RecordedProperty to Variant value -var _is_authoritative: Dictionary = {} # Property key to bool, not present means false +var _data := {} # object to (property to variant) +var _is_authoritative := {} # object to bool, absent means false -static func make_patch(from: Snapshot, to: Snapshot, tick: int = to.tick, include_new: bool = true) -> Snapshot: +static func make_patch(from: Snapshot, to: Snapshot, tick: int = to.tick) -> Snapshot: var patch := Snapshot.new(tick) - - for prop_key in from.data: - # TODO: This works if both props are auth - handle if that differs - if to.data.has(prop_key) and from.data[prop_key] != to.data[prop_key]: - patch.data[prop_key] = to.data[prop_key] - patch._is_authoritative[prop_key] = to._is_authoritative[prop_key] - - if include_new: - for prop_key in to.data: - # TODO: This works if both props are auth - handle if that differs - if not from.data.has(prop_key): - patch.data[prop_key] = to.data[prop_key] - patch._is_authoritative[prop_key] = to._is_authoritative[prop_key] - + + for subject in from._data: + # Target has no knowledge of subject, don't patch + if not to._data.has(subject): + continue + # Only patch to auth subjects + if not to.is_auth(subject): + continue + + for property in to._data[subject]: + # Target snapshot has different value, patch it + if from.get_property(subject, property) != to.get_property(subject, property): + patch.set_property(subject, property, to.get_property(subject, property)) + patch.set_auth(subject, to.is_auth(subject)) + return patch func _init(p_tick: int): @@ -28,146 +29,101 @@ func _init(p_tick: int): func duplicate() -> Snapshot: var result := Snapshot.new(tick) - result.data = data.duplicate() + result._data = _data.duplicate(true) result._is_authoritative = _is_authoritative.duplicate() return result -func set_property(node: Node, property: NodePath, value: Variant, is_authoritative: bool = false) -> void: - data[RecordedProperty.key_of(node, property)] = value - _is_authoritative[RecordedProperty.key_of(node, property)] = is_authoritative - -func get_property(node: Node, property: NodePath) -> Variant: - return data[RecordedProperty.key_of(node, property)] - -func has_property(node: Node, property: NodePath) -> bool: - return data.has(RecordedProperty.key_of(node, property)) - -func merge_property(node: Node, property: NodePath, value: Variant, is_authoritative: bool = false) -> bool: - var prop_key := RecordedProperty.key_of(node, property) - if is_authoritative or not _is_authoritative.get(prop_key, false): - data[prop_key] = value - _is_authoritative[prop_key] = is_authoritative - return true - return false - -func merge(snapshot: Snapshot) -> void: - for prop_key in snapshot.data: - # Merge properties that we don't have, or don't have it authoritatively - if snapshot._is_authoritative.get(prop_key, false) or not _is_authoritative.get(prop_key, false): - data[prop_key] = snapshot.data[prop_key] - _is_authoritative[prop_key] = snapshot._is_authoritative[prop_key] - -func apply() -> void: - for prop_key in data: - var value = data[prop_key] - RecordedProperty.apply(prop_key, value) - -func filtered_to_auth() -> Snapshot: - var snapshot := Snapshot.new(tick) - - for property in data: - if not _is_authoritative[property]: - continue - - snapshot.data[property] = data[property] - snapshot._is_authoritative[property] = _is_authoritative[property] - - return snapshot - -func filtered_to_owned() -> Snapshot: - var snapshot := Snapshot.new(tick) +func set_auth(subject: Object, is_auth: bool) -> void: + _is_authoritative[subject] = is_auth - for property in data: - if not RecordedProperty.get_node(property).is_multiplayer_authority(): - continue - - snapshot.data[property] = data[property] - snapshot._is_authoritative[property] = _is_authoritative[property] - - return snapshot - -func filtered(filter: Callable) -> Snapshot: - var snapshot := Snapshot.new(tick) - - for property in data: - var node := RecordedProperty.get_node(property) - var prop := RecordedProperty.get_property(property) - if not filter.call(node, prop): - continue +func set_property(subject: Object, property: NodePath, value: Variant) -> void: + if not _data.has(subject): + _data[subject] = { property: value } + else: + _data[subject][property] = value - snapshot.data[property] = data[property] - snapshot._is_authoritative[property] = _is_authoritative[property] +func record_property(subject: Object, property: NodePath) -> void: + var value := subject.get_indexed(property) + set_property(subject, property, value) - return snapshot +func get_property(subject: Object, property: NodePath) -> Variant: + return _data.get(subject, {}).get(property) -func has_node(node: Node, require_auth: bool = false) -> bool: - for entry in data.keys(): - var entry_node := entry[0] as Node - if entry_node != node: - continue +func has_property(subject: Object, property: NodePath) -> bool: + if not _data.has(subject): + return false + if not _data[subject].has(property): + return false + return true - var is_auth := _is_authoritative.get(entry, false) as bool - if require_auth and not is_auth: +func merge(snapshot: Snapshot) -> void: + for subject in snapshot._data: + if not _data.has(subject): + # We have no data of the subject, copy all + _data[subject] = snapshot._data[subject].duplicate() + set_auth(subject, snapshot.is_auth(subject)) continue - return true - return false + if snapshot.is_auth(subject) or not is_auth(subject): + var own_props := _data[subject] as Dictionary + var their_props := snapshot._data[subject] as Dictionary + own_props.merge(their_props, true) + set_auth(subject, snapshot.is_auth(subject)) -func has_nodes(nodes: Array[Node], require_auth: bool = false) -> bool: - for entry in data.keys(): - var entry_node := entry[0] as Node - if not nodes.has(entry_node): - continue +func apply() -> void: + for subject in _data: + for property in _data[subject]: + var value = _data[subject][property] + (subject as Object).set_indexed(property, value) - var is_auth := _is_authoritative.get(entry, false) as bool - if require_auth and not is_auth: - continue +func has_subject(subject: Object, require_auth: bool = false) -> bool: + if not _data.has(subject): + return false + if require_auth and not _is_authoritative.get(subject, false): + return false + return true - return true - return false +func has_subjects(subjects: Array, require_auth: bool = false) -> bool: + for subject in subjects: + if not has_subject(subject, require_auth): + return false + return true func get_properties_of_node(node: Node) -> Array[NodePath]: var properties := [] as Array[NodePath] - for entry in data.keys(): - var entry_node := entry[0] as Node - var entry_path := entry[1] as NodePath - if entry_node == node: - properties.append(entry_path) + properties.assign(_data.get(node, [])) return properties -func nodes() -> Array[Node]: - var nodes := [] as Array[Node] - for entry in data.keys(): - var entry_node := entry[0] as Node - if not nodes.has(entry_node): - nodes.append(entry_node) - return nodes - func is_empty() -> bool: - return data.is_empty() + return _data.is_empty() func size() -> int: - return data.size() + var result := 0 + for subject in _data: + result += (_data[subject] as Dictionary).size() + return result -func is_auth(node: Node, property: NodePath) -> bool: - return _is_authoritative.get(RecordedProperty.key_of(node, property), false) +func is_auth(subject: Object) -> bool: + return _is_authoritative.get(subject, false) func equals(other) -> bool: if other is Snapshot: - return tick == other.tick and data == other.data and _is_authoritative == other._is_authoritative + return tick == other.tick and _data == other._data and _is_authoritative == other._is_authoritative else: return false func _to_string() -> String: var result := "Snapshot(#%d" % [tick] - for entry in data: - result += ", %s(%s): %s" % [entry, _is_authoritative.get(entry, false), data[entry]] + for subject in _data: + for property in _data[subject]: + var value = _data[subject][property] + result += ", %s:%s(%s): %s" % [subject, property, _is_authoritative.get(subject, false), value] result += ")" return result func _to_vest(): return { "tick": tick, - "data": data, + "data": _data, "is_auth": _is_authoritative } diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/rollback-history-server.gd index 17785f26..90ab7461 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/rollback-history-server.gd @@ -107,8 +107,8 @@ func get_data_age_for(what: Node, tick: int) -> int: var input_snapshot := get_rollback_input_snapshot(i) var state_snapshot := get_rollback_state_snapshot(i) - var has_input := input_snapshot != null and input_snapshot.has_node(what, true) - var has_state := state_snapshot != null and state_snapshot.has_node(what, true) + var has_input := input_snapshot != null and input_snapshot.has_subject(what, true) + var has_state := state_snapshot != null and state_snapshot.has_subject(what, true) if has_input or has_state: return tick - i @@ -129,13 +129,16 @@ func _record(tick: int, snapshots: Dictionary, property_pool: _PropertyPool, onl assert(subject is Node, "Only nodes supported for now!") var is_auth := auth_filter.call(subject) + if only_auth and not is_auth: continue +# if not is_auth and snapshot.is_auth(subject): +# continue for property in property_pool.get_properties_of(subject): - var value := subject.get_indexed(property) - if snapshot.merge_property(subject, property, value, is_auth): - updates.append([subject, property, value, is_auth]) + snapshot.record_property(subject, property) + updates.append([subject, property, snapshot.get_property(subject, property), is_auth]) + snapshot.set_auth(subject, is_auth) match snapshots: _rb_input_snapshots: diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index ec5ce1ca..91c5c140 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -57,7 +57,7 @@ func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: result.append(node) continue - if not input_snapshot.has_nodes(inputs, true): + if not input_snapshot.has_subjects(inputs, true): # We don't have input for node, don't simulate continue @@ -75,7 +75,7 @@ func is_predicting(input_snapshot: Snapshot, node: Node) -> bool: var has_input := false if not is_inputless and input_snapshot: - has_input = input_snapshot.has_nodes(input_nodes, true) + has_input = input_snapshot.has_subjects(input_nodes, true) if not is_owned and has_input: # We don't own the node, but we own input for it - not (input) predicting diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index c8c4daa2..c8785909 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -20,9 +20,7 @@ var _sync_enable_diffs := true # TODO: Config var _sync_full_interval := 24 # TODO: Config var _sync_full_next := -1 -# TODO: Swap to (object to (property to NetworkSchemaSerializer)) -var _schemas := {} # RecordedProperty key to NetworkSchemaSerializer -var _fallback_schema := NetworkSchemas.variant() +var _schemas := _NetworkSchema.new() var _input_redundancy := 3 # TODO: Config @@ -70,12 +68,10 @@ func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_owned_state_properties.erase(node, property) func register_schema(node: Node, property: NodePath, serializer: NetworkSchemaSerializer) -> void: - var key := RecordedProperty.key_of(node, property) - _schemas[key] = serializer + _schemas.add(node, property, serializer) func deregister_schema(node: Node, property: NodePath) -> void: - var key := RecordedProperty.key_of(node, property) - _schemas.erase(key) + _schemas.erase(node, property) func register_visibility_filter(node: Node, filter: PeerVisibilityFilter) -> void: _visibility_filters[node] = filter @@ -83,7 +79,7 @@ func register_visibility_filter(node: Node, filter: PeerVisibilityFilter) -> voi func deregister_visibility_filter(node: Node) -> void: _visibility_filters.erase(node) -func is_property_visible_to(peer: int, node: Node, property: NodePath) -> bool: +func is_node_visible_to(peer: int, node: Node) -> bool: # TODO: Cache visibilities var filter := _visibility_filters.get(node) as PeerVisibilityFilter if not filter: @@ -101,6 +97,9 @@ func synchronize_input(tick: int) -> void: var notified_peers := _Set.new() if not _rb_enable_input_broadcast: + # If input broadcast is off, find which peers need to know our inputs + # That is all peers who own state controlled by our input + # Grab owned input objects for input_subject in _rb_owned_input_properties.get_subjects(): # Grab state objects controlled by input @@ -110,19 +109,20 @@ func synchronize_input(tick: int) -> void: for node in controlled_nodes: notified_peers.add(node.get_multiplayer_authority()) else: + # If input broadcast is on, send inputs to everyone for peer in multiplayer.get_peers(): notified_peers.add(peer) + # Make sure to not send input to ourselves notified_peers.erase(multiplayer.get_unique_id()) - # Only send input to peers in set + # Prepare snapshot package for offset in _input_redundancy: # Grab snapshot from RollbackHistoryServer var snapshot := RollbackHistoryServer.get_rollback_input_snapshot(tick - offset) if not snapshot: break - # Transmit _logger.trace("Submitting input: %s", [snapshot]) snapshots.append(snapshot) @@ -169,13 +169,13 @@ func synchronize_state(tick: int) -> void: # Send diff states for peer in multiplayer.get_peers(): - # TODO: Filter property set instead? - var peer_diff := diff.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) - if peer_diff.is_empty(): + var filter := func(subject): return is_node_visible_to(peer, subject) + + var data := _sparse_serializer.write_for(peer, diff, _rb_owned_state_properties, filter) + if data.is_empty(): # Peer can't see any changes, send nothing continue - var data := _sparse_serializer.write_for(peer, peer_diff, _rb_owned_state_properties) _cmd_diff_state.send(data, peer) NetworkPerformance.push_full_state_props(snapshot.size()) @@ -185,19 +185,17 @@ func synchronize_state(tick: int) -> void: # Send full states for peer in multiplayer.get_peers(): - # TODO: Filter property set instead? - var peer_snapshot := snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) - if peer_snapshot.is_empty(): + var filter := func(subject): return is_node_visible_to(peer, subject) + + var data := _dense_serializer.write_for(peer, snapshot, _rb_owned_state_properties, filter) + if data.is_empty(): # Peer can't see anything, send nothing continue - var data := _dense_serializer.write_for(peer, peer_snapshot, _rb_owned_state_properties) _cmd_full_state.send(data, peer) - _logger.trace("Sent full state to #%d: %s", [peer, peer_snapshot]) - NetworkPerformance.push_full_state_props(peer_snapshot.size()) - NetworkPerformance.push_sent_state_props(peer_snapshot.size()) - _logger.debug("Pushed full state metrics: %d sent, %d full", [peer_snapshot.data.size(), peer_snapshot.data.size()]) + NetworkPerformance.push_full_state_props(snapshot.size()) + NetworkPerformance.push_sent_state_props(snapshot.size()) func synchronize_sync_state(tick: int) -> void: # We don't own sync state, nothing to synchronize @@ -222,35 +220,34 @@ func synchronize_sync_state(tick: int) -> void: # Send full states for peer in multiplayer.get_peers(): - var peer_snapshot := snapshot.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + var filter := func(subject): return is_node_visible_to(peer, subject) - if peer_snapshot.is_empty(): + var data := _dense_serializer.write_for(peer, snapshot, _sync_owned_state_properties, filter) + if data.is_empty(): # Peer can't see anything, send nothing continue - var data := _dense_serializer.write_for(peer, peer_snapshot, _sync_owned_state_properties) _cmd_full_sync.send(data, peer) - NetworkPerformance.push_full_state_props(peer_snapshot.size()) - NetworkPerformance.push_sent_state_props(peer_snapshot.size()) + NetworkPerformance.push_full_state_props(snapshot.size()) + NetworkPerformance.push_sent_state_props(snapshot.size()) else: _sync_full_next -= 1 var diff := Snapshot.make_patch(_last_sync_state_sent, snapshot) # Send diffs for peer in multiplayer.get_peers(): - var peer_snapshot := diff.filtered(func(node, prop): return is_property_visible_to(peer, node, prop)) + var filter := func(subject): return is_node_visible_to(peer, subject) - if peer_snapshot.is_empty(): - # Nothing changed, don't send anything + var data := _sparse_serializer.write_for(peer, diff, _sync_owned_state_properties, filter) + if data.is_empty(): + # Peer can't see anything, send nothing continue - var me := multiplayer.get_unique_id() - var data := _sparse_serializer.write_for(peer, peer_snapshot, _sync_owned_state_properties) _cmd_diff_sync.send(data, peer) NetworkPerformance.push_full_state_props(snapshot.size()) - NetworkPerformance.push_sent_state_props(peer_snapshot.size()) + NetworkPerformance.push_sent_state_props(diff.size()) # Remember last sent state for diffing # NOTE: This is a shared instance, theoretically shouldn't screw things up diff --git a/project.godot b/project.godot index 6ff30012..28302d5d 100644 --- a/project.godot +++ b/project.godot @@ -131,7 +131,7 @@ escape={ [netfox] general/clear_settings=false -time/tickrate=24 +time/tickrate=4 extras/auto_tile_windows=true autoconnect/enabled=false diff --git a/test/netfox.internals/bitset.test.gd b/test/netfox.internals/bitset.test.gd index b9cd2366..3b84d509 100644 --- a/test/netfox.internals/bitset.test.gd +++ b/test/netfox.internals/bitset.test.gd @@ -10,18 +10,31 @@ func suite(): expect_false(bits.get_bit(1)) ) - define("get_set_indices()", func(): - test("should return expected", func(): - var bits := _Bitset.of_bools([0, 1, 1, 0]) - expect_equal(bits.get_set_indices(), [1, 2]) - ) + test("get_set_indices()", func(): + var bits := _Bitset.of_bools([0, 1, 1, 0]) + expect_equal(bits.get_set_indices(), [1, 2]) ) - - define("set_bit()", func(): - test("should set bit", func(): - var bits := _Bitset.new(8) - bits.set_bit(2) - - expect_true(bits.get_bit(2)) - ) + + test("set_bit()", func(): + var bits := _Bitset.new(4) + var expected := _Bitset.of_bools([0, 1, 0, 1]) + bits.set_bit(1) + bits.set_bit(3) + expect_equal(bits, expected) + ) + + test("clear_bit()", func(): + var bits := _Bitset.of_bools([0, 1, 1, 0]) + var expected := _Bitset.of_bools([0, 0, 1, 0]) + bits.clear_bit(1) + bits.clear_bit(3) + expect_equal(bits, expected) + ) + + test("toggle_bit()", func(): + var bits := _Bitset.of_bools([0, 1, 1, 0]) + var expected := _Bitset.of_bools([1, 0, 1, 0]) + bits.toggle_bit(0) + bits.toggle_bit(1) + expect_equal(bits, expected) ) diff --git a/test/netfox/servers/data/snapshot.perf.gd b/test/netfox/servers/data/snapshot.perf.gd new file mode 100644 index 00000000..27412607 --- /dev/null +++ b/test/netfox/servers/data/snapshot.perf.gd @@ -0,0 +1,77 @@ +extends VestTest + +func get_suite_name() -> String: + return "Snapshot" + +func suite() -> void: + test("apply()", func(): + # 22.46us, 367.25us, 1.44ms, 2.90ms, 54.68ms + # 23.92us, 331.23us, 1.35ms, 2.73ms, 45.37ms + for case in [[16, 2, 64], [128, 4, 8], [512, 4, 4], [1024, 4, 1], [16384, 4, 1]]: + var node_count := case[0] as int + var prop_count := case[1] as int + var batch_size := case[2] as int + + var nodes := get_nodes(node_count) + + var snapshot := Snapshot.new(0) + for node in nodes: + for i in prop_count: + var prop := "property%d" % (i + 1) + snapshot.record_property(node, prop) + + benchmark("apply() - %dn/%dp" % [node_count, prop_count], func(__): + snapshot.apply() + ).with_duration(1.).with_batch_size(batch_size).run() + + free_nodes(nodes) + ) + + test("merge()", func(): + # 30.35us, 502.14us, 2.09ms, 4.85ms, 114.49ms + # 20.75us, 178.53us, 704.67us, 1.46ms, 31.61ms + for case in [[16, 2, 64], [128, 4, 8], [512, 4, 4], [1024, 4, 1], [16384, 4, 1]]: + var node_count := case[0] as int + var prop_count := case[1] as int + var batch_size := case[2] as int + + var nodes := get_nodes(node_count) + + var base_snapshot := Snapshot.new(0) + var patch_snapshot := Snapshot.new(0) + + for node in nodes: + for i in prop_count: + var prop := "property%d" % (i + 1) + base_snapshot.set_property(node, prop, randi()) + patch_snapshot.set_property(node, prop, randi()) + + benchmark("merge() - %dn/%dp" % [node_count, prop_count], func(__): + base_snapshot.merge(patch_snapshot) + ).with_duration(1.).with_batch_size(batch_size).run() + + free_nodes(nodes) + ) + +func get_nodes(count: int) -> Array[TestNode]: + var nodes := [] as Array[TestNode] + for i in count: + var node := TestNode.new() + node.name += " %d" % i + nodes.append(node) + return nodes + +func free_nodes(nodes: Array[TestNode]) -> void: + for node in nodes: + node.queue_free() + +class TestNode extends Node: + var property1 := randi() + var property2 := randi() + var property3 := randi() + var property4 := randi() + + var property5 := randi() + var property6 := randi() + var property7 := randi() + var property8 := randi() diff --git a/test/netfox/servers/snapshot.test.gd b/test/netfox/servers/data/snapshot.test.gd similarity index 100% rename from test/netfox/servers/snapshot.test.gd rename to test/netfox/servers/data/snapshot.test.gd diff --git a/test/rollback-history-server.perf.gd b/test/netfox/servers/rollback-history-server.perf.gd similarity index 100% rename from test/rollback-history-server.perf.gd rename to test/netfox/servers/rollback-history-server.perf.gd From 57c830bc8fb428cee92b27eedbe5dd39a7963346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 19 Jan 2026 16:52:02 +0100 Subject: [PATCH 58/95] serializer sanity tests --- .../serializers/sparse-snapshot-serializer.gd | 1 - addons/netfox/servers/data/property-pool.gd | 10 +++++ addons/netfox/servers/data/snapshot.gd | 15 +++++++ project.godot | 4 +- .../dense-snapshot-serializer.test.gd | 34 +++++++++++++++ .../redundant-snapshot-serializer.test.gd | 43 +++++++++++++++++++ .../serializers/snapshot-serializer-test.gd | 11 +++++ .../sparse-snapshot-serializer.test.gd | 34 +++++++++++++++ 8 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 test/netfox/serializers/dense-snapshot-serializer.test.gd create mode 100644 test/netfox/serializers/redundant-snapshot-serializer.test.gd create mode 100644 test/netfox/serializers/snapshot-serializer-test.gd create mode 100644 test/netfox/serializers/sparse-snapshot-serializer.test.gd diff --git a/addons/netfox/serializers/sparse-snapshot-serializer.gd b/addons/netfox/serializers/sparse-snapshot-serializer.gd index 0f4ace0f..f8bacebe 100644 --- a/addons/netfox/serializers/sparse-snapshot-serializer.gd +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd @@ -40,7 +40,6 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, filter: for i in node_props.size(): var property := node_props[i] - # TODO: Node-level auth tracking if not snapshot.has_property(node, property): continue diff --git a/addons/netfox/servers/data/property-pool.gd b/addons/netfox/servers/data/property-pool.gd index 5cd5f236..5c484547 100644 --- a/addons/netfox/servers/data/property-pool.gd +++ b/addons/netfox/servers/data/property-pool.gd @@ -3,6 +3,16 @@ class_name _PropertyPool var _properties_by_subject := {} # object to property array +static func of(entries: Array[Array]) -> _PropertyPool: + var pool := _PropertyPool.new() + + for entry in entries: + var subject := entry[0] as Object + var property := entry[1] as NodePath + pool.add(subject, property) + + return pool + func add(subject: Object, property: NodePath) -> void: if has(subject, property): return diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index b882f419..9e34e642 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -24,6 +24,21 @@ static func make_patch(from: Snapshot, to: Snapshot, tick: int = to.tick) -> Sna return patch +# Each entry should be [subject, property, value] +static func of(tick: int, entries: Array[Array], auth_subjects: Array[Object]) -> Snapshot: + var snapshot := Snapshot.new(tick) + for entry in entries: + var subject := entry[0] as Object + var property := entry[1] as NodePath + var value := entry[2] as Variant + + snapshot.set_property(subject, property, value) + + for subject in auth_subjects: + snapshot.set_auth(subject, true) + + return snapshot + func _init(p_tick: int): tick = p_tick diff --git a/project.godot b/project.godot index 28302d5d..198f35f8 100644 --- a/project.godot +++ b/project.godot @@ -131,9 +131,9 @@ escape={ [netfox] general/clear_settings=false -time/tickrate=4 +time/tickrate=24 extras/auto_tile_windows=true -autoconnect/enabled=false +autoconnect/enabled=true [rendering] diff --git a/test/netfox/serializers/dense-snapshot-serializer.test.gd b/test/netfox/serializers/dense-snapshot-serializer.test.gd new file mode 100644 index 00000000..5f560415 --- /dev/null +++ b/test/netfox/serializers/dense-snapshot-serializer.test.gd @@ -0,0 +1,34 @@ +extends SnapshotSerializerTest + +func get_suite_name() -> String: + return "DenseSnapshotSerializer" + +func suite() -> void: + test("should deserialize to same", func(): + var schema := _NetworkSchema.new() + var serializer := _DenseSnapshotSerializer.new(schema) + + var subject := Node3D.new() + Vest.get_tree().root.add_child.call_deferred(subject) + await subject.ready + NetworkIdentityServer.register_node(subject) + + var snapshot := Snapshot.of(0, [ + [subject, "position", Vector3.ZERO], + [subject, "quaternion", Quaternion.from_euler(Vector3.ONE)], + [subject, "scale", Vector3.ONE] + ], [subject]) + + var props := _PropertyPool.of([ + [subject, "position"], + [subject, "quaternion"], + [subject, "scale"] + ]) + + var serialized := serializer.write_for(1, snapshot, props) + var deserialized := serializer.read_from(1, props, to_buffer(serialized)) + + expect_equal(deserialized, snapshot) + + Vest.message("Serialized %d props to %d bytes" % [snapshot.size(), serialized.size()]) + ) diff --git a/test/netfox/serializers/redundant-snapshot-serializer.test.gd b/test/netfox/serializers/redundant-snapshot-serializer.test.gd new file mode 100644 index 00000000..d445c23c --- /dev/null +++ b/test/netfox/serializers/redundant-snapshot-serializer.test.gd @@ -0,0 +1,43 @@ +extends SnapshotSerializerTest + +func get_suite_name() -> String: + return "RedundantSnapshotSerializer" + +func suite() -> void: + test("should deserialize to same", func(): + var schema := _NetworkSchema.new() + var serializer := _RedundantSnapshotSerializer.new(schema) + + var subject := Node3D.new() + Vest.get_tree().root.add_child.call_deferred(subject) + await subject.ready + NetworkIdentityServer.register_node(subject) + + var snapshots := [ + Snapshot.of(0, [ + [subject, "position", Vector3(0., 0., 0.)], + [subject, "quaternion", Quaternion.from_euler(Vector3.ONE)], + [subject, "scale", Vector3(1., 1., 1.)] + ], [subject]), + Snapshot.of(1, [ + [subject, "position", Vector3(1., 0., 0.)], + [subject, "quaternion", Quaternion.from_euler(Vector3.ZERO)], + [subject, "scale", Vector3(1., .5, 1.)] + ], [subject]) + ] as Array[Snapshot] + + var props := _PropertyPool.of([ + [subject, "position"], + [subject, "quaternion"], + [subject, "scale"] + ]) + + var serialized := serializer.write_for(1, snapshots, props) + var deserialized := serializer.read_from(1, props, to_buffer(serialized)) + + expect_equal(deserialized.size(), snapshots.size(), "Not all snapshots were deserialized!") + for i in snapshots.size(): + expect_equal(deserialized[i], snapshots[i]) + + Vest.message("Serialized %d snapshots to %d bytes" % [snapshots.size(), serialized.size()]) + ) diff --git a/test/netfox/serializers/snapshot-serializer-test.gd b/test/netfox/serializers/snapshot-serializer-test.gd new file mode 100644 index 00000000..cba516f6 --- /dev/null +++ b/test/netfox/serializers/snapshot-serializer-test.gd @@ -0,0 +1,11 @@ +extends VestTest +class_name SnapshotSerializerTest + +func before_case(__): + # Makes sure local peer is 1, otherwise identifiers get random local IDs + Vest.get_tree().root.multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() + +func to_buffer(data: PackedByteArray) -> StreamPeerBuffer: + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + return buffer diff --git a/test/netfox/serializers/sparse-snapshot-serializer.test.gd b/test/netfox/serializers/sparse-snapshot-serializer.test.gd new file mode 100644 index 00000000..1feecf2e --- /dev/null +++ b/test/netfox/serializers/sparse-snapshot-serializer.test.gd @@ -0,0 +1,34 @@ +extends SnapshotSerializerTest + +func get_suite_name() -> String: + return "SparseSnapshotSerializer" + +func suite() -> void: + test("should deserialize to same", func(): + var schema := _NetworkSchema.new() + var serializer := _SparseSnapshotSerializer.new(schema) + + var subject := Node3D.new() + Vest.get_tree().root.add_child.call_deferred(subject) + await subject.ready + NetworkIdentityServer.register_node(subject) + + var snapshot := Snapshot.of(0, [ + [subject, "position", Vector3.ZERO], + [subject, "quaternion", Quaternion.from_euler(Vector3.ONE)], + [subject, "scale", Vector3.ONE] + ], [subject]) + + var props := _PropertyPool.of([ + [subject, "position"], + [subject, "quaternion"], + [subject, "scale"] + ]) + + var serialized := serializer.write_for(1, snapshot, props) + var deserialized := serializer.read_from(1, props, to_buffer(serialized)) + + expect_equal(deserialized, snapshot) + + Vest.message("Serialized %d props to %d bytes" % [snapshot.size(), serialized.size()]) + ) From e4c7394262815b39d8d17db722335d605b64f665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 19 Jan 2026 17:28:59 +0100 Subject: [PATCH 59/95] settings + input delay --- addons/netfox/netfox.gd | 30 +++++++++++++++++++ addons/netfox/rollback/network-rollback.gd | 4 +-- .../serializers/dense-snapshot-serializer.gd | 2 -- .../serializers/sparse-snapshot-serializer.gd | 3 -- .../netfox/servers/network-command-server.gd | 8 ++--- .../rollback-synchronization-server.gd | 12 ++++---- 6 files changed, 42 insertions(+), 17 deletions(-) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index e2b48ebf..8dd7a199 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -10,6 +10,11 @@ var SETTINGS: Array[Dictionary] = [ "value": true, "type": TYPE_BOOL }, + { + "name": "netfox/general/use_raw_commands", + "value": false, + "type": TYPE_BOOL + }, # Logging NetfoxLogger._make_setting("netfox/logging/netfox_log_level"), # Time settings @@ -112,11 +117,36 @@ var SETTINGS: Array[Dictionary] = [ "hint": PROPERTY_HINT_RANGE, "hint_string": "0,4,or_greater" }, + { + "name": "netfox/rollback/enable_input_broadcast", + "value": false, + "type": TYPE_BOOL + }, { "name": "netfox/rollback/enable_diff_states", "value": true, "type": TYPE_BOOL }, + { + "name": "netfox/rollback/full_state_interval", + "value": 24, + "type": TYPE_INT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "0,60,or_greater" + }, + # StateSynchronizer + { + "name": "netfox/state_synchronizer/enable_diff_states", + "value": true, + "type": TYPE_BOOL + }, + { + "name": "netfox/state_synchronizer/full_state_interval", + "value": 24, + "type": TYPE_INT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "0,60,or_greater" + }, # Events { "name": "netfox/events/enabled", diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 85299024..0baea17c 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -303,8 +303,8 @@ func _ready(): NetfoxLogger.register_tag(_get_rollback_tag) NetworkTime.after_tick_loop.connect(_rollback) NetworkTime.after_tick.connect(func(_dt, tick): - RollbackHistoryServer.record_input(tick) - RollbackSynchronizationServer.synchronize_input(tick) + RollbackHistoryServer.record_input(tick + input_delay) + RollbackSynchronizationServer.synchronize_input(tick + input_delay) ) RollbackSynchronizationServer.on_input.connect(func(snapshot: Snapshot): diff --git a/addons/netfox/serializers/dense-snapshot-serializer.gd b/addons/netfox/serializers/dense-snapshot-serializer.gd index fa7695fb..6d7e7c5d 100644 --- a/addons/netfox/serializers/dense-snapshot-serializer.gd +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd @@ -17,7 +17,6 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, filter: # Write tick buffer.put_u32(snapshot.tick) - # TODO: Include property config hash to detect mismatches # For each node for subject in properties.get_subjects(): @@ -62,7 +61,6 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i # Read tick var tick := buffer.get_u32() var snapshot := Snapshot.new(tick) - # TODO: Include property config hash to detect mismatches while buffer.get_available_bytes() > 0: # Read identity reference, data size, and data diff --git a/addons/netfox/serializers/sparse-snapshot-serializer.gd b/addons/netfox/serializers/sparse-snapshot-serializer.gd index f8bacebe..1f504c96 100644 --- a/addons/netfox/serializers/sparse-snapshot-serializer.gd +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd @@ -18,7 +18,6 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, filter: # Write ticks buffer.put_u32(snapshot.tick) - # TODO: Include property config hash to detect mismatches # For each node for subject in properties.get_subjects(): @@ -66,8 +65,6 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i # Grab ticks var tick := buffer.get_u32() - # TODO: Include property config hash to detect mismatches - var snapshot := Snapshot.new(tick) while buffer.get_available_bytes() > 0: diff --git a/addons/netfox/servers/network-command-server.gd b/addons/netfox/servers/network-command-server.gd index e833599e..78a3c076 100644 --- a/addons/netfox/servers/network-command-server.gd +++ b/addons/netfox/servers/network-command-server.gd @@ -6,7 +6,7 @@ var _rpc_transport := RPCTransport.new() var _packet_transport := PacketTransport.new() var _commands := {} # id to `Command` -var _use_rpcs := false # TODO: Config +var _use_raw := ProjectSettings.get_setting("netfox/general/use_raw_commands", false) static var _logger := NetfoxLogger._for_netfox("NetworkCommandServer") @@ -41,10 +41,10 @@ func register_command_at(idx: int, handler: Callable, mode: MultiplayerPeer.Tran return command func send_command(idx: int, data: PackedByteArray, target_peer: int = 0, mode: MultiplayerPeer.TransferMode = MultiplayerPeer.TRANSFER_MODE_RELIABLE, channel: int = 0) -> void: - if _use_rpcs: - _rpc_transport.send(idx, data, target_peer, mode, channel) - else: + if _use_raw: _packet_transport.send(idx, data, target_peer, mode, channel) + else: + _rpc_transport.send(idx, data, target_peer, mode, channel) class Command: var _command_server: _NetworkCommandServer diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/rollback-synchronization-server.gd index c8785909..10d35f52 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/rollback-synchronization-server.gd @@ -10,19 +10,19 @@ var _sync_owned_state_properties := _PropertyPool.new() var _visibility_filters := {} # Node to PeerVisibilityFilter -var _rb_enable_input_broadcast := false # TODO: Config -var _rb_enable_diffs := true # TODO: Config -var _rb_full_interval := 24 # TODO: Config +var _rb_enable_input_broadcast := ProjectSettings.get_setting("netfox/rollback/enable_input_broadcast", false) +var _rb_enable_diffs := NetworkRollback.enable_diff_states +var _rb_full_interval := ProjectSettings.get_setting("netfox/rollback/full_state_interval", 24) var _rb_full_next := -1 var _last_sync_state_sent := Snapshot.new(0) -var _sync_enable_diffs := true # TODO: Config -var _sync_full_interval := 24 # TODO: Config +var _sync_enable_diffs := ProjectSettings.get_setting("netfox/state_synchronizer/enable_diff_states", true) +var _sync_full_interval := ProjectSettings.get_setting("netfox/state_synchronizer/full_state_interval", 24) var _sync_full_next := -1 var _schemas := _NetworkSchema.new() -var _input_redundancy := 3 # TODO: Config +var _input_redundancy := NetworkRollback.input_redundancy var _dense_serializer := _DenseSnapshotSerializer.new(_schemas) var _sparse_serializer := _SparseSnapshotSerializer.new(_schemas) From 58162f5ed01248668fe7933d08f5697ab4e19edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 19 Jan 2026 20:08:44 +0100 Subject: [PATCH 60/95] cleanups and renames --- addons/netfox.internals/interval-scheduler.gd | 20 +++++ addons/netfox/netfox.gd | 4 +- addons/netfox/network-time.gd | 8 +- addons/netfox/rollback/network-rollback.gd | 20 ++--- .../netfox/rollback/rollback-synchronizer.gd | 28 +++--- .../serializers/dense-snapshot-serializer.gd | 1 - .../serializers/sparse-snapshot-serializer.gd | 1 - ...ry-server.gd => network-history-server.gd} | 5 +- ...r.gd => network-synchronization-server.gd} | 88 ++++++++----------- .../servers/rollback-simulation-server.gd | 4 +- addons/netfox/state-synchronizer.gd | 12 +-- project.godot | 5 +- .../interval-scheduler.test.gd | 30 +++++++ ...perf.gd => network-history-server.perf.gd} | 10 +-- .../rollback-synchronization-server.test.gd | 32 ------- 15 files changed, 136 insertions(+), 132 deletions(-) create mode 100644 addons/netfox.internals/interval-scheduler.gd rename addons/netfox/servers/{rollback-history-server.gd => network-history-server.gd} (97%) rename addons/netfox/servers/{rollback-synchronization-server.gd => network-synchronization-server.gd} (88%) create mode 100644 test/netfox.internals/interval-scheduler.test.gd rename test/netfox/servers/{rollback-history-server.perf.gd => network-history-server.perf.gd} (84%) delete mode 100644 test/netfox/servers/rollback-synchronization-server.test.gd diff --git a/addons/netfox.internals/interval-scheduler.gd b/addons/netfox.internals/interval-scheduler.gd new file mode 100644 index 00000000..cc326e6e --- /dev/null +++ b/addons/netfox.internals/interval-scheduler.gd @@ -0,0 +1,20 @@ +extends RefCounted +class_name _IntervalScheduler + +var interval := 1 +var _idx := 0 + +func _init(p_interval: int = 1): + interval = p_interval + +func is_now() -> bool: + if interval <= 0: + return false + elif interval == 1: + return true + elif _idx + 1 >= interval: + _idx = 0 + return true + else: + _idx += 1 + return false diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 8dd7a199..398e8128 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -181,11 +181,11 @@ const AUTOLOADS: Array[Dictionary] = [ "path": ROOT + "/servers/rollback-simulation-server.gd" }, { - "name": "RollbackHistoryServer", + "name": "NetworkHistoryServer", "path": ROOT + "/servers/rollback-history-server.gd" }, { - "name": "RollbackSynchronizationServer", + "name": "NetworkSynchronizationServer", "path": ROOT + "/servers/rollback-synchronization-server.gd" }, { diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index aeebe554..82232d09 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -556,15 +556,15 @@ func _loop() -> void: _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: - RollbackHistoryServer.restore_synchronizer_state(tick) + NetworkHistoryServer.restore_synchronizer_state(tick) before_tick_loop.emit() before_tick.emit(ticktime, tick) on_tick.emit(ticktime, tick) - RollbackHistoryServer.record_sync_state(tick + 1) - RollbackSynchronizationServer.synchronize_sync_state(tick + 1) + NetworkHistoryServer.record_sync_state(tick + 1) + NetworkSynchronizationServer.synchronize_sync_state(tick + 1) after_tick.emit(ticktime, tick) _tick += 1 @@ -572,7 +572,7 @@ func _loop() -> void: _next_tick_time += ticktime if ticks_in_loop > 0: - RollbackHistoryServer.restore_synchronizer_state(tick) + NetworkHistoryServer.restore_synchronizer_state(tick) after_tick_loop.emit() func _process(delta: float) -> void: diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 0baea17c..3b60db9a 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -303,11 +303,11 @@ func _ready(): NetfoxLogger.register_tag(_get_rollback_tag) NetworkTime.after_tick_loop.connect(_rollback) NetworkTime.after_tick.connect(func(_dt, tick): - RollbackHistoryServer.record_input(tick + input_delay) - RollbackSynchronizationServer.synchronize_input(tick + input_delay) + NetworkHistoryServer.record_input(tick + input_delay) + NetworkSynchronizationServer.synchronize_input(tick + input_delay) ) - RollbackSynchronizationServer.on_input.connect(func(snapshot: Snapshot): + NetworkSynchronizationServer.on_input.connect(func(snapshot: Snapshot): if snapshot.is_empty(): return if _earliest_input < 0 or snapshot.tick < _earliest_input: @@ -317,7 +317,7 @@ func _ready(): _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input]) ) - RollbackSynchronizationServer.on_state.connect(func(snapshot: Snapshot): + NetworkSynchronizationServer.on_state.connect(func(snapshot: Snapshot): if snapshot.is_empty(): return if _latest_state < 0 or snapshot.tick > _latest_state: @@ -389,8 +389,8 @@ func _rollback() -> void: # Done individually by Rewindables ( usually Rollback Synchronizers ) # Restore input and state for tick _rollback_stage = _STAGE_PREPARE - RollbackHistoryServer.restore_rollback_input(tick) - RollbackHistoryServer.restore_rollback_state(tick) + NetworkHistoryServer.restore_rollback_input(tick) + NetworkHistoryServer.restore_rollback_state(tick) on_prepare_tick.emit(tick) after_prepare_tick.emit(tick) @@ -407,14 +407,14 @@ func _rollback() -> void: # Record state for tick + 1 _rollback_stage = _STAGE_RECORD - RollbackHistoryServer.record_state(tick + 1) - RollbackSynchronizationServer.synchronize_state(tick + 1) + NetworkHistoryServer.record_state(tick + 1) + NetworkSynchronizationServer.synchronize_state(tick + 1) on_record_tick.emit(tick + 1) # Restore display state _rollback_stage = _STAGE_AFTER - RollbackHistoryServer.restore_rollback_state(display_tick) - RollbackHistoryServer.trim_history(history_start) + NetworkHistoryServer.restore_rollback_state(display_tick) + NetworkHistoryServer.trim_history(history_start) RollbackSimulationServer.trim_ticks_simulated(history_start) after_loop.emit() diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index c69f9066..0028478b 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -138,7 +138,7 @@ func process_settings() -> void: # Register visibility filter # TODO: Somehow deregister on destroy for node in _state_nodes: - RollbackSynchronizationServer.register_visibility_filter(node, visibility_filter) + NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) ## Process settings based on authority. ## [br][br] @@ -148,16 +148,16 @@ func process_settings() -> void: func process_authority(): # Deregister all recorded properties for prop in _get_recorded_state_props(): - RollbackHistoryServer.deregister_state(prop.node, prop.property) + NetworkHistoryServer.deregister_state(prop.node, prop.property) for prop in _get_recorded_input_props(): - RollbackHistoryServer.deregister_input(prop.node, prop.property) + NetworkHistoryServer.deregister_input(prop.node, prop.property) for prop in _input_property_config.get_properties(): - RollbackSynchronizationServer.deregister_input(prop.node, prop.property) + NetworkSynchronizationServer.deregister_input(prop.node, prop.property) for prop in _state_property_config.get_properties(): - RollbackSynchronizationServer.deregister_state(prop.node, prop.property) + NetworkSynchronizationServer.deregister_state(prop.node, prop.property) # Process authority _state_property_config.local_peer_id = multiplayer.get_unique_id() @@ -168,16 +168,16 @@ func process_authority(): # Register new recorded properties for prop in _get_recorded_state_props(): - RollbackHistoryServer.register_state(prop.node, prop.property) + NetworkHistoryServer.register_state(prop.node, prop.property) for prop in _get_recorded_input_props(): - RollbackHistoryServer.register_input(prop.node, prop.property) + NetworkHistoryServer.register_input(prop.node, prop.property) for prop in _input_property_config.get_properties(): - RollbackSynchronizationServer.register_input(prop.node, prop.property) + NetworkSynchronizationServer.register_input(prop.node, prop.property) for prop in _state_property_config.get_properties(): - RollbackSynchronizationServer.register_state(prop.node, prop.property) + NetworkSynchronizationServer.register_state(prop.node, prop.property) ## Add a state property. ## [br][br] @@ -231,14 +231,14 @@ func set_schema(schema: Dictionary) -> void: for entry in _schema_props: var node = entry[0] var property_path = entry[1] - RollbackSynchronizationServer.deregister_schema(node, property_path) + NetworkSynchronizationServer.deregister_schema(node, property_path) _schema_props.clear() # Register new schema for prop in schema: var prop_entry := PropertyEntry.parse(root, prop) var serializer := schema[prop] as NetworkSchemaSerializer - RollbackSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer) + NetworkSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer) _schema_props.append([prop_entry.node, prop_entry.property]) ## Check if input is available for the current tick. @@ -259,7 +259,7 @@ func get_input_age() -> int: # TODO: Cache these after prepare tick? var max_age := 0 for input_node in _input_nodes: - var age := RollbackHistoryServer.get_data_age_for(input_node, NetworkRollback.tick) + var age := NetworkHistoryServer.get_data_age_for(input_node, NetworkRollback.tick) max_age = maxi(age, max_age) if age < 0: # TODO: Error, somehow @@ -303,7 +303,7 @@ func get_last_known_input() -> int: var max_age := 0 var latest_tick := NetworkTime.tick + 1 for input_node in _input_nodes: - var age := RollbackHistoryServer.get_data_age_for(input_node, latest_tick) + var age := NetworkHistoryServer.get_data_age_for(input_node, latest_tick) if age >= 0: max_age = maxi(age, max_age) return latest_tick - max_age @@ -319,7 +319,7 @@ func get_last_known_state() -> int: var max_age := 0 var latest_tick := NetworkTime.tick + 1 for state_node in _registered_nodes: - var age := RollbackHistoryServer.get_data_age_for(state_node, latest_tick) + var age := NetworkHistoryServer.get_data_age_for(state_node, latest_tick) if age >= 0: max_age = maxi(age, max_age) return latest_tick - max_age diff --git a/addons/netfox/serializers/dense-snapshot-serializer.gd b/addons/netfox/serializers/dense-snapshot-serializer.gd index 6d7e7c5d..29938be0 100644 --- a/addons/netfox/serializers/dense-snapshot-serializer.gd +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd @@ -64,7 +64,6 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i while buffer.get_available_bytes() > 0: # Read identity reference, data size, and data - # TODO: Configurable upper limit on how much netfox is allowed to read here? var idref := netref.decode(buffer) as _NetworkIdentityReference var node_data_size := varuint.decode(buffer) as int node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] diff --git a/addons/netfox/serializers/sparse-snapshot-serializer.gd b/addons/netfox/serializers/sparse-snapshot-serializer.gd index 1f504c96..75674444 100644 --- a/addons/netfox/serializers/sparse-snapshot-serializer.gd +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd @@ -69,7 +69,6 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i while buffer.get_available_bytes() > 0: # Read header, including identity reference - # TODO: Configurable upper limit on how much netfox is allowed to read here? var idref := netref.decode(buffer) as _NetworkIdentityReference var node_data_size := varuint.decode(buffer) as int var changed_bits := varbits.decode(buffer) as _Bitset diff --git a/addons/netfox/servers/rollback-history-server.gd b/addons/netfox/servers/network-history-server.gd similarity index 97% rename from addons/netfox/servers/rollback-history-server.gd rename to addons/netfox/servers/network-history-server.gd index 90ab7461..c40d754a 100644 --- a/addons/netfox/servers/rollback-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -1,6 +1,5 @@ extends Node -class_name _RollbackHistoryServer -# TODO: Rename to Network(ed?)HistoryServer +class_name _NetworkHistoryServer var _rb_input_properties := _PropertyPool.new() var _rb_state_properties := _PropertyPool.new() @@ -10,7 +9,7 @@ var _rb_input_snapshots: Dictionary = {} # tick to Snapshot var _rb_state_snapshots: Dictionary = {} # tick to Snapshot var _sync_state_snapshots: Dictionary = {} # tick to Snapshot -static var _logger := NetfoxLogger._for_netfox("RollbackHistoryServer") +static var _logger := NetfoxLogger._for_netfox("NetworkHistoryServer") func register_state(node: Node, property: NodePath) -> void: _rb_state_properties.add(node, property) diff --git a/addons/netfox/servers/rollback-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd similarity index 88% rename from addons/netfox/servers/rollback-synchronization-server.gd rename to addons/netfox/servers/network-synchronization-server.gd index 10d35f52..b0c19560 100644 --- a/addons/netfox/servers/rollback-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -1,5 +1,5 @@ extends Node -class_name _RollbackSynchronizationServer +class_name _NetworkSynchronizationServer var _rb_input_properties := _PropertyPool.new() var _rb_state_properties := _PropertyPool.new() @@ -13,12 +13,12 @@ var _visibility_filters := {} # Node to PeerVisibilityFilter var _rb_enable_input_broadcast := ProjectSettings.get_setting("netfox/rollback/enable_input_broadcast", false) var _rb_enable_diffs := NetworkRollback.enable_diff_states var _rb_full_interval := ProjectSettings.get_setting("netfox/rollback/full_state_interval", 24) -var _rb_full_next := -1 +var _rb_full_scheduler := _IntervalScheduler.new(_rb_full_interval) var _last_sync_state_sent := Snapshot.new(0) var _sync_enable_diffs := ProjectSettings.get_setting("netfox/state_synchronizer/enable_diff_states", true) var _sync_full_interval := ProjectSettings.get_setting("netfox/state_synchronizer/full_state_interval", 24) -var _sync_full_next := -1 +var _sync_full_scheduler := _IntervalScheduler.new(_sync_full_interval) var _schemas := _NetworkSchema.new() @@ -35,7 +35,7 @@ var _redundant_serializer := _RedundantSnapshotSerializer.new(_schemas) @onready var _cmd_full_sync := NetworkCommandServer.register_command_at(_NetworkCommands.SYNC_FULL, _handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) @onready var _cmd_diff_sync := NetworkCommandServer.register_command_at(_NetworkCommands.SYNC_DIFF, _handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) -static var _logger := NetfoxLogger._for_netfox("RollbackSynchronizationServer") +static var _logger := NetfoxLogger._for_netfox("NetworkSynchronizationServer") signal on_input(snapshot: Snapshot) signal on_state(snapshot: Snapshot) @@ -118,8 +118,8 @@ func synchronize_input(tick: int) -> void: # Prepare snapshot package for offset in _input_redundancy: - # Grab snapshot from RollbackHistoryServer - var snapshot := RollbackHistoryServer.get_rollback_input_snapshot(tick - offset) + # Grab snapshot from NetworkHistoryServer + var snapshot := NetworkHistoryServer.get_rollback_input_snapshot(tick - offset) if not snapshot: break @@ -137,8 +137,8 @@ func synchronize_state(tick: int) -> void: if _rb_owned_state_properties.is_empty(): return - # Grab snapshot from RollbackHistoryServer - var snapshot := RollbackHistoryServer.get_rollback_state_snapshot(tick) + # Grab snapshot from NetworkHistoryServer + var snapshot := NetworkHistoryServer.get_rollback_state_snapshot(tick) if not snapshot: # No data for tick return @@ -148,76 +148,65 @@ func synchronize_state(tick: int) -> void: return # Figure out whether to send full- or diff state - var is_diff := false - if _rb_enable_diffs: - if _rb_full_interval <= 0: - is_diff = true - elif _rb_full_next < 0: - is_diff = true + var is_full := _rb_full_scheduler.is_now() + if not _rb_enable_diffs: + is_full = true # Check if we have history to diff to - var reference_snapshot := RollbackHistoryServer.get_rollback_state_snapshot(tick - 1) + var reference_snapshot := NetworkHistoryServer.get_rollback_state_snapshot(tick - 1) if not reference_snapshot: - is_diff = false + is_full = true - if is_diff: - _rb_full_next -= 1 - var diff := Snapshot.make_patch(reference_snapshot, snapshot) - if diff.is_empty(): - # Nothing changed, don't send anything - return - - # Send diff states + if is_full: + # Send full states for peer in multiplayer.get_peers(): var filter := func(subject): return is_node_visible_to(peer, subject) - var data := _sparse_serializer.write_for(peer, diff, _rb_owned_state_properties, filter) + var data := _dense_serializer.write_for(peer, snapshot, _rb_owned_state_properties, filter) if data.is_empty(): - # Peer can't see any changes, send nothing + # Peer can't see anything, send nothing continue - _cmd_diff_state.send(data, peer) + _cmd_full_state.send(data, peer) NetworkPerformance.push_full_state_props(snapshot.size()) - NetworkPerformance.push_sent_state_props(diff.size()) + NetworkPerformance.push_sent_state_props(snapshot.size()) else: - _rb_full_next = _rb_full_interval + var diff := Snapshot.make_patch(reference_snapshot, snapshot) + if diff.is_empty(): + # Nothing changed, don't send anything + return - # Send full states + # Send diff states for peer in multiplayer.get_peers(): var filter := func(subject): return is_node_visible_to(peer, subject) - var data := _dense_serializer.write_for(peer, snapshot, _rb_owned_state_properties, filter) + var data := _sparse_serializer.write_for(peer, diff, _rb_owned_state_properties, filter) if data.is_empty(): - # Peer can't see anything, send nothing + # Peer can't see any changes, send nothing continue - _cmd_full_state.send(data, peer) + _cmd_diff_state.send(data, peer) NetworkPerformance.push_full_state_props(snapshot.size()) - NetworkPerformance.push_sent_state_props(snapshot.size()) + NetworkPerformance.push_sent_state_props(diff.size()) func synchronize_sync_state(tick: int) -> void: # We don't own sync state, nothing to synchronize if _sync_owned_state_properties.is_empty(): return - # Grab snapshot from RollbackHistoryServer - var snapshot := RollbackHistoryServer.get_synchronizer_state_snapshot(tick) + # Grab snapshot from NetworkHistoryServer + var snapshot := NetworkHistoryServer.get_synchronizer_state_snapshot(tick) if not snapshot: return # Figure out whether to send full- or diff state - var is_diff := false - if _sync_enable_diffs: - if _sync_full_interval <= 0: - is_diff = true - elif _sync_full_next < 0: - is_diff = true - - if not is_diff: - _sync_full_next = _sync_full_interval + var is_full := _sync_full_scheduler.is_now() + if not _sync_enable_diffs: + is_full = true + if is_full: # Send full states for peer in multiplayer.get_peers(): var filter := func(subject): return is_node_visible_to(peer, subject) @@ -232,7 +221,6 @@ func synchronize_sync_state(tick: int) -> void: NetworkPerformance.push_full_state_props(snapshot.size()) NetworkPerformance.push_sent_state_props(snapshot.size()) else: - _sync_full_next -= 1 var diff := Snapshot.make_patch(_last_sync_state_sent, snapshot) # Send diffs @@ -266,7 +254,7 @@ func _handle_input(sender: int, data: PackedByteArray): # TODO: Only merge inputs we don't have yet, so clients don't cheat by # overriding their earlier choices. Only emit signal for snapshots # that contain new input. - var merged := RollbackHistoryServer.merge_rollback_input(snapshot) + var merged := NetworkHistoryServer.merge_rollback_input(snapshot) _logger.debug("Ingested input: %s", [snapshot]) on_input.emit(snapshot) @@ -294,7 +282,7 @@ func _handle_full_sync(sender: int, data: PackedByteArray): var snapshot := _dense_serializer.read_from(sender, _sync_state_properties, buffer, true) - RollbackHistoryServer.merge_synchronizer_state(snapshot) + NetworkHistoryServer.merge_synchronizer_state(snapshot) _logger.trace("Ingested sync state: %s", [snapshot]) func _handle_diff_sync(sender: int, data: PackedByteArray): @@ -303,14 +291,14 @@ func _handle_diff_sync(sender: int, data: PackedByteArray): var snapshot := _sparse_serializer.read_from(sender, _sync_state_properties, buffer) - RollbackHistoryServer.merge_synchronizer_state(snapshot) + NetworkHistoryServer.merge_synchronizer_state(snapshot) _logger.trace("Ingested sync diff: %s", [snapshot]) func _ingest_state(sender: int, snapshot: Snapshot) -> void: # TODO: Sanitize # _logger.debug("Received state snapshot: %s", [snapshot]) - var merged := RollbackHistoryServer.merge_rollback_state(snapshot) + var merged := NetworkHistoryServer.merge_rollback_state(snapshot) _logger.debug("Ingested state: %s", [snapshot]) on_state.emit(snapshot) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 91c5c140..50a119f3 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -120,8 +120,8 @@ func trim_ticks_simulated(beginning: int) -> void: func simulate(delta: float, tick: int) -> void: _current_object = null - var input_snapshot := RollbackHistoryServer.get_rollback_input_snapshot(tick) - var state_snapshot := RollbackHistoryServer.get_rollback_state_snapshot(tick) + var input_snapshot := NetworkHistoryServer.get_rollback_input_snapshot(tick) + var state_snapshot := NetworkHistoryServer.get_rollback_state_snapshot(tick) var nodes := get_nodes_to_simulate(input_snapshot) _predicted_nodes.clear() _logger.trace("Simulating %d nodes: %s", [nodes.size(), nodes]) diff --git a/addons/netfox/state-synchronizer.gd b/addons/netfox/state-synchronizer.gd index 8d81a790..b2270245 100644 --- a/addons/netfox/state-synchronizer.gd +++ b/addons/netfox/state-synchronizer.gd @@ -59,16 +59,16 @@ static var _logger := NetfoxLogger._for_netfox("StateSynchronizer") func process_settings() -> void: # Remove old configuration for property in _registered_properties: - RollbackHistoryServer.deregister_sync_state(property.node, property.property) - RollbackSynchronizationServer.deregister_sync_state(property.node, property.property) + NetworkHistoryServer.deregister_sync_state(property.node, property.property) + NetworkSynchronizationServer.deregister_sync_state(property.node, property.property) # Register new configuration _registered_properties.clear() for property_spec in properties: var property := PropertyEntry.parse(root, property_spec) _registered_properties.append(property) - RollbackHistoryServer.register_sync_state(property.node, property.property) - RollbackSynchronizationServer.register_sync_state(property.node, property.property) + NetworkHistoryServer.register_sync_state(property.node, property.property) + NetworkSynchronizationServer.register_sync_state(property.node, property.property) # TODO: Somehow deregister on destroy NetworkIdentityServer.register_node(property.node) @@ -108,14 +108,14 @@ func add_state(node: Variant, property: String) -> void: func set_schema(schema: Dictionary) -> void: # Remove previous schema for entry in _schema_props: - RollbackSynchronizationServer.deregister_schema(entry.node, entry.property) + NetworkSynchronizationServer.deregister_schema(entry.node, entry.property) _schema_props.clear() # Register new schema for prop in schema: var prop_entry := PropertyEntry.parse(root, prop) var serializer := schema[prop] as NetworkSchemaSerializer - RollbackSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer) + NetworkSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer) _schema_props.append(prop_entry) func _notification(what) -> void: diff --git a/project.godot b/project.godot index 198f35f8..8f8c28a4 100644 --- a/project.godot +++ b/project.godot @@ -31,8 +31,8 @@ NetworkPerformance="*res://addons/netfox/network-performance.gd" WindowTiler="*res://addons/netfox.extras/window-tiler.gd" NetworkSimulator="*res://addons/netfox.extras/network-simulator.gd" RollbackSimulationServer="*res://addons/netfox/servers/rollback-simulation-server.gd" -RollbackHistoryServer="*res://addons/netfox/servers/rollback-history-server.gd" -RollbackSynchronizationServer="*res://addons/netfox/servers/rollback-synchronization-server.gd" +NetworkHistoryServer="*res://addons/netfox/servers/network-history-server.gd" +NetworkSynchronizationServer="*res://addons/netfox/servers/network-synchronization-server.gd" NetworkIdentityServer="*res://addons/netfox/servers/network-identity-server.gd" NetworkCommandServer="*res://addons/netfox/servers/network-command-server.gd" @@ -134,6 +134,7 @@ general/clear_settings=false time/tickrate=24 extras/auto_tile_windows=true autoconnect/enabled=true +logging/log_level=3 [rendering] diff --git a/test/netfox.internals/interval-scheduler.test.gd b/test/netfox.internals/interval-scheduler.test.gd new file mode 100644 index 00000000..1e02554b --- /dev/null +++ b/test/netfox.internals/interval-scheduler.test.gd @@ -0,0 +1,30 @@ +extends VestTest + +func get_suite_name() -> String: + return "IntervalScheduler" + +func suite() -> void: + test("should never schedule on zero interval", func(): + var interval := _IntervalScheduler.new(0) + expect_false(interval.is_now()) + expect_false(interval.is_now()) + expect_false(interval.is_now()) + ) + + test("should always schedule on one interval", func(): + var interval := _IntervalScheduler.new(1) + expect_true(interval.is_now()) + expect_true(interval.is_now()) + expect_true(interval.is_now()) + ) + + test("should schedule on interval", func(): + var interval := _IntervalScheduler.new(3) + expect_false(interval.is_now()) + expect_false(interval.is_now()) + expect_true(interval.is_now()) + + expect_false(interval.is_now()) + expect_false(interval.is_now()) + expect_true(interval.is_now()) + ) diff --git a/test/netfox/servers/rollback-history-server.perf.gd b/test/netfox/servers/network-history-server.perf.gd similarity index 84% rename from test/netfox/servers/rollback-history-server.perf.gd rename to test/netfox/servers/network-history-server.perf.gd index 240bb161..5fcd8999 100644 --- a/test/netfox/servers/rollback-history-server.perf.gd +++ b/test/netfox/servers/network-history-server.perf.gd @@ -1,7 +1,7 @@ extends VestTest func get_suite_name() -> String: - return "RollbackHistoryServer" + return "NetworkHistoryServer" var idx := 0 @@ -17,24 +17,24 @@ func suite(): # Register nodes idx = 0 benchmark("register()", func(__): - RollbackHistoryServer.register_state(nodes[idx], "name") + NetworkHistoryServer.register_state(nodes[idx], "name") idx += 1 ).with_iterations(count).with_batch_size(count).run() # Record benchmark("record()", func(__): - RollbackHistoryServer.record_state(0) + NetworkHistoryServer.record_state(0) ).with_duration(1.).with_batch_size(16).run() # Restore benchmark("restore()", func(__): - RollbackHistoryServer.restore_rollback_state(0) + NetworkHistoryServer.restore_rollback_state(0) ).with_duration(1.).with_batch_size(16).run() # Deregister nodes idx = 0 benchmark("deregister()", func(__): - RollbackHistoryServer.deregister_state(nodes[idx], "name") + NetworkHistoryServer.deregister_state(nodes[idx], "name") idx += 1 ).with_iterations(count).with_batch_size(count).run() diff --git a/test/netfox/servers/rollback-synchronization-server.test.gd b/test/netfox/servers/rollback-synchronization-server.test.gd deleted file mode 100644 index bbbf73a0..00000000 --- a/test/netfox/servers/rollback-synchronization-server.test.gd +++ /dev/null @@ -1,32 +0,0 @@ -extends VestTest - -func get_suite_name() -> String: - return "RollbackSynchronizationServer" - -func suite(): - test("diff state", func(): - var node := Node3D.new() - Vest.get_tree().root.add_child.call_deferred(node) - await node.ready - - NetworkIdentityServer.register_node(node) - RollbackSynchronizationServer.register_state(node, "position") - RollbackSynchronizationServer.register_state(node, "scale") - - var reference_snapshot := Snapshot.new(1) - reference_snapshot.set_property(node, "position", Vector3.ZERO, true) - reference_snapshot.set_property(node, "scale", Vector3.ONE, true) - - var current_snapshot := Snapshot.new(2) - current_snapshot.set_property(node, "position", Vector3.ONE, true) - reference_snapshot.set_property(node, "scale", Vector3.ONE * 2., true) - - var diff_snapshot := Snapshot.make_patch(reference_snapshot, current_snapshot) - - var serialized := RollbackSynchronizationServer._serialize_diff_state_of(1, diff_snapshot, reference_snapshot.tick) - var buffer := StreamPeerBuffer.new(); buffer.data_array = serialized - var deserialized := RollbackSynchronizationServer._deserialize_diff_state_of(1, buffer) - - expect_equal(deserialized.reference_tick, reference_snapshot.tick) - expect_equal(deserialized.snapshot, diff_snapshot) - ) From 4935118b54c887fee53b80b25d119734b84a3fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 19 Jan 2026 20:59:47 +0100 Subject: [PATCH 61/95] even less todos --- .../netfox/rollback/rollback-synchronizer.gd | 122 ++++++------------ addons/netfox/schemas/network-schema.gd | 3 + .../serializers/dense-snapshot-serializer.gd | 2 +- .../redundant-snapshot-serializer.gd | 2 +- .../serializers/sparse-snapshot-serializer.gd | 2 +- addons/netfox/servers/data/property-pool.gd | 10 ++ .../netfox/servers/network-history-server.gd | 5 + .../netfox/servers/network-identity-server.gd | 4 +- .../servers/network-synchronization-server.gd | 11 ++ .../servers/rollback-simulation-server.gd | 4 +- addons/netfox/state-synchronizer.gd | 38 +++--- project.godot | 2 +- 12 files changed, 102 insertions(+), 103 deletions(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 0028478b..9aa03865 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -68,28 +68,25 @@ var diff_ack_interval: int = 0 ## Decides which peers will receive updates var visibility_filter := PeerVisibilityFilter.new() -var _state_property_config: _PropertyConfig = _PropertyConfig.new() -var _input_property_config: _PropertyConfig = _PropertyConfig.new() - -var _input_nodes := [] as Array[Node] -var _state_nodes := [] as Array[Node] -var _schema_props := [] as Array[Array] # [node, property path] tuples +var _state_properties := _PropertyPool.new() +var _input_properties := _PropertyPool.new() +var _sim_nodes := [] as Array[Node] +var _schema_nodes := _Set.new() var _properties_dirty: bool = false var _property_cache := PropertyCache.new(root) static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("RollbackSynchronizer") -var _registered_nodes: Array[Node] = [] - ## Process settings. ## [br][br] ## Call this after any change to configuration. Updates based on authority too ## ( calls process_authority ). func process_settings() -> void: - # Deregister all nodes - for node in _registered_nodes: + # Deregister simulated, state and input nodes + for node in _sim_nodes + _state_properties.get_subjects() + _input_properties.get_subjects(): RollbackSimulationServer.deregister_node(node) + _sim_nodes.clear() # Clear _property_cache.root = root @@ -103,41 +100,23 @@ func process_settings() -> void: nodes = nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it)) nodes.erase(self) - # Gather nodes with input props - _input_nodes.clear() - for prop in _input_property_config.get_properties(): - var input_node := prop.node - if not _input_nodes.has(input_node): - _input_nodes.append(input_node) - - # Gather nodes with state props - # TODO: Move tracking nodes to property configs? - _state_nodes.clear() - for prop in _state_property_config.get_properties(): - var state_node := prop.node - if not _state_nodes.has(state_node): - _state_nodes.append(state_node) - # Register simulation callbacks for node in nodes: RollbackSimulationServer.register(node._rollback_tick) - _registered_nodes.append(node) + _sim_nodes.append(node) # Both simulated and state nodes depend on all inputs # TODO: Write tests for setups where a node is synchronized but not simulated - # TODO: Deregister eventually - for node in nodes + _state_nodes: - for input_node in _input_nodes: + for node in nodes + _state_properties.get_subjects(): + for input_node in _input_properties.get_subjects(): RollbackSimulationServer.register_input_for(node, input_node) # Register identifiers - # TODO: Somehow deregister on destroy - for node in _state_nodes + _input_nodes: + for node in _state_properties.get_subjects() + _input_properties.get_subjects(): NetworkIdentityServer.register_node(node) # Register visibility filter - # TODO: Somehow deregister on destroy - for node in _state_nodes: + for node in _state_properties.get_subjects(): NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) ## Process settings based on authority. @@ -147,37 +126,30 @@ func process_settings() -> void: ## peers. func process_authority(): # Deregister all recorded properties - for prop in _get_recorded_state_props(): - NetworkHistoryServer.deregister_state(prop.node, prop.property) + for node in _state_properties.get_subjects(): + for property in _state_properties.get_properties_of(node): + NetworkHistoryServer.deregister_state(node, property) + NetworkSynchronizationServer.deregister_state(node, property) - for prop in _get_recorded_input_props(): - NetworkHistoryServer.deregister_input(prop.node, prop.property) - - for prop in _input_property_config.get_properties(): - NetworkSynchronizationServer.deregister_input(prop.node, prop.property) - - for prop in _state_property_config.get_properties(): - NetworkSynchronizationServer.deregister_state(prop.node, prop.property) + for node in _input_properties.get_subjects(): + for property in _input_properties.get_properties_of(node): + NetworkHistoryServer.deregister_input(node, property) + NetworkSynchronizationServer.deregister_input(node, property) # Process authority - _state_property_config.local_peer_id = multiplayer.get_unique_id() - _input_property_config.local_peer_id = multiplayer.get_unique_id() - - _state_property_config.set_properties_from_paths(state_properties, _property_cache) - _input_property_config.set_properties_from_paths(input_properties, _property_cache) + _state_properties.set_from_paths(root, state_properties) + _input_properties.set_from_paths(root, input_properties) # Register new recorded properties - for prop in _get_recorded_state_props(): - NetworkHistoryServer.register_state(prop.node, prop.property) + for node in _state_properties.get_subjects(): + for property in _state_properties.get_properties_of(node): + NetworkHistoryServer.register_state(node, property) + NetworkSynchronizationServer.register_state(node, property) - for prop in _get_recorded_input_props(): - NetworkHistoryServer.register_input(prop.node, prop.property) - - for prop in _input_property_config.get_properties(): - NetworkSynchronizationServer.register_input(prop.node, prop.property) - - for prop in _state_property_config.get_properties(): - NetworkSynchronizationServer.register_state(prop.node, prop.property) + for node in _input_properties.get_subjects(): + for property in _input_properties.get_properties_of(node): + NetworkHistoryServer.register_input(node, property) + NetworkSynchronizationServer.register_input(node, property) ## Add a state property. ## [br][br] @@ -228,18 +200,16 @@ func add_input(node: Variant, property: String) -> void: ## [/codeblock] func set_schema(schema: Dictionary) -> void: # Remove previous schema - for entry in _schema_props: - var node = entry[0] - var property_path = entry[1] - NetworkSynchronizationServer.deregister_schema(node, property_path) - _schema_props.clear() + for node in _schema_nodes: + NetworkSynchronizationServer.deregister_schema_for(node) + _schema_nodes.clear() # Register new schema for prop in schema: var prop_entry := PropertyEntry.parse(root, prop) var serializer := schema[prop] as NetworkSchemaSerializer NetworkSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer) - _schema_props.append([prop_entry.node, prop_entry.property]) + _schema_nodes.add(prop_entry.node) ## Check if input is available for the current tick. ## [br][br] @@ -258,7 +228,7 @@ func has_input() -> bool: func get_input_age() -> int: # TODO: Cache these after prepare tick? var max_age := 0 - for input_node in _input_nodes: + for input_node in _input_properties.get_subjects(): var age := NetworkHistoryServer.get_data_age_for(input_node, NetworkRollback.tick) max_age = maxi(age, max_age) if age < 0: @@ -302,7 +272,7 @@ func get_last_known_input() -> int: # TODO: Is there an easier way? var max_age := 0 var latest_tick := NetworkTime.tick + 1 - for input_node in _input_nodes: + for input_node in _input_properties.get_subjects(): var age := NetworkHistoryServer.get_data_age_for(input_node, latest_tick) if age >= 0: max_age = maxi(age, max_age) @@ -318,7 +288,7 @@ func get_last_known_state() -> int: # TODO: Is there an easier way? var max_age := 0 var latest_tick := NetworkTime.tick + 1 - for state_node in _registered_nodes: + for state_node in _state_properties.get_subjects(): var age := NetworkHistoryServer.get_data_age_for(state_node, latest_tick) if age >= 0: max_age = maxi(age, max_age) @@ -338,6 +308,12 @@ func _ready() -> void: func _notification(what: int) -> void: if what == NOTIFICATION_EDITOR_PRE_SAVE: update_configuration_warnings() + elif what == NOTIFICATION_PREDELETE: + for node in _sim_nodes + _state_properties.get_subjects() + _input_properties.get_subjects(): + RollbackSimulationServer.deregister_node(node) + NetworkSynchronizationServer.deregister(node) + NetworkIdentityServer.deregister_node(node) + NetworkHistoryServer.deregister(node) func _get_configuration_warnings() -> PackedStringArray: if not root: @@ -381,15 +357,3 @@ func _reprocess_settings() -> void: _properties_dirty = false process_settings() - -func _get_recorded_state_props() -> Array[PropertyEntry]: - return _state_property_config.get_properties() - -func _get_owned_state_props() -> Array[PropertyEntry]: - return _state_property_config.get_owned_properties() - -func _get_recorded_input_props() -> Array[PropertyEntry]: - return _input_property_config.get_owned_properties() - -func _get_owned_input_props() -> Array[PropertyEntry]: - return _input_property_config.get_owned_properties() diff --git a/addons/netfox/schemas/network-schema.gd b/addons/netfox/schemas/network-schema.gd index c82a2655..2e1a0881 100644 --- a/addons/netfox/schemas/network-schema.gd +++ b/addons/netfox/schemas/network-schema.gd @@ -23,6 +23,9 @@ func erase(subject: Object, property: NodePath) -> void: if subject_schema.is_empty(): _serializers.erase(subject) +func erase_subject(subject: Object) -> void: + _serializers.erase(subject) + func encode(subject: Object, property: NodePath, value: Variant, buffer: StreamPeerBuffer) -> void: _get_serializer(subject, property).encode(value, buffer) diff --git a/addons/netfox/serializers/dense-snapshot-serializer.gd b/addons/netfox/serializers/dense-snapshot-serializer.gd index 29938be0..68a9971f 100644 --- a/addons/netfox/serializers/dense-snapshot-serializer.gd +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd @@ -71,7 +71,7 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i # Resolve to identifier var identifier := NetworkIdentityServer.resolve_reference(peer, idref) if not identifier: - # TODO: Handle unknown IDs gracefully + # TODO(#???): Handle unknown IDs gracefully # TODO: Test that unknown nodes are INDEED SKIPPED _logger.warning("Received unknown identity reference %s, skipping data", [idref]) continue diff --git a/addons/netfox/serializers/redundant-snapshot-serializer.gd b/addons/netfox/serializers/redundant-snapshot-serializer.gd index 0b85a9ac..3d3b6943 100644 --- a/addons/netfox/serializers/redundant-snapshot-serializer.gd +++ b/addons/netfox/serializers/redundant-snapshot-serializer.gd @@ -16,7 +16,7 @@ func write_for(peer: int, snapshots: Array[Snapshot], properties: _PropertyPool, if buffer == null: buffer = StreamPeerBuffer.new() - # TODO: How about encoding the first snapshot as-is, and then the rest as diffs + # TODO(#???): How about encoding the first snapshot as-is, and then the rest as diffs for snapshot in snapshots: var serialized := _dense_serializer.write_for(peer, snapshot, properties) diff --git a/addons/netfox/serializers/sparse-snapshot-serializer.gd b/addons/netfox/serializers/sparse-snapshot-serializer.gd index 75674444..a07a4c53 100644 --- a/addons/netfox/serializers/sparse-snapshot-serializer.gd +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd @@ -77,7 +77,7 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i # Resolve to identifier var identifier := NetworkIdentityServer.resolve_reference(peer, idref) if not identifier: - # TODO: Handle unknown IDs gracefully + # TODO(#???): Handle unknown IDs gracefully # TODO: Test that unknown nodes are INDEED SKIPPED _logger.warning("Received unknown identity reference %s, skipping data", [idref]) break diff --git a/addons/netfox/servers/data/property-pool.gd b/addons/netfox/servers/data/property-pool.gd index 5c484547..4ddccaab 100644 --- a/addons/netfox/servers/data/property-pool.gd +++ b/addons/netfox/servers/data/property-pool.gd @@ -38,6 +38,9 @@ func erase(subject: Object, property: NodePath) -> void: func erase_subject(subject: Object) -> void: _properties_by_subject.erase(subject) +func clear() -> void: + _properties_by_subject.clear() + func get_properties_of(subject: Object) -> Array[NodePath]: var properties := [] as Array[NodePath] properties.assign(_properties_by_subject.get(subject, [])) @@ -50,3 +53,10 @@ func get_subjects() -> Array[Object]: func is_empty() -> bool: return _properties_by_subject.is_empty() + +func set_from_paths(root: Node, paths: Array[String]) -> void: + clear() + + for path in paths: + var prop := PropertyEntry.parse(root, path) + add(prop.node, prop.property) diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index c40d754a..829e5efa 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -29,6 +29,11 @@ func register_sync_state(node: Node, property: NodePath) -> void: func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.erase(node, property) +func deregister(node: Node) -> void: + _rb_state_properties.erase_subject(node) + _rb_input_properties.erase_subject(node) + _sync_state_properties.erase_subject(node) + func record_input(tick: int) -> void: _record(tick, _rb_input_snapshots, _rb_input_properties, false, func(subject: Node): return subject.is_multiplayer_authority() diff --git a/addons/netfox/servers/network-identity-server.gd b/addons/netfox/servers/network-identity-server.gd index fbea1230..647f0173 100644 --- a/addons/netfox/servers/network-identity-server.gd +++ b/addons/netfox/servers/network-identity-server.gd @@ -49,7 +49,7 @@ func clear() -> void: _identifier_by_id.clear() _next_id = 0 -# TODO: Handle peer disconnect by clearing up data +# TODO(#???): Handle peer disconnect by clearing up data func register_node(node: Node) -> void: if not node.is_inside_tree(): @@ -118,7 +118,7 @@ func _handle_ids(sender: int, data: PackedByteArray) -> void: var identifier := _get_identifier_by_name(full_name) if not identifier: # Probably deleted since then - # TODO: Queue in case node was not registered *yet* + # TODO(#???): Queue in case node was not registered *yet* _logger.debug("Received identifier for unknown object with full name %s, id #%d", [full_name, id]) continue identifier.set_id_for(sender, id) diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index b0c19560..ef5ad503 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -73,12 +73,23 @@ func register_schema(node: Node, property: NodePath, serializer: NetworkSchemaSe func deregister_schema(node: Node, property: NodePath) -> void: _schemas.erase(node, property) +func deregister_schema_for(node: Node) -> void: + _schemas.erase_subject(node) + func register_visibility_filter(node: Node, filter: PeerVisibilityFilter) -> void: _visibility_filters[node] = filter func deregister_visibility_filter(node: Node) -> void: _visibility_filters.erase(node) +func deregister(node: Node) -> void: + _rb_state_properties.erase_subject(node) + _rb_input_properties.erase_subject(node) + _rb_owned_state_properties.erase_subject(node) + _rb_owned_input_properties.erase_subject(node) + _sync_state_properties.erase_subject(node) + _visibility_filters.erase(node) + func is_node_visible_to(peer: int, node: Node) -> bool: # TODO: Cache visibilities var filter := _visibility_filters.get(node) as PeerVisibilityFilter diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 50a119f3..2345223b 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -35,7 +35,9 @@ func deregister(callback: Callable) -> void: _simulated_ticks.erase(object) func deregister_node(node: Node) -> void: - deregister(_callbacks.get(node)) + if _callbacks.has(node): + deregister(_callbacks[node]) + _input_graph.erase(node) func register_input_for(node: Node, input: Node) -> void: _input_graph.link(input, node) diff --git a/addons/netfox/state-synchronizer.gd b/addons/netfox/state-synchronizer.gd index b2270245..bc640723 100644 --- a/addons/netfox/state-synchronizer.gd +++ b/addons/netfox/state-synchronizer.gd @@ -46,8 +46,8 @@ var diff_ack_interval: int = 0 var visibility_filter := PeerVisibilityFilter.new() var _properties_dirty: bool = false -var _registered_properties := [] as Array[PropertyEntry] -var _schema_props := [] as Array[PropertyEntry] +var _properties := _PropertyPool.new() +var _schema_nodes := _Set.new() var _is_initialized: bool = false @@ -58,19 +58,18 @@ static var _logger := NetfoxLogger._for_netfox("StateSynchronizer") ## Call this after any change to configuration. func process_settings() -> void: # Remove old configuration - for property in _registered_properties: - NetworkHistoryServer.deregister_sync_state(property.node, property.property) - NetworkSynchronizationServer.deregister_sync_state(property.node, property.property) + for node in _properties.get_subjects(): + for property in _properties.get_properties_of(node): + NetworkHistoryServer.deregister_sync_state(node, property) + NetworkSynchronizationServer.deregister_sync_state(node, property) # Register new configuration - _registered_properties.clear() - for property_spec in properties: - var property := PropertyEntry.parse(root, property_spec) - _registered_properties.append(property) - NetworkHistoryServer.register_sync_state(property.node, property.property) - NetworkSynchronizationServer.register_sync_state(property.node, property.property) - # TODO: Somehow deregister on destroy - NetworkIdentityServer.register_node(property.node) + _properties.set_from_paths(root, properties) + for node in _properties.get_subjects(): + for property in _properties.get_properties_of(node): + NetworkHistoryServer.register_sync_state(node, property) + NetworkSynchronizationServer.register_sync_state(node, property) + NetworkIdentityServer.register_node(node) _is_initialized = true @@ -107,20 +106,25 @@ func add_state(node: Variant, property: String) -> void: ## [/codeblock] func set_schema(schema: Dictionary) -> void: # Remove previous schema - for entry in _schema_props: - NetworkSynchronizationServer.deregister_schema(entry.node, entry.property) - _schema_props.clear() + for node in _schema_nodes: + NetworkSynchronizationServer.deregister_schema_for(node) + _schema_nodes.clear() # Register new schema for prop in schema: var prop_entry := PropertyEntry.parse(root, prop) var serializer := schema[prop] as NetworkSchemaSerializer NetworkSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer) - _schema_props.append(prop_entry) + _schema_nodes.add(prop_entry.node) func _notification(what) -> void: if what == NOTIFICATION_EDITOR_PRE_SAVE: update_configuration_warnings() + elif what == NOTIFICATION_PREDELETE: + for node in _properties.get_subjects(): + NetworkSynchronizationServer.deregister(node) + NetworkHistoryServer.deregister(node) + NetworkIdentityServer.deregister(node) func _get_configuration_warnings() -> PackedStringArray: if not root: diff --git a/project.godot b/project.godot index 8f8c28a4..d352c1db 100644 --- a/project.godot +++ b/project.godot @@ -133,7 +133,7 @@ escape={ general/clear_settings=false time/tickrate=24 extras/auto_tile_windows=true -autoconnect/enabled=true +autoconnect/enabled=false logging/log_level=3 [rendering] From 1cda83b68a734a13afb39f754048cff0aeb27686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 19 Jan 2026 21:12:20 +0100 Subject: [PATCH 62/95] use commands for time sync --- addons/netfox/network-time-synchronizer.gd | 35 ++++++++++++------- .../netfox/servers/data/network-commands.gd | 15 +++++--- .../netfox/servers/network-command-server.gd | 2 -- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index 7f591daf..aedc8b01 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -115,6 +115,11 @@ var _offset: float = 0. var _rtt: float = 0. var _rtt_jitter: float = 0. +@onready var _cmd_ping := NetworkCommandServer.register_command_at(_NetworkCommands.NTP_PING, _handle_ping, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) +@onready var _cmd_pong := NetworkCommandServer.register_command_at(_NetworkCommands.NTP_PONG, _handle_pong, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) +@onready var _cmd_req_time := NetworkCommandServer.register_command_at(_NetworkCommands.NTP_REQ_TIME, _handle_request_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE) +@onready var _cmd_set_time := NetworkCommandServer.register_command_at(_NetworkCommands.NTP_SET_TIME, _handle_set_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE) + ## Emitted after the initial time sync. ## ## At the start of the game, clients request an initial timestamp to kickstart @@ -145,7 +150,7 @@ func start() -> void: _sample_idx = 0 _sample_buffer = _RingBuffer.new(sync_samples) - _request_timestamp.rpc_id(1) + _cmd_req_time.send(PackedByteArray(), 1) ## Stop the time synchronization loop. func stop() -> void: @@ -169,7 +174,7 @@ func _loop() -> void: _awaiting_samples[_sample_idx] = sample sample.ping_sent = _clock.get_time() - _send_ping.rpc_id(1, _sample_idx) + _cmd_ping.send(var_to_bytes(_sample_idx), 1) _sample_idx += 1 @@ -235,15 +240,19 @@ func _discipline_clock() -> void: _offset = offset - nudge -@rpc("any_peer", "call_remote", "unreliable") -func _send_ping(idx: int) -> void: +func _handle_ping(sender: int, data: PackedByteArray) -> void: + var idx := bytes_to_var(data) as 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()) + _cmd_pong.send(var_to_bytes([idx, ping_received, _clock.get_time()]), sender) + +func _handle_pong(sender: int, data: PackedByteArray) -> void: + var args := bytes_to_var(data) + + var idx := args[0] as int + var ping_received := args[1] as float + var pong_sent := args[2] as float -@rpc("any_peer", "call_remote", "unreliable") -func _send_pong(idx: int, ping_received: float, pong_sent: float) -> void: var pong_received := _clock.get_time() if not _awaiting_samples.has(idx): @@ -264,13 +273,13 @@ func _send_pong(idx: int, ping_received: float, pong_sent: float) -> void: # Discipline clock based on new sample _discipline_clock() -@rpc("any_peer", "call_remote", "reliable") -func _request_timestamp() -> void: +func _handle_request_timestamp(sender: int, data: PackedByteArray) -> void: _logger.debug("Requested initial timestamp @ %.4fs raw time", [_clock.get_raw_time()]) - _set_timestamp.rpc_id(multiplayer.get_remote_sender_id(), _clock.get_time()) + _cmd_set_time.send(var_to_bytes(_clock.get_time()), sender) + +func _handle_set_timestamp(sender: int, data: PackedByteArray) -> void: + var timestamp := bytes_to_var(data) as float -@rpc("any_peer", "call_remote", "reliable") -func _set_timestamp(timestamp: float) -> void: _logger.debug("Received initial timestamp @ %.4fs raw time", [_clock.get_raw_time()]) _clock.set_time(timestamp) _loop() diff --git a/addons/netfox/servers/data/network-commands.gd b/addons/netfox/servers/data/network-commands.gd index a2509416..ed38a8b5 100644 --- a/addons/netfox/servers/data/network-commands.gd +++ b/addons/netfox/servers/data/network-commands.gd @@ -3,9 +3,14 @@ class_name _NetworkCommands const IDS := 0 -const RB_FULL_STATE := 1 -const RB_DIFF_STATE := 2 -const INPUT := 3 +const NTP_PING := 1 +const NTP_PONG := 2 +const NTP_REQ_TIME := 3 +const NTP_SET_TIME := 4 -const SYNC_FULL := 4 -const SYNC_DIFF := 5 +const RB_FULL_STATE := 5 +const RB_DIFF_STATE := 6 +const INPUT := 7 + +const SYNC_FULL := 8 +const SYNC_DIFF := 9 diff --git a/addons/netfox/servers/network-command-server.gd b/addons/netfox/servers/network-command-server.gd index 78a3c076..2b55b3a0 100644 --- a/addons/netfox/servers/network-command-server.gd +++ b/addons/netfox/servers/network-command-server.gd @@ -10,8 +10,6 @@ var _use_raw := ProjectSettings.get_setting("netfox/general/use_raw_commands", f static var _logger := NetfoxLogger._for_netfox("NetworkCommandServer") -# TODO: Update time synchronizer to use commands - func _ready(): add_child(_rpc_transport, true) add_child(_packet_transport, true) From 13171c7dc6e3322b0508a943d4ec1a7783819529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 19 Jan 2026 22:43:27 +0100 Subject: [PATCH 63/95] use history buffer for storing snapshots, as ring-buffer --- addons/netfox.internals/history-buffer.gd | 162 ++++++++++++------ addons/netfox/netfox.gd | 5 + addons/netfox/rollback/network-rollback.gd | 1 - addons/netfox/schemas/network-schemas.gd | 4 +- .../netfox/servers/network-command-server.gd | 2 +- .../netfox/servers/network-history-server.gd | 50 +++--- .../servers/network-synchronization-server.gd | 8 +- test/netfox.internals/history-buffer.test.gd | 111 ++++++------ 8 files changed, 199 insertions(+), 144 deletions(-) diff --git a/addons/netfox.internals/history-buffer.gd b/addons/netfox.internals/history-buffer.gd index 0ca8175f..808cb4c2 100644 --- a/addons/netfox.internals/history-buffer.gd +++ b/addons/netfox.internals/history-buffer.gd @@ -2,69 +2,125 @@ extends RefCounted class_name _HistoryBuffer # Maps ticks (int) to arbitrary data -var _buffer: Dictionary = {} +var _capacity := 64 +var _buffer := [] +var _previous := [] + +var _tail := 0 +var _head := 0 + +static func of(capacity: int, data: Dictionary) -> _HistoryBuffer: + var history_buffer := _HistoryBuffer.new(capacity) + for idx in data: + history_buffer.set_at(idx, data[idx]) + return history_buffer + +func _init(capacity: int = 64): + _capacity = capacity + _buffer.resize(_capacity) + _previous.resize(_capacity) + +func duplicate(deep: bool = false) -> _HistoryBuffer: + var buffer := _HistoryBuffer.new(_capacity) + + buffer._buffer = _buffer.duplicate(deep) + buffer._previous = _previous.duplicate() + buffer._tail = _tail + buffer._head = _head + + return buffer + +func push(value: Variant) -> void: + _buffer[_head % _capacity] = value + _previous[_head % _capacity] = _head + _head += 1 + _tail += maxi(0, size() - capacity()) + +func pop() -> Variant: + assert(is_not_empty(), "History buffer is empty!") + + var value = _buffer[_tail % _capacity] + _tail += 1 + return value + +func set_at(at: int, value: Variant) -> void: + # Why does this need so many branches? + if is_empty(): + # Buffer is empty, jump to specified index + _tail = at + _head = at + push(value) + elif at < _tail: + # Trying to set something before tail, ignore + return + elif at == _head: + # Simply adding a new item + push(value) + elif at < _head: + _buffer[at % _capacity] = value + # Update prev-buffer + for i in range(at, _head): + if _previous[i % _capacity] == i: + break + _previous[i % _capacity] = at + elif at >= _head + _capacity: + # We're leaving all data behind + _tail = at + _head = at + push(value) + elif at >= _head: + var previous := _head - 1 + while _head < at: + _previous[_head % _capacity] = previous + _head += 1 + _tail += maxi(0, size() - _capacity) + + push(value) + +func has_at(at: int) -> bool: + if is_empty(): return false + if at < _tail: return false + if at >= _head: return false + return _previous[at % _capacity] == at + +func get_at(at: int, default: Variant = null) -> Variant: + if not has_at(at): + return default + return _buffer[at % _capacity] + +func has_latest_at(at: int) -> bool: + if is_empty(): return false + if at < _tail: return false + return true -func get_snapshot(tick: int): - if _buffer.has(tick): - return _buffer[tick] - else: - return null - -func set_snapshot(tick: int, data): - _buffer[tick] = data - -func get_buffer() -> Dictionary: - return _buffer +func size() -> int: + return _head - _tail -func get_earliest_tick() -> int: - return _buffer.keys().min() +func capacity() -> int: + return _capacity -func get_latest_tick() -> int: - return _buffer.keys().max() +func get_earliest_index() -> int: + return _tail -func get_closest_tick(tick: int) -> int: - if _buffer.has(tick): - return tick +func get_latest_index() -> int: + return _head - 1 - if _buffer.is_empty(): +func get_latest_index_at(at: int) -> int: + if not has_latest_at(at): return -1 + if at >= _head: + return get_latest_index() - var earliest_tick = get_earliest_tick() - if tick < earliest_tick: - return earliest_tick - - var latest_tick = get_latest_tick() - if tick > latest_tick: - return latest_tick - - return _buffer.keys() \ - .filter(func (key): return key < tick) \ - .max() + return _previous[at % _capacity] -func get_history(tick: int): - var closest_tick = get_closest_tick(tick) - return _buffer.get(closest_tick) - -func trim(earliest_tick_to_keep: int): - var ticks := _buffer.keys() - for tick in ticks: - if tick < earliest_tick_to_keep: - _buffer.erase(tick) +func get_latest_at(at: int) -> Variant: + return get_at(get_latest_index_at(at)) func clear(): - _buffer.clear() - -func size() -> int: - return _buffer.size() + _tail = _head func is_empty() -> bool: - return _buffer.is_empty() - -func has(tick) -> bool: - return _buffer.has(tick) - -func ticks() -> Array: - return _buffer.keys() + return size() == 0 -func erase(tick): - _buffer.erase(tick) +func is_not_empty() -> bool: + return not is_empty() diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 398e8128..dc8d5a78 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -140,6 +140,11 @@ var SETTINGS: Array[Dictionary] = [ "value": true, "type": TYPE_BOOL }, + { + "name": "netfox/state_synchronizer/history_limit", + "value": 64, + "type": TYPE_INT + }, { "name": "netfox/state_synchronizer/full_state_interval", "value": 24, diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 3b60db9a..f5aa056c 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -414,7 +414,6 @@ func _rollback() -> void: # Restore display state _rollback_stage = _STAGE_AFTER NetworkHistoryServer.restore_rollback_state(display_tick) - NetworkHistoryServer.trim_history(history_start) RollbackSimulationServer.trim_ticks_simulated(history_start) after_loop.emit() diff --git a/addons/netfox/schemas/network-schemas.gd b/addons/netfox/schemas/network-schemas.gd index 0b149405..1bba8d15 100644 --- a/addons/netfox/schemas/network-schemas.gd +++ b/addons/netfox/schemas/network-schemas.gd @@ -869,8 +869,8 @@ class _NetworkIdentityReferenceSerializer extends NetworkSchemaSerializer: varuint.encode(ref.get_id(), b) else: b.put_u8(0) - # TODO: Get rid of Godot's prepended 32 bits of string length - # TODO: Write is easy, prefer not manually iterating till \0 on read + # TODO(#???): Get rid of Godot's prepended 32 bits of string length + # TODO(#???): Write is easy, prefer not manually iterating till \0 on read b.put_utf8_string(ref.get_full_name()) func decode(b: StreamPeerBuffer) -> Variant: diff --git a/addons/netfox/servers/network-command-server.gd b/addons/netfox/servers/network-command-server.gd index 2b55b3a0..3efe53a8 100644 --- a/addons/netfox/servers/network-command-server.gd +++ b/addons/netfox/servers/network-command-server.gd @@ -6,7 +6,7 @@ var _rpc_transport := RPCTransport.new() var _packet_transport := PacketTransport.new() var _commands := {} # id to `Command` -var _use_raw := ProjectSettings.get_setting("netfox/general/use_raw_commands", false) +var _use_raw := ProjectSettings.get_setting("netfox/general/use_raw_commands", false) as bool static var _logger := NetfoxLogger._for_netfox("NetworkCommandServer") diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 829e5efa..834a2427 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -5,9 +5,12 @@ var _rb_input_properties := _PropertyPool.new() var _rb_state_properties := _PropertyPool.new() var _sync_state_properties := _PropertyPool.new() -var _rb_input_snapshots: Dictionary = {} # tick to Snapshot -var _rb_state_snapshots: Dictionary = {} # tick to Snapshot -var _sync_state_snapshots: Dictionary = {} # tick to Snapshot +var _rb_history_size := NetworkRollback.history_limit +var _sync_history_size := ProjectSettings.get_setting("netfox/state_synchronizer/history_limit", 64) as int + +var _rb_input_snapshots := _HistoryBuffer.new(_rb_history_size) +var _rb_state_snapshots := _HistoryBuffer.new(_rb_history_size) +var _sync_state_snapshots := _HistoryBuffer.new(_sync_history_size) static var _logger := NetfoxLogger._for_netfox("NetworkHistoryServer") @@ -63,32 +66,22 @@ func restore_rollback_state(tick: int) -> bool: func restore_synchronizer_state(tick: int) -> bool: return _restore(tick, _sync_state_snapshots) -func trim_history(earliest_tick: int) -> void: - var snapshot_pools := [_rb_input_snapshots, _rb_state_snapshots, _sync_state_snapshots] as Array[Dictionary] - - for snapshots in snapshot_pools: - while not snapshots.is_empty(): - var earliest_stored_tick := snapshots.keys().min() - if earliest_stored_tick >= earliest_tick: - break - snapshots.erase(earliest_stored_tick) - func get_rollback_input_snapshot(tick: int) -> Snapshot: - return _rb_input_snapshots.get(tick) + return _rb_input_snapshots.get_at(tick) func get_rollback_state_snapshot(tick: int) -> Snapshot: - return _rb_state_snapshots.get(tick) + return _rb_state_snapshots.get_at(tick) func get_synchronizer_state_snapshot(tick: int) -> Snapshot: - return _sync_state_snapshots.get(tick) + return _sync_state_snapshots.get_at(tick) -func merge_snapshot(snapshot: Snapshot, snapshots: Dictionary) -> Snapshot: +func merge_snapshot(snapshot: Snapshot, snapshots: _HistoryBuffer) -> Snapshot: var tick := snapshot.tick - if not snapshots.has(snapshot.tick): - snapshots[tick] = snapshot + if not snapshots.has_at(snapshot.tick): + snapshots.set_at(tick, snapshot) return snapshot - var stored_snapshot := snapshots[tick] as Snapshot + var stored_snapshot := snapshots.get_at(tick) as Snapshot stored_snapshot.merge(snapshot) return stored_snapshot @@ -118,13 +111,13 @@ func get_data_age_for(what: Node, tick: int) -> int: return tick - i return -1 -func _record(tick: int, snapshots: Dictionary, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: +func _record(tick: int, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: # Ensure snapshot - var snapshot := snapshots.get(tick) as Snapshot + var snapshot := snapshots.get_at(tick) as Snapshot var is_new := false - if snapshot == null: + if not snapshot: snapshot = Snapshot.new(tick) - snapshots[tick] = snapshot + snapshots.set_at(tick, snapshot) is_new = true # Record values @@ -152,14 +145,11 @@ func _record(tick: int, snapshots: Dictionary, property_pool: _PropertyPool, onl _logger.debug("Updates to%s state @%d: %s" % [" new" if is_new else "", tick, updates]) _logger.debug("Recorded state @%d: %s", [tick, snapshot]) -func _restore(tick: int, snapshots: Dictionary) -> bool: - # TODO: Prettier recreation of HistoryBuffer logic and / or reuse HistoryBuffer - if snapshots.is_empty() or tick < snapshots.keys().min(): +func _restore(tick: int, snapshots: _HistoryBuffer) -> bool: + if not snapshots.has_latest_at(tick): return false - while not snapshots.has(tick) and tick >= snapshots.keys().min(): - tick -= 1 - var snapshot := snapshots[tick] as Snapshot + var snapshot := snapshots.get_latest_at(tick) as Snapshot snapshot.apply() match snapshots: diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index ef5ad503..357fc08a 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -10,14 +10,14 @@ var _sync_owned_state_properties := _PropertyPool.new() var _visibility_filters := {} # Node to PeerVisibilityFilter -var _rb_enable_input_broadcast := ProjectSettings.get_setting("netfox/rollback/enable_input_broadcast", false) +var _rb_enable_input_broadcast := ProjectSettings.get_setting("netfox/rollback/enable_input_broadcast", false) as bool var _rb_enable_diffs := NetworkRollback.enable_diff_states -var _rb_full_interval := ProjectSettings.get_setting("netfox/rollback/full_state_interval", 24) +var _rb_full_interval := ProjectSettings.get_setting("netfox/rollback/full_state_interval", 24) as int var _rb_full_scheduler := _IntervalScheduler.new(_rb_full_interval) var _last_sync_state_sent := Snapshot.new(0) -var _sync_enable_diffs := ProjectSettings.get_setting("netfox/state_synchronizer/enable_diff_states", true) -var _sync_full_interval := ProjectSettings.get_setting("netfox/state_synchronizer/full_state_interval", 24) +var _sync_enable_diffs := ProjectSettings.get_setting("netfox/state_synchronizer/enable_diff_states", true) as bool +var _sync_full_interval := ProjectSettings.get_setting("netfox/state_synchronizer/full_state_interval", 24) as int var _sync_full_scheduler := _IntervalScheduler.new(_sync_full_interval) var _schemas := _NetworkSchema.new() diff --git a/test/netfox.internals/history-buffer.test.gd b/test/netfox.internals/history-buffer.test.gd index ee3883b3..1c031545 100644 --- a/test/netfox.internals/history-buffer.test.gd +++ b/test/netfox.internals/history-buffer.test.gd @@ -3,56 +3,61 @@ extends VestTest func get_suite_name() -> String: return "HistoryBuffer" -func test_get_closest_tick_should_return_negative_on_empty(): - # Given - var history_buffer := _HistoryBuffer.new() - - # When + then - expect_equal(history_buffer.get_closest_tick(16), -1) - -func test_get_closest_tick_should_return_earliest(): - # Given - var history_buffer := _HistoryBuffer.new() - history_buffer.set_snapshot(2, {}) - history_buffer.set_snapshot(4, {}) - history_buffer.set_snapshot(6, {}) - - # When + then - expect_equal(history_buffer.get_closest_tick(0), 2) - -func test_get_closest_tick_should_return_latest(): - # Given - var history_buffer := _HistoryBuffer.new() - history_buffer.set_snapshot(2, {}) - history_buffer.set_snapshot(4, {}) - history_buffer.set_snapshot(6, {}) - - # When + then - expect_equal(history_buffer.get_closest_tick(8), 6) - -func test_get_closest_tick_should_return_exact(): - # Given - var history_buffer := _HistoryBuffer.new() - history_buffer.set_snapshot(2, {}) - history_buffer.set_snapshot(4, {}) - history_buffer.set_snapshot(6, {}) - - # When + then - expect_equal(history_buffer.get_closest_tick(4), 4) - -func test_get_closest_tick_should_return_previous(): - # Given - var history_buffer := _HistoryBuffer.new() - history_buffer.set_snapshot(2, {}) - history_buffer.set_snapshot(4, {}) - history_buffer.set_snapshot(6, {}) - - # When + then - expect_equal(history_buffer.get_closest_tick(5), 4) - -func test_get_snapshot_should_return_null_on_unknown(): - # Given - var history_buffer := _HistoryBuffer.new() - - # When + then - expect_equal(history_buffer.get_snapshot(1), null) +func suite() -> void: + var empty_buffer := _HistoryBuffer.new() + var filled_buffer := _HistoryBuffer.of(16, { 2: "foo", 4: "bar", 8: "baz" }) + + define("get latest", func(): + test("should not have latest if empty", func(): + expect_not(empty_buffer.has_at(16)) + expect_not(empty_buffer.has_latest_at(16)) + ) + + test("should not have latest out of bounds", func(): + expect_not(filled_buffer.has_latest_at(0)) + ) + + test("should return self on known item", func(): + expect_equal(filled_buffer.get_latest_index_at(2), 2) + expect_equal(filled_buffer.get_latest_index_at(4), 4) + expect_equal(filled_buffer.get_latest_index_at(8), 8) + ) + + test("should return latest on unknown", func(): + expect_equal(filled_buffer.get_latest_index_at(3), 2) + expect_equal(filled_buffer.get_latest_index_at(5), 4) + expect_equal(filled_buffer.get_latest_index_at(9), 8) + ) + ) + + define("set_at()", func(): + test("should ignore behind tail", func(): + var buffer := filled_buffer.duplicate() + buffer.set_at(-2, 4) + expect_not(buffer.has_at(-2)) + ) + + test("should update prev buffer if in bounds", func(): + var buffer := filled_buffer.duplicate() + buffer.set_at(6, "quoo") + expect_not(buffer.has_at(7)) + expect(buffer.has_latest_at(7)) + expect_equal(buffer.get_latest_at(7), "quoo") + ) + + test("should update prev buffer if after head", func(): + var buffer := filled_buffer.duplicate() + buffer.set_at(14, "quoo") + expect_not(buffer.has_at(11)) + expect(buffer.has_latest_at(11)) + expect_equal(buffer.get_latest_at(11), "baz") + ) + + test("should jump if way after head", func(): + var buffer := filled_buffer.duplicate() + buffer.set_at(130, "quoo") + expect_not(buffer.has_at(8)) + expect_not(buffer.has_latest_at(8)) + expect_equal(buffer.size(), 1) + ) + ) From a6c0e1a6cd0f1d6c92bd4f67c82b6bfd41ae8324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 19 Jan 2026 23:01:16 +0100 Subject: [PATCH 64/95] down by a bunch of todos --- .../netfox/rollback/rollback-synchronizer.gd | 30 ++------------- addons/netfox/servers/data/snapshot.gd | 10 +++++ .../netfox/servers/network-history-server.gd | 37 +++++++++++-------- .../servers/network-synchronization-server.gd | 6 ++- 4 files changed, 38 insertions(+), 45 deletions(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 9aa03865..45141bfd 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -223,18 +223,8 @@ func has_input() -> bool: ## [br][br] ## The available input may be from the current tick, or from multiple ticks ago. ## This number of tick is the input's age. -## [br][br] -## Calling this when [member has_input] is false will yield an error. func get_input_age() -> int: - # TODO: Cache these after prepare tick? - var max_age := 0 - for input_node in _input_properties.get_subjects(): - var age := NetworkHistoryServer.get_data_age_for(input_node, NetworkRollback.tick) - max_age = maxi(age, max_age) - if age < 0: - # TODO: Error, somehow - return -1 - return max_age + return NetworkHistoryServer.get_input_age_for(_input_properties.get_subjects(), NetworkRollback.tick) ## Check if the current tick is predicted. ## [br][br] @@ -269,14 +259,7 @@ func ignore_prediction(node: Node) -> void: ## [br][br] ## Returns -1 if there's no known input. func get_last_known_input() -> int: - # TODO: Is there an easier way? - var max_age := 0 - var latest_tick := NetworkTime.tick + 1 - for input_node in _input_properties.get_subjects(): - var age := NetworkHistoryServer.get_data_age_for(input_node, latest_tick) - if age >= 0: - max_age = maxi(age, max_age) - return latest_tick - max_age + return NetworkHistoryServer.get_input_age_for(_input_properties.get_subjects(), NetworkTime.tick) ## Get the tick of the last known state. ## [br][br] @@ -285,14 +268,7 @@ func get_last_known_input() -> int: ## data may change as new input arrives. For peers that don't own state, this ## will be the tick of the latest state received from the state owner. func get_last_known_state() -> int: - # TODO: Is there an easier way? - var max_age := 0 - var latest_tick := NetworkTime.tick + 1 - for state_node in _state_properties.get_subjects(): - var age := NetworkHistoryServer.get_data_age_for(state_node, latest_tick) - if age >= 0: - max_age = maxi(age, max_age) - return latest_tick - max_age + return NetworkHistoryServer.get_state_age_for(_state_properties.get_subjects(), NetworkTime.tick) func _ready() -> void: if Engine.is_editor_hint(): diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index 9e34e642..5b076699 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -91,6 +91,16 @@ func apply() -> void: var value = _data[subject][property] (subject as Object).set_indexed(property, value) +func sanitize(sender: int) -> void: + var invalid_subjects := [] + for subject in _data: + if subject is Node: + if subject.get_multiplayer_authority() != sender: + invalid_subjects.append(subject) + + for subject in invalid_subjects: + _data.erase(invalid_subjects) + func has_subject(subject: Object, require_auth: bool = false) -> bool: if not _data.has(subject): return false diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 834a2427..24e62948 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -95,21 +95,11 @@ func merge_rollback_state(snapshot: Snapshot) -> Snapshot: func merge_synchronizer_state(snapshot: Snapshot) -> Snapshot: return merge_snapshot(snapshot, _sync_state_snapshots) -func get_data_age_for(what: Node, tick: int) -> int: - if _rb_state_snapshots.is_empty() or _rb_input_snapshots.is_empty(): - return -1 +func get_input_age_for(subjects: Array, tick: int) -> int: + return _get_age_for(subjects, tick, _rb_input_snapshots) - var earliest_tick := mini(_rb_state_snapshots.keys().min(), _rb_input_snapshots.keys().min()) - for i in range(tick, earliest_tick - 1, -1): - var input_snapshot := get_rollback_input_snapshot(i) - var state_snapshot := get_rollback_state_snapshot(i) - - var has_input := input_snapshot != null and input_snapshot.has_subject(what, true) - var has_state := state_snapshot != null and state_snapshot.has_subject(what, true) - - if has_input or has_state: - return tick - i - return -1 +func get_state_age_for(subjects: Array, tick: int) -> int: + return _get_age_for(subjects, tick, _rb_state_snapshots) func _record(tick: int, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: # Ensure snapshot @@ -129,8 +119,8 @@ func _record(tick: int, snapshots: _HistoryBuffer, property_pool: _PropertyPool, if only_auth and not is_auth: continue -# if not is_auth and snapshot.is_auth(subject): -# continue + if not is_auth and snapshot.is_auth(subject): + continue for property in property_pool.get_properties_of(subject): snapshot.record_property(subject, property) @@ -157,3 +147,18 @@ func _restore(tick: int, snapshots: _HistoryBuffer) -> bool: _rb_state_snapshots: _logger.debug("Restored state @%d: %s", [tick, snapshot]) return true + +func _get_age_for(subjects: Array, tick: int, snapshots: _HistoryBuffer) -> int: + var at := tick + + # Bounded while loop + for i in range(1024): + if not snapshots.has_latest_at(at): + return -1 + + at = snapshots.get_latest_index_at(at) + var snapshot := snapshots.get_at(at) as Snapshot + if snapshot.has_subjects(subjects, true): + return tick - at + + return -1 diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 357fc08a..38ce4a3d 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -260,7 +260,7 @@ func _handle_input(sender: int, data: PackedByteArray): _logger.trace("Received input snapshots: %s", [snapshots]) for snapshot in snapshots: - # TODO: Sanitize + snapshot.sanitize(sender) # TODO: Only merge inputs we don't have yet, so clients don't cheat by # overriding their earlier choices. Only emit signal for snapshots @@ -292,6 +292,7 @@ func _handle_full_sync(sender: int, data: PackedByteArray): buffer.data_array = data var snapshot := _dense_serializer.read_from(sender, _sync_state_properties, buffer, true) + snapshot.sanitize(sender) NetworkHistoryServer.merge_synchronizer_state(snapshot) _logger.trace("Ingested sync state: %s", [snapshot]) @@ -301,12 +302,13 @@ func _handle_diff_sync(sender: int, data: PackedByteArray): buffer.data_array = data var snapshot := _sparse_serializer.read_from(sender, _sync_state_properties, buffer) + snapshot.sanitize(sender) NetworkHistoryServer.merge_synchronizer_state(snapshot) _logger.trace("Ingested sync diff: %s", [snapshot]) func _ingest_state(sender: int, snapshot: Snapshot) -> void: - # TODO: Sanitize + snapshot.sanitize(sender) # _logger.debug("Received state snapshot: %s", [snapshot]) var merged := NetworkHistoryServer.merge_rollback_state(snapshot) From edc1b93403906d521306f9e291d2cc338a859fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 20 Jan 2026 00:03:59 +0100 Subject: [PATCH 65/95] less todos + prevent input rewriting --- addons/netfox.internals/set.gd | 3 + addons/netfox/rollback/network-rollback.gd | 49 +++++++--------- .../netfox/rollback/rollback-synchronizer.gd | 8 ++- addons/netfox/servers/data/snapshot.gd | 38 ++++++++----- .../netfox/servers/network-history-server.gd | 46 +++++++++------ .../servers/network-synchronization-server.gd | 16 ++---- .../servers/rollback-simulation-server.gd | 11 ++++ project.godot | 3 +- test/netfox/servers/data/snapshot.test.gd | 57 +++++++++++-------- 9 files changed, 135 insertions(+), 96 deletions(-) diff --git a/addons/netfox.internals/set.gd b/addons/netfox.internals/set.gd index aaaa21bf..7ae5b093 100644 --- a/addons/netfox.internals/set.gd +++ b/addons/netfox.internals/set.gd @@ -46,6 +46,9 @@ func equals(other) -> bool: func _to_string(): return "Set" + str(values()) +func _to_vest(): + return _data.keys() + func _iter_init(arg) -> bool: _iterator_idx = 0 return _can_iterate() diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index f5aa056c..c0825596 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -85,7 +85,6 @@ var display_tick: int: ## with input latency higher than network latency. ## [br][br] ## [i]read-only[/i], you can change this in the project settings - var input_delay: int: get: return _input_delay @@ -100,7 +99,6 @@ var input_delay: int: ## in transmission, the next (n-1) packets will contain the data for it. ## [br][br] ## [i]read-only[/i], you can change this in the project settings - var input_redundancy: int: get: return max(1, _input_redundancy) @@ -168,7 +166,6 @@ var _rollback_stage: String = "" var _is_rollback: bool = false var _simulated_nodes: _Set = _Set.new() var _mutated_nodes: Dictionary = {} -var _input_submissions: Dictionary = {} var _earliest_input := -1 var _latest_state := -1 @@ -271,33 +268,34 @@ func is_just_mutated(target: Object, p_tick: int = tick) -> bool: return false ## Register that a node has submitted its input for a specific tick -# TODO: Make sure this works -func register_input_submission(root_node: Node, tick: int) -> void: - if not _input_submissions.has(root_node): - _input_submissions[root_node] = tick - else: - _input_submissions[root_node] = maxi(_input_submissions[root_node], tick) +## @deprecated +func register_input_submission(_node: Node, _tick: int) -> void: + pass -## Get the latest input tick submitted by a specific root node +## Get the latest input tick submitted for a specific node ## [br][br] ## Returns [code]-1[/code] if no input was submitted for the node, ever. -# TODO: Make sure this works -func get_latest_input_tick(root_node: Node) -> int: - if _input_submissions.has(root_node): - return _input_submissions[root_node] - return -1 +func get_latest_input_tick(node: Node) -> int: + var input_nodes := RollbackSimulationServer.get_inputs_of(node) + var reference_tick := NetworkTime.tick + var input_age := NetworkHistoryServer.get_input_age_for(input_nodes, reference_tick) + + if input_age >= 0: + return reference_tick - input_age + else: + return -1 ## Check if a node has submitted input for a specific tick (or later) -# TODO: Make sure this works -func has_input_for_tick(root_node: Node, tick: int) -> bool: - return _input_submissions.has(root_node) and _input_submissions[root_node] >= tick +func has_input_for_tick(node: Node, tick: int) -> bool: + var latest_input := get_latest_input_tick(node) + return latest_input != -1 and latest_input >= tick ## Free all input submission data for a node ## [br][br] ## Use this once the node is freed. -# TODO: Make sure this works -func free_input_submission_data_for(root_node: Node) -> void: - _input_submissions.erase(root_node) +## @deprecated +func free_input_submission_data_for(_node: Node) -> void: + pass func _ready(): NetfoxLogger.register_tag(_get_rollback_tag) @@ -343,20 +341,17 @@ func _rollback() -> void: # Ask all rewindables to submit their earliest inputs _resim_from = NetworkTime.tick before_loop.emit() - - # TODO: Move to RollbackSimulationServer? + var range_source = "notif" if _earliest_input >= 0 and _earliest_input <= _resim_from: range_source = "earliest input" - _resim_from = mini(_resim_from, _earliest_input) + _resim_from = _earliest_input if _latest_state >= 0 and _latest_state <= _resim_from: range_source = "latest state" - _resim_from = mini(_resim_from, _latest_state) + _resim_from = _latest_state _resim_from = mini(_resim_from, NetworkTime.tick - 1) _logger.trace("Simulating range @%d>@%d using %s", [_resim_from, NetworkTime.tick, range_source]) -# _resim_from = maxi(1, history_start + 1) - # Only set _is_rollback *after* emitting before_loop _is_rollback = true _rollback_stage = _STAGE_BEFORE diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 45141bfd..ef2bbd4d 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -244,8 +244,12 @@ func is_predicting() -> bool: ## Call this when the input is too old to base predictions on. This call is ## ignored if [member enable_prediction] is false. func ignore_prediction(node: Node) -> void: - # TODO: Does this even make sense in its current form? - return + # Not needed, netfox records properties as non-auth if predicting + # Once the data is received from the owner, it won't be overwritten by + # predictions. + # + # This method may see some use again, otherwise it will be deprecated. + pass ## Get the tick of the last known input. ## [br][br] diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index 5b076699..b2d526ad 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -3,7 +3,7 @@ class_name Snapshot var tick: int var _data := {} # object to (property to variant) -var _is_authoritative := {} # object to bool, absent means false +var _auth_subjects := _Set.new() static func make_patch(from: Snapshot, to: Snapshot, tick: int = to.tick) -> Snapshot: var patch := Snapshot.new(tick) @@ -45,11 +45,14 @@ func _init(p_tick: int): func duplicate() -> Snapshot: var result := Snapshot.new(tick) result._data = _data.duplicate(true) - result._is_authoritative = _is_authoritative.duplicate() + result._auth_subjects = _auth_subjects.duplicate() return result func set_auth(subject: Object, is_auth: bool) -> void: - _is_authoritative[subject] = is_auth + if is_auth: + _auth_subjects.add(subject) + else: + _auth_subjects.erase(subject) func set_property(subject: Object, property: NodePath, value: Variant) -> void: if not _data.has(subject): @@ -71,20 +74,28 @@ func has_property(subject: Object, property: NodePath) -> bool: return false return true -func merge(snapshot: Snapshot) -> void: +func merge(snapshot: Snapshot) -> bool: + var has_changed := false + for subject in snapshot._data: if not _data.has(subject): # We have no data of the subject, copy all _data[subject] = snapshot._data[subject].duplicate() set_auth(subject, snapshot.is_auth(subject)) + has_changed = true continue if snapshot.is_auth(subject) or not is_auth(subject): var own_props := _data[subject] as Dictionary var their_props := snapshot._data[subject] as Dictionary + + has_changed = has_changed or own_props != their_props + own_props.merge(their_props, true) set_auth(subject, snapshot.is_auth(subject)) + return has_changed + func apply() -> void: for subject in _data: for property in _data[subject]: @@ -104,7 +115,7 @@ func sanitize(sender: int) -> void: func has_subject(subject: Object, require_auth: bool = false) -> bool: if not _data.has(subject): return false - if require_auth and not _is_authoritative.get(subject, false): + if require_auth and not is_auth(subject): return false return true @@ -114,10 +125,11 @@ func has_subjects(subjects: Array, require_auth: bool = false) -> bool: return false return true -func get_properties_of_node(node: Node) -> Array[NodePath]: - var properties := [] as Array[NodePath] - properties.assign(_data.get(node, [])) - return properties +func get_subjects() -> Array: + return _data.keys() + +func get_auth_subjects() -> Array: + return _auth_subjects.values() func is_empty() -> bool: return _data.is_empty() @@ -129,11 +141,11 @@ func size() -> int: return result func is_auth(subject: Object) -> bool: - return _is_authoritative.get(subject, false) + return _auth_subjects.has(subject) func equals(other) -> bool: if other is Snapshot: - return tick == other.tick and _data == other._data and _is_authoritative == other._is_authoritative + return tick == other.tick and _data == other._data and _auth_subjects.equals(other._auth_subjects) else: return false @@ -142,7 +154,7 @@ func _to_string() -> String: for subject in _data: for property in _data[subject]: var value = _data[subject][property] - result += ", %s:%s(%s): %s" % [subject, property, _is_authoritative.get(subject, false), value] + result += ", %s:%s(%s): %s" % [subject, property, is_auth(subject), value] result += ")" return result @@ -150,5 +162,5 @@ func _to_vest(): return { "tick": tick, "data": _data, - "is_auth": _is_authoritative + "auth_subjects": _auth_subjects } diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 24e62948..66386dc2 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -75,25 +75,14 @@ func get_rollback_state_snapshot(tick: int) -> Snapshot: func get_synchronizer_state_snapshot(tick: int) -> Snapshot: return _sync_state_snapshots.get_at(tick) -func merge_snapshot(snapshot: Snapshot, snapshots: _HistoryBuffer) -> Snapshot: - var tick := snapshot.tick - if not snapshots.has_at(snapshot.tick): - snapshots.set_at(tick, snapshot) - return snapshot - - var stored_snapshot := snapshots.get_at(tick) as Snapshot - stored_snapshot.merge(snapshot) +func merge_rollback_input(snapshot: Snapshot) -> bool: + return _merge(snapshot, _rb_input_snapshots, true) - return stored_snapshot +func merge_rollback_state(snapshot: Snapshot) -> bool: + return _merge(snapshot, _rb_state_snapshots) -func merge_rollback_input(snapshot: Snapshot) -> Snapshot: - return merge_snapshot(snapshot, _rb_input_snapshots) - -func merge_rollback_state(snapshot: Snapshot) -> Snapshot: - return merge_snapshot(snapshot, _rb_state_snapshots) - -func merge_synchronizer_state(snapshot: Snapshot) -> Snapshot: - return merge_snapshot(snapshot, _sync_state_snapshots) +func merge_synchronizer_state(snapshot: Snapshot) -> bool: + return _merge(snapshot, _sync_state_snapshots) func get_input_age_for(subjects: Array, tick: int) -> int: return _get_age_for(subjects, tick, _rb_input_snapshots) @@ -148,6 +137,29 @@ func _restore(tick: int, snapshots: _HistoryBuffer) -> bool: return true +func _merge(snapshot: Snapshot, snapshots: _HistoryBuffer, reverse: bool = false) -> bool: + var tick := snapshot.tick + + if not snapshots.has_at(snapshot.tick): + snapshots.set_at(tick, snapshot) + return true + + var original_snapshot := snapshots.get_at(tick) as Snapshot + if reverse: + var original_subjects := original_snapshot.get_auth_subjects() + var incoming_subjects := snapshot.get_auth_subjects() + + # Merge the original snapshot on top of the incoming + # This prevents players from changing history, e.g. rewrite their past + # inputs + snapshots.set_at(tick, snapshot) + snapshot.merge(original_snapshot) + + # Only return true if we've received inputs for a new node + return incoming_subjects.any(func(it): return not original_subjects.has(it)) + else: + return original_snapshot.merge(snapshot) + func _get_age_for(subjects: Array, tick: int, snapshots: _HistoryBuffer) -> int: var at := tick diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 38ce4a3d..d2c9a1eb 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -91,12 +91,11 @@ func deregister(node: Node) -> void: _visibility_filters.erase(node) func is_node_visible_to(peer: int, node: Node) -> bool: - # TODO: Cache visibilities var filter := _visibility_filters.get(node) as PeerVisibilityFilter if not filter: return true else: - return filter.get_visibility_for(peer) + return filter.get_visible_peers().has(peer) # TODO: Make this testable somehow, I beg of you func synchronize_input(tick: int) -> void: @@ -257,18 +256,13 @@ func _handle_input(sender: int, data: PackedByteArray): buffer.data_array = data var snapshots := _redundant_serializer.read_from(sender, _rb_input_properties, buffer, true) - _logger.trace("Received input snapshots: %s", [snapshots]) for snapshot in snapshots: snapshot.sanitize(sender) - # TODO: Only merge inputs we don't have yet, so clients don't cheat by - # overriding their earlier choices. Only emit signal for snapshots - # that contain new input. - var merged := NetworkHistoryServer.merge_rollback_input(snapshot) - _logger.debug("Ingested input: %s", [snapshot]) - - on_input.emit(snapshot) + if NetworkHistoryServer.merge_rollback_input(snapshot): + _logger.debug("Ingested input: %s", [snapshot]) + on_input.emit(snapshot) func _handle_full_state(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() @@ -311,7 +305,7 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: snapshot.sanitize(sender) # _logger.debug("Received state snapshot: %s", [snapshot]) - var merged := NetworkHistoryServer.merge_rollback_state(snapshot) + NetworkHistoryServer.merge_rollback_state(snapshot) _logger.debug("Ingested state: %s", [snapshot]) on_state.emit(snapshot) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 2345223b..c1a3b7e4 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -47,6 +47,7 @@ func deregister_input(node: Node, input: Node) -> void: func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: var result: Array[Node] = [] + var tick := input_snapshot.tick if not input_snapshot: return [] @@ -59,6 +60,11 @@ func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: result.append(node) continue + if NetworkRollback.is_mutated(node, tick): + # Node is mutated, must simulate + result.append(node) + continue + if not input_snapshot.has_subjects(inputs, true): # We don't have input for node, don't simulate continue @@ -157,3 +163,8 @@ func get_controlled_by(input: Node) -> Array[Node]: var result := [] as Array[Node] result.assign(_input_graph.get_linked_from(input)) return result + +func get_inputs_of(node: Node) -> Array[Node]: + var result := [] as Array[Node] + result.assign(_input_graph.get_linked_to(node)) + return result diff --git a/project.godot b/project.godot index d352c1db..3a27c927 100644 --- a/project.godot +++ b/project.godot @@ -133,8 +133,7 @@ escape={ general/clear_settings=false time/tickrate=24 extras/auto_tile_windows=true -autoconnect/enabled=false -logging/log_level=3 +autoconnect/enabled=true [rendering] diff --git a/test/netfox/servers/data/snapshot.test.gd b/test/netfox/servers/data/snapshot.test.gd index a4dc84e7..d701180d 100644 --- a/test/netfox/servers/data/snapshot.test.gd +++ b/test/netfox/servers/data/snapshot.test.gd @@ -8,50 +8,59 @@ func suite() -> void: define("make_patch()", func(): test("should return empty on same", func(): - var snapshot := Snapshot.new(1) - snapshot.set_property(node, "position", Vector3.ZERO, true) - snapshot.set_property(node, "scale", Vector3.ONE, true) + var snapshot := Snapshot.of(0, [ + [node, "position", Vector3.ZERO], + [node, "scale", Vector3.ONE] + ], [node]) expect_empty(Snapshot.make_patch(snapshot, snapshot)) ) test("should include differing property", func(): - var from := Snapshot.new(1) - from.set_property(node, "position", Vector3.ZERO, true) - from.set_property(node, "scale", Vector3.ONE, true) + var from := Snapshot.of(0, [ + [node, "position", Vector3.ZERO], + [node, "scale", Vector3.ONE] + ], [node]) - var to := Snapshot.new(2) - to.set_property(node, "position", Vector3.ONE, true) - to.set_property(node, "scale", Vector3.ONE, true) + var to := Snapshot.of(0, [ + [node, "position", Vector3.ONE], + [node, "scale", Vector3.ONE] + ], [node]) - var expected := Snapshot.new(2) - expected.set_property(node, "position", Vector3.ONE, true) + var expected := Snapshot.of(0, [ + [node, "position", Vector3.ONE] + ], [node]) expect_equal(Snapshot.make_patch(from, to), expected) ) test("should include new property", func(): - var from := Snapshot.new(1) - from.set_property(node, "position", Vector3.ZERO, true) + var from := Snapshot.of(0, [ + [node, "position", Vector3.ZERO] + ], [node]) - var to := Snapshot.new(2) - to.set_property(node, "position", Vector3.ZERO, true) - to.set_property(node, "scale", Vector3.ONE, true) + var to := Snapshot.of(0, [ + [node, "position", Vector3.ZERO], + [node, "scale", Vector3.ONE] + ], [node]) - var expected := Snapshot.new(2) - expected.set_property(node, "scale", Vector3.ONE, true) + var expected := Snapshot.of(0, [ + [node, "scale", Vector3.ONE] + ], [node]) expect_equal(Snapshot.make_patch(from, to), expected) ) test("patch should yield `to` on merge", func(): - var from := Snapshot.new(1) - from.set_property(node, "position", Vector3.ZERO, true) - from.set_property(node, "scale", Vector3.ONE, true) + var from := Snapshot.of(0, [ + [node, "position", Vector3.ZERO], + [node, "scale", Vector3.ONE] + ], [node]) - var to := Snapshot.new(2) - to.set_property(node, "position", Vector3.ONE, true) - to.set_property(node, "scale", Vector3.ONE, true) + var to := Snapshot.of(0, [ + [node, "position", Vector3.ONE], + [node, "scale", Vector3.ONE] + ], [node]) var patch := Snapshot.make_patch(from, to) var applied := from.duplicate() From 3218b99ee67b8c6cc168dc5f3518fe46886fa5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 20 Jan 2026 00:41:28 +0100 Subject: [PATCH 66/95] more serializer tests --- .../serializers/base-snapshot-serializer.gd | 11 ++- .../serializers/dense-snapshot-serializer.gd | 4 +- .../serializers/sparse-snapshot-serializer.gd | 13 ++-- .../dense-snapshot-serializer.test.gd | 74 ++++++++++++++++++- .../serializers/snapshot-serializer-test.gd | 8 ++ .../sparse-snapshot-serializer.test.gd | 38 ++++++++++ test/netfox/servers/testing-command-server.gd | 7 ++ 7 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 test/netfox/servers/testing-command-server.gd diff --git a/addons/netfox/serializers/base-snapshot-serializer.gd b/addons/netfox/serializers/base-snapshot-serializer.gd index 0b273af3..60091a80 100644 --- a/addons/netfox/serializers/base-snapshot-serializer.gd +++ b/addons/netfox/serializers/base-snapshot-serializer.gd @@ -1,16 +1,18 @@ extends RefCounted class_name _BaseSnapshotSerializer +var _identity_server: _NetworkIdentityServer var _schemas: _NetworkSchema static var _default_filter := func(subject: Object): return true static var _logger := NetfoxLogger._for_netfox("DenseSnapshotSerializer") -func _init(p_schemas: _NetworkSchema): +func _init(p_schemas: _NetworkSchema, p_identity_server: _NetworkIdentityServer = null): assert(p_schemas != null, "Missing schemas!") # Intentionally storing reference, so it can be modified from the outside # e.g. RollbackSynchronizerServer adds a property _schemas = p_schemas + _identity_server = p_identity_server func _write_property(node: Node, property: NodePath, value: Variant, buffer: StreamPeerBuffer) -> void: _schemas.encode(node, property, value, buffer) @@ -20,7 +22,7 @@ func _read_property(node: Node, property: NodePath, buffer: StreamPeerBuffer) -> func _write_identifier(subject: Object, peer: int, buffer: StreamPeerBuffer) -> Error: var netref := NetworkSchemas._netref() - var identifier := NetworkIdentityServer.get_identifier_of(subject) + var identifier := _get_identity_server().get_identifier_of(subject) if not identifier: _logger.error("Can't synchronize %s, identifier missing!", [subject]) return ERR_DOES_NOT_EXIST @@ -28,3 +30,8 @@ func _write_identifier(subject: Object, peer: int, buffer: StreamPeerBuffer) -> var idref := identifier.reference_for(peer) netref.encode(idref, buffer) return OK + +func _get_identity_server() -> _NetworkIdentityServer: + if not _identity_server: + _identity_server = NetworkIdentityServer + return _identity_server diff --git a/addons/netfox/serializers/dense-snapshot-serializer.gd b/addons/netfox/serializers/dense-snapshot-serializer.gd index 68a9971f..8df7cfa6 100644 --- a/addons/netfox/serializers/dense-snapshot-serializer.gd +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd @@ -69,17 +69,15 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] # Resolve to identifier - var identifier := NetworkIdentityServer.resolve_reference(peer, idref) + var identifier := _get_identity_server().resolve_reference(peer, idref) if not identifier: # TODO(#???): Handle unknown IDs gracefully - # TODO: Test that unknown nodes are INDEED SKIPPED _logger.warning("Received unknown identity reference %s, skipping data", [idref]) continue var node := identifier.get_subject() as Node # Read properties for property in properties.get_properties_of(node): - # TODO: Test if less bytes remain than an entire property ( e.g. 2 bytes ) if node_buffer.get_available_bytes() == 0: break var value := _read_property(node, property, node_buffer) diff --git a/addons/netfox/serializers/sparse-snapshot-serializer.gd b/addons/netfox/serializers/sparse-snapshot-serializer.gd index a07a4c53..a241e51b 100644 --- a/addons/netfox/serializers/sparse-snapshot-serializer.gd +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd @@ -45,7 +45,7 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, filter: changed_bits.set_bit(i) var value := snapshot.get_property(node, property) _write_property(node, property, value, node_buffer) - + varuint.encode(node_buffer.data_array.size(), buffer) # Node props len varbits.encode(changed_bits, buffer) # Changed prop bits buffer.put_data(node_buffer.data_array) # Changed props @@ -62,27 +62,26 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i var varuint := NetworkSchemas.varuint() var varbits := NetworkSchemas._varbits() var node_buffer := StreamPeerBuffer.new() - + # Grab ticks var tick := buffer.get_u32() var snapshot := Snapshot.new(tick) - + while buffer.get_available_bytes() > 0: # Read header, including identity reference var idref := netref.decode(buffer) as _NetworkIdentityReference var node_data_size := varuint.decode(buffer) as int var changed_bits := varbits.decode(buffer) as _Bitset node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] - + # Resolve to identifier - var identifier := NetworkIdentityServer.resolve_reference(peer, idref) + var identifier := _get_identity_server().resolve_reference(peer, idref) if not identifier: # TODO(#???): Handle unknown IDs gracefully - # TODO: Test that unknown nodes are INDEED SKIPPED _logger.warning("Received unknown identity reference %s, skipping data", [idref]) break var node := identifier.get_subject() as Node - + # Read changed properties var node_props := properties.get_properties_of(node) for idx in changed_bits.get_set_indices(): diff --git a/test/netfox/serializers/dense-snapshot-serializer.test.gd b/test/netfox/serializers/dense-snapshot-serializer.test.gd index 5f560415..c19416fd 100644 --- a/test/netfox/serializers/dense-snapshot-serializer.test.gd +++ b/test/netfox/serializers/dense-snapshot-serializer.test.gd @@ -8,9 +8,7 @@ func suite() -> void: var schema := _NetworkSchema.new() var serializer := _DenseSnapshotSerializer.new(schema) - var subject := Node3D.new() - Vest.get_tree().root.add_child.call_deferred(subject) - await subject.ready + var subject := await get_subject() NetworkIdentityServer.register_node(subject) var snapshot := Snapshot.of(0, [ @@ -32,3 +30,73 @@ func suite() -> void: Vest.message("Serialized %d props to %d bytes" % [snapshot.size(), serialized.size()]) ) + + test("should ignore unknown identifiers", func(): + var schema := _NetworkSchema.new() + var writer_identity_server := _NetworkIdentityServer.new(TestingCommandServer.new()) + var reader_identity_server := _NetworkIdentityServer.new(TestingCommandServer.new()) + + Vest.get_tree().root.add_child.call_deferred(writer_identity_server) + Vest.get_tree().root.add_child.call_deferred(reader_identity_server) + await writer_identity_server.ready; await reader_identity_server.ready + + var writer_serializer := _DenseSnapshotSerializer.new(schema, writer_identity_server) + var reader_serializer := _DenseSnapshotSerializer.new(schema, reader_identity_server) + + var known_subject := await get_subject() + var unknown_subject := await get_subject() + + var writer_props := _PropertyPool.of([[known_subject, "position"], [unknown_subject, "position"]]) + var reader_props := _PropertyPool.of([[known_subject, "position"]]) + + writer_identity_server.register_node(known_subject) + writer_identity_server.register_node(unknown_subject) + + reader_identity_server.register_node(known_subject) + + var snapshot := Snapshot.of(0, [ + [unknown_subject, "position", Vector3.ZERO], + [known_subject, "position", Vector3.ZERO] + ], [known_subject, unknown_subject]) + + var expected := Snapshot.of(0, [ + [known_subject, "position", Vector3.ZERO] + ], [known_subject]) + + var serialized := writer_serializer.write_for(1, snapshot, writer_props) + var deserialized := reader_serializer.read_from(1, reader_props, to_buffer(serialized)) + + expect_equal(deserialized, expected) + ) + + test("should handle missing data", func(): + var schema := _NetworkSchema.new() + var serializer := _DenseSnapshotSerializer.new(schema) + + var subject := await get_subject() + NetworkIdentityServer.register_node(subject) + + var snapshot := Snapshot.of(0, [ + [subject, "position", Vector3.ZERO], + [subject, "quaternion", Quaternion.from_euler(Vector3.ONE)], + [subject, "scale", Vector3.ONE] + ], [subject]) + + var props := _PropertyPool.of([ + [subject, "position"], + [subject, "quaternion"], + [subject, "scale"] + ]) + + var expected := Snapshot.of(0, [ + [subject, "position", Vector3.ZERO], + [subject, "quaternion", Quaternion.from_euler(Vector3.ONE)], + [subject, "scale", null] + ], [subject]) + + var serialized := serializer.write_for(1, snapshot, props) + serialized = serialized.slice(0, serialized.size() - 4) + var deserialized := serializer.read_from(1, props, to_buffer(serialized)) + + expect_equal(deserialized, expected) + ) diff --git a/test/netfox/serializers/snapshot-serializer-test.gd b/test/netfox/serializers/snapshot-serializer-test.gd index cba516f6..d13ce204 100644 --- a/test/netfox/serializers/snapshot-serializer-test.gd +++ b/test/netfox/serializers/snapshot-serializer-test.gd @@ -9,3 +9,11 @@ func to_buffer(data: PackedByteArray) -> StreamPeerBuffer: var buffer := StreamPeerBuffer.new() buffer.data_array = data return buffer + +func get_subject() -> Node3D: + var subject := Node3D.new() + + Vest.get_tree().root.add_child.call_deferred(subject) + await subject.ready + + return subject diff --git a/test/netfox/serializers/sparse-snapshot-serializer.test.gd b/test/netfox/serializers/sparse-snapshot-serializer.test.gd index 1feecf2e..6ae87098 100644 --- a/test/netfox/serializers/sparse-snapshot-serializer.test.gd +++ b/test/netfox/serializers/sparse-snapshot-serializer.test.gd @@ -32,3 +32,41 @@ func suite() -> void: Vest.message("Serialized %d props to %d bytes" % [snapshot.size(), serialized.size()]) ) + + test("should ignore unknown identifiers", func(): + var schema := _NetworkSchema.new() + var writer_identity_server := _NetworkIdentityServer.new(TestingCommandServer.new()) + var reader_identity_server := _NetworkIdentityServer.new(TestingCommandServer.new()) + + Vest.get_tree().root.add_child.call_deferred(writer_identity_server) + Vest.get_tree().root.add_child.call_deferred(reader_identity_server) + await writer_identity_server.ready; await reader_identity_server.ready + + var writer_serializer := _SparseSnapshotSerializer.new(schema, writer_identity_server) + var reader_serializer := _SparseSnapshotSerializer.new(schema, reader_identity_server) + + var known_subject := await get_subject() + var unknown_subject := await get_subject() + + var writer_props := _PropertyPool.of([[known_subject, "position"], [unknown_subject, "position"]]) + var reader_props := _PropertyPool.of([[known_subject, "position"]]) + + writer_identity_server.register_node(known_subject) + writer_identity_server.register_node(unknown_subject) + + reader_identity_server.register_node(known_subject) + + var snapshot := Snapshot.of(0, [ + [unknown_subject, "position", Vector3.ZERO], + [known_subject, "position", Vector3.ZERO] + ], [known_subject, unknown_subject]) + + var expected := Snapshot.of(0, [ + [known_subject, "position", Vector3.ZERO] + ], [known_subject]) + + var serialized := writer_serializer.write_for(1, snapshot, writer_props) + var deserialized := reader_serializer.read_from(1, reader_props, to_buffer(serialized)) + + expect_equal(deserialized, expected) + ) diff --git a/test/netfox/servers/testing-command-server.gd b/test/netfox/servers/testing-command-server.gd new file mode 100644 index 00000000..885ce28e --- /dev/null +++ b/test/netfox/servers/testing-command-server.gd @@ -0,0 +1,7 @@ +extends _NetworkCommandServer +class_name TestingCommandServer + +var commands_sent := [] as Array[Array] + +func send_command(idx: int, data: PackedByteArray, target_peer: int = 0, mode: MultiplayerPeer.TransferMode = MultiplayerPeer.TRANSFER_MODE_RELIABLE, channel: int = 0) -> void: + commands_sent.append([idx, data, target_peer, mode, channel]) From 2da31b0dd45040c564f67c4f3056ec1a7b55e9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 20 Jan 2026 00:55:04 +0100 Subject: [PATCH 67/95] almost done w/ tests --- .../servers/rollback-simulation-server.gd | 1 - .../rollback-simulation-server.test.gd | 114 ++++++++++++++++++ test/rollback-simulation-server.test.gd | 53 -------- 3 files changed, 114 insertions(+), 54 deletions(-) create mode 100644 test/netfox/servers/rollback-simulation-server.test.gd delete mode 100644 test/rollback-simulation-server.test.gd diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index c1a3b7e4..4eb1ca8e 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -73,7 +73,6 @@ func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: return result -# TODO: *Thorough* test for node predict rules func is_predicting(input_snapshot: Snapshot, node: Node) -> bool: var input_nodes := [] as Array[Node] input_nodes.assign(_input_graph.get_linked_to(node)) diff --git a/test/netfox/servers/rollback-simulation-server.test.gd b/test/netfox/servers/rollback-simulation-server.test.gd new file mode 100644 index 00000000..f6384d26 --- /dev/null +++ b/test/netfox/servers/rollback-simulation-server.test.gd @@ -0,0 +1,114 @@ +extends VestTest + +func get_suite_name() -> String: + return "RollbackSimulationServer" + +func before_case(__): + # Makes sure local peer is 1, otherwise identifiers get random local IDs + Vest.get_tree().root.multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() + +func suite() -> void: + define("is_predicting()", func(): + test("should predict non-owned node", func(): + var state_node := await get_node() + var input_node := await get_node() + var input_snapshot := Snapshot.of(1, [[input_node, "name", "Input"]], []) + + state_node.set_multiplayer_authority(2) + RollbackSimulationServer.register_input_for(state_node, input_node) + + expect(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) + ) + + test("should predict owned node without input", func(): + var input_snapshot := Snapshot.new(0) + var state_node := await get_node() + var input_node := await get_node() + + RollbackSimulationServer.register_input_for(state_node, input_node) + + expect(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) + ) + + test("should predict non-owned inputless", func(): + var input_snapshot := Snapshot.new(0) + var state_node := await get_node() + + state_node.set_multiplayer_authority(2) + + expect(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) + ) + + test("should not predict owned inputless", func(): + var input_snapshot := Snapshot.new(0) + var state_node := await get_node() + + expect_not(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) + ) + + test("should not predict owned with input", func(): + var state_node := await get_node() + var input_node := await get_node() + var input_snapshot := Snapshot.of(1, [[input_node, "name", "Input"]], [input_node]) + + RollbackSimulationServer.register_input_for(state_node, input_node) + + expect_not(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) + ) + ) + + define("get_nodes_to_simulate()", func(): + test("should not simulate without input", func(): + var node := RewindableNode.new() + var input_node := Node.new() + + var server := _RollbackSimulationServer.new() + server.register(node._rollback_tick) + server.register_input_for(node, input_node) + + var snapshot := Snapshot.new(1) + + expect_empty(server.get_nodes_to_simulate(snapshot)) + ) + + test("should simulate with input", func(): + var node := RewindableNode.new() + var input_node := Node.new() + + var server := _RollbackSimulationServer.new() + server.register(node._rollback_tick) + server.register_input_for(node, input_node) + + var snapshot := Snapshot.new(1) + snapshot.set_property(input_node, "editor_description", "Test input node") + snapshot.set_auth(input_node, true) + + expect_equal(server.get_nodes_to_simulate(snapshot), [node]) + ) + + test("should simulate mutated", func(): + var node := RewindableNode.new() + var input_node := Node.new() + + var server := _RollbackSimulationServer.new() + server.register(node._rollback_tick) + server.register_input_for(node, input_node) + + var snapshot := Snapshot.new(1) + NetworkRollback.mutate(node) + + expect_equal(server.get_nodes_to_simulate(snapshot), [node]) + ) + ) + +class RewindableNode extends Node: + func _rollback_tick(_dt, _t, _if) -> void: + pass + +func get_node() -> Node: + var node := Node.new() + + Vest.get_tree().root.add_child.call_deferred(node) + await node.ready + + return node diff --git a/test/rollback-simulation-server.test.gd b/test/rollback-simulation-server.test.gd deleted file mode 100644 index 0842255a..00000000 --- a/test/rollback-simulation-server.test.gd +++ /dev/null @@ -1,53 +0,0 @@ -extends VestTest - -func get_suite_name() -> String: - return "RollbackSimulationServer" - -func suite() -> void: - define("is_predicting()", func(): - test("should predict non-owned node", func(): todo()) - test("should predict owned node without input", func(): todo()) - test("should predict non-owned inputless", func(): todo()) - test("should not predict owned inputless", func(): todo()) - test("should not predict owned with input", func(): todo()) - ) - - define("is_predicting_current()", func(): - test("should use current node", func(): - # Setup a predicted and non-predicted node - # Should return correct value inside `_rollback_tick()` - # Maybe create a class that takes a rollback tick callback as param - todo() - ) - ) - - test("should not simulate without input", func(): - var node := RewindableNode.new() - var input_node := Node.new() - - var server := _RollbackSimulationServer.new() - server.register(node._rollback_tick) - server.register_input_for(node, input_node) - - var snapshot := Snapshot.new(1) - - expect_empty(server.get_nodes_to_simulate(snapshot)) - ) - - test("should simulate with input", func(): - var node := RewindableNode.new() - var input_node := Node.new() - - var server := _RollbackSimulationServer.new() - server.register(node._rollback_tick) - server.register_input_for(node, input_node) - - var snapshot := Snapshot.new(1) - snapshot.set_property(input_node, "editor_description", "Test input node", true) - - expect_equal(server.get_nodes_to_simulate(snapshot), [node]) - ) - -class RewindableNode extends Node: - func _rollback_tick(_dt, _t, _if) -> void: - pass From fc875e2bd6d7395aa3015fa541dad7f82f4ae4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 20 Jan 2026 22:53:06 +0100 Subject: [PATCH 68/95] some strides and cleanups in testing --- addons/netfox.internals/set.gd | 5 ++ .../redundant-snapshot-serializer.gd | 4 +- .../servers/network-synchronization-server.gd | 54 +++++++++--- .../servers/rollback-simulation-server.gd | 9 ++ test/netfox/rollback-synchronizer.test.gd | 1 + test/netfox/rollback/network-rollback.test.gd | 30 ------- test/netfox/servers/data/snapshot.test.gd | 82 ++++++++++++++++--- .../network-synchronization-server.test.gd | 58 +++++++++++++ test/netfox/servers/testing-servers.gd | 52 ++++++++++++ 9 files changed, 243 insertions(+), 52 deletions(-) create mode 100644 test/netfox/servers/network-synchronization-server.test.gd create mode 100644 test/netfox/servers/testing-servers.gd diff --git a/addons/netfox.internals/set.gd b/addons/netfox.internals/set.gd index 7ae5b093..20d337c6 100644 --- a/addons/netfox.internals/set.gd +++ b/addons/netfox.internals/set.gd @@ -10,6 +10,11 @@ static func of(items: Array) -> _Set: result.add(item) return result +func duplicate(deep: bool = false) -> _Set: + var result := _Set.new() + result._data = _data.duplicate(deep) + return result + func add(value): _data[value] = true diff --git a/addons/netfox/serializers/redundant-snapshot-serializer.gd b/addons/netfox/serializers/redundant-snapshot-serializer.gd index 3d3b6943..107bda67 100644 --- a/addons/netfox/serializers/redundant-snapshot-serializer.gd +++ b/addons/netfox/serializers/redundant-snapshot-serializer.gd @@ -6,9 +6,9 @@ var _dense_serializer: _DenseSnapshotSerializer static func _static_init(): _logger = NetfoxLogger._for_netfox("RedundantSnapshotSerializer") -func _init(p_schemas: _NetworkSchema): +func _init(p_schemas: _NetworkSchema, p_identity_server: _NetworkIdentityServer = null): super(p_schemas) - _dense_serializer = _DenseSnapshotSerializer.new(_schemas) + _dense_serializer = _DenseSnapshotSerializer.new(_schemas, p_identity_server) func write_for(peer: int, snapshots: Array[Snapshot], properties: _PropertyPool, buffer: StreamPeerBuffer = null) -> PackedByteArray: var varuint := NetworkSchemas.varuint() diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index d2c9a1eb..dd4872f9 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -1,6 +1,11 @@ extends Node class_name _NetworkSynchronizationServer +var _command_server: _NetworkCommandServer +var _history_server: _NetworkHistoryServer +var _identity_server: _NetworkIdentityServer +var _simulation_server: _RollbackSimulationServer + var _rb_input_properties := _PropertyPool.new() var _rb_state_properties := _PropertyPool.new() var _rb_owned_input_properties := _PropertyPool.new() @@ -24,16 +29,16 @@ var _schemas := _NetworkSchema.new() var _input_redundancy := NetworkRollback.input_redundancy -var _dense_serializer := _DenseSnapshotSerializer.new(_schemas) -var _sparse_serializer := _SparseSnapshotSerializer.new(_schemas) -var _redundant_serializer := _RedundantSnapshotSerializer.new(_schemas) +var _dense_serializer: _DenseSnapshotSerializer +var _sparse_serializer: _SparseSnapshotSerializer +var _redundant_serializer: _RedundantSnapshotSerializer -@onready var _cmd_full_state := NetworkCommandServer.register_command_at(_NetworkCommands.RB_FULL_STATE, _handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) -@onready var _cmd_diff_state := NetworkCommandServer.register_command_at(_NetworkCommands.RB_DIFF_STATE, _handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) -@onready var _cmd_input := NetworkCommandServer.register_command_at(_NetworkCommands.INPUT, _handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) +var _cmd_full_state: NetworkCommandServer.Command +var _cmd_diff_state: NetworkCommandServer.Command +var _cmd_input: NetworkCommandServer.Command -@onready var _cmd_full_sync := NetworkCommandServer.register_command_at(_NetworkCommands.SYNC_FULL, _handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) -@onready var _cmd_diff_sync := NetworkCommandServer.register_command_at(_NetworkCommands.SYNC_DIFF, _handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) +var _cmd_full_sync: NetworkCommandServer.Command +var _cmd_diff_sync: NetworkCommandServer.Command static var _logger := NetfoxLogger._for_netfox("NetworkSynchronizationServer") @@ -97,7 +102,6 @@ func is_node_visible_to(peer: int, node: Node) -> bool: else: return filter.get_visible_peers().has(peer) -# TODO: Make this testable somehow, I beg of you func synchronize_input(tick: int) -> void: # We don't own inputs, nothing to synchronize if _rb_owned_input_properties.is_empty(): @@ -141,7 +145,6 @@ func synchronize_input(tick: int) -> void: var data := _redundant_serializer.write_for(peer, snapshots, _rb_owned_input_properties) _cmd_input.send(data, peer) -# TODO: Make this testable somehow, I beg of you func synchronize_state(tick: int) -> void: # We don't own state, nothing to synchronize if _rb_owned_state_properties.is_empty(): @@ -251,6 +254,37 @@ func synchronize_sync_state(tick: int) -> void: # NOTE: This is a shared instance, theoretically shouldn't screw things up _last_sync_state_sent = snapshot +func _init( + p_command_server: _NetworkCommandServer = null, + p_history_server: _NetworkHistoryServer = null, + p_identity_server: _NetworkIdentityServer = null, + p_simulation_server: _RollbackSimulationServer = null + ): + _command_server = p_command_server + _history_server = p_history_server + _identity_server = p_identity_server + _simulation_server = p_simulation_server + +func _ready(): + # Ensure dependencies + if not _command_server: _command_server = NetworkCommandServer + if not _history_server: _history_server = NetworkHistoryServer + if not _identity_server: _identity_server = NetworkIdentityServer + if not _simulation_server: _simulation_server = RollbackSimulationServer + + # Setup serializers + _dense_serializer = _DenseSnapshotSerializer.new(_schemas, _identity_server) + _sparse_serializer = _SparseSnapshotSerializer.new(_schemas, _identity_server) + _redundant_serializer = _RedundantSnapshotSerializer.new(_schemas, _identity_server) + + # Setup commands + _cmd_full_state = _command_server.register_command_at(_NetworkCommands.RB_FULL_STATE, _handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + _cmd_diff_state = _command_server.register_command_at(_NetworkCommands.RB_DIFF_STATE, _handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + _cmd_input = _command_server.register_command_at(_NetworkCommands.INPUT, _handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + + _cmd_full_sync = _command_server.register_command_at(_NetworkCommands.SYNC_FULL, _handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) + _cmd_diff_sync = _command_server.register_command_at(_NetworkCommands.SYNC_DIFF, _handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) + func _handle_input(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 4eb1ca8e..7a3e363e 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -1,6 +1,8 @@ extends Node class_name _RollbackSimulationServer +var _history_server: _NetworkHistoryServer + var _callbacks := {} # node to callback var _simulated_ticks := {} # node to array of ticks @@ -167,3 +169,10 @@ func get_inputs_of(node: Node) -> Array[Node]: var result := [] as Array[Node] result.assign(_input_graph.get_linked_to(node)) return result + +func _init(p_history_server: _NetworkHistoryServer = null): + _history_server = p_history_server + +func _ready(): + # Ensure dependencies + if not _history_server: _history_server = NetworkHistoryServer diff --git a/test/netfox/rollback-synchronizer.test.gd b/test/netfox/rollback-synchronizer.test.gd index 9c04c156..b0e8c02e 100644 --- a/test/netfox/rollback-synchronizer.test.gd +++ b/test/netfox/rollback-synchronizer.test.gd @@ -4,6 +4,7 @@ func get_suite_name() -> String: return "RollbackSynchronizer" func suite(): + # Messy to set up, keeping cases for later define("Input age and predicting", func(): test("should return -1 on no input", func(): todo()) test("should return 0 on recent input", func(): todo()) diff --git a/test/netfox/rollback/network-rollback.test.gd b/test/netfox/rollback/network-rollback.test.gd index 66be4ba6..c89cb4eb 100644 --- a/test/netfox/rollback/network-rollback.test.gd +++ b/test/netfox/rollback/network-rollback.test.gd @@ -3,10 +3,6 @@ extends VestTest func get_suite_name() -> String: return "NetworkRollback" -# NOTE: When instantiating _NetworkRollback, Godot tries to resolve NetworkTime, -# which it can't in CI. Until that's figured out and/or netfox moves away from -# autoloads, these tests will have to be skipped. - var network_rollback: _NetworkRollback var mutated_node: Node @@ -55,29 +51,3 @@ func suite() -> void: expect_not(network_rollback.is_just_mutated(mutated_node, 8)) ) ) - - define("input submission", func(): - test("should have input after submit", func(): - # Given - network_rollback.register_input_submission(mutated_node, 2) - - # Then - expect(network_rollback.has_input_for_tick(mutated_node, 2), "Node should have input!") - expect(network_rollback.has_input_for_tick(mutated_node, 1), "Node should have future input!") - expect_not(network_rollback.has_input_for_tick(mutated_node, 3), "Node shouldn't yet have input!") - ) - - test("should return latest input tick", func(): - # Given - network_rollback.register_input_submission(mutated_node, 2) - - # Then - expect_equal(network_rollback.get_latest_input_tick(mutated_node), 2) - ) - - test("should return no input tick", func(): - # Given nothing - # Then - expect_equal(network_rollback.get_latest_input_tick(mutated_node), -1) - ) - ) diff --git a/test/netfox/servers/data/snapshot.test.gd b/test/netfox/servers/data/snapshot.test.gd index d701180d..c9a7a3cb 100644 --- a/test/netfox/servers/data/snapshot.test.gd +++ b/test/netfox/servers/data/snapshot.test.gd @@ -5,6 +5,7 @@ func get_suite_name() -> String: func suite() -> void: var node := Node3D.new() + var other_node := Node3D.new() define("make_patch()", func(): test("should return empty on same", func(): @@ -71,15 +72,76 @@ func suite() -> void: ) ) - define("merge_property()", func(): - test("auth should override non-auth", func(): todo()) - test("auth should update auth", func(): todo()) - test("non-auth should not update auth", func(): todo()) - test("non-auth should update non-auth", func(): todo()) - ) + define("merge()", func(): + test("auth should override non-auth", func(): + var snapshot := Snapshot.of(0, [ + [node, "position", Vector3.ZERO], + [other_node, "position", Vector3.ZERO] + ], []) + + var patch := Snapshot.of(0, [ + [node, "position", Vector3.ONE], + [other_node, "position", Vector3.ONE] + ], [node]) + + expect_true(snapshot.merge(patch)) + expect_equal(snapshot, Snapshot.of(0, [ + [node, "position", Vector3.ONE], + [other_node, "position", Vector3.ONE] + ], [node])) + ) - define("filtered()", func(): - test("should return empty", func(): todo()) - test("should return identical", func(): todo()) - test("should remove property", func(): todo()) + test("auth should update auth", func(): + var snapshot := Snapshot.of(0, [ + [node, "position", Vector3.ZERO], + [other_node, "position", Vector3.ZERO] + ], [node]) + + var patch := Snapshot.of(0, [ + [node, "position", Vector3.ONE], + [other_node, "position", Vector3.ONE] + ], [node]) + + expect_true(snapshot.merge(patch)) + expect_equal(snapshot, Snapshot.of(0, [ + [node, "position", Vector3.ONE], + [other_node, "position", Vector3.ONE] + ], [node])) + ) + + test("non-auth should not update auth", func(): + var snapshot := Snapshot.of(0, [ + [node, "position", Vector3.ZERO], + [other_node, "position", Vector3.ZERO] + ], [node]) + + var patch := Snapshot.of(0, [ + [node, "position", Vector3.ONE], + [other_node, "position", Vector3.ONE] + ], []) + + expect_true(snapshot.merge(patch)) + expect_equal(snapshot, Snapshot.of(0, [ + [node, "position", Vector3.ZERO], + [other_node, "position", Vector3.ONE] + ], [node])) + ) + + test("non-auth should update non-auth", func(): + var snapshot := Snapshot.of(0, [ + [node, "position", Vector3.ZERO], + [other_node, "position", Vector3.ZERO] + ], []) + + var patch := Snapshot.of(0, [ + [node, "position", Vector3.ONE], + [other_node, "position", Vector3.ONE] + ], []) + + expect_true(snapshot.merge(patch)) + expect_equal(snapshot, Snapshot.of(0, [ + [node, "position", Vector3.ONE], + [other_node, "position", Vector3.ONE] + ], [])) + ) ) diff --git a/test/netfox/servers/network-synchronization-server.test.gd b/test/netfox/servers/network-synchronization-server.test.gd new file mode 100644 index 00000000..770ddeb7 --- /dev/null +++ b/test/netfox/servers/network-synchronization-server.test.gd @@ -0,0 +1,58 @@ +extends VestTest + +func get_suite_name() -> String: + return "NetworkSynchronizationServer" + +var servers: TestingServers + +func before_case(__): + # Makes sure local peer is 1, otherwise identifiers get random local IDs + Vest.get_tree().root.multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() + servers = await TestingServers.create() + + servers.synchronization_server()._rb_enable_input_broadcast = true # Force input broadcast + +func after_case(__): + servers.queue_free() + +func suite() -> void: + define("synchronize_input()", func(): + test("should submit owned", func(): + var owned_node := await get_node() + var other_node := await get_node() + + other_node.set_multiplayer_authority(2) + + servers.history_server().register_input(owned_node, "position") + servers.history_server().register_input(other_node, "position") + + servers.synchronization_server().register_input(owned_node, "position") + servers.synchronization_server().register_input(other_node, "position") + + servers.history_server().record_input(0) + servers.synchronization_server().synchronize_input(0) + + skip() # TODO: Somehow setup a live client-server connection + ) + ) + + define("synchronize_state()", func(): + test("should submit owned", func(): + skip() + ) + ) + + define("synchronize_sync_state()", func(): + test("should submit owned", func(): + skip() + ) + ) + +func get_node(name: String = "") -> Node3D: + var node := Node3D.new() + + Vest.get_tree().root.add_child.call_deferred(node) + await node.ready + if name: node.name = name + + return node diff --git a/test/netfox/servers/testing-servers.gd b/test/netfox/servers/testing-servers.gd new file mode 100644 index 00000000..7c5b7216 --- /dev/null +++ b/test/netfox/servers/testing-servers.gd @@ -0,0 +1,52 @@ +extends Node +class_name TestingServers + +var _command_server: CommandServer +var _identity_server: _NetworkIdentityServer +var _history_server: _NetworkHistoryServer +var _synchronization_server: _NetworkSynchronizationServer +var _simulation_server: _RollbackSimulationServer + +static func create() -> TestingServers: + var servers := TestingServers.new() + + Vest.get_tree().root.add_child.call_deferred(servers) + await servers.ready + + return servers + +func _ready(): + _command_server = CommandServer.new() + _history_server = _NetworkHistoryServer.new() + _identity_server = _NetworkIdentityServer.new(_command_server) + _synchronization_server = _NetworkSynchronizationServer.new(_command_server, _history_server, _identity_server, _simulation_server) + _simulation_server = _RollbackSimulationServer.new(_history_server) + var servers := [_command_server, _history_server, _identity_server, _synchronization_server, _simulation_server] + + for server in servers: + add_child.call_deferred(server) + + for server in servers: + await server.ready + +func command_server() -> CommandServer: + return _command_server + +func identity_server() -> _NetworkIdentityServer: + return _identity_server + +func history_server() -> _NetworkHistoryServer: + return _history_server + +func synchronization_server() -> _NetworkSynchronizationServer: + return _synchronization_server + +func simulation_server() -> _RollbackSimulationServer: + return _simulation_server + + +class CommandServer extends _NetworkCommandServer: + var commands_sent := [] as Array[Array] + + func send_command(idx: int, data: PackedByteArray, target_peer: int = 0, mode: MultiplayerPeer.TransferMode = MultiplayerPeer.TRANSFER_MODE_RELIABLE, channel: int = 0) -> void: + commands_sent.append([idx, data, target_peer, mode, channel]) From f39d4839ece5d14a7ef467a5fbacb1dd3c12a037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 20 Jan 2026 23:05:31 +0100 Subject: [PATCH 69/95] software engineering --- addons/netfox/rollback/rollback-synchronizer.gd | 2 +- test/netfox/servers/network-synchronization-server.test.gd | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index ef2bbd4d..e65d0e5a 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -106,7 +106,7 @@ func process_settings() -> void: _sim_nodes.append(node) # Both simulated and state nodes depend on all inputs - # TODO: Write tests for setups where a node is synchronized but not simulated + # TODO(#???): Write tests for setups where a node is synchronized but not simulated for node in nodes + _state_properties.get_subjects(): for input_node in _input_properties.get_subjects(): RollbackSimulationServer.register_input_for(node, input_node) diff --git a/test/netfox/servers/network-synchronization-server.test.gd b/test/netfox/servers/network-synchronization-server.test.gd index 770ddeb7..106eaa47 100644 --- a/test/netfox/servers/network-synchronization-server.test.gd +++ b/test/netfox/servers/network-synchronization-server.test.gd @@ -32,7 +32,7 @@ func suite() -> void: servers.history_server().record_input(0) servers.synchronization_server().synchronize_input(0) - skip() # TODO: Somehow setup a live client-server connection + skip() # Somehow setup a live client-server connection ) ) From 2340cbd341cc36236d3cbe1c3b738670d3b6cd32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Thu, 22 Jan 2026 13:37:33 +0100 Subject: [PATCH 70/95] fix autoload paths --- addons/netfox/netfox.gd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index dc8d5a78..9ba499e5 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -187,11 +187,11 @@ const AUTOLOADS: Array[Dictionary] = [ }, { "name": "NetworkHistoryServer", - "path": ROOT + "/servers/rollback-history-server.gd" + "path": ROOT + "/servers/network-history-server.gd" }, { "name": "NetworkSynchronizationServer", - "path": ROOT + "/servers/rollback-synchronization-server.gd" + "path": ROOT + "/servers/network-synchronization-server.gd" }, { "name": "NetworkIdentityServer", From 02049ebe8e2a32e11175d6b0ad89acdb9d560896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 4 Feb 2026 01:41:30 +0100 Subject: [PATCH 71/95] suggestions --- addons/netfox/network-time.gd | 6 +++--- addons/netfox/rollback/network-rollback.gd | 8 ++++---- project.godot | 5 +++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 82232d09..3bb22ce5 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -556,24 +556,24 @@ func _loop() -> void: _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: - NetworkHistoryServer.restore_synchronizer_state(tick) before_tick_loop.emit() + NetworkHistoryServer.restore_synchronizer_state(tick) before_tick.emit(ticktime, tick) on_tick.emit(ticktime, tick) + after_tick.emit(ticktime, tick) NetworkHistoryServer.record_sync_state(tick + 1) NetworkSynchronizationServer.synchronize_sync_state(tick + 1) - after_tick.emit(ticktime, tick) _tick += 1 ticks_in_loop += 1 _next_tick_time += ticktime if ticks_in_loop > 0: - NetworkHistoryServer.restore_synchronizer_state(tick) after_tick_loop.emit() + NetworkHistoryServer.restore_synchronizer_state(tick) func _process(delta: float) -> void: _process_delta = delta diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index c0825596..4f25c528 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -384,9 +384,9 @@ func _rollback() -> void: # Done individually by Rewindables ( usually Rollback Synchronizers ) # Restore input and state for tick _rollback_stage = _STAGE_PREPARE + on_prepare_tick.emit(tick) NetworkHistoryServer.restore_rollback_input(tick) NetworkHistoryServer.restore_rollback_state(tick) - on_prepare_tick.emit(tick) after_prepare_tick.emit(tick) # Simulate rollback tick @@ -396,21 +396,21 @@ func _rollback() -> void: # If authority: Latest input >= tick >= Latest state # If not: Latest input >= tick >= Earliest input _rollback_stage = _STAGE_SIMULATE - RollbackSimulationServer.simulate(NetworkTime.ticktime, tick) on_process_tick.emit(tick) + RollbackSimulationServer.simulate(NetworkTime.ticktime, tick) after_process_tick.emit(tick) # Record state for tick + 1 _rollback_stage = _STAGE_RECORD + on_record_tick.emit(tick + 1) NetworkHistoryServer.record_state(tick + 1) NetworkSynchronizationServer.synchronize_state(tick + 1) - on_record_tick.emit(tick + 1) # Restore display state _rollback_stage = _STAGE_AFTER + after_loop.emit() NetworkHistoryServer.restore_rollback_state(display_tick) RollbackSimulationServer.trim_ticks_simulated(history_start) - after_loop.emit() # Cleanup _mutated_nodes.clear() diff --git a/project.godot b/project.godot index 3a27c927..40c9c05b 100644 --- a/project.godot +++ b/project.godot @@ -23,13 +23,13 @@ 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" +WindowTiler="*res://addons/netfox.extras/window-tiler.gd" +NetworkSimulator="*res://addons/netfox.extras/network-simulator.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" NetworkPerformance="*res://addons/netfox/network-performance.gd" -WindowTiler="*res://addons/netfox.extras/window-tiler.gd" -NetworkSimulator="*res://addons/netfox.extras/network-simulator.gd" RollbackSimulationServer="*res://addons/netfox/servers/rollback-simulation-server.gd" NetworkHistoryServer="*res://addons/netfox/servers/network-history-server.gd" NetworkSynchronizationServer="*res://addons/netfox/servers/network-synchronization-server.gd" @@ -134,6 +134,7 @@ general/clear_settings=false time/tickrate=24 extras/auto_tile_windows=true autoconnect/enabled=true +autoconnect/simulated_latency_ms=50 [rendering] From 8225e4a7f177e2239d688557db3179b3df88f7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 4 Feb 2026 15:51:19 +0100 Subject: [PATCH 72/95] link todos --- addons/netfox/rollback/rollback-synchronizer.gd | 2 +- addons/netfox/schemas/network-schemas.gd | 4 ++-- addons/netfox/serializers/dense-snapshot-serializer.gd | 2 +- addons/netfox/serializers/redundant-snapshot-serializer.gd | 2 +- addons/netfox/serializers/sparse-snapshot-serializer.gd | 2 +- addons/netfox/servers/network-identity-server.gd | 5 ++--- 6 files changed, 8 insertions(+), 9 deletions(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index e65d0e5a..fa54d840 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -106,7 +106,7 @@ func process_settings() -> void: _sim_nodes.append(node) # Both simulated and state nodes depend on all inputs - # TODO(#???): Write tests for setups where a node is synchronized but not simulated + # TODO(#564): Write tests for setups where a node is synchronized but not simulated for node in nodes + _state_properties.get_subjects(): for input_node in _input_properties.get_subjects(): RollbackSimulationServer.register_input_for(node, input_node) diff --git a/addons/netfox/schemas/network-schemas.gd b/addons/netfox/schemas/network-schemas.gd index 1bba8d15..4151ecf1 100644 --- a/addons/netfox/schemas/network-schemas.gd +++ b/addons/netfox/schemas/network-schemas.gd @@ -869,8 +869,8 @@ class _NetworkIdentityReferenceSerializer extends NetworkSchemaSerializer: varuint.encode(ref.get_id(), b) else: b.put_u8(0) - # TODO(#???): Get rid of Godot's prepended 32 bits of string length - # TODO(#???): Write is easy, prefer not manually iterating till \0 on read + # TODO(#562): Get rid of Godot's prepended 32 bits of string length + # TODO(#562): Write is easy, prefer not manually iterating till \0 on read b.put_utf8_string(ref.get_full_name()) func decode(b: StreamPeerBuffer) -> Variant: diff --git a/addons/netfox/serializers/dense-snapshot-serializer.gd b/addons/netfox/serializers/dense-snapshot-serializer.gd index 8df7cfa6..c30b738f 100644 --- a/addons/netfox/serializers/dense-snapshot-serializer.gd +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd @@ -71,7 +71,7 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i # Resolve to identifier var identifier := _get_identity_server().resolve_reference(peer, idref) if not identifier: - # TODO(#???): Handle unknown IDs gracefully + # TODO(#563): Handle unknown IDs gracefully _logger.warning("Received unknown identity reference %s, skipping data", [idref]) continue var node := identifier.get_subject() as Node diff --git a/addons/netfox/serializers/redundant-snapshot-serializer.gd b/addons/netfox/serializers/redundant-snapshot-serializer.gd index 107bda67..8cf1cc2b 100644 --- a/addons/netfox/serializers/redundant-snapshot-serializer.gd +++ b/addons/netfox/serializers/redundant-snapshot-serializer.gd @@ -16,7 +16,7 @@ func write_for(peer: int, snapshots: Array[Snapshot], properties: _PropertyPool, if buffer == null: buffer = StreamPeerBuffer.new() - # TODO(#???): How about encoding the first snapshot as-is, and then the rest as diffs + # TODO(#560): How about encoding the first snapshot as-is, and then the rest as diffs for snapshot in snapshots: var serialized := _dense_serializer.write_for(peer, snapshot, properties) diff --git a/addons/netfox/serializers/sparse-snapshot-serializer.gd b/addons/netfox/serializers/sparse-snapshot-serializer.gd index a241e51b..52e3bbf8 100644 --- a/addons/netfox/serializers/sparse-snapshot-serializer.gd +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd @@ -77,7 +77,7 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i # Resolve to identifier var identifier := _get_identity_server().resolve_reference(peer, idref) if not identifier: - # TODO(#???): Handle unknown IDs gracefully + # TODO(#563): Handle unknown IDs gracefully _logger.warning("Received unknown identity reference %s, skipping data", [idref]) break var node := identifier.get_subject() as Node diff --git a/addons/netfox/servers/network-identity-server.gd b/addons/netfox/servers/network-identity-server.gd index 647f0173..a8dad7f7 100644 --- a/addons/netfox/servers/network-identity-server.gd +++ b/addons/netfox/servers/network-identity-server.gd @@ -49,7 +49,7 @@ func clear() -> void: _identifier_by_id.clear() _next_id = 0 -# TODO(#???): Handle peer disconnect by clearing up data +# TODO(#561): Handle peer disconnect by clearing up data func register_node(node: Node) -> void: if not node.is_inside_tree(): @@ -117,8 +117,7 @@ func _handle_ids(sender: int, data: PackedByteArray) -> void: var id := ids[full_name] as int var identifier := _get_identifier_by_name(full_name) if not identifier: - # Probably deleted since then - # TODO(#???): Queue in case node was not registered *yet* + # Deleted since then _logger.debug("Received identifier for unknown object with full name %s, id #%d", [full_name, id]) continue identifier.set_id_for(sender, id) From 272e12ae8fd525e5019c49404c07200eb79cc58b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 9 Feb 2026 19:58:58 +0100 Subject: [PATCH 73/95] fxs --- .../properties/property-history-buffer.gd | 28 ------------------- .../servers/rollback-simulation-server.gd | 2 +- 2 files changed, 1 insertion(+), 29 deletions(-) delete mode 100644 addons/netfox/properties/property-history-buffer.gd diff --git a/addons/netfox/properties/property-history-buffer.gd b/addons/netfox/properties/property-history-buffer.gd deleted file mode 100644 index 738adabf..00000000 --- a/addons/netfox/properties/property-history-buffer.gd +++ /dev/null @@ -1,28 +0,0 @@ -extends _HistoryBuffer -class_name _PropertyHistoryBuffer - -func get_snapshot(tick: int) -> _PropertySnapshot: - if _buffer.has(tick): - return _buffer[tick] - else: - return _PropertySnapshot.new() - -func set_snapshot(tick: int, data) -> void: - if data is Dictionary: - var snapshot := _PropertySnapshot.from_dictionary(data) - super(tick, snapshot) - elif data is _PropertySnapshot: - super(tick, data) - else: - push_error("Data not a PropertSnapshot! %s" % [data]) - -func get_history(tick: int) -> _PropertySnapshot: - var snapshot = super(tick) - - return snapshot if snapshot else _PropertySnapshot.new() - -func trim(earliest_tick_to_keep: int = NetworkRollback.history_start) -> void: - super(earliest_tick_to_keep) - -func merge(data: _PropertySnapshot, tick:int) -> void: - set_snapshot(tick, get_snapshot(tick).merge(data)) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 7a3e363e..35360059 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -49,10 +49,10 @@ func deregister_input(node: Node, input: Node) -> void: func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: var result: Array[Node] = [] - var tick := input_snapshot.tick if not input_snapshot: return [] + var tick := input_snapshot.tick for node in _callbacks.keys(): var inputs := [] as Array[Node] inputs.assign(_input_graph.get_linked_to(node)) From 4f80948c5d16818419c8f6acbf9ec52fc523407b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 9 Feb 2026 20:32:18 +0100 Subject: [PATCH 74/95] wp --- addons/netfox/servers/data/object-snapshot.gd | 12 ++++++++++++ addons/netfox/servers/data/tick_snapshot.gd | 5 +++++ 2 files changed, 17 insertions(+) create mode 100644 addons/netfox/servers/data/object-snapshot.gd create mode 100644 addons/netfox/servers/data/tick_snapshot.gd diff --git a/addons/netfox/servers/data/object-snapshot.gd b/addons/netfox/servers/data/object-snapshot.gd new file mode 100644 index 00000000..2bae8b53 --- /dev/null +++ b/addons/netfox/servers/data/object-snapshot.gd @@ -0,0 +1,12 @@ +extends RefCounted +class_name ObjectSnapshot + +var _object: Object +var _is_auth: bool +var _data: Dictionary = {} + +func get_value(property: NodePath) -> Variant: + return _data[property] + +func set_value(property: NodePath, value: Variant) -> void: + _data[property] = value diff --git a/addons/netfox/servers/data/tick_snapshot.gd b/addons/netfox/servers/data/tick_snapshot.gd new file mode 100644 index 00000000..ac1113c2 --- /dev/null +++ b/addons/netfox/servers/data/tick_snapshot.gd @@ -0,0 +1,5 @@ +extends RefCounted +class_name TickSnapshot + +var tick: int +var data: Dictionary = {} # object to ObjectSnapshot From 373f878de7b662d0255a067585225a3b663cdbd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 11 Feb 2026 00:11:49 +0100 Subject: [PATCH 75/95] start rewriting to per-object history --- addons/netfox.internals/history-buffer.gd | 5 +- addons/netfox/servers/data/object-snapshot.gd | 38 +++++- .../netfox/servers/data/per-object-history.gd | 91 +++++++++++++ addons/netfox/servers/data/snapshot.gd | 3 + .../netfox/servers/network-history-server.gd | 124 ++++++++++++++++-- .../servers/network-synchronization-server.gd | 2 +- 6 files changed, 247 insertions(+), 16 deletions(-) create mode 100644 addons/netfox/servers/data/per-object-history.gd diff --git a/addons/netfox.internals/history-buffer.gd b/addons/netfox.internals/history-buffer.gd index 808cb4c2..42a87bc5 100644 --- a/addons/netfox.internals/history-buffer.gd +++ b/addons/netfox.internals/history-buffer.gd @@ -50,8 +50,9 @@ func set_at(at: int, value: Variant) -> void: _tail = at _head = at push(value) - elif at < _tail: - # Trying to set something before tail, ignore + elif at < _head - capacity(): + # Trying to set something that would wrap back around and overwrite + # current data return elif at == _head: # Simply adding a new item diff --git a/addons/netfox/servers/data/object-snapshot.gd b/addons/netfox/servers/data/object-snapshot.gd index 2bae8b53..5251cfc3 100644 --- a/addons/netfox/servers/data/object-snapshot.gd +++ b/addons/netfox/servers/data/object-snapshot.gd @@ -2,11 +2,43 @@ extends RefCounted class_name ObjectSnapshot var _object: Object -var _is_auth: bool +var _is_auth: bool = false var _data: Dictionary = {} -func get_value(property: NodePath) -> Variant: - return _data[property] +func _init(p_object: Object) -> void: + _object = p_object + +func duplicate() -> ObjectSnapshot: + var result := ObjectSnapshot.new(_object) + result._is_auth = _is_auth + result._data = _data.duplicate() + return result + +func get_value(property: NodePath, default: Variant = null) -> Variant: + return _data.get(property, default) func set_value(property: NodePath, value: Variant) -> void: _data[property] = value + +func has_value(property: NodePath) -> bool: + return _data.has(property) + +func record_property(property: NodePath) -> void: + set_value(property, _object.get_indexed(property)) + +func apply() -> void: + for property in properties(): + var value := get_value(property) + _object.set_indexed(property, value) + +func is_auth() -> bool: + return _is_auth + +func set_auth(p_auth: bool) -> void: + _is_auth = p_auth + +func properties() -> Array: + return _data.keys() + +func _to_string() -> String: + return "ObjectSnapshot(%s(%d), %s, %s)" % [_object, (_object as Node).get_multiplayer_authority() if _object is Node else -1, _is_auth, _data] diff --git a/addons/netfox/servers/data/per-object-history.gd b/addons/netfox/servers/data/per-object-history.gd new file mode 100644 index 00000000..9b1fd2f3 --- /dev/null +++ b/addons/netfox/servers/data/per-object-history.gd @@ -0,0 +1,91 @@ +extends RefCounted +class_name _PerObjectHistory + +var _history_size: int +var _data := {} # Object to _HistoryBuffer + +func _init(p_history_size: int): + _history_size = p_history_size + +func subjects() -> Array[Object]: + var result := [] as Array[Object] + result.assign(_data.keys()) + return result + +func is_auth(tick: int, subject: Object) -> bool: + if not _data.has(subject): + return false + + var history := _data[subject] as _HistoryBuffer + if not history.has_at(tick): + return false + + var snapshot := history.get_at(tick) as ObjectSnapshot + return snapshot._is_auth + +func erase_subject(subject: Object) -> void: + _data.erase(subject) + +func ensure_snapshot(tick: int, subject: Object, carry_forward: bool) -> ObjectSnapshot: + var has_subject := _data.has(subject) + if not _data.has(subject): + _data[subject] = _HistoryBuffer.new(_history_size) + + var history := _data[subject] as _HistoryBuffer + var has_tick := history.has_at(tick) + var has_latest := history.has_latest_at(tick) + + if not history.has_at(tick): + if not history.has_latest_at(tick): + history.set_at(tick, ObjectSnapshot.new(subject)) + push_warning("Set @%d to new snapshot, no latest: %s" % [tick, history.get_at(tick)]) + elif carry_forward: + history.set_at(tick, history.get_latest_at(tick).duplicate()) + else: + history.set_at(tick, ObjectSnapshot.new(subject)) + + assert(history.get_at(tick) != null, "Somehow no snapshot?!") + return history.get_at(tick) as ObjectSnapshot + +func get_latest_snapshot(tick: int, subject: Object) -> ObjectSnapshot: + if not _data.has(subject): + return null + + var history := _data[subject] as _HistoryBuffer + if not history.has_latest_at(tick): + return null + + return history.get_latest_at(tick) as ObjectSnapshot + +func set_property(tick: int, subject: Object, property: NodePath, value: Variant) -> void: + if not _data.has(subject): + _data[subject] = _HistoryBuffer.new(_history_size) + + var history := _data[subject] as _HistoryBuffer + if not history.has_at(tick): + history.set_at(tick, ObjectSnapshot.new(subject)) + + var snapshot := history.get_at(tick) as ObjectSnapshot + snapshot.set_value(property, value) + +func has_property(tick: int, subject: Object, property: NodePath) -> bool: + if not _data.has(subject): + return false + + var history := _data[subject] as _HistoryBuffer + if not history.has_at(tick): + return false + + var snapshot := history.get_at(tick) as ObjectSnapshot + return snapshot.has_value(property) + +func get_property(tick: int, subject: Object, property: NodePath, default: Variant = null) -> Variant: + if not _data.has(subject): + return default + + var history := _data[subject] as _HistoryBuffer + if not history.has_at(tick): + return default + + var snapshot := history.get_at(tick) as ObjectSnapshot + return snapshot.get_value(property, default) diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index b2d526ad..14cbb0e1 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -131,6 +131,9 @@ func get_subjects() -> Array: func get_auth_subjects() -> Array: return _auth_subjects.values() +func get_subject_properties(subject: Object) -> Array: + return _data.get(subject, {}).keys() + func is_empty() -> bool: return _data.is_empty() diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 66386dc2..afa63adc 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -12,6 +12,10 @@ var _rb_input_snapshots := _HistoryBuffer.new(_rb_history_size) var _rb_state_snapshots := _HistoryBuffer.new(_rb_history_size) var _sync_state_snapshots := _HistoryBuffer.new(_sync_history_size) +var _rb_input_history := _PerObjectHistory.new(_rb_history_size) +var _rb_state_history := _PerObjectHistory.new(_rb_history_size) +var _sync_history := _PerObjectHistory.new(_sync_history_size) + static var _logger := NetfoxLogger._for_netfox("NetworkHistoryServer") func register_state(node: Node, property: NodePath) -> void: @@ -37,10 +41,17 @@ func deregister(node: Node) -> void: _rb_input_properties.erase_subject(node) _sync_state_properties.erase_subject(node) + _rb_state_history.erase_subject(node) + _rb_input_history.erase_subject(node) + _sync_history.erase_subject(node) + func record_input(tick: int) -> void: _record(tick, _rb_input_snapshots, _rb_input_properties, false, func(subject: Node): return subject.is_multiplayer_authority() ) + _record_history(tick, _rb_input_history, _rb_input_properties, true, func(subject: Node): + return subject.is_multiplayer_authority() + ) func record_state(tick: int) -> void: var input_snapshot := get_rollback_input_snapshot(tick - 1) @@ -51,19 +62,35 @@ func record_state(tick: int) -> void: return false return true ) + _record_history(tick, _rb_state_history, _rb_state_properties, false, func(subject: Node): + if not subject.is_multiplayer_authority(): + return false + if RollbackSimulationServer.is_predicting(input_snapshot, subject): + return false + return true + ) func record_sync_state(tick: int) -> void: _record(tick, _sync_state_snapshots, _sync_state_properties, true, func(subject: Node): return subject.is_multiplayer_authority() ) + _record_history(tick, _sync_history, _sync_state_properties, true, func(subject: Node): + return subject.is_multiplayer_authority() + ) func restore_rollback_input(tick: int) -> bool: + _restore_latest(tick, _rb_input_history) + return true return _restore(tick, _rb_input_snapshots) func restore_rollback_state(tick: int) -> bool: + _restore_latest(tick, _rb_state_history) + return true return _restore(tick, _rb_state_snapshots) func restore_synchronizer_state(tick: int) -> bool: + _restore_latest(tick, _sync_history) + return true return _restore(tick, _sync_state_snapshots) func get_rollback_input_snapshot(tick: int) -> Snapshot: @@ -76,12 +103,15 @@ func get_synchronizer_state_snapshot(tick: int) -> Snapshot: return _sync_state_snapshots.get_at(tick) func merge_rollback_input(snapshot: Snapshot) -> bool: + _merge_uh_please(snapshot, _rb_input_history, true) return _merge(snapshot, _rb_input_snapshots, true) func merge_rollback_state(snapshot: Snapshot) -> bool: + _merge_uh_please(snapshot, _rb_state_history) return _merge(snapshot, _rb_state_snapshots) func merge_synchronizer_state(snapshot: Snapshot) -> bool: + _merge_uh_please(snapshot, _sync_history) return _merge(snapshot, _sync_state_snapshots) func get_input_age_for(subjects: Array, tick: int) -> int: @@ -90,6 +120,29 @@ func get_input_age_for(subjects: Array, tick: int) -> int: func get_state_age_for(subjects: Array, tick: int) -> int: return _get_age_for(subjects, tick, _rb_state_snapshots) +func _record_history(tick: int, history: _PerObjectHistory, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: + for subject in property_pool.get_subjects(): + assert(subject is Node, "Only nodes supported for now!") + + var is_auth := auth_filter.call(subject) + + if only_auth and not is_auth: + continue + if not is_auth and history.is_auth(tick, subject): + continue + + var snapshot := history.ensure_snapshot(tick, subject, false) #!! + assert(not property_pool.get_properties_of(subject).is_empty(), "Subject present in property pool without properties! Please report a bug!") + for property in property_pool.get_properties_of(subject): + snapshot.record_property(property) + snapshot.set_auth(is_auth) + + match history: + _rb_input_history: + _logger.debug("Recorded input @%d: %s", [tick, snapshot]) + _rb_state_history: + _logger.debug("Recorded state @%d: %s", [tick, snapshot]) + func _record(tick: int, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: # Ensure snapshot var snapshot := snapshots.get_at(tick) as Snapshot @@ -116,13 +169,27 @@ func _record(tick: int, snapshots: _HistoryBuffer, property_pool: _PropertyPool, updates.append([subject, property, snapshot.get_property(subject, property), is_auth]) snapshot.set_auth(subject, is_auth) - match snapshots: - _rb_input_snapshots: - _logger.debug("Updates to%s input @%d: %s" % [" new" if is_new else "", tick, updates]) - _logger.debug("Recorded input @%d: %s", [tick, snapshot]) - _rb_state_snapshots: - _logger.debug("Updates to%s state @%d: %s" % [" new" if is_new else "", tick, updates]) - _logger.debug("Recorded state @%d: %s", [tick, snapshot]) +# match snapshots: +# _rb_input_snapshots: +# _logger.debug("Updates to%s input @%d: %s" % [" new" if is_new else "", tick, updates]) +# _logger.debug("Recorded input @%d: %s", [tick, snapshot]) +# _rb_state_snapshots: +# _logger.debug("Updates to%s state @%d: %s" % [" new" if is_new else "", tick, updates]) +# _logger.debug("Recorded state @%d: %s", [tick, snapshot]) + +func _restore_latest(tick: int, history: _PerObjectHistory) -> void: + for subject in history.subjects(): + # Grab latest snapshot up to tick + var snapshot := history.get_latest_snapshot(tick, subject) + + # Apply if any + if snapshot: + snapshot.apply() + + match history: + _rb_input_history: _logger.debug("Restored input @%d: %s", [tick, snapshot]) + _rb_state_history: _logger.debug("Restored state @%d: %s", [tick, snapshot]) + func _restore(tick: int, snapshots: _HistoryBuffer) -> bool: if not snapshots.has_latest_at(tick): @@ -131,12 +198,49 @@ func _restore(tick: int, snapshots: _HistoryBuffer) -> bool: var snapshot := snapshots.get_latest_at(tick) as Snapshot snapshot.apply() - match snapshots: - _rb_input_snapshots: _logger.debug("Restored input @%d: %s", [tick, snapshot]) - _rb_state_snapshots: _logger.debug("Restored state @%d: %s", [tick, snapshot]) +# match snapshots: +# _rb_input_snapshots: _logger.debug("Restored input @%d: %s", [tick, snapshot]) +# _rb_state_snapshots: _logger.debug("Restored state @%d: %s", [tick, snapshot]) return true +func _merge_uh_please(snapshot: Snapshot, history: _PerObjectHistory, reverse: bool = false) -> bool: + var tick := snapshot.tick + var has_updated := false + + if tick < NetworkRollback.history_start: # TODO: Local variable? + # TODO: Warn? + return false + + for subject in snapshot.get_subjects(): + var object_snapshot := history.ensure_snapshot(tick, subject, not reverse) # TODO: Check if carry-forward is valid here + + # Never overwrite auth data + if object_snapshot.is_auth() and not snapshot.is_auth(subject): + continue + + for property in snapshot.get_subject_properties(subject): + # If merging in reverse, don't update anything that we already have + # a value for - only accept previously unknown property values + if reverse and object_snapshot.has_value(property): + _logger.debug( + "Rejecting incoming %s:%s=%s for reverse merge, already have %s locally: %s", + [subject, property, snapshot.get_property(subject, property), object_snapshot.get_value(property), object_snapshot] + ) + continue + + var original_value := object_snapshot.get_value(property) + var new_value := snapshot.get_property(subject, property) + + object_snapshot.set_value(property, new_value) + if not has_updated and original_value != new_value: + has_updated = true + object_snapshot.set_auth(snapshot.is_auth(subject)) + match history: + _rb_input_history: _logger.debug("Merged input @%d: %s", [tick, object_snapshot]) + + return has_updated + func _merge(snapshot: Snapshot, snapshots: _HistoryBuffer, reverse: bool = false) -> bool: var tick := snapshot.tick diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index dd4872f9..c5c6ad06 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -294,8 +294,8 @@ func _handle_input(sender: int, data: PackedByteArray): for snapshot in snapshots: snapshot.sanitize(sender) + _logger.debug("Ingesting input: %s", [snapshot]) if NetworkHistoryServer.merge_rollback_input(snapshot): - _logger.debug("Ingested input: %s", [snapshot]) on_input.emit(snapshot) func _handle_full_state(sender: int, data: PackedByteArray): From c9d4f4cd92fcfee29573a04ae196d13f80def8e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 1 Mar 2026 03:06:52 +0100 Subject: [PATCH 76/95] wip typa shi --- addons/netfox/servers/network-history-server.gd | 2 +- project.godot | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index afa63adc..6caa2f29 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -46,7 +46,7 @@ func deregister(node: Node) -> void: _sync_history.erase_subject(node) func record_input(tick: int) -> void: - _record(tick, _rb_input_snapshots, _rb_input_properties, false, func(subject: Node): + _record(tick, _rb_input_snapshots, _rb_input_properties, true, func(subject: Node): return subject.is_multiplayer_authority() ) _record_history(tick, _rb_input_history, _rb_input_properties, true, func(subject: Node): diff --git a/project.godot b/project.godot index 40c9c05b..5ddf4dc4 100644 --- a/project.godot +++ b/project.godot @@ -133,7 +133,7 @@ escape={ general/clear_settings=false time/tickrate=24 extras/auto_tile_windows=true -autoconnect/enabled=true +autoconnect/enabled=false autoconnect/simulated_latency_ms=50 [rendering] From 8221fb2e72d059c1941b3542e77106c54731d937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Sun, 1 Mar 2026 22:18:02 +0100 Subject: [PATCH 77/95] migrate rocket league changes --- addons/netfox/network-time.gd | 72 +++++++++---------- addons/netfox/rollback/network-rollback.gd | 13 ++-- .../netfox/rollback/rollback-synchronizer.gd | 2 +- .../netfox/servers/data/per-object-history.gd | 12 ++++ .../netfox/servers/network-history-server.gd | 45 ++++++++++-- 5 files changed, 92 insertions(+), 52 deletions(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 3bb22ce5..ab8f4e1d 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -19,7 +19,7 @@ var tickrate: int: ## Whether to sync the network ticks to physics updates. ## -## When set to true, tickrate will be the same as the physics ticks per second, +## 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 @@ -33,7 +33,7 @@ var sync_to_physics: bool: ## ## If the game itself runs slower than the configured tickrate, multiple ticks ## will be run in a single go. However, to avoid an endless feedback loop of -## running too many ticks in a frame, which makes the game even slower, which +## running too many ticks in a frame, which makes the game even slower, which ## results in even more ticks and so on, this setting is an upper limit on how ## many ticks can be simulated in a single go. ## @@ -51,7 +51,7 @@ var max_ticks_per_frame: int: ## ## Use this value in cases where timestamps need to be shared with the server. ## -## [i]Note:[/i] Time is continuously synced with the server. If the difference +## [i]Note:[/i] Time is continuously synced with the server. If the difference ## between local and server time is above a certain threshold, this value will ## be adjusted. ## @@ -72,7 +72,7 @@ var time: float: ## ## Use this value in cases where timestamps need to be shared with the server. ## -## [i]Note:[/i] Time is continuously synced with the server. If the difference +## [i]Note:[/i] Time is continuously synced with the server. If the difference ## between local and server time is above a certain threshold, this value will ## be adjusted. ## @@ -88,7 +88,7 @@ var tick: int: ## Threshold before recalibrating [member tick] and [member time]. ## -## Time is continuously synced to the server. In case the time difference is +## 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] @@ -119,7 +119,7 @@ var stall_threshold: float: ## Current network time in ticks on the server. ## -## This is value is only an estimate, and is regularly updated. This means that +## This is value is only an estimate, and is regularly updated. This means that ## this value can and probably will change depending on network conditions. ## [br][br] ## [i]read-only[/i] @@ -133,7 +133,7 @@ var remote_tick: int: ## Current network time in seconds on the server. ## -## This is value is only an estimate, and is regularly updated. This means that +## This is value is only an estimate, and is regularly updated. This means that ## this value can and probably will change depending on network conditions. ## [br][br] ## [i]read-only[/i] @@ -147,8 +147,8 @@ 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. Returns the same as [member +## 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] ## Will always be 0 on servers. @@ -163,7 +163,7 @@ var remote_rtt: float: ## Current network time in ticks. ## ## 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 +## the game. After that, it will increase monotonically, incrementing every ## single tick. ## [br][br] ## When hosting, this value is simply the number of ticks since game start. @@ -183,7 +183,7 @@ var local_tick: int: ## Current network time in seconds. ## ## 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 +## the game. After that, it will increase monotonically, incrementing every ## single tick. ## [br][br] ## When hosting, this value is simply the seconds elapsed since game start. @@ -199,7 +199,7 @@ var local_time: float: return time set(v): push_error("Trying to set read-only variable local_time") - + ## Amount of time a single tick takes, in seconds. ## @@ -231,13 +231,13 @@ var tick_factor: float: ## Multiplier to get from physics process speeds to tick speeds. ## ## Some methods, like CharacterBody's move_and_slide take velocity in units/sec -## and figure out the time delta on their own. However, they are not aware of +## and figure out the time delta on their own. However, they are not aware of ## netfox's time, so motion is all wrong in a network tick. For example, the -## network ticks run at 30 fps, while the game is running at 60fps, thus +## network ticks run at 30 fps, while the game is running at 60fps, thus ## move_and_slide will also assume that it's running on 60fps, resulting in ## slower than expected movement. ## -## To circument this, you can multiply any velocities with this variable, and +## To circument this, you can multiply any velocities with this variable, and ## get the desired speed. Don't forget to then divide by this value if it's a ## persistent variable ( e.g. CharacterBody's velocity ). ## @@ -256,9 +256,9 @@ var physics_factor: float: ## 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 +## For more context on clock stretch, see [member clock_stretch_factor]. The +## minimum allowed clock stretch factor is derived as 1.0 / clock_stretch_max. +## Setting this to larger values will allow for quicker clock adjustment at the ## cost of bigger deviations in game speed. ## [br][br] ## Make sure to adjust this value based on the game's needs. @@ -286,7 +286,7 @@ var suppress_offline_peer_warning: bool: ## 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 +## This value indicates the current clock speed multiplier. Values over 1.0 ## indicate speeding up, under 1.0 indicate slowing down. ## [br][br] ## See [member clock_stretch_max] for clock stretch bounds.[br] @@ -300,7 +300,7 @@ var clock_stretch_factor: float: ## The current estimated offset between the reference clock and the simulation ## clock. -## +## ## Positive values mean the simulation clock is behind, and needs to run ## slightly faster to catch up. Negative values mean the simulation clock is ## ahead, and needs to slow down slightly. @@ -316,7 +316,7 @@ var clock_offset: float: ## The current estimated offset between the reference clock and the remote ## clock. ## -## Positive values mean the reference clock is behind the remote clock. +## Positive values mean the reference clock is behind the remote clock. ## Negative values mean the reference clock is ahead of the remote clock. ## [br][br] ## Returns the same as [member _NetworkTimeSynchronizer.remote_offset]. @@ -426,21 +426,21 @@ func start() -> int: # Reset state _tick = 0 _initial_sync_done = false - + # Host is always synced, as their time is considered ground truth _synced_peers[1] = true # Start sync NetworkTimeSynchronizer.start() _state = _STATE_SYNCING - + if not multiplayer.is_server(): await NetworkTimeSynchronizer.on_initial_sync _tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time()) _initial_sync_done = true _state = _STATE_ACTIVE - + _submit_sync_success.rpc() else: _state = _STATE_ACTIVE @@ -462,7 +462,7 @@ func start() -> int: ## Stop NetworkTime. ## -## This will stop the time sync in the background, and no more ticks will be +## This will stop the time sync in the background, and no more ticks will be ## emitted until the next start. func stop() -> void: NetworkTimeSynchronizer.stop() @@ -508,7 +508,7 @@ func _ready() -> void: _tickrate_handshake = NetworkTickrateHandshake.new() add_child(_tickrate_handshake) - + # Proxy tickrate mismatch event _tickrate_handshake.on_tickrate_mismatch.connect(func(peer, tickrate): on_tickrate_mismatch.emit(peer, tickrate) @@ -524,10 +524,10 @@ func _loop() -> void: # Adjust local clock _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.) - + var clock_stretch_min := 1. / clock_stretch_max # var clock_stretch_f = (1. + clock_diff / (1. * ticktime)) / 2. var clock_stretch_f := inverse_lerp(-ticktime, +ticktime, clock_diff) @@ -535,7 +535,7 @@ func _loop() -> void: var previous_stretch_factor := _clock_stretch_factor _clock_stretch_factor = lerpf(clock_stretch_min, clock_stretch_max, clock_stretch_f) - + # Detect editor pause var clock_step := _clock.get_time() - _last_process_time var clock_step_raw := clock_step / previous_stretch_factor @@ -550,14 +550,14 @@ func _loop() -> void: _was_paused = false _next_tick_time += clock_step _tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time()) - + # 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() - NetworkHistoryServer.restore_synchronizer_state(tick) + #NetworkHistoryServer.restore_synchronizer_state(tick) before_tick.emit(ticktime, tick) @@ -566,18 +566,18 @@ func _loop() -> void: after_tick.emit(ticktime, tick) NetworkHistoryServer.record_sync_state(tick + 1) NetworkSynchronizationServer.synchronize_sync_state(tick + 1) - + _tick += 1 ticks_in_loop += 1 _next_tick_time += ticktime - + if ticks_in_loop > 0: after_tick_loop.emit() NetworkHistoryServer.restore_synchronizer_state(tick) func _process(delta: float) -> void: _process_delta = delta - + if _is_active() and not sync_to_physics: _loop() @@ -598,9 +598,9 @@ func _handle_peer_disconnect(peer: int) -> void: @rpc("any_peer", "reliable", "call_local") func _submit_sync_success() -> void: var peer_id := multiplayer.get_remote_sender_id() - + _logger.trace("Received time sync success from #%s, synced peers: %s", [peer_id, _synced_peers.keys()]) - + if not _synced_peers.has(peer_id): _synced_peers[peer_id] = true after_client_sync.emit(peer_id) diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 4f25c528..e8551373 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -278,12 +278,8 @@ func register_input_submission(_node: Node, _tick: int) -> void: func get_latest_input_tick(node: Node) -> int: var input_nodes := RollbackSimulationServer.get_inputs_of(node) var reference_tick := NetworkTime.tick - var input_age := NetworkHistoryServer.get_input_age_for(input_nodes, reference_tick) - if input_age >= 0: - return reference_tick - input_age - else: - return -1 + return NetworkHistoryServer.get_latest_input_for(input_nodes, reference_tick) ## Check if a node has submitted input for a specific tick (or later) func has_input_for_tick(node: Node, tick: int) -> bool: @@ -304,7 +300,7 @@ func _ready(): NetworkHistoryServer.record_input(tick + input_delay) NetworkSynchronizationServer.synchronize_input(tick + input_delay) ) - + NetworkSynchronizationServer.on_input.connect(func(snapshot: Snapshot): if snapshot.is_empty(): return @@ -314,11 +310,12 @@ func _ready(): else: _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input]) ) - + NetworkSynchronizationServer.on_state.connect(func(snapshot: Snapshot): if snapshot.is_empty(): return - if _latest_state < 0 or snapshot.tick > _latest_state: + if _latest_state < 0 or snapshot.tick < _latest_state: + # TODO: Actually, by 'latest' state, track earliest tick and resim from there _logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _latest_state, snapshot.tick]) _latest_state = snapshot.tick else: diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index fa54d840..6399c930 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -104,7 +104,7 @@ func process_settings() -> void: for node in nodes: RollbackSimulationServer.register(node._rollback_tick) _sim_nodes.append(node) - + # Both simulated and state nodes depend on all inputs # TODO(#564): Write tests for setups where a node is synchronized but not simulated for node in nodes + _state_properties.get_subjects(): diff --git a/addons/netfox/servers/data/per-object-history.gd b/addons/netfox/servers/data/per-object-history.gd index 9b1fd2f3..e7498aea 100644 --- a/addons/netfox/servers/data/per-object-history.gd +++ b/addons/netfox/servers/data/per-object-history.gd @@ -44,6 +44,8 @@ func ensure_snapshot(tick: int, subject: Object, carry_forward: bool) -> ObjectS else: history.set_at(tick, ObjectSnapshot.new(subject)) + if history.get_at(tick) == null: + return ObjectSnapshot.new(subject) # HACK assert(history.get_at(tick) != null, "Somehow no snapshot?!") return history.get_at(tick) as ObjectSnapshot @@ -57,6 +59,16 @@ func get_latest_snapshot(tick: int, subject: Object) -> ObjectSnapshot: return history.get_latest_at(tick) as ObjectSnapshot +func get_latest_tick(tick: int, subject: Object) -> int: + if not _data.has(subject): + return -1 + + var history := _data[subject] as _HistoryBuffer + if not history.has_latest_at(tick): + return -1 + + return history.get_latest_index_at(tick) + func set_property(tick: int, subject: Object, property: NodePath, value: Variant) -> void: if not _data.has(subject): _data[subject] = _HistoryBuffer.new(_history_size) diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 6caa2f29..19da9ff1 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -117,6 +117,9 @@ func merge_synchronizer_state(snapshot: Snapshot) -> bool: func get_input_age_for(subjects: Array, tick: int) -> int: return _get_age_for(subjects, tick, _rb_input_snapshots) +func get_latest_input_for(subjects: Array, tick: int) -> int: + return _get_latest_for(subjects, tick, _rb_input_history) + func get_state_age_for(subjects: Array, tick: int) -> int: return _get_age_for(subjects, tick, _rb_state_snapshots) @@ -130,7 +133,7 @@ func _record_history(tick: int, history: _PerObjectHistory, property_pool: _Prop continue if not is_auth and history.is_auth(tick, subject): continue - + var snapshot := history.ensure_snapshot(tick, subject, false) #!! assert(not property_pool.get_properties_of(subject).is_empty(), "Subject present in property pool without properties! Please report a bug!") for property in property_pool.get_properties_of(subject): @@ -197,11 +200,11 @@ func _restore(tick: int, snapshots: _HistoryBuffer) -> bool: var snapshot := snapshots.get_latest_at(tick) as Snapshot snapshot.apply() - + # match snapshots: # _rb_input_snapshots: _logger.debug("Restored input @%d: %s", [tick, snapshot]) # _rb_state_snapshots: _logger.debug("Restored state @%d: %s", [tick, snapshot]) - + return true func _merge_uh_please(snapshot: Snapshot, history: _PerObjectHistory, reverse: bool = false) -> bool: @@ -212,30 +215,42 @@ func _merge_uh_please(snapshot: Snapshot, history: _PerObjectHistory, reverse: b # TODO: Warn? return false + _logger.debug("Merging snapshot: %s", [snapshot]) + _logger.debug("Subjects: %s", [snapshot.get_subjects()]) for subject in snapshot.get_subjects(): + _logger.debug("Ensuring snapshot for %s for @%d", [subject, tick]) var object_snapshot := history.ensure_snapshot(tick, subject, not reverse) # TODO: Check if carry-forward is valid here + if not object_snapshot: + _logger.error("fucking snapshot missing") + continue + _logger.debug("Using snapshot %s", [object_snapshot]) # Never overwrite auth data + _logger.debug("Local auth: %s; Remote auth: %s", [object_snapshot.is_auth(), snapshot.is_auth(subject)]) if object_snapshot.is_auth() and not snapshot.is_auth(subject): + _logger.debug("Skipping snapshot, won't overwrite auth") continue for property in snapshot.get_subject_properties(subject): # If merging in reverse, don't update anything that we already have # a value for - only accept previously unknown property values if reverse and object_snapshot.has_value(property): - _logger.debug( - "Rejecting incoming %s:%s=%s for reverse merge, already have %s locally: %s", - [subject, property, snapshot.get_property(subject, property), object_snapshot.get_value(property), object_snapshot] - ) + if snapshot.get_property(subject, property) != object_snapshot.get_value(property): + _logger.debug( + "Rejecting incoming %s:%s=%s for reverse merge, already have %s locally: %s", + [subject, property, snapshot.get_property(subject, property), object_snapshot.get_value(property), object_snapshot] + ) continue var original_value := object_snapshot.get_value(property) var new_value := snapshot.get_property(subject, property) object_snapshot.set_value(property, new_value) + _logger.debug("Changed %s:%s - %s -> %s", [subject, property, original_value, new_value]) if not has_updated and original_value != new_value: has_updated = true object_snapshot.set_auth(snapshot.is_auth(subject)) + _logger.debug("Final snapshot: %s", [object_snapshot]) match history: _rb_input_history: _logger.debug("Merged input @%d: %s", [tick, object_snapshot]) @@ -267,6 +282,7 @@ func _merge(snapshot: Snapshot, snapshots: _HistoryBuffer, reverse: bool = false func _get_age_for(subjects: Array, tick: int, snapshots: _HistoryBuffer) -> int: var at := tick + # TODO: Rewrite, we now have per-object history # Bounded while loop for i in range(1024): if not snapshots.has_latest_at(at): @@ -278,3 +294,18 @@ func _get_age_for(subjects: Array, tick: int, snapshots: _HistoryBuffer) -> int: return tick - at return -1 + +func _get_latest_for(subjects: Array, tick: int, history: _PerObjectHistory) -> int: + var latest := -1 + + for subject in subjects: + var subject_latest := history.get_latest_tick(tick, subject) + if subject_latest < 0: + continue + + if latest < 0: + latest = subject_latest + else: + latest = mini(latest, subject_latest) + + return latest From e682cd88f0ca36d101abf6c52a83d41753572c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 2 Mar 2026 02:22:04 +0100 Subject: [PATCH 78/95] cleanups and fxs --- addons/netfox.internals/history-buffer.gd | 7 +- .../netfox/servers/data/per-object-history.gd | 4 +- .../netfox/servers/network-history-server.gd | 160 ++++++------------ test/netfox.internals/history-buffer.test.gd | 12 +- 4 files changed, 71 insertions(+), 112 deletions(-) diff --git a/addons/netfox.internals/history-buffer.gd b/addons/netfox.internals/history-buffer.gd index 42a87bc5..69842e00 100644 --- a/addons/netfox.internals/history-buffer.gd +++ b/addons/netfox.internals/history-buffer.gd @@ -64,10 +64,15 @@ func set_at(at: int, value: Variant) -> void: if _previous[i % _capacity] == i: break _previous[i % _capacity] = at + _tail = mini(_tail, at) elif at >= _head + _capacity: # We're leaving all data behind _tail = at _head = at + + _previous.fill(null) + _buffer.fill(null) + push(value) elif at >= _head: var previous := _head - 1 @@ -80,7 +85,7 @@ func set_at(at: int, value: Variant) -> void: func has_at(at: int) -> bool: if is_empty(): return false - if at < _tail: return false + if at < _head - capacity(): return false if at >= _head: return false return _previous[at % _capacity] == at diff --git a/addons/netfox/servers/data/per-object-history.gd b/addons/netfox/servers/data/per-object-history.gd index e7498aea..54467d7f 100644 --- a/addons/netfox/servers/data/per-object-history.gd +++ b/addons/netfox/servers/data/per-object-history.gd @@ -44,8 +44,8 @@ func ensure_snapshot(tick: int, subject: Object, carry_forward: bool) -> ObjectS else: history.set_at(tick, ObjectSnapshot.new(subject)) - if history.get_at(tick) == null: - return ObjectSnapshot.new(subject) # HACK +# if history.get_at(tick) == null: +# return ObjectSnapshot.new(subject) # HACK assert(history.get_at(tick) != null, "Somehow no snapshot?!") return history.get_at(tick) as ObjectSnapshot diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 19da9ff1..263cd4dd 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -8,14 +8,16 @@ var _sync_state_properties := _PropertyPool.new() var _rb_history_size := NetworkRollback.history_limit var _sync_history_size := ProjectSettings.get_setting("netfox/state_synchronizer/history_limit", 64) as int -var _rb_input_snapshots := _HistoryBuffer.new(_rb_history_size) -var _rb_state_snapshots := _HistoryBuffer.new(_rb_history_size) -var _sync_state_snapshots := _HistoryBuffer.new(_sync_history_size) - +# Source of truth for history var _rb_input_history := _PerObjectHistory.new(_rb_history_size) var _rb_state_history := _PerObjectHistory.new(_rb_history_size) var _sync_history := _PerObjectHistory.new(_sync_history_size) +# Cached snapshots for syncing +var _rb_input_snapshots := _HistoryBuffer.new(_rb_history_size) +var _rb_state_snapshots := _HistoryBuffer.new(_rb_history_size) +var _sync_state_snapshots := _HistoryBuffer.new(_sync_history_size) + static var _logger := NetfoxLogger._for_netfox("NetworkHistoryServer") func register_state(node: Node, property: NodePath) -> void: @@ -46,23 +48,14 @@ func deregister(node: Node) -> void: _sync_history.erase_subject(node) func record_input(tick: int) -> void: - _record(tick, _rb_input_snapshots, _rb_input_properties, true, func(subject: Node): - return subject.is_multiplayer_authority() - ) - _record_history(tick, _rb_input_history, _rb_input_properties, true, func(subject: Node): + _record(tick, _rb_input_history, _rb_input_snapshots, _rb_input_properties, true, func(subject: Node): return subject.is_multiplayer_authority() ) func record_state(tick: int) -> void: var input_snapshot := get_rollback_input_snapshot(tick - 1) - _record(tick, _rb_state_snapshots, _rb_state_properties, false, func(subject: Node): - if not subject.is_multiplayer_authority(): - return false - if RollbackSimulationServer.is_predicting(input_snapshot, subject): - return false - return true - ) - _record_history(tick, _rb_state_history, _rb_state_properties, false, func(subject: Node): + + _record(tick, _rb_state_history, _rb_state_snapshots, _rb_state_properties, false, func(subject: Node): if not subject.is_multiplayer_authority(): return false if RollbackSimulationServer.is_predicting(input_snapshot, subject): @@ -71,27 +64,18 @@ func record_state(tick: int) -> void: ) func record_sync_state(tick: int) -> void: - _record(tick, _sync_state_snapshots, _sync_state_properties, true, func(subject: Node): - return subject.is_multiplayer_authority() - ) - _record_history(tick, _sync_history, _sync_state_properties, true, func(subject: Node): + _record(tick, _sync_history, _sync_state_snapshots, _sync_state_properties, true, func(subject: Node): return subject.is_multiplayer_authority() ) func restore_rollback_input(tick: int) -> bool: - _restore_latest(tick, _rb_input_history) - return true - return _restore(tick, _rb_input_snapshots) + return _restore_latest(tick, _rb_input_history) func restore_rollback_state(tick: int) -> bool: - _restore_latest(tick, _rb_state_history) - return true - return _restore(tick, _rb_state_snapshots) + return _restore_latest(tick, _rb_state_history) func restore_synchronizer_state(tick: int) -> bool: - _restore_latest(tick, _sync_history) - return true - return _restore(tick, _sync_state_snapshots) + return _restore_latest(tick, _sync_history) func get_rollback_input_snapshot(tick: int) -> Snapshot: return _rb_input_snapshots.get_at(tick) @@ -103,16 +87,16 @@ func get_synchronizer_state_snapshot(tick: int) -> Snapshot: return _sync_state_snapshots.get_at(tick) func merge_rollback_input(snapshot: Snapshot) -> bool: - _merge_uh_please(snapshot, _rb_input_history, true) - return _merge(snapshot, _rb_input_snapshots, true) + _merge_snapshot(snapshot, _rb_input_snapshots, true) + return _merge_history(snapshot, _rb_input_history, true) func merge_rollback_state(snapshot: Snapshot) -> bool: - _merge_uh_please(snapshot, _rb_state_history) - return _merge(snapshot, _rb_state_snapshots) + _merge_snapshot(snapshot, _rb_state_snapshots, true) + return _merge_history(snapshot, _rb_state_history) func merge_synchronizer_state(snapshot: Snapshot) -> bool: - _merge_uh_please(snapshot, _sync_history) - return _merge(snapshot, _sync_state_snapshots) + _merge_snapshot(snapshot, _sync_state_snapshots, true) + return _merge_history(snapshot, _sync_history) func get_input_age_for(subjects: Array, tick: int) -> int: return _get_age_for(subjects, tick, _rb_input_snapshots) @@ -123,7 +107,11 @@ func get_latest_input_for(subjects: Array, tick: int) -> int: func get_state_age_for(subjects: Array, tick: int) -> int: return _get_age_for(subjects, tick, _rb_state_snapshots) -func _record_history(tick: int, history: _PerObjectHistory, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: +func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: + var snapshot := snapshots.get_at(tick, Snapshot.new(tick)) as Snapshot + if not snapshots.has_at(tick): + snapshots.set_at(tick, snapshot) + for subject in property_pool.get_subjects(): assert(subject is Node, "Only nodes supported for now!") @@ -134,11 +122,13 @@ func _record_history(tick: int, history: _PerObjectHistory, property_pool: _Prop if not is_auth and history.is_auth(tick, subject): continue - var snapshot := history.ensure_snapshot(tick, subject, false) #!! + var subject_snapshot := history.ensure_snapshot(tick, subject, false) #!! assert(not property_pool.get_properties_of(subject).is_empty(), "Subject present in property pool without properties! Please report a bug!") for property in property_pool.get_properties_of(subject): - snapshot.record_property(property) - snapshot.set_auth(is_auth) + subject_snapshot.record_property(property) + snapshot.record_property(subject, property) + snapshot.set_auth(subject, is_auth) + subject_snapshot.set_auth(is_auth) match history: _rb_input_history: @@ -146,41 +136,9 @@ func _record_history(tick: int, history: _PerObjectHistory, property_pool: _Prop _rb_state_history: _logger.debug("Recorded state @%d: %s", [tick, snapshot]) -func _record(tick: int, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: - # Ensure snapshot - var snapshot := snapshots.get_at(tick) as Snapshot - var is_new := false - if not snapshot: - snapshot = Snapshot.new(tick) - snapshots.set_at(tick, snapshot) - is_new = true - - # Record values - var updates := [] - for subject in property_pool.get_subjects(): - assert(subject is Node, "Only nodes supported for now!") - - var is_auth := auth_filter.call(subject) - - if only_auth and not is_auth: - continue - if not is_auth and snapshot.is_auth(subject): - continue - - for property in property_pool.get_properties_of(subject): - snapshot.record_property(subject, property) - updates.append([subject, property, snapshot.get_property(subject, property), is_auth]) - snapshot.set_auth(subject, is_auth) - -# match snapshots: -# _rb_input_snapshots: -# _logger.debug("Updates to%s input @%d: %s" % [" new" if is_new else "", tick, updates]) -# _logger.debug("Recorded input @%d: %s", [tick, snapshot]) -# _rb_state_snapshots: -# _logger.debug("Updates to%s state @%d: %s" % [" new" if is_new else "", tick, updates]) -# _logger.debug("Recorded state @%d: %s", [tick, snapshot]) +func _restore_latest(tick: int, history: _PerObjectHistory) -> bool: + var any_applied := false -func _restore_latest(tick: int, history: _PerObjectHistory) -> void: for subject in history.subjects(): # Grab latest snapshot up to tick var snapshot := history.get_latest_snapshot(tick, subject) @@ -188,26 +146,39 @@ func _restore_latest(tick: int, history: _PerObjectHistory) -> void: # Apply if any if snapshot: snapshot.apply() + any_applied = true match history: _rb_input_history: _logger.debug("Restored input @%d: %s", [tick, snapshot]) _rb_state_history: _logger.debug("Restored state @%d: %s", [tick, snapshot]) + return any_applied -func _restore(tick: int, snapshots: _HistoryBuffer) -> bool: - if not snapshots.has_latest_at(tick): - return false +func _merge_snapshot(snapshot: Snapshot, snapshots: _HistoryBuffer, reverse: bool = false) -> bool: + var tick := snapshot.tick - var snapshot := snapshots.get_latest_at(tick) as Snapshot - snapshot.apply() + if not snapshots.has_at(snapshot.tick): + snapshots.set_at(tick, snapshot) + return true + + var original_snapshot := snapshots.get_at(tick) as Snapshot + if reverse: + var original_subjects := original_snapshot.get_auth_subjects() + var incoming_subjects := snapshot.get_auth_subjects() -# match snapshots: -# _rb_input_snapshots: _logger.debug("Restored input @%d: %s", [tick, snapshot]) -# _rb_state_snapshots: _logger.debug("Restored state @%d: %s", [tick, snapshot]) + # Merge the original snapshot on top of the incoming + # This prevents players from changing history, e.g. rewrite their past + # inputs + snapshots.set_at(tick, snapshot) + snapshot.merge(original_snapshot) - return true + # Only return true if we've received inputs for a new node + return incoming_subjects.any(func(it): return not original_subjects.has(it)) + else: + return original_snapshot.merge(snapshot) -func _merge_uh_please(snapshot: Snapshot, history: _PerObjectHistory, reverse: bool = false) -> bool: +func _merge_history(snapshot: Snapshot, history: _PerObjectHistory, reverse: bool = false) -> bool: + # TODO: Update snapshot history too, not just the per-object history var tick := snapshot.tick var has_updated := false @@ -256,29 +227,6 @@ func _merge_uh_please(snapshot: Snapshot, history: _PerObjectHistory, reverse: b return has_updated -func _merge(snapshot: Snapshot, snapshots: _HistoryBuffer, reverse: bool = false) -> bool: - var tick := snapshot.tick - - if not snapshots.has_at(snapshot.tick): - snapshots.set_at(tick, snapshot) - return true - - var original_snapshot := snapshots.get_at(tick) as Snapshot - if reverse: - var original_subjects := original_snapshot.get_auth_subjects() - var incoming_subjects := snapshot.get_auth_subjects() - - # Merge the original snapshot on top of the incoming - # This prevents players from changing history, e.g. rewrite their past - # inputs - snapshots.set_at(tick, snapshot) - snapshot.merge(original_snapshot) - - # Only return true if we've received inputs for a new node - return incoming_subjects.any(func(it): return not original_subjects.has(it)) - else: - return original_snapshot.merge(snapshot) - func _get_age_for(subjects: Array, tick: int, snapshots: _HistoryBuffer) -> int: var at := tick diff --git a/test/netfox.internals/history-buffer.test.gd b/test/netfox.internals/history-buffer.test.gd index 1c031545..c2d41211 100644 --- a/test/netfox.internals/history-buffer.test.gd +++ b/test/netfox.internals/history-buffer.test.gd @@ -31,10 +31,16 @@ func suite() -> void: ) define("set_at()", func(): - test("should ignore behind tail", func(): + test("should set behind tail", func(): var buffer := filled_buffer.duplicate() - buffer.set_at(-2, 4) - expect_not(buffer.has_at(-2)) + buffer.set_at(1, 4) + expect(buffer.has_at(1)) + ) + + test("should not set behind limit", func(): + var buffer := filled_buffer.duplicate() + buffer.set_at(-64, 4) + expect_not(buffer.has_at(-64)) ) test("should update prev buffer if in bounds", func(): From 09dd8f8405cb7fda24a5fd1875ae4da1ad0946a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 2 Mar 2026 02:22:14 +0100 Subject: [PATCH 79/95] uids --- addons/netfox.internals/bitset.gd.uid | 1 + addons/netfox.internals/graph.gd.uid | 1 + addons/netfox.internals/interval-scheduler.gd.uid | 1 + addons/netfox/serializers/base-snapshot-serializer.gd.uid | 1 + addons/netfox/serializers/dense-snapshot-serializer.gd.uid | 1 + addons/netfox/serializers/redundant-snapshot-serializer.gd.uid | 1 + addons/netfox/serializers/sparse-snapshot-serializer.gd.uid | 1 + addons/netfox/servers/data/network-commands.gd.uid | 1 + addons/netfox/servers/data/network-identifier.gd.uid | 1 + addons/netfox/servers/data/network-identity-reference.gd.uid | 1 + addons/netfox/servers/data/object-snapshot.gd.uid | 1 + addons/netfox/servers/data/per-object-history.gd.uid | 1 + addons/netfox/servers/data/property-pool.gd.uid | 1 + addons/netfox/servers/data/snapshot.gd.uid | 1 + addons/netfox/servers/data/tick_snapshot.gd.uid | 1 + addons/netfox/servers/network-command-server.gd.uid | 1 + addons/netfox/servers/network-history-server.gd.uid | 1 + addons/netfox/servers/network-identity-server.gd.uid | 1 + addons/netfox/servers/network-synchronization-server.gd.uid | 1 + addons/netfox/servers/rollback-simulation-server.gd.uid | 1 + 20 files changed, 20 insertions(+) create mode 100644 addons/netfox.internals/bitset.gd.uid create mode 100644 addons/netfox.internals/graph.gd.uid create mode 100644 addons/netfox.internals/interval-scheduler.gd.uid create mode 100644 addons/netfox/serializers/base-snapshot-serializer.gd.uid create mode 100644 addons/netfox/serializers/dense-snapshot-serializer.gd.uid create mode 100644 addons/netfox/serializers/redundant-snapshot-serializer.gd.uid create mode 100644 addons/netfox/serializers/sparse-snapshot-serializer.gd.uid create mode 100644 addons/netfox/servers/data/network-commands.gd.uid create mode 100644 addons/netfox/servers/data/network-identifier.gd.uid create mode 100644 addons/netfox/servers/data/network-identity-reference.gd.uid create mode 100644 addons/netfox/servers/data/object-snapshot.gd.uid create mode 100644 addons/netfox/servers/data/per-object-history.gd.uid create mode 100644 addons/netfox/servers/data/property-pool.gd.uid create mode 100644 addons/netfox/servers/data/snapshot.gd.uid create mode 100644 addons/netfox/servers/data/tick_snapshot.gd.uid create mode 100644 addons/netfox/servers/network-command-server.gd.uid create mode 100644 addons/netfox/servers/network-history-server.gd.uid create mode 100644 addons/netfox/servers/network-identity-server.gd.uid create mode 100644 addons/netfox/servers/network-synchronization-server.gd.uid create mode 100644 addons/netfox/servers/rollback-simulation-server.gd.uid diff --git a/addons/netfox.internals/bitset.gd.uid b/addons/netfox.internals/bitset.gd.uid new file mode 100644 index 00000000..141d9aec --- /dev/null +++ b/addons/netfox.internals/bitset.gd.uid @@ -0,0 +1 @@ +uid://c46ub48lgga12 diff --git a/addons/netfox.internals/graph.gd.uid b/addons/netfox.internals/graph.gd.uid new file mode 100644 index 00000000..9fcfd69d --- /dev/null +++ b/addons/netfox.internals/graph.gd.uid @@ -0,0 +1 @@ +uid://djknl4n6akxue diff --git a/addons/netfox.internals/interval-scheduler.gd.uid b/addons/netfox.internals/interval-scheduler.gd.uid new file mode 100644 index 00000000..2b0ef979 --- /dev/null +++ b/addons/netfox.internals/interval-scheduler.gd.uid @@ -0,0 +1 @@ +uid://egfuyvoj0r2s diff --git a/addons/netfox/serializers/base-snapshot-serializer.gd.uid b/addons/netfox/serializers/base-snapshot-serializer.gd.uid new file mode 100644 index 00000000..41b7024f --- /dev/null +++ b/addons/netfox/serializers/base-snapshot-serializer.gd.uid @@ -0,0 +1 @@ +uid://b8mn7i6dnt0bu diff --git a/addons/netfox/serializers/dense-snapshot-serializer.gd.uid b/addons/netfox/serializers/dense-snapshot-serializer.gd.uid new file mode 100644 index 00000000..ce649461 --- /dev/null +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd.uid @@ -0,0 +1 @@ +uid://k37sxuptebt3 diff --git a/addons/netfox/serializers/redundant-snapshot-serializer.gd.uid b/addons/netfox/serializers/redundant-snapshot-serializer.gd.uid new file mode 100644 index 00000000..535fe2b5 --- /dev/null +++ b/addons/netfox/serializers/redundant-snapshot-serializer.gd.uid @@ -0,0 +1 @@ +uid://ch0p4f4nuy1c diff --git a/addons/netfox/serializers/sparse-snapshot-serializer.gd.uid b/addons/netfox/serializers/sparse-snapshot-serializer.gd.uid new file mode 100644 index 00000000..a702ace6 --- /dev/null +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd.uid @@ -0,0 +1 @@ +uid://b01d03njlfhh1 diff --git a/addons/netfox/servers/data/network-commands.gd.uid b/addons/netfox/servers/data/network-commands.gd.uid new file mode 100644 index 00000000..cbde19a2 --- /dev/null +++ b/addons/netfox/servers/data/network-commands.gd.uid @@ -0,0 +1 @@ +uid://dke6mgxyk12me diff --git a/addons/netfox/servers/data/network-identifier.gd.uid b/addons/netfox/servers/data/network-identifier.gd.uid new file mode 100644 index 00000000..eab41887 --- /dev/null +++ b/addons/netfox/servers/data/network-identifier.gd.uid @@ -0,0 +1 @@ +uid://gnyygflpr78h diff --git a/addons/netfox/servers/data/network-identity-reference.gd.uid b/addons/netfox/servers/data/network-identity-reference.gd.uid new file mode 100644 index 00000000..006cf3f9 --- /dev/null +++ b/addons/netfox/servers/data/network-identity-reference.gd.uid @@ -0,0 +1 @@ +uid://c6eyaau5r8g51 diff --git a/addons/netfox/servers/data/object-snapshot.gd.uid b/addons/netfox/servers/data/object-snapshot.gd.uid new file mode 100644 index 00000000..43b96870 --- /dev/null +++ b/addons/netfox/servers/data/object-snapshot.gd.uid @@ -0,0 +1 @@ +uid://bx4b8w54gleym diff --git a/addons/netfox/servers/data/per-object-history.gd.uid b/addons/netfox/servers/data/per-object-history.gd.uid new file mode 100644 index 00000000..f8807352 --- /dev/null +++ b/addons/netfox/servers/data/per-object-history.gd.uid @@ -0,0 +1 @@ +uid://70ow466d8yqh diff --git a/addons/netfox/servers/data/property-pool.gd.uid b/addons/netfox/servers/data/property-pool.gd.uid new file mode 100644 index 00000000..bbb45eda --- /dev/null +++ b/addons/netfox/servers/data/property-pool.gd.uid @@ -0,0 +1 @@ +uid://j4luqnbeo8jv diff --git a/addons/netfox/servers/data/snapshot.gd.uid b/addons/netfox/servers/data/snapshot.gd.uid new file mode 100644 index 00000000..388e26e3 --- /dev/null +++ b/addons/netfox/servers/data/snapshot.gd.uid @@ -0,0 +1 @@ +uid://b76plodlwp2u6 diff --git a/addons/netfox/servers/data/tick_snapshot.gd.uid b/addons/netfox/servers/data/tick_snapshot.gd.uid new file mode 100644 index 00000000..43386bb7 --- /dev/null +++ b/addons/netfox/servers/data/tick_snapshot.gd.uid @@ -0,0 +1 @@ +uid://ch0j0cclkpey6 diff --git a/addons/netfox/servers/network-command-server.gd.uid b/addons/netfox/servers/network-command-server.gd.uid new file mode 100644 index 00000000..8636d32b --- /dev/null +++ b/addons/netfox/servers/network-command-server.gd.uid @@ -0,0 +1 @@ +uid://mkgjb8gdl0fp diff --git a/addons/netfox/servers/network-history-server.gd.uid b/addons/netfox/servers/network-history-server.gd.uid new file mode 100644 index 00000000..e1719593 --- /dev/null +++ b/addons/netfox/servers/network-history-server.gd.uid @@ -0,0 +1 @@ +uid://c554fbsy6yos3 diff --git a/addons/netfox/servers/network-identity-server.gd.uid b/addons/netfox/servers/network-identity-server.gd.uid new file mode 100644 index 00000000..1b19aa73 --- /dev/null +++ b/addons/netfox/servers/network-identity-server.gd.uid @@ -0,0 +1 @@ +uid://pc8gwg1lbusp diff --git a/addons/netfox/servers/network-synchronization-server.gd.uid b/addons/netfox/servers/network-synchronization-server.gd.uid new file mode 100644 index 00000000..3e9cace1 --- /dev/null +++ b/addons/netfox/servers/network-synchronization-server.gd.uid @@ -0,0 +1 @@ +uid://yrhqsbd5ubhs diff --git a/addons/netfox/servers/rollback-simulation-server.gd.uid b/addons/netfox/servers/rollback-simulation-server.gd.uid new file mode 100644 index 00000000..51e34737 --- /dev/null +++ b/addons/netfox/servers/rollback-simulation-server.gd.uid @@ -0,0 +1 @@ +uid://d5vx6blln3jr From 8e771fefd30cc57bb5175a2a1bd2dc2e4f3dfb27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 4 Mar 2026 22:32:10 +0100 Subject: [PATCH 80/95] migrate predictive synchronizer --- .../rollback/predictive-synchronizer.gd | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 addons/netfox/rollback/predictive-synchronizer.gd diff --git a/addons/netfox/rollback/predictive-synchronizer.gd b/addons/netfox/rollback/predictive-synchronizer.gd new file mode 100644 index 00000000..c7274a23 --- /dev/null +++ b/addons/netfox/rollback/predictive-synchronizer.gd @@ -0,0 +1,121 @@ +@tool +extends Node +class_name PredictiveSynchronizer + +## Similar to [RollbackSynchronizer], this class manages local variables in a +## rollback context for predictive simulation without networking. +## +## This is a simplified version that focuses on local state management. +## [br][br] +## Like [RollbackSynchronizer], it automatically discovers nodes +## with a [code]_rollback_tick(delta: float, tick: int)[/code] +## method and calls them during the prediction phase. + +## The root node for resolving node paths in properties. Defaults to the parent +## node. +@export var root: Node = get_parent() + +@export_group("State") +## Properties that define the game state. +## [br][br] +## State properties are recorded for each tick and restored during rollback. +## State is restored before every rollback tick, and recorded after simulating +## the tick. +@export var state_properties: Array[String] + +var _state_properties := _PropertyPool.new() +var _sim_nodes: Array[Node] = [] + +var _properties_dirty: bool = false + +## Process settings. +## +## Call this after any change to configuration. +func process_settings() -> void: + # Deregister all nodes we've registered previously + for subject in _state_properties.get_subjects(): + NetworkHistoryServer.deregister(subject) + + for node in _sim_nodes: + RollbackSimulationServer.deregister_node(node) + + # Gather all prediction-aware nodes to call during prediction ticks + _sim_nodes = root.find_children("*") + _sim_nodes.push_front(root) + _sim_nodes = _sim_nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it)) + _sim_nodes.erase(self) + + # Keep history of state properties + _state_properties.set_from_paths(root, state_properties) + for subject in _state_properties.get_subjects(): + for property in _state_properties.get_properties_of(subject): + NetworkHistoryServer.register_state(subject, property) + + # Simulated notes to participate in rollback + for node in _sim_nodes: + RollbackSimulationServer.register(NetworkRollback._get_rollback_method(node)) + +func _ready() -> void: + if Engine.is_editor_hint(): + return + + if not NetworkTime.is_initial_sync_done(): + # Wait for time sync to complete + await NetworkTime.after_sync + + process_settings.call_deferred() + +func _enter_tree() -> void: + if Engine.is_editor_hint(): + return + + if not NetworkTime.is_initial_sync_done(): + # Wait for time sync to complete + await NetworkTime.after_sync + process_settings.call_deferred() + +func _reprocess_settings() -> void: + if not _properties_dirty or Engine.is_editor_hint(): + return + + _properties_dirty = false + process_settings() + +## Add a state property. +## [br][br] +## Settings will be automatically updated. The [param node] may be a string or +## [NodePath] pointing to a node, or an actual [Node] instance. If the given +## property is already tracked, this method does nothing. +func add_state(node: Variant, property: String): + var property_path := PropertyEntry.make_path(root, node, property) + if not property_path or state_properties.has(property_path): + return + + state_properties.push_back(property_path) + _properties_dirty = true + _reprocess_settings.call_deferred() + +func _notification(what: int) -> void: + if what == NOTIFICATION_EDITOR_PRE_SAVE: + update_configuration_warnings() + if what == NOTIFICATION_PREDELETE: + for node in _sim_nodes: + RollbackSimulationServer.deregister_node(node) + for subject in _state_properties.get_subjects(): + NetworkHistoryServer.deregister(subject) + +func _get_configuration_warnings() -> PackedStringArray: + if not root: + root = get_parent() + + # Explore state properties + if not root: + return ["No valid root node found!"] + + var result := PackedStringArray() + result.append_array(_NetfoxEditorUtils.gather_properties(root, "_get_rollback_state_properties", + func(node, prop): + add_state(node, prop) + )) + + return result From 7e053aeb5fb5871fa337c39af1818bba36d3c27d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Fri, 6 Mar 2026 13:53:20 +0100 Subject: [PATCH 81/95] 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 9359a6dc..f538e303 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 and contributors" -version="1.39.0" +version="1.40.0" script="netfox-extras.gd" diff --git a/addons/netfox.internals/plugin.cfg b/addons/netfox.internals/plugin.cfg index a5d82280..0dbe5834 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 and contributors" -version="1.39.0" +version="1.40.0" script="plugin.gd" diff --git a/addons/netfox.noray/plugin.cfg b/addons/netfox.noray/plugin.cfg index 7ff08a7a..c0fc146f 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 and contributors" -version="1.39.0" +version="1.40.0" script="netfox-noray.gd" diff --git a/addons/netfox/plugin.cfg b/addons/netfox/plugin.cfg index bcef0a03..f7bcbd57 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 and contributors" -version="1.39.0" +version="1.40.0" script="netfox.gd" From 7ff0ac74677363d0d17901f04928160fb0206267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Fri, 6 Mar 2026 14:07:52 +0100 Subject: [PATCH 82/95] fxs --- addons/netfox/netfox.gd | 3 +++ addons/netfox/schemas/network-schemas.gd | 21 +++++++++++++++++++ .../serializers/base-snapshot-serializer.gd | 2 +- .../serializers/dense-snapshot-serializer.gd | 2 +- .../serializers/sparse-snapshot-serializer.gd | 2 +- 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 0087bf99..546f7545 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -277,3 +277,6 @@ func remove_setting(setting: Dictionary) -> void: return ProjectSettings.clear(setting.name) + +func has_autoload(name: String) -> bool: + return ProjectSettings.has_setting("autoload/" + name) diff --git a/addons/netfox/schemas/network-schemas.gd b/addons/netfox/schemas/network-schemas.gd index 705bf8b3..9918f020 100644 --- a/addons/netfox/schemas/network-schemas.gd +++ b/addons/netfox/schemas/network-schemas.gd @@ -859,3 +859,24 @@ class _DictionarySerializer extends NetworkSchemaSerializer: dictionary[key] = value return dictionary + +class _NetworkIdentityReferenceSerializer extends NetworkSchemaSerializer: + static var varuint := _VaruintSerializer.new() + + func encode(v: Variant, b: StreamPeerBuffer) -> void: + var ref := v as _NetworkIdentityReference + if ref.has_id(): + varuint.encode(ref.get_id(), b) + else: + b.put_u8(0) + # TODO(#562): Get rid of Godot's prepended 32 bits of string length + # TODO(#562): Write is easy, prefer not manually iterating till \0 on read + b.put_utf8_string(ref.get_full_name()) + + func decode(b: StreamPeerBuffer) -> Variant: + var id := varuint.decode(b) as int + if id == 0: + var full_name := b.get_utf8_string() + return _NetworkIdentityReference.of_full_name(full_name) + else: + return _NetworkIdentityReference.of_id(id) diff --git a/addons/netfox/serializers/base-snapshot-serializer.gd b/addons/netfox/serializers/base-snapshot-serializer.gd index 60091a80..b7a154b5 100644 --- a/addons/netfox/serializers/base-snapshot-serializer.gd +++ b/addons/netfox/serializers/base-snapshot-serializer.gd @@ -22,7 +22,7 @@ func _read_property(node: Node, property: NodePath, buffer: StreamPeerBuffer) -> func _write_identifier(subject: Object, peer: int, buffer: StreamPeerBuffer) -> Error: var netref := NetworkSchemas._netref() - var identifier := _get_identity_server().get_identifier_of(subject) + var identifier := _get_identity_server()._get_identifier_of(subject) if not identifier: _logger.error("Can't synchronize %s, identifier missing!", [subject]) return ERR_DOES_NOT_EXIST diff --git a/addons/netfox/serializers/dense-snapshot-serializer.gd b/addons/netfox/serializers/dense-snapshot-serializer.gd index c30b738f..e3efc878 100644 --- a/addons/netfox/serializers/dense-snapshot-serializer.gd +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd @@ -69,7 +69,7 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] # Resolve to identifier - var identifier := _get_identity_server().resolve_reference(peer, idref) + var identifier := _get_identity_server()._resolve_reference(peer, idref) if not identifier: # TODO(#563): Handle unknown IDs gracefully _logger.warning("Received unknown identity reference %s, skipping data", [idref]) diff --git a/addons/netfox/serializers/sparse-snapshot-serializer.gd b/addons/netfox/serializers/sparse-snapshot-serializer.gd index 52e3bbf8..5bf92d75 100644 --- a/addons/netfox/serializers/sparse-snapshot-serializer.gd +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd @@ -75,7 +75,7 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i node_buffer.data_array = buffer.get_partial_data(node_data_size)[1] # Resolve to identifier - var identifier := _get_identity_server().resolve_reference(peer, idref) + var identifier := _get_identity_server()._resolve_reference(peer, idref) if not identifier: # TODO(#563): Handle unknown IDs gracefully _logger.warning("Received unknown identity reference %s, skipping data", [idref]) From 5cf02d4e06bb9cf2e67967ce6f3d8e1fd6990fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Fri, 6 Mar 2026 14:14:28 +0100 Subject: [PATCH 83/95] uids --- test/netfox.internals/bitset.test.gd.uid | 1 + test/netfox.internals/graph.perf.gd.uid | 1 + test/netfox.internals/graph.test.gd.uid | 1 + test/netfox.internals/interval-scheduler.test.gd.uid | 1 + test/netfox/rollback-synchronizer.test.gd.uid | 1 + test/netfox/serializers/dense-snapshot-serializer.test.gd.uid | 1 + .../netfox/serializers/redundant-snapshot-serializer.test.gd.uid | 1 + test/netfox/serializers/snapshot-serializer-test.gd.uid | 1 + test/netfox/serializers/sparse-snapshot-serializer.test.gd.uid | 1 + test/netfox/servers/data/snapshot.perf.gd.uid | 1 + test/netfox/servers/data/snapshot.test.gd.uid | 1 + test/netfox/servers/network-history-server.perf.gd.uid | 1 + test/netfox/servers/network-synchronization-server.test.gd.uid | 1 + test/netfox/servers/rollback-simulation-server.test.gd.uid | 1 + test/netfox/servers/testing-servers.gd.uid | 1 + 15 files changed, 15 insertions(+) create mode 100644 test/netfox.internals/bitset.test.gd.uid create mode 100644 test/netfox.internals/graph.perf.gd.uid create mode 100644 test/netfox.internals/graph.test.gd.uid create mode 100644 test/netfox.internals/interval-scheduler.test.gd.uid create mode 100644 test/netfox/rollback-synchronizer.test.gd.uid create mode 100644 test/netfox/serializers/dense-snapshot-serializer.test.gd.uid create mode 100644 test/netfox/serializers/redundant-snapshot-serializer.test.gd.uid create mode 100644 test/netfox/serializers/snapshot-serializer-test.gd.uid create mode 100644 test/netfox/serializers/sparse-snapshot-serializer.test.gd.uid create mode 100644 test/netfox/servers/data/snapshot.perf.gd.uid create mode 100644 test/netfox/servers/data/snapshot.test.gd.uid create mode 100644 test/netfox/servers/network-history-server.perf.gd.uid create mode 100644 test/netfox/servers/network-synchronization-server.test.gd.uid create mode 100644 test/netfox/servers/rollback-simulation-server.test.gd.uid create mode 100644 test/netfox/servers/testing-servers.gd.uid diff --git a/test/netfox.internals/bitset.test.gd.uid b/test/netfox.internals/bitset.test.gd.uid new file mode 100644 index 00000000..b9c2e229 --- /dev/null +++ b/test/netfox.internals/bitset.test.gd.uid @@ -0,0 +1 @@ +uid://bil8ugs8ol5h4 diff --git a/test/netfox.internals/graph.perf.gd.uid b/test/netfox.internals/graph.perf.gd.uid new file mode 100644 index 00000000..2fa5fa3e --- /dev/null +++ b/test/netfox.internals/graph.perf.gd.uid @@ -0,0 +1 @@ +uid://brow8r8ya3xa0 diff --git a/test/netfox.internals/graph.test.gd.uid b/test/netfox.internals/graph.test.gd.uid new file mode 100644 index 00000000..f4214832 --- /dev/null +++ b/test/netfox.internals/graph.test.gd.uid @@ -0,0 +1 @@ +uid://dm1d6ldar45q8 diff --git a/test/netfox.internals/interval-scheduler.test.gd.uid b/test/netfox.internals/interval-scheduler.test.gd.uid new file mode 100644 index 00000000..8b5bbdbe --- /dev/null +++ b/test/netfox.internals/interval-scheduler.test.gd.uid @@ -0,0 +1 @@ +uid://014vcn6wjlpc diff --git a/test/netfox/rollback-synchronizer.test.gd.uid b/test/netfox/rollback-synchronizer.test.gd.uid new file mode 100644 index 00000000..45a7326c --- /dev/null +++ b/test/netfox/rollback-synchronizer.test.gd.uid @@ -0,0 +1 @@ +uid://5ufnoe5bk8kt diff --git a/test/netfox/serializers/dense-snapshot-serializer.test.gd.uid b/test/netfox/serializers/dense-snapshot-serializer.test.gd.uid new file mode 100644 index 00000000..c7d10de8 --- /dev/null +++ b/test/netfox/serializers/dense-snapshot-serializer.test.gd.uid @@ -0,0 +1 @@ +uid://cluy2t885ych6 diff --git a/test/netfox/serializers/redundant-snapshot-serializer.test.gd.uid b/test/netfox/serializers/redundant-snapshot-serializer.test.gd.uid new file mode 100644 index 00000000..2448c48e --- /dev/null +++ b/test/netfox/serializers/redundant-snapshot-serializer.test.gd.uid @@ -0,0 +1 @@ +uid://b8k4gyko3nrqj diff --git a/test/netfox/serializers/snapshot-serializer-test.gd.uid b/test/netfox/serializers/snapshot-serializer-test.gd.uid new file mode 100644 index 00000000..92afe231 --- /dev/null +++ b/test/netfox/serializers/snapshot-serializer-test.gd.uid @@ -0,0 +1 @@ +uid://drf3txqsw1vlv diff --git a/test/netfox/serializers/sparse-snapshot-serializer.test.gd.uid b/test/netfox/serializers/sparse-snapshot-serializer.test.gd.uid new file mode 100644 index 00000000..ac9a6115 --- /dev/null +++ b/test/netfox/serializers/sparse-snapshot-serializer.test.gd.uid @@ -0,0 +1 @@ +uid://2l21sx6aw4ek diff --git a/test/netfox/servers/data/snapshot.perf.gd.uid b/test/netfox/servers/data/snapshot.perf.gd.uid new file mode 100644 index 00000000..60bd611c --- /dev/null +++ b/test/netfox/servers/data/snapshot.perf.gd.uid @@ -0,0 +1 @@ +uid://do3iolh8ysmcb diff --git a/test/netfox/servers/data/snapshot.test.gd.uid b/test/netfox/servers/data/snapshot.test.gd.uid new file mode 100644 index 00000000..4b289fb0 --- /dev/null +++ b/test/netfox/servers/data/snapshot.test.gd.uid @@ -0,0 +1 @@ +uid://csptcun1bgc5d diff --git a/test/netfox/servers/network-history-server.perf.gd.uid b/test/netfox/servers/network-history-server.perf.gd.uid new file mode 100644 index 00000000..6bd4cd11 --- /dev/null +++ b/test/netfox/servers/network-history-server.perf.gd.uid @@ -0,0 +1 @@ +uid://bygji0wruckul diff --git a/test/netfox/servers/network-synchronization-server.test.gd.uid b/test/netfox/servers/network-synchronization-server.test.gd.uid new file mode 100644 index 00000000..1c64d312 --- /dev/null +++ b/test/netfox/servers/network-synchronization-server.test.gd.uid @@ -0,0 +1 @@ +uid://csdkme0ex4dxl diff --git a/test/netfox/servers/rollback-simulation-server.test.gd.uid b/test/netfox/servers/rollback-simulation-server.test.gd.uid new file mode 100644 index 00000000..612913f1 --- /dev/null +++ b/test/netfox/servers/rollback-simulation-server.test.gd.uid @@ -0,0 +1 @@ +uid://bxv65630oy2jj diff --git a/test/netfox/servers/testing-servers.gd.uid b/test/netfox/servers/testing-servers.gd.uid new file mode 100644 index 00000000..31f71a2f --- /dev/null +++ b/test/netfox/servers/testing-servers.gd.uid @@ -0,0 +1 @@ +uid://b0xnh4u80f7ux From 1500530c95f48174cff745f92e1d99eebe54626c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 9 Mar 2026 22:06:19 +0100 Subject: [PATCH 84/95] some cleanups --- addons/netfox.internals/bitset.gd | 2 ++ addons/netfox.internals/graph.gd | 3 +++ addons/netfox.internals/history-buffer.gd | 4 +++- addons/netfox.internals/interval-scheduler.gd | 2 ++ addons/netfox/servers/data/per-object-history.gd | 2 +- addons/netfox/servers/data/tick_snapshot.gd | 5 ----- addons/netfox/servers/rollback-simulation-server.gd | 2 ++ 7 files changed, 13 insertions(+), 7 deletions(-) delete mode 100644 addons/netfox/servers/data/tick_snapshot.gd diff --git a/addons/netfox.internals/bitset.gd b/addons/netfox.internals/bitset.gd index 054e6d79..1e54250d 100644 --- a/addons/netfox.internals/bitset.gd +++ b/addons/netfox.internals/bitset.gd @@ -1,6 +1,8 @@ extends RefCounted class_name _Bitset +# Stores a list of booleans, representing them efficiently as a PackedByteArray + var _data: PackedByteArray var _bit_count: int diff --git a/addons/netfox.internals/graph.gd b/addons/netfox.internals/graph.gd index cb452c27..8a15642c 100644 --- a/addons/netfox.internals/graph.gd +++ b/addons/netfox.internals/graph.gd @@ -1,6 +1,9 @@ extends RefCounted class_name _Graph +# Represents a graph, in the sense of a set of nodes, arbitrarily connected by +# links + var _links_from := {} # `from` to `to[]` var _links_to := {} # `to` to `from[]` diff --git a/addons/netfox.internals/history-buffer.gd b/addons/netfox.internals/history-buffer.gd index 69842e00..b2b7fde9 100644 --- a/addons/netfox.internals/history-buffer.gd +++ b/addons/netfox.internals/history-buffer.gd @@ -1,7 +1,8 @@ extends RefCounted class_name _HistoryBuffer -# Maps ticks (int) to arbitrary data +# Maps ticks (int) to arbitrary data, stored in a sliding ring buffer + var _capacity := 64 var _buffer := [] var _previous := [] @@ -75,6 +76,7 @@ func set_at(at: int, value: Variant) -> void: push(value) elif at >= _head: + # Skipping forward a bit var previous := _head - 1 while _head < at: _previous[_head % _capacity] = previous diff --git a/addons/netfox.internals/interval-scheduler.gd b/addons/netfox.internals/interval-scheduler.gd index cc326e6e..d2188483 100644 --- a/addons/netfox.internals/interval-scheduler.gd +++ b/addons/netfox.internals/interval-scheduler.gd @@ -1,6 +1,8 @@ extends RefCounted class_name _IntervalScheduler +# Returns true on every nth `is_now()` call + var interval := 1 var _idx := 0 diff --git a/addons/netfox/servers/data/per-object-history.gd b/addons/netfox/servers/data/per-object-history.gd index 54467d7f..90cc6ddf 100644 --- a/addons/netfox/servers/data/per-object-history.gd +++ b/addons/netfox/servers/data/per-object-history.gd @@ -21,7 +21,7 @@ func is_auth(tick: int, subject: Object) -> bool: return false var snapshot := history.get_at(tick) as ObjectSnapshot - return snapshot._is_auth + return snapshot.is_auth() func erase_subject(subject: Object) -> void: _data.erase(subject) diff --git a/addons/netfox/servers/data/tick_snapshot.gd b/addons/netfox/servers/data/tick_snapshot.gd deleted file mode 100644 index ac1113c2..00000000 --- a/addons/netfox/servers/data/tick_snapshot.gd +++ /dev/null @@ -1,5 +0,0 @@ -extends RefCounted -class_name TickSnapshot - -var tick: int -var data: Dictionary = {} # object to ObjectSnapshot diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 35360059..db4da473 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -22,6 +22,8 @@ func register(callback: Callable) -> void: _logger.error("Trying to register callback that belongs to an invalid object!") return + assert(callback.get_object() is Node, "Only nodes supported for now!") + _callbacks[callback.get_object()] = callback func deregister(callback: Callable) -> void: From d33f41f95be9dc281af470d408f303ce036f9f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 9 Mar 2026 22:37:00 +0100 Subject: [PATCH 85/95] more cleanups --- addons/netfox/rollback/network-rollback.gd | 49 ++++++++++--------- .../netfox/rollback/rollback-synchronizer.gd | 19 ++----- .../netfox/servers/network-history-server.gd | 35 +++++++++++-- addons/netfox/state-synchronizer.gd | 20 ++------ 4 files changed, 64 insertions(+), 59 deletions(-) diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index e8551373..a1eb5661 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -168,7 +168,7 @@ var _simulated_nodes: _Set = _Set.new() var _mutated_nodes: Dictionary = {} var _earliest_input := -1 -var _latest_state := -1 +var _earliest_state := -1 const _STAGE_BEFORE := "B" const _STAGE_PREPARE := "P" @@ -301,26 +301,8 @@ func _ready(): NetworkSynchronizationServer.synchronize_input(tick + input_delay) ) - NetworkSynchronizationServer.on_input.connect(func(snapshot: Snapshot): - if snapshot.is_empty(): - return - if _earliest_input < 0 or snapshot.tick < _earliest_input: - _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, snapshot.tick]) - _earliest_input = snapshot.tick - else: - _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input]) - ) - - NetworkSynchronizationServer.on_state.connect(func(snapshot: Snapshot): - if snapshot.is_empty(): - return - if _latest_state < 0 or snapshot.tick < _latest_state: - # TODO: Actually, by 'latest' state, track earliest tick and resim from there - _logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _latest_state, snapshot.tick]) - _latest_state = snapshot.tick - else: - _logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _latest_state, _latest_state]) - ) + NetworkSynchronizationServer.on_input.connect(_handle_input) + NetworkSynchronizationServer.on_state.connect(_handle_state) func _exit_tree(): NetfoxLogger.free_tag(_get_rollback_tag) @@ -339,13 +321,14 @@ func _rollback() -> void: _resim_from = NetworkTime.tick before_loop.emit() + # Figure out where to start rollback from var range_source = "notif" if _earliest_input >= 0 and _earliest_input <= _resim_from: range_source = "earliest input" _resim_from = _earliest_input - if _latest_state >= 0 and _latest_state <= _resim_from: + if _earliest_state >= 0 and _earliest_state <= _resim_from: range_source = "latest state" - _resim_from = _latest_state + _resim_from = _earliest_state _resim_from = mini(_resim_from, NetworkTime.tick - 1) _logger.trace("Simulating range @%d>@%d using %s", [_resim_from, NetworkTime.tick, range_source]) @@ -368,7 +351,7 @@ func _rollback() -> void: from = NetworkTime.tick - history_limit _earliest_input = -1 - _latest_state = -1 + _earliest_state = -1 # for tick in from .. to: _rollback_from = from @@ -413,6 +396,24 @@ func _rollback() -> void: _mutated_nodes.clear() _is_rollback = false +func _handle_input(snapshot: Snapshot): + if snapshot.is_empty(): + return + if _earliest_input < 0 or snapshot.tick < _earliest_input: + _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, snapshot.tick]) + _earliest_input = snapshot.tick + else: + _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input]) + +func _handle_state(snapshot: Snapshot): + if snapshot.is_empty(): + return + if _earliest_state < 0 or snapshot.tick < _earliest_state: + _logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _earliest_state, snapshot.tick]) + _earliest_state = snapshot.tick + else: + _logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _earliest_state, _earliest_state]) + # Insight 1: # state(x) = simulate(state(x - 1), input(x - 1)) # state(x + 1) = simulate(state(x), input(x)) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 6399c930..0a032e6d 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -32,23 +32,11 @@ class_name RollbackSynchronizer ## for every tick. ## [br][br] ## Only considered if [member _NetworkRollback.enable_diff_states] is true. +## @deprecated: This can now be configured in the project settings. @export_range(0, 128, 1, "or_greater") var full_state_interval: int = 24 -## Ticks to wait between unreliably acknowledging diff states. -## [br][br] -## This can reduce the amount of properties sent in diff states, due to clients -## more often acknowledging received states. To avoid introducing hickups, these -## are sent unreliably. -## [br][br] -## If set to 0, diff states will never be acknowledged. If set to 1, all diff -## states will be acknowledged. If set higher, ack's will be sent regularly, but -## not for every diff state. -## [br][br] -## If enabled, it's worth to tune this setting until network traffic is actually -## reduced. -## [br][br] -## Only considered if [member _NetworkRollback.enable_diff_states] is true. +## @deprecated: This is no longer used. @export_range(0, 128, 1, "or_greater") var diff_ack_interval: int = 0 @@ -62,6 +50,7 @@ var diff_ack_interval: int = 0 ## This will broadcast input to all peers, turning this off will limit to ## sending it to the server only. Turning this off is recommended to save ## bandwidth and reduce cheating risks. +## @deprecated: This can now be configured in the project settings. @export var enable_input_broadcast: bool = true # Make sure this exists from the get-go, just not in the scene tree @@ -263,7 +252,7 @@ func ignore_prediction(node: Node) -> void: ## [br][br] ## Returns -1 if there's no known input. func get_last_known_input() -> int: - return NetworkHistoryServer.get_input_age_for(_input_properties.get_subjects(), NetworkTime.tick) + return NetworkHistoryServer.get_latest_input_for(_input_properties.get_subjects(), NetworkTime.tick) ## Get the tick of the last known state. ## [br][br] diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 263cd4dd..73ffa9a5 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -98,14 +98,25 @@ func merge_synchronizer_state(snapshot: Snapshot) -> bool: _merge_snapshot(snapshot, _sync_state_snapshots, true) return _merge_history(snapshot, _sync_history) -func get_input_age_for(subjects: Array, tick: int) -> int: - return _get_age_for(subjects, tick, _rb_input_snapshots) - func get_latest_input_for(subjects: Array, tick: int) -> int: return _get_latest_for(subjects, tick, _rb_input_history) +func get_input_age_for(subjects: Array, tick: int) -> int: + var latest_input := get_latest_input_for(subjects, tick) + if latest_input < 0: + return -1 + else: + return tick - latest_input + +func get_latest_state_for(subjects: Array, tick: int) -> int: + return _get_latest_for(subjects, tick, _rb_state_history) + func get_state_age_for(subjects: Array, tick: int) -> int: - return _get_age_for(subjects, tick, _rb_state_snapshots) + var latest_state := get_latest_state_for(subjects, tick) + if latest_state < 0: + return -1 + else: + return tick - latest_state func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: var snapshot := snapshots.get_at(tick, Snapshot.new(tick)) as Snapshot @@ -228,6 +239,7 @@ func _merge_history(snapshot: Snapshot, history: _PerObjectHistory, reverse: boo return has_updated func _get_age_for(subjects: Array, tick: int, snapshots: _HistoryBuffer) -> int: + # Find the latest tick where var at := tick # TODO: Rewrite, we now have per-object history @@ -257,3 +269,18 @@ func _get_latest_for(subjects: Array, tick: int, history: _PerObjectHistory) -> latest = mini(latest, subject_latest) return latest + +func _get_earliest_for(subjects: Array, tick: int, history: _PerObjectHistory) -> int: + var earliest := -1 + + for subject in subjects: + var subject_latest := history.get_latest_tick(tick, subject) + if subject_latest < 0: + return -1 + + if earliest < 0: + earliest = earliest + else: + earliest = mini(earliest, subject_latest) + + return earliest diff --git a/addons/netfox/state-synchronizer.gd b/addons/netfox/state-synchronizer.gd index 7fdb7728..7dbd5675 100644 --- a/addons/netfox/state-synchronizer.gd +++ b/addons/netfox/state-synchronizer.gd @@ -26,21 +26,7 @@ class_name StateSynchronizer @export_range(0, 128, 1, "or_greater") var full_state_interval: int = 24 -## Ticks to wait between unreliably acknowledging diff states. -## [br][br] -## This can reduce the amount of properties sent in diff states, due to clients -## more often acknowledging received states. To avoid introducing hickups, these -## are sent unreliably. -## [br][br] -## If set to 0, diff states will never be acknowledged. If set to 1, all diff -## states will be acknowledged. If set higher, ack's will be sent regularly, but -## not for every diff state. -## [br][br] -## If enabled, it's worth to tune this setting until network traffic is actually -## reduced. -## [br][br] -## Only considered if [member _NetworkRollback.enable_diff_states] is true. -## @deprecated: This can now be configured in the project settings. +## @deprecated: This is no longer used. @export_range(0, 128, 1, "or_greater") var diff_ack_interval: int = 0 @@ -68,10 +54,12 @@ func process_settings() -> void: # Register new configuration _properties.set_from_paths(root, properties) for node in _properties.get_subjects(): + NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) + NetworkIdentityServer.register_node(node) + for property in _properties.get_properties_of(node): NetworkHistoryServer.register_sync_state(node, property) NetworkSynchronizationServer.register_sync_state(node, property) - NetworkIdentityServer.register_node(node) _is_initialized = true From 9b36792aec0a32376e0025d8ece0f2453961d714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 9 Mar 2026 22:44:39 +0100 Subject: [PATCH 86/95] uh --- addons/netfox/servers/data/object-snapshot.gd | 3 +++ addons/netfox/servers/data/per-object-history.gd | 8 ++++---- addons/netfox/servers/data/property-pool.gd | 2 ++ addons/netfox/servers/data/snapshot.gd | 2 ++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/addons/netfox/servers/data/object-snapshot.gd b/addons/netfox/servers/data/object-snapshot.gd index 5251cfc3..588c0782 100644 --- a/addons/netfox/servers/data/object-snapshot.gd +++ b/addons/netfox/servers/data/object-snapshot.gd @@ -1,6 +1,9 @@ extends RefCounted class_name ObjectSnapshot +# Represents snapshot data for a single object, by storing the object's values +# for specified properties + var _object: Object var _is_auth: bool = false var _data: Dictionary = {} diff --git a/addons/netfox/servers/data/per-object-history.gd b/addons/netfox/servers/data/per-object-history.gd index 90cc6ddf..a6ee04f6 100644 --- a/addons/netfox/servers/data/per-object-history.gd +++ b/addons/netfox/servers/data/per-object-history.gd @@ -1,6 +1,9 @@ extends RefCounted class_name _PerObjectHistory +# Stores a per-object history of ObjectSnapshots +# Each managed object has its own timeline of snapshots + var _history_size: int var _data := {} # Object to _HistoryBuffer @@ -38,15 +41,12 @@ func ensure_snapshot(tick: int, subject: Object, carry_forward: bool) -> ObjectS if not history.has_at(tick): if not history.has_latest_at(tick): history.set_at(tick, ObjectSnapshot.new(subject)) - push_warning("Set @%d to new snapshot, no latest: %s" % [tick, history.get_at(tick)]) elif carry_forward: history.set_at(tick, history.get_latest_at(tick).duplicate()) else: history.set_at(tick, ObjectSnapshot.new(subject)) -# if history.get_at(tick) == null: -# return ObjectSnapshot.new(subject) # HACK - assert(history.get_at(tick) != null, "Somehow no snapshot?!") + assert(history.get_at(tick) != null, "Failed to ensure snapshot!") return history.get_at(tick) as ObjectSnapshot func get_latest_snapshot(tick: int, subject: Object) -> ObjectSnapshot: diff --git a/addons/netfox/servers/data/property-pool.gd b/addons/netfox/servers/data/property-pool.gd index 4ddccaab..63db467f 100644 --- a/addons/netfox/servers/data/property-pool.gd +++ b/addons/netfox/servers/data/property-pool.gd @@ -1,6 +1,8 @@ extends RefCounted class_name _PropertyPool +# Stores a set of properties, with each property belonging to a subject + var _properties_by_subject := {} # object to property array static func of(entries: Array[Array]) -> _PropertyPool: diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index 14cbb0e1..e3124ea7 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -1,6 +1,8 @@ extends RefCounted class_name Snapshot +# Stores property values of multiple subjects, recorded for a specific tick + var tick: int var _data := {} # object to (property to variant) var _auth_subjects := _Set.new() From e457e16091e3370acf35c31936a985661eb3c7c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 9 Mar 2026 22:48:32 +0100 Subject: [PATCH 87/95] remove fixed cmd ids --- addons/netfox/network-time-synchronizer.gd | 8 ++++---- addons/netfox/servers/data/network-commands.gd | 16 ---------------- addons/netfox/servers/network-command-server.gd | 2 +- addons/netfox/servers/network-identity-server.gd | 2 +- .../servers/network-synchronization-server.gd | 10 +++++----- .../servers/network-identity-server.test.gd | 2 +- 6 files changed, 12 insertions(+), 28 deletions(-) delete mode 100644 addons/netfox/servers/data/network-commands.gd diff --git a/addons/netfox/network-time-synchronizer.gd b/addons/netfox/network-time-synchronizer.gd index aedc8b01..2a979341 100644 --- a/addons/netfox/network-time-synchronizer.gd +++ b/addons/netfox/network-time-synchronizer.gd @@ -115,10 +115,10 @@ var _offset: float = 0. var _rtt: float = 0. var _rtt_jitter: float = 0. -@onready var _cmd_ping := NetworkCommandServer.register_command_at(_NetworkCommands.NTP_PING, _handle_ping, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) -@onready var _cmd_pong := NetworkCommandServer.register_command_at(_NetworkCommands.NTP_PONG, _handle_pong, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) -@onready var _cmd_req_time := NetworkCommandServer.register_command_at(_NetworkCommands.NTP_REQ_TIME, _handle_request_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE) -@onready var _cmd_set_time := NetworkCommandServer.register_command_at(_NetworkCommands.NTP_SET_TIME, _handle_set_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE) +@onready var _cmd_ping := NetworkCommandServer.register_command(_handle_ping, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) +@onready var _cmd_pong := NetworkCommandServer.register_command(_handle_pong, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) +@onready var _cmd_req_time := NetworkCommandServer.register_command(_handle_request_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE) +@onready var _cmd_set_time := NetworkCommandServer.register_command(_handle_set_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE) ## Emitted after the initial time sync. ## diff --git a/addons/netfox/servers/data/network-commands.gd b/addons/netfox/servers/data/network-commands.gd deleted file mode 100644 index ed38a8b5..00000000 --- a/addons/netfox/servers/data/network-commands.gd +++ /dev/null @@ -1,16 +0,0 @@ -extends Object -class_name _NetworkCommands - -const IDS := 0 - -const NTP_PING := 1 -const NTP_PONG := 2 -const NTP_REQ_TIME := 3 -const NTP_SET_TIME := 4 - -const RB_FULL_STATE := 5 -const RB_DIFF_STATE := 6 -const INPUT := 7 - -const SYNC_FULL := 8 -const SYNC_DIFF := 9 diff --git a/addons/netfox/servers/network-command-server.gd b/addons/netfox/servers/network-command-server.gd index 9204d3df..72593110 100644 --- a/addons/netfox/servers/network-command-server.gd +++ b/addons/netfox/servers/network-command-server.gd @@ -36,7 +36,7 @@ func _ready(): _packet_transport.on_receive.connect(_handle_command) ## Register a command at the next available ID -func register_command(handler: Callable) -> Command: +func register_command(handler: Callable, mode: MultiplayerPeer.TransferMode = MultiplayerPeer.TRANSFER_MODE_RELIABLE, channel: int = 0) -> Command: var idx := _next_idx _next_idx += 1 return register_command_at(idx, handler) diff --git a/addons/netfox/servers/network-identity-server.gd b/addons/netfox/servers/network-identity-server.gd index d8cdafe5..ef622622 100644 --- a/addons/netfox/servers/network-identity-server.gd +++ b/addons/netfox/servers/network-identity-server.gd @@ -38,7 +38,7 @@ func _ready(): if not _command_server: _command_server = NetworkCommandServer - _cmd_ids = _command_server.register_command_at(_NetworkCommands.IDS, _handle_ids) + _cmd_ids = _command_server.register_command(_handle_ids, MultiplayerPeer.TRANSFER_MODE_RELIABLE) # TODO(#561): Handle peer disconnect by clearing up data diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index c5c6ad06..8a63a9eb 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -278,12 +278,12 @@ func _ready(): _redundant_serializer = _RedundantSnapshotSerializer.new(_schemas, _identity_server) # Setup commands - _cmd_full_state = _command_server.register_command_at(_NetworkCommands.RB_FULL_STATE, _handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) - _cmd_diff_state = _command_server.register_command_at(_NetworkCommands.RB_DIFF_STATE, _handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) - _cmd_input = _command_server.register_command_at(_NetworkCommands.INPUT, _handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + _cmd_full_state = _command_server.register_command(_handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + _cmd_diff_state = _command_server.register_command(_handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + _cmd_input = _command_server.register_command(_handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) - _cmd_full_sync = _command_server.register_command_at(_NetworkCommands.SYNC_FULL, _handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) - _cmd_diff_sync = _command_server.register_command_at(_NetworkCommands.SYNC_DIFF, _handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) + _cmd_full_sync = _command_server.register_command(_handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) + _cmd_diff_sync = _command_server.register_command(_handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) func _handle_input(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() diff --git a/test/netfox/servers/network-identity-server.test.gd b/test/netfox/servers/network-identity-server.test.gd index 3a8e3a7f..e60ee30a 100644 --- a/test/netfox/servers/network-identity-server.test.gd +++ b/test/netfox/servers/network-identity-server.test.gd @@ -94,7 +94,7 @@ func suite() -> void: for i in command_server.commands_sent.size(): var command := command_server.commands_sent[i] - expect_equal(command[0], _NetworkCommands.IDS) # Command id + expect_equal(command[0], 0) # Command id expect_equal(command[2], 2 + i) # Peer expect_equal(command[3], MultiplayerPeer.TRANSFER_MODE_RELIABLE) ) From b56e493fd2dfd85a4ab02caeb7199666baa3815c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Mon, 9 Mar 2026 22:52:52 +0100 Subject: [PATCH 88/95] cleanup history server --- .../netfox/servers/network-history-server.gd | 50 +++---------------- 1 file changed, 7 insertions(+), 43 deletions(-) diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 73ffa9a5..cf9e7faf 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -143,9 +143,9 @@ func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, p match history: _rb_input_history: - _logger.debug("Recorded input @%d: %s", [tick, snapshot]) + _logger.trace("Recorded input @%d: %s", [tick, snapshot]) _rb_state_history: - _logger.debug("Recorded state @%d: %s", [tick, snapshot]) + _logger.trace("Recorded state @%d: %s", [tick, snapshot]) func _restore_latest(tick: int, history: _PerObjectHistory) -> bool: var any_applied := false @@ -160,8 +160,8 @@ func _restore_latest(tick: int, history: _PerObjectHistory) -> bool: any_applied = true match history: - _rb_input_history: _logger.debug("Restored input @%d: %s", [tick, snapshot]) - _rb_state_history: _logger.debug("Restored state @%d: %s", [tick, snapshot]) + _rb_input_history: _logger.trace("Restored input @%d: %s", [tick, snapshot]) + _rb_state_history: _logger.trace("Restored state @%d: %s", [tick, snapshot]) return any_applied @@ -189,72 +189,36 @@ func _merge_snapshot(snapshot: Snapshot, snapshots: _HistoryBuffer, reverse: boo return original_snapshot.merge(snapshot) func _merge_history(snapshot: Snapshot, history: _PerObjectHistory, reverse: bool = false) -> bool: - # TODO: Update snapshot history too, not just the per-object history var tick := snapshot.tick var has_updated := false - if tick < NetworkRollback.history_start: # TODO: Local variable? - # TODO: Warn? + if tick < NetworkRollback.history_start: + _logger.warning("Snapshot being merged is too old! (@%d)", [tick]) return false - _logger.debug("Merging snapshot: %s", [snapshot]) - _logger.debug("Subjects: %s", [snapshot.get_subjects()]) for subject in snapshot.get_subjects(): - _logger.debug("Ensuring snapshot for %s for @%d", [subject, tick]) - var object_snapshot := history.ensure_snapshot(tick, subject, not reverse) # TODO: Check if carry-forward is valid here - if not object_snapshot: - _logger.error("fucking snapshot missing") - continue - _logger.debug("Using snapshot %s", [object_snapshot]) + var object_snapshot := history.ensure_snapshot(tick, subject, not reverse) # Never overwrite auth data - _logger.debug("Local auth: %s; Remote auth: %s", [object_snapshot.is_auth(), snapshot.is_auth(subject)]) if object_snapshot.is_auth() and not snapshot.is_auth(subject): - _logger.debug("Skipping snapshot, won't overwrite auth") continue for property in snapshot.get_subject_properties(subject): # If merging in reverse, don't update anything that we already have # a value for - only accept previously unknown property values if reverse and object_snapshot.has_value(property): - if snapshot.get_property(subject, property) != object_snapshot.get_value(property): - _logger.debug( - "Rejecting incoming %s:%s=%s for reverse merge, already have %s locally: %s", - [subject, property, snapshot.get_property(subject, property), object_snapshot.get_value(property), object_snapshot] - ) continue var original_value := object_snapshot.get_value(property) var new_value := snapshot.get_property(subject, property) object_snapshot.set_value(property, new_value) - _logger.debug("Changed %s:%s - %s -> %s", [subject, property, original_value, new_value]) if not has_updated and original_value != new_value: has_updated = true object_snapshot.set_auth(snapshot.is_auth(subject)) - _logger.debug("Final snapshot: %s", [object_snapshot]) - match history: - _rb_input_history: _logger.debug("Merged input @%d: %s", [tick, object_snapshot]) return has_updated -func _get_age_for(subjects: Array, tick: int, snapshots: _HistoryBuffer) -> int: - # Find the latest tick where - var at := tick - - # TODO: Rewrite, we now have per-object history - # Bounded while loop - for i in range(1024): - if not snapshots.has_latest_at(at): - return -1 - - at = snapshots.get_latest_index_at(at) - var snapshot := snapshots.get_at(at) as Snapshot - if snapshot.has_subjects(subjects, true): - return tick - at - - return -1 - func _get_latest_for(subjects: Array, tick: int, history: _PerObjectHistory) -> int: var latest := -1 From 8bace0ec2ed77cc9b39f5af7631c31d97f73b841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 10 Mar 2026 18:05:18 +0100 Subject: [PATCH 89/95] document and privatize history server --- addons/netfox/network-time.gd | 3 +- addons/netfox/rollback/network-rollback.gd | 19 ++-- .../rollback/predictive-synchronizer.gd | 2 +- .../netfox/rollback/rollback-synchronizer.gd | 20 ++-- .../netfox/servers/network-history-server.gd | 105 +++++++++++------- .../servers/network-synchronization-server.gd | 53 +++++---- .../servers/rollback-simulation-server.gd | 9 +- .../servers/network-history-server.perf.gd | 8 +- .../network-synchronization-server.test.gd | 10 +- .../rollback-simulation-server.test.gd | 12 +- 10 files changed, 140 insertions(+), 101 deletions(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index ab8f4e1d..f2e992af 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -557,14 +557,13 @@ func _loop() -> void: while _next_tick_time < _last_process_time and ticks_in_loop < max_ticks_per_frame: if ticks_in_loop == 0: before_tick_loop.emit() - #NetworkHistoryServer.restore_synchronizer_state(tick) before_tick.emit(ticktime, tick) on_tick.emit(ticktime, tick) after_tick.emit(ticktime, tick) - NetworkHistoryServer.record_sync_state(tick + 1) + NetworkHistoryServer._record_sync_state(tick + 1) NetworkSynchronizationServer.synchronize_sync_state(tick + 1) _tick += 1 diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index a1eb5661..808894d4 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -269,7 +269,7 @@ func is_just_mutated(target: Object, p_tick: int = tick) -> bool: ## Register that a node has submitted its input for a specific tick ## @deprecated -func register_input_submission(_node: Node, _tick: int) -> void: +func register_rollback_input_submission(_node: Node, _tick: int) -> void: pass ## Get the latest input tick submitted for a specific node @@ -293,16 +293,19 @@ func has_input_for_tick(node: Node, tick: int) -> bool: func free_input_submission_data_for(_node: Node) -> void: pass +func _get_rollback_method(object: Object) -> Callable: + return object._rollback_tick + func _ready(): NetfoxLogger.register_tag(_get_rollback_tag) NetworkTime.after_tick_loop.connect(_rollback) NetworkTime.after_tick.connect(func(_dt, tick): - NetworkHistoryServer.record_input(tick + input_delay) + NetworkHistoryServer._record_rollback_input(tick + input_delay) NetworkSynchronizationServer.synchronize_input(tick + input_delay) ) - NetworkSynchronizationServer.on_input.connect(_handle_input) - NetworkSynchronizationServer.on_state.connect(_handle_state) + NetworkSynchronizationServer._on_input.connect(_handle_input) + NetworkSynchronizationServer._on_state.connect(_handle_state) func _exit_tree(): NetfoxLogger.free_tag(_get_rollback_tag) @@ -365,8 +368,8 @@ func _rollback() -> void: # Restore input and state for tick _rollback_stage = _STAGE_PREPARE on_prepare_tick.emit(tick) - NetworkHistoryServer.restore_rollback_input(tick) - NetworkHistoryServer.restore_rollback_state(tick) + NetworkHistoryServer._restore_rollback_input(tick) + NetworkHistoryServer._restore_rollback_state(tick) after_prepare_tick.emit(tick) # Simulate rollback tick @@ -383,13 +386,13 @@ func _rollback() -> void: # Record state for tick + 1 _rollback_stage = _STAGE_RECORD on_record_tick.emit(tick + 1) - NetworkHistoryServer.record_state(tick + 1) + NetworkHistoryServer._record_rollback_state(tick + 1) NetworkSynchronizationServer.synchronize_state(tick + 1) # Restore display state _rollback_stage = _STAGE_AFTER after_loop.emit() - NetworkHistoryServer.restore_rollback_state(display_tick) + NetworkHistoryServer._restore_rollback_state(display_tick) RollbackSimulationServer.trim_ticks_simulated(history_start) # Cleanup diff --git a/addons/netfox/rollback/predictive-synchronizer.gd b/addons/netfox/rollback/predictive-synchronizer.gd index c7274a23..d5ce5bdd 100644 --- a/addons/netfox/rollback/predictive-synchronizer.gd +++ b/addons/netfox/rollback/predictive-synchronizer.gd @@ -49,7 +49,7 @@ func process_settings() -> void: _state_properties.set_from_paths(root, state_properties) for subject in _state_properties.get_subjects(): for property in _state_properties.get_properties_of(subject): - NetworkHistoryServer.register_state(subject, property) + NetworkHistoryServer.register_rollback_state(subject, property) # Simulated notes to participate in rollback for node in _sim_nodes: diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 0a032e6d..a332a7b5 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -91,14 +91,14 @@ func process_settings() -> void: # Register simulation callbacks for node in nodes: - RollbackSimulationServer.register(node._rollback_tick) + RollbackSimulationServer.register(NetworkRollback._get_rollback_method(node)) _sim_nodes.append(node) # Both simulated and state nodes depend on all inputs # TODO(#564): Write tests for setups where a node is synchronized but not simulated for node in nodes + _state_properties.get_subjects(): for input_node in _input_properties.get_subjects(): - RollbackSimulationServer.register_input_for(node, input_node) + RollbackSimulationServer.register_rollback_input_for(node, input_node) # Register identifiers for node in _state_properties.get_subjects() + _input_properties.get_subjects(): @@ -117,13 +117,13 @@ func process_authority(): # Deregister all recorded properties for node in _state_properties.get_subjects(): for property in _state_properties.get_properties_of(node): - NetworkHistoryServer.deregister_state(node, property) - NetworkSynchronizationServer.deregister_state(node, property) + NetworkHistoryServer.deregister_rollback_state(node, property) + NetworkSynchronizationServer.deregister_rollback_state(node, property) for node in _input_properties.get_subjects(): for property in _input_properties.get_properties_of(node): - NetworkHistoryServer.deregister_input(node, property) - NetworkSynchronizationServer.deregister_input(node, property) + NetworkHistoryServer.deregister_rollback_input(node, property) + NetworkSynchronizationServer.deregister_rollback_input(node, property) # Process authority _state_properties.set_from_paths(root, state_properties) @@ -132,13 +132,13 @@ func process_authority(): # Register new recorded properties for node in _state_properties.get_subjects(): for property in _state_properties.get_properties_of(node): - NetworkHistoryServer.register_state(node, property) - NetworkSynchronizationServer.register_state(node, property) + NetworkHistoryServer.register_rollback_state(node, property) + NetworkSynchronizationServer.register_rollback_state(node, property) for node in _input_properties.get_subjects(): for property in _input_properties.get_properties_of(node): - NetworkHistoryServer.register_input(node, property) - NetworkSynchronizationServer.register_input(node, property) + NetworkHistoryServer.register_rollback_input(node, property) + NetworkSynchronizationServer.register_rollback_input(node, property) ## Add a state property. ## [br][br] diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index cf9e7faf..d68b1f96 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -1,6 +1,15 @@ extends Node class_name _NetworkHistoryServer +## Tracks the history of objects' properties +## +## Specifically, history is stored for rollback state properties, rollback input +## properties, and synchronized state properties. +## [br][br] +## Keeping history lets rollback restore earlier game states for resimulation, +## and enables [_NetworkSynchronizationServer] to send diff states by comparing +## against historical data. + var _rb_input_properties := _PropertyPool.new() var _rb_state_properties := _PropertyPool.new() var _sync_state_properties := _PropertyPool.new() @@ -20,24 +29,32 @@ var _sync_state_snapshots := _HistoryBuffer.new(_sync_history_size) static var _logger := NetfoxLogger._for_netfox("NetworkHistoryServer") -func register_state(node: Node, property: NodePath) -> void: +## Register a rollback state property +func register_rollback_state(node: Node, property: NodePath) -> void: _rb_state_properties.add(node, property) -func deregister_state(node: Node, property: NodePath) -> void: +## Deregister a rollback state property +func deregister_rollback_state(node: Node, property: NodePath) -> void: _rb_state_properties.erase(node, property) -func register_input(node: Node, property: NodePath) -> void: +## Register a rollback input property +func register_rollback_input(node: Node, property: NodePath) -> void: _rb_input_properties.add(node, property) -func deregister_input(node: Node, property: NodePath) -> void: +## Deregister a rollback input property +func deregister_rollback_input(node: Node, property: NodePath) -> void: _rb_input_properties.erase(node, property) +## Register a synchronized state property func register_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.add(node, property) +## Deregister a synchronized state property func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.erase(node, property) +## Deregister a node, no longer tracking any property it had registered using +## any of the [code]register_*()[/code] methods func deregister(node: Node) -> void: _rb_state_properties.erase_subject(node) _rb_input_properties.erase_subject(node) @@ -47,13 +64,41 @@ func deregister(node: Node) -> void: _rb_input_history.erase_subject(node) _sync_history.erase_subject(node) -func record_input(tick: int) -> void: +## Return the latest tick where any of the [param]subjects[/param] had rollback +## state data available +func get_latest_state_tick_for(subjects: Array, tick: int) -> int: + return _get_latest_for(subjects, tick, _rb_state_history) + +## Return how old is the latest rollback state data for any of the [param] +## subjects[/param], in ticks +func get_state_age_for(subjects: Array, tick: int) -> int: + var latest_state := get_latest_state_tick_for(subjects, tick) + if latest_state < 0: + return -1 + else: + return tick - latest_state + +## Return the latest tick where any of the [param]subjects[/param] had rollback +## input data available +func get_latest_input_for(subjects: Array, tick: int) -> int: + return _get_latest_for(subjects, tick, _rb_input_history) + +## Return how old is the latest rollback input data for any of the [param] +## subjects[/param], in ticks +func get_input_age_for(subjects: Array, tick: int) -> int: + var latest_input := get_latest_input_for(subjects, tick) + if latest_input < 0: + return -1 + else: + return tick - latest_input + +func _record_rollback_input(tick: int) -> void: _record(tick, _rb_input_history, _rb_input_snapshots, _rb_input_properties, true, func(subject: Node): return subject.is_multiplayer_authority() ) -func record_state(tick: int) -> void: - var input_snapshot := get_rollback_input_snapshot(tick - 1) +func _record_rollback_state(tick: int) -> void: + var input_snapshot := _get_rollback_input_snapshot(tick - 1) _record(tick, _rb_state_history, _rb_state_snapshots, _rb_state_properties, false, func(subject: Node): if not subject.is_multiplayer_authority(): @@ -63,61 +108,41 @@ func record_state(tick: int) -> void: return true ) -func record_sync_state(tick: int) -> void: +func _record_sync_state(tick: int) -> void: _record(tick, _sync_history, _sync_state_snapshots, _sync_state_properties, true, func(subject: Node): return subject.is_multiplayer_authority() ) -func restore_rollback_input(tick: int) -> bool: +func _restore_rollback_input(tick: int) -> bool: return _restore_latest(tick, _rb_input_history) -func restore_rollback_state(tick: int) -> bool: +func _restore_rollback_state(tick: int) -> bool: return _restore_latest(tick, _rb_state_history) -func restore_synchronizer_state(tick: int) -> bool: +func _restore_synchronizer_state(tick: int) -> bool: return _restore_latest(tick, _sync_history) -func get_rollback_input_snapshot(tick: int) -> Snapshot: +func _get_rollback_input_snapshot(tick: int) -> Snapshot: return _rb_input_snapshots.get_at(tick) -func get_rollback_state_snapshot(tick: int) -> Snapshot: +func _get_rollback_state_snapshot(tick: int) -> Snapshot: return _rb_state_snapshots.get_at(tick) -func get_synchronizer_state_snapshot(tick: int) -> Snapshot: +func _get_synchronizer_state_snapshot(tick: int) -> Snapshot: return _sync_state_snapshots.get_at(tick) -func merge_rollback_input(snapshot: Snapshot) -> bool: +func _merge_rollback_input(snapshot: Snapshot) -> bool: _merge_snapshot(snapshot, _rb_input_snapshots, true) return _merge_history(snapshot, _rb_input_history, true) -func merge_rollback_state(snapshot: Snapshot) -> bool: +func _merge_rollback_state(snapshot: Snapshot) -> bool: _merge_snapshot(snapshot, _rb_state_snapshots, true) return _merge_history(snapshot, _rb_state_history) -func merge_synchronizer_state(snapshot: Snapshot) -> bool: +func _merge_synchronizer_state(snapshot: Snapshot) -> bool: _merge_snapshot(snapshot, _sync_state_snapshots, true) return _merge_history(snapshot, _sync_history) -func get_latest_input_for(subjects: Array, tick: int) -> int: - return _get_latest_for(subjects, tick, _rb_input_history) - -func get_input_age_for(subjects: Array, tick: int) -> int: - var latest_input := get_latest_input_for(subjects, tick) - if latest_input < 0: - return -1 - else: - return tick - latest_input - -func get_latest_state_for(subjects: Array, tick: int) -> int: - return _get_latest_for(subjects, tick, _rb_state_history) - -func get_state_age_for(subjects: Array, tick: int) -> int: - var latest_state := get_latest_state_for(subjects, tick) - if latest_state < 0: - return -1 - else: - return tick - latest_state - func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: var snapshot := snapshots.get_at(tick, Snapshot.new(tick)) as Snapshot if not snapshots.has_at(tick): @@ -227,10 +252,8 @@ func _get_latest_for(subjects: Array, tick: int, history: _PerObjectHistory) -> if subject_latest < 0: continue - if latest < 0: - latest = subject_latest - else: - latest = mini(latest, subject_latest) + # Should we track `mini()` instead? + latest = maxi(latest, subject_latest) return latest diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 8a63a9eb..9cec8711 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -1,11 +1,26 @@ extends Node class_name _NetworkSynchronizationServer +## Synchronizes properties over the network +## +## Handles synchronization of rollback and state properties ( +## [RollbackSynchronizer] and [StateSynchronizer] ), while respecting visibility +## filters and schemas for serialization. +## [br][br] +## Packets are sent per tick, instead of per object. So for every simulated +## rollback tick, a packet is sent with states, and for every recorded input, +## a packet is sent with the inputs. +## [br][br] +## Optionally, diff states can be used, sending only the property values that +## have changed, saving on bandwidth. + +# Dependencies var _command_server: _NetworkCommandServer var _history_server: _NetworkHistoryServer var _identity_server: _NetworkIdentityServer var _simulation_server: _RollbackSimulationServer +# Configuration var _rb_input_properties := _PropertyPool.new() var _rb_state_properties := _PropertyPool.new() var _rb_owned_input_properties := _PropertyPool.new() @@ -20,6 +35,8 @@ var _rb_enable_diffs := NetworkRollback.enable_diff_states var _rb_full_interval := ProjectSettings.get_setting("netfox/rollback/full_state_interval", 24) as int var _rb_full_scheduler := _IntervalScheduler.new(_rb_full_interval) +var _input_redundancy := NetworkRollback.input_redundancy + var _last_sync_state_sent := Snapshot.new(0) var _sync_enable_diffs := ProjectSettings.get_setting("netfox/state_synchronizer/enable_diff_states", true) as bool var _sync_full_interval := ProjectSettings.get_setting("netfox/state_synchronizer/full_state_interval", 24) as int @@ -27,8 +44,6 @@ var _sync_full_scheduler := _IntervalScheduler.new(_sync_full_interval) var _schemas := _NetworkSchema.new() -var _input_redundancy := NetworkRollback.input_redundancy - var _dense_serializer: _DenseSnapshotSerializer var _sparse_serializer: _SparseSnapshotSerializer var _redundant_serializer: _RedundantSnapshotSerializer @@ -42,24 +57,24 @@ var _cmd_diff_sync: NetworkCommandServer.Command static var _logger := NetfoxLogger._for_netfox("NetworkSynchronizationServer") -signal on_input(snapshot: Snapshot) -signal on_state(snapshot: Snapshot) +signal _on_input(snapshot: Snapshot) +signal _on_state(snapshot: Snapshot) -func register_state(node: Node, property: NodePath) -> void: +func register_rollback_state(node: Node, property: NodePath) -> void: _rb_state_properties.add(node, property) if node.is_multiplayer_authority(): _rb_owned_state_properties.add(node, property) -func deregister_state(node: Node, property: NodePath) -> void: +func deregister_rollback_state(node: Node, property: NodePath) -> void: _rb_state_properties.erase(node, property) _rb_owned_state_properties.erase(node, property) -func register_input(node: Node, property: NodePath) -> void: +func register_rollback_input(node: Node, property: NodePath) -> void: _rb_input_properties.add(node, property) if node.is_multiplayer_authority(): _rb_owned_input_properties.add(node, property) -func deregister_input(node: Node, property: NodePath) -> void: +func deregister_rollback_input(node: Node, property: NodePath) -> void: _rb_input_properties.erase(node, property) _rb_owned_input_properties.erase(node, property) @@ -94,6 +109,7 @@ func deregister(node: Node) -> void: _rb_owned_input_properties.erase_subject(node) _sync_state_properties.erase_subject(node) _visibility_filters.erase(node) + _schemas.erase_subject(node) func is_node_visible_to(peer: int, node: Node) -> bool: var filter := _visibility_filters.get(node) as PeerVisibilityFilter @@ -133,7 +149,7 @@ func synchronize_input(tick: int) -> void: # Prepare snapshot package for offset in _input_redundancy: # Grab snapshot from NetworkHistoryServer - var snapshot := NetworkHistoryServer.get_rollback_input_snapshot(tick - offset) + var snapshot := NetworkHistoryServer._get_rollback_input_snapshot(tick - offset) if not snapshot: break @@ -151,7 +167,7 @@ func synchronize_state(tick: int) -> void: return # Grab snapshot from NetworkHistoryServer - var snapshot := NetworkHistoryServer.get_rollback_state_snapshot(tick) + var snapshot := NetworkHistoryServer._get_rollback_state_snapshot(tick) if not snapshot: # No data for tick return @@ -166,7 +182,7 @@ func synchronize_state(tick: int) -> void: is_full = true # Check if we have history to diff to - var reference_snapshot := NetworkHistoryServer.get_rollback_state_snapshot(tick - 1) + var reference_snapshot := NetworkHistoryServer._get_rollback_state_snapshot(tick - 1) if not reference_snapshot: is_full = true @@ -210,7 +226,7 @@ func synchronize_sync_state(tick: int) -> void: return # Grab snapshot from NetworkHistoryServer - var snapshot := NetworkHistoryServer.get_synchronizer_state_snapshot(tick) + var snapshot := NetworkHistoryServer._get_synchronizer_state_snapshot(tick) if not snapshot: return @@ -295,8 +311,8 @@ func _handle_input(sender: int, data: PackedByteArray): snapshot.sanitize(sender) _logger.debug("Ingesting input: %s", [snapshot]) - if NetworkHistoryServer.merge_rollback_input(snapshot): - on_input.emit(snapshot) + if NetworkHistoryServer._merge_rollback_input(snapshot): + _on_input.emit(snapshot) func _handle_full_state(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() @@ -322,7 +338,7 @@ func _handle_full_sync(sender: int, data: PackedByteArray): var snapshot := _dense_serializer.read_from(sender, _sync_state_properties, buffer, true) snapshot.sanitize(sender) - NetworkHistoryServer.merge_synchronizer_state(snapshot) + NetworkHistoryServer._merge_synchronizer_state(snapshot) _logger.trace("Ingested sync state: %s", [snapshot]) func _handle_diff_sync(sender: int, data: PackedByteArray): @@ -332,14 +348,13 @@ func _handle_diff_sync(sender: int, data: PackedByteArray): var snapshot := _sparse_serializer.read_from(sender, _sync_state_properties, buffer) snapshot.sanitize(sender) - NetworkHistoryServer.merge_synchronizer_state(snapshot) + NetworkHistoryServer._merge_synchronizer_state(snapshot) _logger.trace("Ingested sync diff: %s", [snapshot]) func _ingest_state(sender: int, snapshot: Snapshot) -> void: snapshot.sanitize(sender) -# _logger.debug("Received state snapshot: %s", [snapshot]) - NetworkHistoryServer.merge_rollback_state(snapshot) + NetworkHistoryServer._merge_rollback_state(snapshot) _logger.debug("Ingested state: %s", [snapshot]) - on_state.emit(snapshot) + _on_state.emit(snapshot) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index db4da473..2b45b311 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -43,10 +43,10 @@ func deregister_node(node: Node) -> void: deregister(_callbacks[node]) _input_graph.erase(node) -func register_input_for(node: Node, input: Node) -> void: +func register_rollback_input_for(node: Node, input: Node) -> void: _input_graph.link(input, node) -func deregister_input(node: Node, input: Node) -> void: +func deregister_rollback_input(node: Node, input: Node) -> void: _input_graph.unlink(input, node) func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: @@ -131,11 +131,10 @@ func trim_ticks_simulated(beginning: int) -> void: func simulate(delta: float, tick: int) -> void: _current_object = null - var input_snapshot := NetworkHistoryServer.get_rollback_input_snapshot(tick) - var state_snapshot := NetworkHistoryServer.get_rollback_state_snapshot(tick) + var input_snapshot := NetworkHistoryServer._get_rollback_input_snapshot(tick) + var state_snapshot := NetworkHistoryServer._get_rollback_state_snapshot(tick) var nodes := get_nodes_to_simulate(input_snapshot) _predicted_nodes.clear() - _logger.trace("Simulating %d nodes: %s", [nodes.size(), nodes]) # Sort based on SceneTree order for node in nodes: diff --git a/test/netfox/servers/network-history-server.perf.gd b/test/netfox/servers/network-history-server.perf.gd index 5fcd8999..3b860dca 100644 --- a/test/netfox/servers/network-history-server.perf.gd +++ b/test/netfox/servers/network-history-server.perf.gd @@ -17,24 +17,24 @@ func suite(): # Register nodes idx = 0 benchmark("register()", func(__): - NetworkHistoryServer.register_state(nodes[idx], "name") + NetworkHistoryServer.register_rollback_state(nodes[idx], "name") idx += 1 ).with_iterations(count).with_batch_size(count).run() # Record benchmark("record()", func(__): - NetworkHistoryServer.record_state(0) + NetworkHistoryServer._record_rollback_state(0) ).with_duration(1.).with_batch_size(16).run() # Restore benchmark("restore()", func(__): - NetworkHistoryServer.restore_rollback_state(0) + NetworkHistoryServer._restore_rollback_state(0) ).with_duration(1.).with_batch_size(16).run() # Deregister nodes idx = 0 benchmark("deregister()", func(__): - NetworkHistoryServer.deregister_state(nodes[idx], "name") + NetworkHistoryServer.deregister_rollback_state(nodes[idx], "name") idx += 1 ).with_iterations(count).with_batch_size(count).run() diff --git a/test/netfox/servers/network-synchronization-server.test.gd b/test/netfox/servers/network-synchronization-server.test.gd index 106eaa47..a98e21f7 100644 --- a/test/netfox/servers/network-synchronization-server.test.gd +++ b/test/netfox/servers/network-synchronization-server.test.gd @@ -23,13 +23,13 @@ func suite() -> void: other_node.set_multiplayer_authority(2) - servers.history_server().register_input(owned_node, "position") - servers.history_server().register_input(other_node, "position") + servers.history_server().register_rollback_input(owned_node, "position") + servers.history_server().register_rollback_input(other_node, "position") - servers.synchronization_server().register_input(owned_node, "position") - servers.synchronization_server().register_input(other_node, "position") + servers.synchronization_server().register_rollback_input(owned_node, "position") + servers.synchronization_server().register_rollback_input(other_node, "position") - servers.history_server().record_input(0) + servers.history_server()._record_rollback_input(0) servers.synchronization_server().synchronize_input(0) skip() # Somehow setup a live client-server connection diff --git a/test/netfox/servers/rollback-simulation-server.test.gd b/test/netfox/servers/rollback-simulation-server.test.gd index f6384d26..42141c2b 100644 --- a/test/netfox/servers/rollback-simulation-server.test.gd +++ b/test/netfox/servers/rollback-simulation-server.test.gd @@ -15,7 +15,7 @@ func suite() -> void: var input_snapshot := Snapshot.of(1, [[input_node, "name", "Input"]], []) state_node.set_multiplayer_authority(2) - RollbackSimulationServer.register_input_for(state_node, input_node) + RollbackSimulationServer.register_rollback_input_for(state_node, input_node) expect(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) ) @@ -25,7 +25,7 @@ func suite() -> void: var state_node := await get_node() var input_node := await get_node() - RollbackSimulationServer.register_input_for(state_node, input_node) + RollbackSimulationServer.register_rollback_input_for(state_node, input_node) expect(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) ) @@ -51,7 +51,7 @@ func suite() -> void: var input_node := await get_node() var input_snapshot := Snapshot.of(1, [[input_node, "name", "Input"]], [input_node]) - RollbackSimulationServer.register_input_for(state_node, input_node) + RollbackSimulationServer.register_rollback_input_for(state_node, input_node) expect_not(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) ) @@ -64,7 +64,7 @@ func suite() -> void: var server := _RollbackSimulationServer.new() server.register(node._rollback_tick) - server.register_input_for(node, input_node) + server.register_rollback_input_for(node, input_node) var snapshot := Snapshot.new(1) @@ -77,7 +77,7 @@ func suite() -> void: var server := _RollbackSimulationServer.new() server.register(node._rollback_tick) - server.register_input_for(node, input_node) + server.register_rollback_input_for(node, input_node) var snapshot := Snapshot.new(1) snapshot.set_property(input_node, "editor_description", "Test input node") @@ -92,7 +92,7 @@ func suite() -> void: var server := _RollbackSimulationServer.new() server.register(node._rollback_tick) - server.register_input_for(node, input_node) + server.register_rollback_input_for(node, input_node) var snapshot := Snapshot.new(1) NetworkRollback.mutate(node) From af0231787a93f97b23a5d65eebb0e56d4328f712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 10 Mar 2026 18:06:58 +0100 Subject: [PATCH 90/95] fxs --- addons/netfox/network-time.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index f2e992af..87baf16a 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -572,7 +572,7 @@ func _loop() -> void: if ticks_in_loop > 0: after_tick_loop.emit() - NetworkHistoryServer.restore_synchronizer_state(tick) + NetworkHistoryServer._restore_synchronizer_state(tick) func _process(delta: float) -> void: _process_delta = delta From 4eba50fcf9b32399c491070f995c14f641babdce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Tue, 10 Mar 2026 19:13:10 +0100 Subject: [PATCH 91/95] document and privatize sim server --- addons/netfox/rollback/network-rollback.gd | 4 +- .../netfox/servers/network-history-server.gd | 2 +- .../servers/network-synchronization-server.gd | 6 +- .../servers/rollback-simulation-server.gd | 118 +++++++++++------- .../rollback-simulation-server.test.gd | 16 +-- 5 files changed, 84 insertions(+), 62 deletions(-) diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 808894d4..0cd4e652 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -276,7 +276,7 @@ func register_rollback_input_submission(_node: Node, _tick: int) -> void: ## [br][br] ## Returns [code]-1[/code] if no input was submitted for the node, ever. func get_latest_input_tick(node: Node) -> int: - var input_nodes := RollbackSimulationServer.get_inputs_of(node) + var input_nodes := RollbackSimulationServer._get_inputs_of(node) var reference_tick := NetworkTime.tick return NetworkHistoryServer.get_latest_input_for(input_nodes, reference_tick) @@ -393,7 +393,7 @@ func _rollback() -> void: _rollback_stage = _STAGE_AFTER after_loop.emit() NetworkHistoryServer._restore_rollback_state(display_tick) - RollbackSimulationServer.trim_ticks_simulated(history_start) + RollbackSimulationServer._trim_ticks_simulated(history_start) # Cleanup _mutated_nodes.clear() diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index d68b1f96..83b00eea 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -103,7 +103,7 @@ func _record_rollback_state(tick: int) -> void: _record(tick, _rb_state_history, _rb_state_snapshots, _rb_state_properties, false, func(subject: Node): if not subject.is_multiplayer_authority(): return false - if RollbackSimulationServer.is_predicting(input_snapshot, subject): + if RollbackSimulationServer._is_predicting(input_snapshot, subject): return false return true ) diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 9cec8711..ec60ccaa 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -133,7 +133,7 @@ func synchronize_input(tick: int) -> void: # Grab owned input objects for input_subject in _rb_owned_input_properties.get_subjects(): # Grab state objects controlled by input - var controlled_nodes := RollbackSimulationServer.get_controlled_by(input_subject) + var controlled_nodes := RollbackSimulationServer._get_controlled_by(input_subject) # Notify peers owning nodes about the input for node in controlled_nodes: @@ -310,7 +310,7 @@ func _handle_input(sender: int, data: PackedByteArray): for snapshot in snapshots: snapshot.sanitize(sender) - _logger.debug("Ingesting input: %s", [snapshot]) + _logger.trace("Ingesting input: %s", [snapshot]) if NetworkHistoryServer._merge_rollback_input(snapshot): _on_input.emit(snapshot) @@ -355,6 +355,6 @@ func _ingest_state(sender: int, snapshot: Snapshot) -> void: snapshot.sanitize(sender) NetworkHistoryServer._merge_rollback_state(snapshot) - _logger.debug("Ingested state: %s", [snapshot]) + _logger.trace("Ingested state: %s", [snapshot]) _on_state.emit(snapshot) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 2b45b311..00db606c 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -1,6 +1,20 @@ extends Node class_name _RollbackSimulationServer +## Runs gameplay simulation during rollback +## +## Rollback involves restoring the game state to an earlier point in time and +## re-running game logic from there. This class tracks which nodes participate +## in rollback, which of them need to be actually simulated, and runs the +## simulation itself. +## [br][br] +## Node simulation honors scene tree order. Processing order and physics +## processing order are [b]not[/b] considered. +## [br][br] +## To participate in rollback, call [method register] using the method to call +## to simulate the node. Only one [Callable] can be actively registered per +## node. + var _history_server: _NetworkHistoryServer var _callbacks := {} # node to callback @@ -17,6 +31,7 @@ var _group := StringName("__nf_rollback_sim" + str(get_instance_id())) static var _logger := NetfoxLogger._for_netfox("RollbackSimulationServer") +## Register a [param]callback[/param] to run as part of the rollback loop func register(callback: Callable) -> void: if not is_instance_valid(callback.get_object()): _logger.error("Trying to register callback that belongs to an invalid object!") @@ -26,6 +41,7 @@ func register(callback: Callable) -> void: _callbacks[callback.get_object()] = callback +## Deregister a [param]callback[/param] from the rollback loop func deregister(callback: Callable) -> void: if not callback or not callback.is_valid(): return @@ -38,18 +54,65 @@ func deregister(callback: Callable) -> void: _input_graph.erase(object) _simulated_ticks.erase(object) +## Deregister a [param]node[/param] from the rollback loop func deregister_node(node: Node) -> void: if _callbacks.has(node): deregister(_callbacks[node]) _input_graph.erase(node) +## Register [param]input[/param] as providing input for [param]node[/param] func register_rollback_input_for(node: Node, input: Node) -> void: _input_graph.link(input, node) +## Deregister [param]input[/param] from providing input for [param]node[/param] func deregister_rollback_input(node: Node, input: Node) -> void: _input_graph.unlink(input, node) -func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: +## Return true if the currently simulated node is being predicted +func is_predicting_current() -> bool: + if not _current_object or not is_instance_valid(_current_object): + return false + return _predicted_nodes.has(_current_object) + +## Get the currently simulated object +func get_simulated_object() -> Object: + return _current_object + +## Simulate a tick +func simulate(delta: float, tick: int) -> void: + _current_object = null + + var input_snapshot := NetworkHistoryServer._get_rollback_input_snapshot(tick) + var state_snapshot := NetworkHistoryServer._get_rollback_state_snapshot(tick) + var nodes := _get_nodes_to_simulate(input_snapshot) + _predicted_nodes.clear() + + # Sort based on SceneTree order + for node in nodes: + node.add_to_group(_group) + nodes = get_tree().get_nodes_in_group(_group) + + # Determine predicted nodes + for node in _callbacks.keys(): + if _is_predicting(input_snapshot, node): + _predicted_nodes.add(node) + + # Run callbacks and clear group + for node in nodes: + _current_object = node + + var callback := _callbacks[node] as Callable + var is_fresh := _is_tick_fresh_for(node, tick) + callback.call(delta, tick, is_fresh) + node.remove_from_group(_group) + + _current_object = null + _set_tick_simulated_for(node, tick) + + # Metrics + NetworkPerformance.push_rollback_nodes_simulated(nodes.size()) + +func _get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: var result: Array[Node] = [] if not input_snapshot: return [] @@ -77,7 +140,7 @@ func get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: return result -func is_predicting(input_snapshot: Snapshot, node: Node) -> bool: +func _is_predicting(input_snapshot: Snapshot, node: Node) -> bool: var input_nodes := [] as Array[Node] input_nodes.assign(_input_graph.get_linked_to(node)) @@ -103,70 +166,29 @@ func is_predicting(input_snapshot: Snapshot, node: Node) -> bool: # We own the node and we have data for node's input - we're sure return false -func is_predicting_current() -> bool: - if not _current_object or not is_instance_valid(_current_object): - return false - return _predicted_nodes.has(_current_object) - -func get_simulated_object() -> Object: - return _current_object - -func is_tick_fresh_for(node: Node, tick: int) -> bool: +func _is_tick_fresh_for(node: Node, tick: int) -> bool: if not _simulated_ticks.has(node): return true var ticks := _simulated_ticks.get(node) as Array[int] return not ticks.has(tick) -func set_tick_simulated_for(node: Node, tick: int) -> void: +func _set_tick_simulated_for(node: Node, tick: int) -> void: if not _simulated_ticks.has(node): _simulated_ticks[node] = [tick] as Array[int] else: _simulated_ticks[node].append(tick) -func trim_ticks_simulated(beginning: int) -> void: +func _trim_ticks_simulated(beginning: int) -> void: for object in _simulated_ticks: _simulated_ticks[object] = _simulated_ticks[object]\ .filter(func(tick): return tick >= beginning) -func simulate(delta: float, tick: int) -> void: - _current_object = null - - var input_snapshot := NetworkHistoryServer._get_rollback_input_snapshot(tick) - var state_snapshot := NetworkHistoryServer._get_rollback_state_snapshot(tick) - var nodes := get_nodes_to_simulate(input_snapshot) - _predicted_nodes.clear() - - # Sort based on SceneTree order - for node in nodes: - node.add_to_group(_group) - nodes = get_tree().get_nodes_in_group(_group) - - # Determine predicted nodes - for node in _callbacks.keys(): - if is_predicting(input_snapshot, node): - _predicted_nodes.add(node) - - # Run callbacks and clear group - for node in nodes: - _current_object = node - - var callback := _callbacks[node] as Callable - var is_fresh := is_tick_fresh_for(node, tick) - callback.call(delta, tick, is_fresh) - node.remove_from_group(_group) - - _current_object = null - set_tick_simulated_for(node, tick) - - # Metrics - NetworkPerformance.push_rollback_nodes_simulated(nodes.size()) - -func get_controlled_by(input: Node) -> Array[Node]: +func _get_controlled_by(input: Node) -> Array[Node]: var result := [] as Array[Node] result.assign(_input_graph.get_linked_from(input)) return result -func get_inputs_of(node: Node) -> Array[Node]: +func _get_inputs_of(node: Node) -> Array[Node]: var result := [] as Array[Node] result.assign(_input_graph.get_linked_to(node)) return result diff --git a/test/netfox/servers/rollback-simulation-server.test.gd b/test/netfox/servers/rollback-simulation-server.test.gd index 42141c2b..b33506bb 100644 --- a/test/netfox/servers/rollback-simulation-server.test.gd +++ b/test/netfox/servers/rollback-simulation-server.test.gd @@ -17,7 +17,7 @@ func suite() -> void: state_node.set_multiplayer_authority(2) RollbackSimulationServer.register_rollback_input_for(state_node, input_node) - expect(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) + expect(RollbackSimulationServer._is_predicting(input_snapshot, state_node)) ) test("should predict owned node without input", func(): @@ -27,7 +27,7 @@ func suite() -> void: RollbackSimulationServer.register_rollback_input_for(state_node, input_node) - expect(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) + expect(RollbackSimulationServer._is_predicting(input_snapshot, state_node)) ) test("should predict non-owned inputless", func(): @@ -36,14 +36,14 @@ func suite() -> void: state_node.set_multiplayer_authority(2) - expect(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) + expect(RollbackSimulationServer._is_predicting(input_snapshot, state_node)) ) test("should not predict owned inputless", func(): var input_snapshot := Snapshot.new(0) var state_node := await get_node() - expect_not(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) + expect_not(RollbackSimulationServer._is_predicting(input_snapshot, state_node)) ) test("should not predict owned with input", func(): @@ -53,7 +53,7 @@ func suite() -> void: RollbackSimulationServer.register_rollback_input_for(state_node, input_node) - expect_not(RollbackSimulationServer.is_predicting(input_snapshot, state_node)) + expect_not(RollbackSimulationServer._is_predicting(input_snapshot, state_node)) ) ) @@ -68,7 +68,7 @@ func suite() -> void: var snapshot := Snapshot.new(1) - expect_empty(server.get_nodes_to_simulate(snapshot)) + expect_empty(server._get_nodes_to_simulate(snapshot)) ) test("should simulate with input", func(): @@ -83,7 +83,7 @@ func suite() -> void: snapshot.set_property(input_node, "editor_description", "Test input node") snapshot.set_auth(input_node, true) - expect_equal(server.get_nodes_to_simulate(snapshot), [node]) + expect_equal(server._get_nodes_to_simulate(snapshot), [node]) ) test("should simulate mutated", func(): @@ -97,7 +97,7 @@ func suite() -> void: var snapshot := Snapshot.new(1) NetworkRollback.mutate(node) - expect_equal(server.get_nodes_to_simulate(snapshot), [node]) + expect_equal(server._get_nodes_to_simulate(snapshot), [node]) ) ) From e7a5b929ca3bbf563cb1d90fdfdc08a47887cfd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 11 Mar 2026 16:29:39 +0100 Subject: [PATCH 92/95] privatize snapshot classes --- addons/netfox/rollback/network-rollback.gd | 4 +- .../serializers/dense-snapshot-serializer.gd | 6 +-- .../redundant-snapshot-serializer.gd | 6 +-- .../serializers/sparse-snapshot-serializer.gd | 6 +-- addons/netfox/servers/data/object-snapshot.gd | 6 +-- .../netfox/servers/data/per-object-history.gd | 22 ++++---- addons/netfox/servers/data/snapshot.gd | 20 ++++---- .../netfox/servers/network-history-server.gd | 20 ++++---- .../servers/network-synchronization-server.gd | 14 +++--- .../servers/rollback-simulation-server.gd | 4 +- .../dense-snapshot-serializer.test.gd | 10 ++-- .../redundant-snapshot-serializer.test.gd | 6 +-- .../sparse-snapshot-serializer.test.gd | 6 +-- test/netfox/servers/data/snapshot.perf.gd | 6 +-- test/netfox/servers/data/snapshot.test.gd | 50 +++++++++---------- .../rollback-simulation-server.test.gd | 16 +++--- 16 files changed, 101 insertions(+), 101 deletions(-) diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 0cd4e652..6d34ca71 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -399,7 +399,7 @@ func _rollback() -> void: _mutated_nodes.clear() _is_rollback = false -func _handle_input(snapshot: Snapshot): +func _handle_input(snapshot: _Snapshot): if snapshot.is_empty(): return if _earliest_input < 0 or snapshot.tick < _earliest_input: @@ -408,7 +408,7 @@ func _handle_input(snapshot: Snapshot): else: _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input]) -func _handle_state(snapshot: Snapshot): +func _handle_state(snapshot: _Snapshot): if snapshot.is_empty(): return if _earliest_state < 0 or snapshot.tick < _earliest_state: diff --git a/addons/netfox/serializers/dense-snapshot-serializer.gd b/addons/netfox/serializers/dense-snapshot-serializer.gd index e3efc878..5db0b834 100644 --- a/addons/netfox/serializers/dense-snapshot-serializer.gd +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd @@ -4,7 +4,7 @@ class_name _DenseSnapshotSerializer static func _static_init(): _logger = NetfoxLogger._for_netfox("DenseSnapshotSerializer") -func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, filter: Callable = _default_filter, buffer: StreamPeerBuffer = null) -> PackedByteArray: +func write_for(peer: int, snapshot: _Snapshot, properties: _PropertyPool, filter: Callable = _default_filter, buffer: StreamPeerBuffer = null) -> PackedByteArray: if buffer == null: buffer = StreamPeerBuffer.new() @@ -53,14 +53,14 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, filter: else: return PackedByteArray() -func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: +func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, is_auth: bool = true) -> _Snapshot: var netref := NetworkSchemas._netref() var varuint := NetworkSchemas.varuint() var node_buffer := StreamPeerBuffer.new() # Read tick var tick := buffer.get_u32() - var snapshot := Snapshot.new(tick) + var snapshot := _Snapshot.new(tick) while buffer.get_available_bytes() > 0: # Read identity reference, data size, and data diff --git a/addons/netfox/serializers/redundant-snapshot-serializer.gd b/addons/netfox/serializers/redundant-snapshot-serializer.gd index 8cf1cc2b..b8a8fc45 100644 --- a/addons/netfox/serializers/redundant-snapshot-serializer.gd +++ b/addons/netfox/serializers/redundant-snapshot-serializer.gd @@ -10,7 +10,7 @@ func _init(p_schemas: _NetworkSchema, p_identity_server: _NetworkIdentityServer super(p_schemas) _dense_serializer = _DenseSnapshotSerializer.new(_schemas, p_identity_server) -func write_for(peer: int, snapshots: Array[Snapshot], properties: _PropertyPool, buffer: StreamPeerBuffer = null) -> PackedByteArray: +func write_for(peer: int, snapshots: Array[_Snapshot], properties: _PropertyPool, buffer: StreamPeerBuffer = null) -> PackedByteArray: var varuint := NetworkSchemas.varuint() if buffer == null: @@ -26,10 +26,10 @@ func write_for(peer: int, snapshots: Array[Snapshot], properties: _PropertyPool, return buffer.data_array -func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, is_auth: bool = true) -> Array[Snapshot]: +func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, is_auth: bool = true) -> Array[_Snapshot]: var varuint := NetworkSchemas.varuint() - var snapshots := [] as Array[Snapshot] + var snapshots := [] as Array[_Snapshot] while buffer.get_available_bytes() > 0: var snapshot_size := varuint.decode(buffer) var snapshot_buffer := StreamPeerBuffer.new() diff --git a/addons/netfox/serializers/sparse-snapshot-serializer.gd b/addons/netfox/serializers/sparse-snapshot-serializer.gd index 5bf92d75..1705ce8c 100644 --- a/addons/netfox/serializers/sparse-snapshot-serializer.gd +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd @@ -4,7 +4,7 @@ class_name _SparseSnapshotSerializer static func _static_init(): _logger = NetfoxLogger._for_netfox("SparseSnapshotSerializer") -func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, filter: Callable = _default_filter, buffer: StreamPeerBuffer = null) -> PackedByteArray: +func write_for(peer: int, snapshot: _Snapshot, properties: _PropertyPool, filter: Callable = _default_filter, buffer: StreamPeerBuffer = null) -> PackedByteArray: if buffer == null: buffer = StreamPeerBuffer.new() @@ -57,7 +57,7 @@ func write_for(peer: int, snapshot: Snapshot, properties: _PropertyPool, filter: # Return an empty buffer if we ended up not serializing anything return PackedByteArray() -func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, is_auth: bool = true) -> Snapshot: +func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, is_auth: bool = true) -> _Snapshot: var netref := NetworkSchemas._netref() var varuint := NetworkSchemas.varuint() var varbits := NetworkSchemas._varbits() @@ -65,7 +65,7 @@ func read_from(peer: int, properties: _PropertyPool, buffer: StreamPeerBuffer, i # Grab ticks var tick := buffer.get_u32() - var snapshot := Snapshot.new(tick) + var snapshot := _Snapshot.new(tick) while buffer.get_available_bytes() > 0: # Read header, including identity reference diff --git a/addons/netfox/servers/data/object-snapshot.gd b/addons/netfox/servers/data/object-snapshot.gd index 588c0782..4119df85 100644 --- a/addons/netfox/servers/data/object-snapshot.gd +++ b/addons/netfox/servers/data/object-snapshot.gd @@ -1,5 +1,5 @@ extends RefCounted -class_name ObjectSnapshot +class_name _ObjectSnapshot # Represents snapshot data for a single object, by storing the object's values # for specified properties @@ -11,8 +11,8 @@ var _data: Dictionary = {} func _init(p_object: Object) -> void: _object = p_object -func duplicate() -> ObjectSnapshot: - var result := ObjectSnapshot.new(_object) +func duplicate() -> _ObjectSnapshot: + var result := _ObjectSnapshot.new(_object) result._is_auth = _is_auth result._data = _data.duplicate() return result diff --git a/addons/netfox/servers/data/per-object-history.gd b/addons/netfox/servers/data/per-object-history.gd index a6ee04f6..6f0f9f84 100644 --- a/addons/netfox/servers/data/per-object-history.gd +++ b/addons/netfox/servers/data/per-object-history.gd @@ -23,13 +23,13 @@ func is_auth(tick: int, subject: Object) -> bool: if not history.has_at(tick): return false - var snapshot := history.get_at(tick) as ObjectSnapshot + var snapshot := history.get_at(tick) as _ObjectSnapshot return snapshot.is_auth() func erase_subject(subject: Object) -> void: _data.erase(subject) -func ensure_snapshot(tick: int, subject: Object, carry_forward: bool) -> ObjectSnapshot: +func ensure_snapshot(tick: int, subject: Object, carry_forward: bool) -> _ObjectSnapshot: var has_subject := _data.has(subject) if not _data.has(subject): _data[subject] = _HistoryBuffer.new(_history_size) @@ -40,16 +40,16 @@ func ensure_snapshot(tick: int, subject: Object, carry_forward: bool) -> ObjectS if not history.has_at(tick): if not history.has_latest_at(tick): - history.set_at(tick, ObjectSnapshot.new(subject)) + history.set_at(tick, _ObjectSnapshot.new(subject)) elif carry_forward: history.set_at(tick, history.get_latest_at(tick).duplicate()) else: - history.set_at(tick, ObjectSnapshot.new(subject)) + history.set_at(tick, _ObjectSnapshot.new(subject)) assert(history.get_at(tick) != null, "Failed to ensure snapshot!") - return history.get_at(tick) as ObjectSnapshot + return history.get_at(tick) as _ObjectSnapshot -func get_latest_snapshot(tick: int, subject: Object) -> ObjectSnapshot: +func get_latest_snapshot(tick: int, subject: Object) -> _ObjectSnapshot: if not _data.has(subject): return null @@ -57,7 +57,7 @@ func get_latest_snapshot(tick: int, subject: Object) -> ObjectSnapshot: if not history.has_latest_at(tick): return null - return history.get_latest_at(tick) as ObjectSnapshot + return history.get_latest_at(tick) as _ObjectSnapshot func get_latest_tick(tick: int, subject: Object) -> int: if not _data.has(subject): @@ -75,9 +75,9 @@ func set_property(tick: int, subject: Object, property: NodePath, value: Variant var history := _data[subject] as _HistoryBuffer if not history.has_at(tick): - history.set_at(tick, ObjectSnapshot.new(subject)) + history.set_at(tick, _ObjectSnapshot.new(subject)) - var snapshot := history.get_at(tick) as ObjectSnapshot + var snapshot := history.get_at(tick) as _ObjectSnapshot snapshot.set_value(property, value) func has_property(tick: int, subject: Object, property: NodePath) -> bool: @@ -88,7 +88,7 @@ func has_property(tick: int, subject: Object, property: NodePath) -> bool: if not history.has_at(tick): return false - var snapshot := history.get_at(tick) as ObjectSnapshot + var snapshot := history.get_at(tick) as _ObjectSnapshot return snapshot.has_value(property) func get_property(tick: int, subject: Object, property: NodePath, default: Variant = null) -> Variant: @@ -99,5 +99,5 @@ func get_property(tick: int, subject: Object, property: NodePath, default: Varia if not history.has_at(tick): return default - var snapshot := history.get_at(tick) as ObjectSnapshot + var snapshot := history.get_at(tick) as _ObjectSnapshot return snapshot.get_value(property, default) diff --git a/addons/netfox/servers/data/snapshot.gd b/addons/netfox/servers/data/snapshot.gd index e3124ea7..12963efb 100644 --- a/addons/netfox/servers/data/snapshot.gd +++ b/addons/netfox/servers/data/snapshot.gd @@ -1,5 +1,5 @@ extends RefCounted -class_name Snapshot +class_name _Snapshot # Stores property values of multiple subjects, recorded for a specific tick @@ -7,8 +7,8 @@ var tick: int var _data := {} # object to (property to variant) var _auth_subjects := _Set.new() -static func make_patch(from: Snapshot, to: Snapshot, tick: int = to.tick) -> Snapshot: - var patch := Snapshot.new(tick) +static func make_patch(from: _Snapshot, to: _Snapshot, tick: int = to.tick) -> _Snapshot: + var patch := _Snapshot.new(tick) for subject in from._data: # Target has no knowledge of subject, don't patch @@ -27,8 +27,8 @@ static func make_patch(from: Snapshot, to: Snapshot, tick: int = to.tick) -> Sna return patch # Each entry should be [subject, property, value] -static func of(tick: int, entries: Array[Array], auth_subjects: Array[Object]) -> Snapshot: - var snapshot := Snapshot.new(tick) +static func of(tick: int, entries: Array[Array], auth_subjects: Array[Object]) -> _Snapshot: + var snapshot := _Snapshot.new(tick) for entry in entries: var subject := entry[0] as Object var property := entry[1] as NodePath @@ -44,8 +44,8 @@ static func of(tick: int, entries: Array[Array], auth_subjects: Array[Object]) - func _init(p_tick: int): tick = p_tick -func duplicate() -> Snapshot: - var result := Snapshot.new(tick) +func duplicate() -> _Snapshot: + var result := _Snapshot.new(tick) result._data = _data.duplicate(true) result._auth_subjects = _auth_subjects.duplicate() return result @@ -76,7 +76,7 @@ func has_property(subject: Object, property: NodePath) -> bool: return false return true -func merge(snapshot: Snapshot) -> bool: +func merge(snapshot: _Snapshot) -> bool: var has_changed := false for subject in snapshot._data: @@ -149,13 +149,13 @@ func is_auth(subject: Object) -> bool: return _auth_subjects.has(subject) func equals(other) -> bool: - if other is Snapshot: + if other is _Snapshot: return tick == other.tick and _data == other._data and _auth_subjects.equals(other._auth_subjects) else: return false func _to_string() -> String: - var result := "Snapshot(#%d" % [tick] + var result := "_Snapshot(#%d" % [tick] for subject in _data: for property in _data[subject]: var value = _data[subject][property] diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 83b00eea..e3ae09bd 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -122,29 +122,29 @@ func _restore_rollback_state(tick: int) -> bool: func _restore_synchronizer_state(tick: int) -> bool: return _restore_latest(tick, _sync_history) -func _get_rollback_input_snapshot(tick: int) -> Snapshot: +func _get_rollback_input_snapshot(tick: int) -> _Snapshot: return _rb_input_snapshots.get_at(tick) -func _get_rollback_state_snapshot(tick: int) -> Snapshot: +func _get_rollback_state_snapshot(tick: int) -> _Snapshot: return _rb_state_snapshots.get_at(tick) -func _get_synchronizer_state_snapshot(tick: int) -> Snapshot: +func _get_synchronizer_state_snapshot(tick: int) -> _Snapshot: return _sync_state_snapshots.get_at(tick) -func _merge_rollback_input(snapshot: Snapshot) -> bool: +func _merge_rollback_input(snapshot: _Snapshot) -> bool: _merge_snapshot(snapshot, _rb_input_snapshots, true) return _merge_history(snapshot, _rb_input_history, true) -func _merge_rollback_state(snapshot: Snapshot) -> bool: +func _merge_rollback_state(snapshot: _Snapshot) -> bool: _merge_snapshot(snapshot, _rb_state_snapshots, true) return _merge_history(snapshot, _rb_state_history) -func _merge_synchronizer_state(snapshot: Snapshot) -> bool: +func _merge_synchronizer_state(snapshot: _Snapshot) -> bool: _merge_snapshot(snapshot, _sync_state_snapshots, true) return _merge_history(snapshot, _sync_history) func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: - var snapshot := snapshots.get_at(tick, Snapshot.new(tick)) as Snapshot + var snapshot := snapshots.get_at(tick, _Snapshot.new(tick)) as _Snapshot if not snapshots.has_at(tick): snapshots.set_at(tick, snapshot) @@ -190,14 +190,14 @@ func _restore_latest(tick: int, history: _PerObjectHistory) -> bool: return any_applied -func _merge_snapshot(snapshot: Snapshot, snapshots: _HistoryBuffer, reverse: bool = false) -> bool: +func _merge_snapshot(snapshot: _Snapshot, snapshots: _HistoryBuffer, reverse: bool = false) -> bool: var tick := snapshot.tick if not snapshots.has_at(snapshot.tick): snapshots.set_at(tick, snapshot) return true - var original_snapshot := snapshots.get_at(tick) as Snapshot + var original_snapshot := snapshots.get_at(tick) as _Snapshot if reverse: var original_subjects := original_snapshot.get_auth_subjects() var incoming_subjects := snapshot.get_auth_subjects() @@ -213,7 +213,7 @@ func _merge_snapshot(snapshot: Snapshot, snapshots: _HistoryBuffer, reverse: boo else: return original_snapshot.merge(snapshot) -func _merge_history(snapshot: Snapshot, history: _PerObjectHistory, reverse: bool = false) -> bool: +func _merge_history(snapshot: _Snapshot, history: _PerObjectHistory, reverse: bool = false) -> bool: var tick := snapshot.tick var has_updated := false diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index ec60ccaa..8a7289f8 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -37,7 +37,7 @@ var _rb_full_scheduler := _IntervalScheduler.new(_rb_full_interval) var _input_redundancy := NetworkRollback.input_redundancy -var _last_sync_state_sent := Snapshot.new(0) +var _last_sync_state_sent := _Snapshot.new(0) var _sync_enable_diffs := ProjectSettings.get_setting("netfox/state_synchronizer/enable_diff_states", true) as bool var _sync_full_interval := ProjectSettings.get_setting("netfox/state_synchronizer/full_state_interval", 24) as int var _sync_full_scheduler := _IntervalScheduler.new(_sync_full_interval) @@ -57,8 +57,8 @@ var _cmd_diff_sync: NetworkCommandServer.Command static var _logger := NetfoxLogger._for_netfox("NetworkSynchronizationServer") -signal _on_input(snapshot: Snapshot) -signal _on_state(snapshot: Snapshot) +signal _on_input(snapshot: _Snapshot) +signal _on_state(snapshot: _Snapshot) func register_rollback_state(node: Node, property: NodePath) -> void: _rb_state_properties.add(node, property) @@ -123,7 +123,7 @@ func synchronize_input(tick: int) -> void: if _rb_owned_input_properties.is_empty(): return - var snapshots := [] as Array[Snapshot] + var snapshots := [] as Array[_Snapshot] var notified_peers := _Set.new() if not _rb_enable_input_broadcast: @@ -201,7 +201,7 @@ func synchronize_state(tick: int) -> void: NetworkPerformance.push_full_state_props(snapshot.size()) NetworkPerformance.push_sent_state_props(snapshot.size()) else: - var diff := Snapshot.make_patch(reference_snapshot, snapshot) + var diff := _Snapshot.make_patch(reference_snapshot, snapshot) if diff.is_empty(): # Nothing changed, don't send anything return @@ -250,7 +250,7 @@ func synchronize_sync_state(tick: int) -> void: NetworkPerformance.push_full_state_props(snapshot.size()) NetworkPerformance.push_sent_state_props(snapshot.size()) else: - var diff := Snapshot.make_patch(_last_sync_state_sent, snapshot) + var diff := _Snapshot.make_patch(_last_sync_state_sent, snapshot) # Send diffs for peer in multiplayer.get_peers(): @@ -351,7 +351,7 @@ func _handle_diff_sync(sender: int, data: PackedByteArray): NetworkHistoryServer._merge_synchronizer_state(snapshot) _logger.trace("Ingested sync diff: %s", [snapshot]) -func _ingest_state(sender: int, snapshot: Snapshot) -> void: +func _ingest_state(sender: int, snapshot: _Snapshot) -> void: snapshot.sanitize(sender) NetworkHistoryServer._merge_rollback_state(snapshot) diff --git a/addons/netfox/servers/rollback-simulation-server.gd b/addons/netfox/servers/rollback-simulation-server.gd index 00db606c..e6ae999b 100644 --- a/addons/netfox/servers/rollback-simulation-server.gd +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -112,7 +112,7 @@ func simulate(delta: float, tick: int) -> void: # Metrics NetworkPerformance.push_rollback_nodes_simulated(nodes.size()) -func _get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: +func _get_nodes_to_simulate(input_snapshot: _Snapshot) -> Array[Node]: var result: Array[Node] = [] if not input_snapshot: return [] @@ -140,7 +140,7 @@ func _get_nodes_to_simulate(input_snapshot: Snapshot) -> Array[Node]: return result -func _is_predicting(input_snapshot: Snapshot, node: Node) -> bool: +func _is_predicting(input_snapshot: _Snapshot, node: Node) -> bool: var input_nodes := [] as Array[Node] input_nodes.assign(_input_graph.get_linked_to(node)) diff --git a/test/netfox/serializers/dense-snapshot-serializer.test.gd b/test/netfox/serializers/dense-snapshot-serializer.test.gd index c19416fd..9ed52621 100644 --- a/test/netfox/serializers/dense-snapshot-serializer.test.gd +++ b/test/netfox/serializers/dense-snapshot-serializer.test.gd @@ -11,7 +11,7 @@ func suite() -> void: var subject := await get_subject() NetworkIdentityServer.register_node(subject) - var snapshot := Snapshot.of(0, [ + var snapshot := _Snapshot.of(0, [ [subject, "position", Vector3.ZERO], [subject, "quaternion", Quaternion.from_euler(Vector3.ONE)], [subject, "scale", Vector3.ONE] @@ -54,12 +54,12 @@ func suite() -> void: reader_identity_server.register_node(known_subject) - var snapshot := Snapshot.of(0, [ + var snapshot := _Snapshot.of(0, [ [unknown_subject, "position", Vector3.ZERO], [known_subject, "position", Vector3.ZERO] ], [known_subject, unknown_subject]) - var expected := Snapshot.of(0, [ + var expected := _Snapshot.of(0, [ [known_subject, "position", Vector3.ZERO] ], [known_subject]) @@ -76,7 +76,7 @@ func suite() -> void: var subject := await get_subject() NetworkIdentityServer.register_node(subject) - var snapshot := Snapshot.of(0, [ + var snapshot := _Snapshot.of(0, [ [subject, "position", Vector3.ZERO], [subject, "quaternion", Quaternion.from_euler(Vector3.ONE)], [subject, "scale", Vector3.ONE] @@ -88,7 +88,7 @@ func suite() -> void: [subject, "scale"] ]) - var expected := Snapshot.of(0, [ + var expected := _Snapshot.of(0, [ [subject, "position", Vector3.ZERO], [subject, "quaternion", Quaternion.from_euler(Vector3.ONE)], [subject, "scale", null] diff --git a/test/netfox/serializers/redundant-snapshot-serializer.test.gd b/test/netfox/serializers/redundant-snapshot-serializer.test.gd index d445c23c..5b77fce5 100644 --- a/test/netfox/serializers/redundant-snapshot-serializer.test.gd +++ b/test/netfox/serializers/redundant-snapshot-serializer.test.gd @@ -14,17 +14,17 @@ func suite() -> void: NetworkIdentityServer.register_node(subject) var snapshots := [ - Snapshot.of(0, [ + _Snapshot.of(0, [ [subject, "position", Vector3(0., 0., 0.)], [subject, "quaternion", Quaternion.from_euler(Vector3.ONE)], [subject, "scale", Vector3(1., 1., 1.)] ], [subject]), - Snapshot.of(1, [ + _Snapshot.of(1, [ [subject, "position", Vector3(1., 0., 0.)], [subject, "quaternion", Quaternion.from_euler(Vector3.ZERO)], [subject, "scale", Vector3(1., .5, 1.)] ], [subject]) - ] as Array[Snapshot] + ] as Array[_Snapshot] var props := _PropertyPool.of([ [subject, "position"], diff --git a/test/netfox/serializers/sparse-snapshot-serializer.test.gd b/test/netfox/serializers/sparse-snapshot-serializer.test.gd index 6ae87098..cc0d8dd3 100644 --- a/test/netfox/serializers/sparse-snapshot-serializer.test.gd +++ b/test/netfox/serializers/sparse-snapshot-serializer.test.gd @@ -13,7 +13,7 @@ func suite() -> void: await subject.ready NetworkIdentityServer.register_node(subject) - var snapshot := Snapshot.of(0, [ + var snapshot := _Snapshot.of(0, [ [subject, "position", Vector3.ZERO], [subject, "quaternion", Quaternion.from_euler(Vector3.ONE)], [subject, "scale", Vector3.ONE] @@ -56,12 +56,12 @@ func suite() -> void: reader_identity_server.register_node(known_subject) - var snapshot := Snapshot.of(0, [ + var snapshot := _Snapshot.of(0, [ [unknown_subject, "position", Vector3.ZERO], [known_subject, "position", Vector3.ZERO] ], [known_subject, unknown_subject]) - var expected := Snapshot.of(0, [ + var expected := _Snapshot.of(0, [ [known_subject, "position", Vector3.ZERO] ], [known_subject]) diff --git a/test/netfox/servers/data/snapshot.perf.gd b/test/netfox/servers/data/snapshot.perf.gd index 27412607..478ae674 100644 --- a/test/netfox/servers/data/snapshot.perf.gd +++ b/test/netfox/servers/data/snapshot.perf.gd @@ -14,7 +14,7 @@ func suite() -> void: var nodes := get_nodes(node_count) - var snapshot := Snapshot.new(0) + var snapshot := _Snapshot.new(0) for node in nodes: for i in prop_count: var prop := "property%d" % (i + 1) @@ -37,8 +37,8 @@ func suite() -> void: var nodes := get_nodes(node_count) - var base_snapshot := Snapshot.new(0) - var patch_snapshot := Snapshot.new(0) + var base_snapshot := _Snapshot.new(0) + var patch_snapshot := _Snapshot.new(0) for node in nodes: for i in prop_count: diff --git a/test/netfox/servers/data/snapshot.test.gd b/test/netfox/servers/data/snapshot.test.gd index c9a7a3cb..115e9dbd 100644 --- a/test/netfox/servers/data/snapshot.test.gd +++ b/test/netfox/servers/data/snapshot.test.gd @@ -9,61 +9,61 @@ func suite() -> void: define("make_patch()", func(): test("should return empty on same", func(): - var snapshot := Snapshot.of(0, [ + var snapshot := _Snapshot.of(0, [ [node, "position", Vector3.ZERO], [node, "scale", Vector3.ONE] ], [node]) - expect_empty(Snapshot.make_patch(snapshot, snapshot)) + expect_empty(_Snapshot.make_patch(snapshot, snapshot)) ) test("should include differing property", func(): - var from := Snapshot.of(0, [ + var from := _Snapshot.of(0, [ [node, "position", Vector3.ZERO], [node, "scale", Vector3.ONE] ], [node]) - var to := Snapshot.of(0, [ + var to := _Snapshot.of(0, [ [node, "position", Vector3.ONE], [node, "scale", Vector3.ONE] ], [node]) - var expected := Snapshot.of(0, [ + var expected := _Snapshot.of(0, [ [node, "position", Vector3.ONE] ], [node]) - expect_equal(Snapshot.make_patch(from, to), expected) + expect_equal(_Snapshot.make_patch(from, to), expected) ) test("should include new property", func(): - var from := Snapshot.of(0, [ + var from := _Snapshot.of(0, [ [node, "position", Vector3.ZERO] ], [node]) - var to := Snapshot.of(0, [ + var to := _Snapshot.of(0, [ [node, "position", Vector3.ZERO], [node, "scale", Vector3.ONE] ], [node]) - var expected := Snapshot.of(0, [ + var expected := _Snapshot.of(0, [ [node, "scale", Vector3.ONE] ], [node]) - expect_equal(Snapshot.make_patch(from, to), expected) + expect_equal(_Snapshot.make_patch(from, to), expected) ) test("patch should yield `to` on merge", func(): - var from := Snapshot.of(0, [ + var from := _Snapshot.of(0, [ [node, "position", Vector3.ZERO], [node, "scale", Vector3.ONE] ], [node]) - var to := Snapshot.of(0, [ + var to := _Snapshot.of(0, [ [node, "position", Vector3.ONE], [node, "scale", Vector3.ONE] ], [node]) - var patch := Snapshot.make_patch(from, to) + var patch := _Snapshot.make_patch(from, to) var applied := from.duplicate() applied.tick = to.tick applied.merge(patch) @@ -74,72 +74,72 @@ func suite() -> void: define("merge()", func(): test("auth should override non-auth", func(): - var snapshot := Snapshot.of(0, [ + var snapshot := _Snapshot.of(0, [ [node, "position", Vector3.ZERO], [other_node, "position", Vector3.ZERO] ], []) - var patch := Snapshot.of(0, [ + var patch := _Snapshot.of(0, [ [node, "position", Vector3.ONE], [other_node, "position", Vector3.ONE] ], [node]) expect_true(snapshot.merge(patch)) - expect_equal(snapshot, Snapshot.of(0, [ + expect_equal(snapshot, _Snapshot.of(0, [ [node, "position", Vector3.ONE], [other_node, "position", Vector3.ONE] ], [node])) ) test("auth should update auth", func(): - var snapshot := Snapshot.of(0, [ + var snapshot := _Snapshot.of(0, [ [node, "position", Vector3.ZERO], [other_node, "position", Vector3.ZERO] ], [node]) - var patch := Snapshot.of(0, [ + var patch := _Snapshot.of(0, [ [node, "position", Vector3.ONE], [other_node, "position", Vector3.ONE] ], [node]) expect_true(snapshot.merge(patch)) - expect_equal(snapshot, Snapshot.of(0, [ + expect_equal(snapshot, _Snapshot.of(0, [ [node, "position", Vector3.ONE], [other_node, "position", Vector3.ONE] ], [node])) ) test("non-auth should not update auth", func(): - var snapshot := Snapshot.of(0, [ + var snapshot := _Snapshot.of(0, [ [node, "position", Vector3.ZERO], [other_node, "position", Vector3.ZERO] ], [node]) - var patch := Snapshot.of(0, [ + var patch := _Snapshot.of(0, [ [node, "position", Vector3.ONE], [other_node, "position", Vector3.ONE] ], []) expect_true(snapshot.merge(patch)) - expect_equal(snapshot, Snapshot.of(0, [ + expect_equal(snapshot, _Snapshot.of(0, [ [node, "position", Vector3.ZERO], [other_node, "position", Vector3.ONE] ], [node])) ) test("non-auth should update non-auth", func(): - var snapshot := Snapshot.of(0, [ + var snapshot := _Snapshot.of(0, [ [node, "position", Vector3.ZERO], [other_node, "position", Vector3.ZERO] ], []) - var patch := Snapshot.of(0, [ + var patch := _Snapshot.of(0, [ [node, "position", Vector3.ONE], [other_node, "position", Vector3.ONE] ], []) expect_true(snapshot.merge(patch)) - expect_equal(snapshot, Snapshot.of(0, [ + expect_equal(snapshot, _Snapshot.of(0, [ [node, "position", Vector3.ONE], [other_node, "position", Vector3.ONE] ], [])) diff --git a/test/netfox/servers/rollback-simulation-server.test.gd b/test/netfox/servers/rollback-simulation-server.test.gd index b33506bb..007df60a 100644 --- a/test/netfox/servers/rollback-simulation-server.test.gd +++ b/test/netfox/servers/rollback-simulation-server.test.gd @@ -12,7 +12,7 @@ func suite() -> void: test("should predict non-owned node", func(): var state_node := await get_node() var input_node := await get_node() - var input_snapshot := Snapshot.of(1, [[input_node, "name", "Input"]], []) + var input_snapshot := _Snapshot.of(1, [[input_node, "name", "Input"]], []) state_node.set_multiplayer_authority(2) RollbackSimulationServer.register_rollback_input_for(state_node, input_node) @@ -21,7 +21,7 @@ func suite() -> void: ) test("should predict owned node without input", func(): - var input_snapshot := Snapshot.new(0) + var input_snapshot := _Snapshot.new(0) var state_node := await get_node() var input_node := await get_node() @@ -31,7 +31,7 @@ func suite() -> void: ) test("should predict non-owned inputless", func(): - var input_snapshot := Snapshot.new(0) + var input_snapshot := _Snapshot.new(0) var state_node := await get_node() state_node.set_multiplayer_authority(2) @@ -40,7 +40,7 @@ func suite() -> void: ) test("should not predict owned inputless", func(): - var input_snapshot := Snapshot.new(0) + var input_snapshot := _Snapshot.new(0) var state_node := await get_node() expect_not(RollbackSimulationServer._is_predicting(input_snapshot, state_node)) @@ -49,7 +49,7 @@ func suite() -> void: test("should not predict owned with input", func(): var state_node := await get_node() var input_node := await get_node() - var input_snapshot := Snapshot.of(1, [[input_node, "name", "Input"]], [input_node]) + var input_snapshot := _Snapshot.of(1, [[input_node, "name", "Input"]], [input_node]) RollbackSimulationServer.register_rollback_input_for(state_node, input_node) @@ -66,7 +66,7 @@ func suite() -> void: server.register(node._rollback_tick) server.register_rollback_input_for(node, input_node) - var snapshot := Snapshot.new(1) + var snapshot := _Snapshot.new(1) expect_empty(server._get_nodes_to_simulate(snapshot)) ) @@ -79,7 +79,7 @@ func suite() -> void: server.register(node._rollback_tick) server.register_rollback_input_for(node, input_node) - var snapshot := Snapshot.new(1) + var snapshot := _Snapshot.new(1) snapshot.set_property(input_node, "editor_description", "Test input node") snapshot.set_auth(input_node, true) @@ -94,7 +94,7 @@ func suite() -> void: server.register(node._rollback_tick) server.register_rollback_input_for(node, input_node) - var snapshot := Snapshot.new(1) + var snapshot := _Snapshot.new(1) NetworkRollback.mutate(node) expect_equal(server._get_nodes_to_simulate(snapshot), [node]) From 373cc790804aa083a64ff9b414a4ac0ac023473a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 11 Mar 2026 22:27:34 +0100 Subject: [PATCH 93/95] document and privatize sync server --- addons/netfox/network-time.gd | 2 +- addons/netfox/rollback/network-rollback.gd | 4 +- .../servers/network-synchronization-server.gd | 37 +++++++++++++++---- .../network-synchronization-server.test.gd | 2 +- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 87baf16a..3077c201 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -564,7 +564,7 @@ func _loop() -> void: after_tick.emit(ticktime, tick) NetworkHistoryServer._record_sync_state(tick + 1) - NetworkSynchronizationServer.synchronize_sync_state(tick + 1) + NetworkSynchronizationServer._synchronize_sync_state(tick + 1) _tick += 1 ticks_in_loop += 1 diff --git a/addons/netfox/rollback/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 6d34ca71..5ce08809 100644 --- a/addons/netfox/rollback/network-rollback.gd +++ b/addons/netfox/rollback/network-rollback.gd @@ -301,7 +301,7 @@ func _ready(): NetworkTime.after_tick_loop.connect(_rollback) NetworkTime.after_tick.connect(func(_dt, tick): NetworkHistoryServer._record_rollback_input(tick + input_delay) - NetworkSynchronizationServer.synchronize_input(tick + input_delay) + NetworkSynchronizationServer._synchronize_input(tick + input_delay) ) NetworkSynchronizationServer._on_input.connect(_handle_input) @@ -387,7 +387,7 @@ func _rollback() -> void: _rollback_stage = _STAGE_RECORD on_record_tick.emit(tick + 1) NetworkHistoryServer._record_rollback_state(tick + 1) - NetworkSynchronizationServer.synchronize_state(tick + 1) + NetworkSynchronizationServer._synchronize_state(tick + 1) # Restore display state _rollback_stage = _STAGE_AFTER diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 8a7289f8..f150b9bc 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -60,48 +60,69 @@ static var _logger := NetfoxLogger._for_netfox("NetworkSynchronizationServer") signal _on_input(snapshot: _Snapshot) signal _on_state(snapshot: _Snapshot) +## Register a [param]property[/param] of [param]node[/param] to be synchronized +## as rollback state func register_rollback_state(node: Node, property: NodePath) -> void: _rb_state_properties.add(node, property) if node.is_multiplayer_authority(): _rb_owned_state_properties.add(node, property) +## Deregister a [param]property[/param] of [param]node[/param] from being +## synchronized as rollback state func deregister_rollback_state(node: Node, property: NodePath) -> void: _rb_state_properties.erase(node, property) _rb_owned_state_properties.erase(node, property) +## Register a [param]property[/param] of [param]node[/param] to be synchronized +## as rollback input func register_rollback_input(node: Node, property: NodePath) -> void: _rb_input_properties.add(node, property) if node.is_multiplayer_authority(): _rb_owned_input_properties.add(node, property) +## Deregister a [param]property[/param] of [param]node[/param] from being +## synchronized as rollback input func deregister_rollback_input(node: Node, property: NodePath) -> void: _rb_input_properties.erase(node, property) _rb_owned_input_properties.erase(node, property) +## Register a [param]property[/param] of [param]node[/param] to be synchronized +## as synchronized state func register_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.add(node, property) if node.is_multiplayer_authority(): _sync_owned_state_properties.add(node, property) +## Deregister a [param]property[/param] of [param]node[/param] from being +## synchronized as synchronized state func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.erase(node, property) _sync_owned_state_properties.erase(node, property) +## Register a [param]serializer[/param] to use when transmitting +## [param]property[/param] of [param]node[/param] over the network func register_schema(node: Node, property: NodePath, serializer: NetworkSchemaSerializer) -> void: _schemas.add(node, property, serializer) +## Deregister any serializers used for [param]property[/param] on +## [param]node[/param] when transmitting over the network func deregister_schema(node: Node, property: NodePath) -> void: _schemas.erase(node, property) +## Deregister all serializers registered for any properties of +## [param]node[/param] func deregister_schema_for(node: Node) -> void: _schemas.erase_subject(node) +## Register a visibility [param]filter[/param] for use with [param]node[/param] func register_visibility_filter(node: Node, filter: PeerVisibilityFilter) -> void: _visibility_filters[node] = filter +## Deregister the visibility filter used for [param]node[/param] func deregister_visibility_filter(node: Node) -> void: _visibility_filters.erase(node) +## Deregister any and all settings associated with [param]node[/param] func deregister(node: Node) -> void: _rb_state_properties.erase_subject(node) _rb_input_properties.erase_subject(node) @@ -111,14 +132,14 @@ func deregister(node: Node) -> void: _visibility_filters.erase(node) _schemas.erase_subject(node) -func is_node_visible_to(peer: int, node: Node) -> bool: +func _is_node_visible_to(peer: int, node: Node) -> bool: var filter := _visibility_filters.get(node) as PeerVisibilityFilter if not filter: return true else: return filter.get_visible_peers().has(peer) -func synchronize_input(tick: int) -> void: +func _synchronize_input(tick: int) -> void: # We don't own inputs, nothing to synchronize if _rb_owned_input_properties.is_empty(): return @@ -161,7 +182,7 @@ func synchronize_input(tick: int) -> void: var data := _redundant_serializer.write_for(peer, snapshots, _rb_owned_input_properties) _cmd_input.send(data, peer) -func synchronize_state(tick: int) -> void: +func _synchronize_state(tick: int) -> void: # We don't own state, nothing to synchronize if _rb_owned_state_properties.is_empty(): return @@ -189,7 +210,7 @@ func synchronize_state(tick: int) -> void: if is_full: # Send full states for peer in multiplayer.get_peers(): - var filter := func(subject): return is_node_visible_to(peer, subject) + var filter := func(subject): return _is_node_visible_to(peer, subject) var data := _dense_serializer.write_for(peer, snapshot, _rb_owned_state_properties, filter) if data.is_empty(): @@ -208,7 +229,7 @@ func synchronize_state(tick: int) -> void: # Send diff states for peer in multiplayer.get_peers(): - var filter := func(subject): return is_node_visible_to(peer, subject) + var filter := func(subject): return _is_node_visible_to(peer, subject) var data := _sparse_serializer.write_for(peer, diff, _rb_owned_state_properties, filter) if data.is_empty(): @@ -220,7 +241,7 @@ func synchronize_state(tick: int) -> void: NetworkPerformance.push_full_state_props(snapshot.size()) NetworkPerformance.push_sent_state_props(diff.size()) -func synchronize_sync_state(tick: int) -> void: +func _synchronize_sync_state(tick: int) -> void: # We don't own sync state, nothing to synchronize if _sync_owned_state_properties.is_empty(): return @@ -238,7 +259,7 @@ func synchronize_sync_state(tick: int) -> void: if is_full: # Send full states for peer in multiplayer.get_peers(): - var filter := func(subject): return is_node_visible_to(peer, subject) + var filter := func(subject): return _is_node_visible_to(peer, subject) var data := _dense_serializer.write_for(peer, snapshot, _sync_owned_state_properties, filter) if data.is_empty(): @@ -254,7 +275,7 @@ func synchronize_sync_state(tick: int) -> void: # Send diffs for peer in multiplayer.get_peers(): - var filter := func(subject): return is_node_visible_to(peer, subject) + var filter := func(subject): return _is_node_visible_to(peer, subject) var data := _sparse_serializer.write_for(peer, diff, _sync_owned_state_properties, filter) if data.is_empty(): diff --git a/test/netfox/servers/network-synchronization-server.test.gd b/test/netfox/servers/network-synchronization-server.test.gd index a98e21f7..f2da6c67 100644 --- a/test/netfox/servers/network-synchronization-server.test.gd +++ b/test/netfox/servers/network-synchronization-server.test.gd @@ -30,7 +30,7 @@ func suite() -> void: servers.synchronization_server().register_rollback_input(other_node, "position") servers.history_server()._record_rollback_input(0) - servers.synchronization_server().synchronize_input(0) + servers.synchronization_server()._synchronize_input(0) skip() # Somehow setup a live client-server connection ) From 8c4ca321c248ee8c9f431ac2d4a80638e188cfcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 11 Mar 2026 22:42:40 +0100 Subject: [PATCH 94/95] cleanup --- addons/netfox/servers/data/network-commands.gd.uid | 1 - 1 file changed, 1 deletion(-) delete mode 100644 addons/netfox/servers/data/network-commands.gd.uid diff --git a/addons/netfox/servers/data/network-commands.gd.uid b/addons/netfox/servers/data/network-commands.gd.uid deleted file mode 100644 index cbde19a2..00000000 --- a/addons/netfox/servers/data/network-commands.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dke6mgxyk12me From 5521af3163f4288fc48a624c816c8b4bd6545a30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 11 Mar 2026 22:48:45 +0100 Subject: [PATCH 95/95] docs --- docs/upgrading.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/upgrading.md b/docs/upgrading.md index c7d64837..9dca6f64 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -30,6 +30,20 @@ case v1.1.1. If there are no sections here for your version range, that means that the upgrade should need no extra action, aside from replacing the old netfox addon(s) with the new one(s). +### Unreleased + +* `StateSynchronizer.full_state_interval` is deprecated - use project settings +* `StateSynchronizer.diff_ack_interval` is deprecated and is ignored +* `RollbackSynchronizer.full_state_interval` is deprecated - use project + settings +* `RollbackSynchronizer.diff_ack_interval` is deprecated and is ignored +* `RollbackSynchronizer.enable_input_broadcast` is deprecated - use project + settings +* `NetworkRollback.register_rollback_input_submission()` is deprecated and does + nothing +* `NetworkRollback.free_input_submission_data_for()` is deprecated and does + nothing + ### v1.1.1 * Remove `Interpolators` from the project autoloads, it's a static class now.