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/bitset.gd b/addons/netfox.internals/bitset.gd new file mode 100644 index 00000000..1e54250d --- /dev/null +++ b/addons/netfox.internals/bitset.gd @@ -0,0 +1,95 @@ +extends RefCounted +class_name _Bitset + +# Stores a list of booleans, representing them efficiently as a PackedByteArray + +var _data: PackedByteArray +var _bit_count: int + +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) + + _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.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 b/addons/netfox.internals/graph.gd new file mode 100644 index 00000000..8a15642c --- /dev/null +++ b/addons/netfox.internals/graph.gd @@ -0,0 +1,57 @@ +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[]` + +func link(from: Variant, to: Variant) -> void: + if has_link(from, to): + return + + _append(_links_from, from, to) + _append(_links_to, to, from) + +func unlink(from: Variant, to: Variant) -> void: + _erase(_links_from, from, to) + _erase(_links_to, to, from) + +func erase(node: Variant) -> void: + 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_to: + _erase(_links_to, link, node) + + for link in links_from: + _erase(_links_from, link, node) + +func get_linked_from(from: Variant) -> Array: + return _links_from.get(from, []) + +func get_linked_to(to: Variant) -> Array: + return _links_to.get(to, []) + +func has_link(from: Variant, to: Variant) -> bool: + return get_linked_from(from).has(to) + +func _append(pool: Dictionary, key: Variant, value: Variant) -> void: + if not pool.has(key): + pool[key] = [value] + else: + pool[key].append(value) + +func _erase(pool: Dictionary, key: Variant, value: Variant) -> void: + if not pool.has(key): + return + + var values := pool[key] as Array + values.erase(value) + + if values.is_empty(): + pool.erase(key) 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/history-buffer.gd b/addons/netfox.internals/history-buffer.gd index 0ca8175f..b2b7fde9 100644 --- a/addons/netfox.internals/history-buffer.gd +++ b/addons/netfox.internals/history-buffer.gd @@ -1,70 +1,134 @@ extends RefCounted class_name _HistoryBuffer -# Maps ticks (int) to arbitrary data -var _buffer: Dictionary = {} +# Maps ticks (int) to arbitrary data, stored in a sliding ring buffer + +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 < _head - capacity(): + # Trying to set something that would wrap back around and overwrite + # current data + 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 + _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: + # Skipping forward a bit + 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 < _head - capacity(): 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.internals/interval-scheduler.gd b/addons/netfox.internals/interval-scheduler.gd new file mode 100644 index 00000000..d2188483 --- /dev/null +++ b/addons/netfox.internals/interval-scheduler.gd @@ -0,0 +1,22 @@ +extends RefCounted +class_name _IntervalScheduler + +# Returns true on every nth `is_now()` call + +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.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.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.internals/set.gd b/addons/netfox.internals/set.gd index aaaa21bf..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 @@ -46,6 +51,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.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/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/netfox.gd b/addons/netfox/netfox.gd index 1a5f0442..546f7545 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -117,11 +117,41 @@ 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/history_limit", + "value": 64, + "type": TYPE_INT + }, + { + "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", @@ -151,6 +181,22 @@ const AUTOLOADS: Array[Dictionary] = [ "name": "NetworkPerformance", "path": ROOT + "/network-performance.gd" }, + { + "name": "RollbackSimulationServer", + "path": ROOT + "/servers/rollback-simulation-server.gd" + }, + { + "name": "NetworkHistoryServer", + "path": ROOT + "/servers/network-history-server.gd" + }, + { + "name": "NetworkSynchronizationServer", + "path": ROOT + "/servers/network-synchronization-server.gd" + }, + { + "name": "NetworkIdentityServer", + "path": ROOT + "/servers/network-identity-server.gd" + }, { "name": "NetworkCommandServer", "path": ROOT + "/servers/network-command-server.gd" 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/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/network-time.gd b/addons/netfox/network-time.gd index af6ef343..3077c201 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,7 +550,7 @@ 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() @@ -559,19 +559,24 @@ func _loop() -> void: before_tick_loop.emit() 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) + _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() @@ -592,9 +597,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/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" 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/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 d7d676c3..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 and is_instance_valid(NetworkRollback): - 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/network-rollback.gd b/addons/netfox/rollback/network-rollback.gd index 7a216f27..5ce08809 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,9 @@ 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 _earliest_state := -1 const _STAGE_BEFORE := "B" const _STAGE_PREPARE := "P" @@ -268,33 +268,44 @@ 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 -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_rollback_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. -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 + + 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(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. -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 _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_rollback_input(tick + input_delay) + NetworkSynchronizationServer._synchronize_input(tick + input_delay) + ) + + NetworkSynchronizationServer._on_input.connect(_handle_input) + NetworkSynchronizationServer._on_state.connect(_handle_state) func _exit_tree(): NetfoxLogger.free_tag(_get_rollback_tag) @@ -313,6 +324,17 @@ 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 _earliest_state >= 0 and _earliest_state <= _resim_from: + range_source = "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]) + # Only set _is_rollback *after* emitting before_loop _is_rollback = true _rollback_stage = _STAGE_BEFORE @@ -331,6 +353,9 @@ func _rollback() -> void: ) from = NetworkTime.tick - history_limit + _earliest_input = -1 + _earliest_state = -1 + # for tick in from .. to: _rollback_from = from _rollback_to = to @@ -343,6 +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) after_prepare_tick.emit(tick) # Simulate rollback tick @@ -353,20 +380,43 @@ func _rollback() -> void: # If not: Latest input >= tick >= Earliest input _rollback_stage = _STAGE_SIMULATE 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_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) + RollbackSimulationServer._trim_ticks_simulated(history_start) # Cleanup _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/predictive-synchronizer.gd b/addons/netfox/rollback/predictive-synchronizer.gd index 114a2b6e..d5ce5bdd 100644 --- a/addons/netfox/rollback/predictive-synchronizer.gd +++ b/addons/netfox/rollback/predictive-synchronizer.gd @@ -23,44 +23,37 @@ class_name PredictiveSynchronizer ## the tick. @export var state_properties: Array[String] -var _state_property_config: _PropertyConfig = _PropertyConfig.new() -var _property_cache := PropertyCache.new(root) -var _freshness_store := RollbackFreshnessStore.new() - -var _states := _PropertyHistoryBuffer.new() -var _nodes: Array[Node] = [] -var _skipset: _Set = _Set.new() +var _state_properties := _PropertyPool.new() +var _sim_nodes: Array[Node] = [] var _properties_dirty: bool = false -# Composition -var _history_recorder: _RollbackHistoryRecorder - ## Process settings. ## ## Call this after any change to configuration. func process_settings() -> void: - _property_cache.root = root - _property_cache.clear() - _freshness_store.clear() + # Deregister all nodes we've registered previously + for subject in _state_properties.get_subjects(): + NetworkHistoryServer.deregister(subject) - _nodes.clear() - _states.clear() + for node in _sim_nodes: + RollbackSimulationServer.deregister_node(node) # Gather all prediction-aware nodes to call during prediction ticks - _nodes = root.find_children("*") - _nodes.push_front(root) - _nodes = _nodes.filter(func(n): return NetworkRollback.is_rollback_aware(n)) - _nodes.erase(self) + _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) - _state_property_config.set_properties_from_paths(state_properties, _property_cache) + # 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_rollback_state(subject, property) - if _history_recorder == null: - _history_recorder = _RollbackHistoryRecorder.new() - - var _inputs := _PropertyHistoryBuffer.new() - var _input_property_config := _PropertyConfig.new() - _history_recorder.configure(_states, _inputs, _state_property_config, _input_property_config, _property_cache, _skipset) + # 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(): @@ -72,41 +65,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(_run_prediction_tick) - NetworkRollback.on_record_tick.connect(_on_record_tick) - -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(_run_prediction_tick) - NetworkRollback.on_record_tick.disconnect(_on_record_tick) - -func _before_tick(_dt: float, tick: int) -> void: - _history_recorder.apply_state(tick) - -func _after_tick(_dt: float, tick: int) -> void: - _history_recorder.trim_history() - _freshness_store.trim() - -func _on_prepare_tick(tick: int) -> void: - _history_recorder.apply_tick(tick) - -func _on_record_tick(tick: int) -> void: - _history_recorder.record_state(tick) - -func _run_prediction_tick(tick: int) -> void: - for node in _nodes: - var is_fresh := _freshness_store.is_fresh(node, tick) - NetworkRollback.process_rollback(node, NetworkTime.ticktime, tick, is_fresh) - _freshness_store.notify_processed(node, tick) - func _enter_tree() -> void: if Engine.is_editor_hint(): return @@ -114,15 +72,8 @@ func _enter_tree() -> void: 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 _reprocess_settings() -> void: if not _properties_dirty or Engine.is_editor_hint(): return @@ -147,6 +98,11 @@ func add_state(node: Variant, property: String): 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: 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/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index b5aa5eed..a332a7b5 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,64 +50,63 @@ 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 ## 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 _nodes: Array[Node] = [] - -var _simset: _Set = _Set.new() -var _skipset: _Set = _Set.new() +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 _schema := _NetworkSchema.new({}) - 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 - ## Process settings. ## [br][br] ## Call this after any change to configuration. Updates based on authority too ## ( calls process_authority ). func process_settings() -> void: + # 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 _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) - - _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, _schema) - _history_recorder.configure(_states, _inputs, _state_property_config, _input_property_config, _property_cache, _skipset) + 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) + + # Register simulation callbacks + for node in nodes: + 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_rollback_input_for(node, input_node) + + # Register identifiers + for node in _state_properties.get_subjects() + _input_properties.get_subjects(): + NetworkIdentityServer.register_node(node) + + # Register visibility filter + for node in _state_properties.get_subjects(): + NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) ## Process settings based on authority. ## [br][br] @@ -127,11 +114,31 @@ func process_settings() -> void: ## RollbackSynchronizer changes. Make sure to do this at the same time on all ## peers. func 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) + # Deregister all recorded properties + for node in _state_properties.get_subjects(): + for property in _state_properties.get_properties_of(node): + 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_rollback_input(node, property) + NetworkSynchronizationServer.deregister_rollback_input(node, property) + + # Process authority + _state_properties.set_from_paths(root, state_properties) + _input_properties.set_from_paths(root, input_properties) + + # Register new recorded properties + for node in _state_properties.get_subjects(): + for property in _state_properties.get_properties_of(node): + 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_rollback_input(node, property) + NetworkSynchronizationServer.register_rollback_input(node, property) ## Add a state property. ## [br][br] @@ -181,9 +188,17 @@ func add_input(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 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_nodes.add(prop_entry.node) ## Check if input is available for the current tick. ## [br][br] @@ -191,20 +206,14 @@ func set_schema(schema: Dictionary) -> void: ## [br][br] ## Returns true if input is available. func has_input() -> bool: - return _has_input + return get_input_age() >= 0 ## Get the age of currently available input in ticks. ## [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: - if has_input(): - return NetworkRollback.tick - _input_tick - else: - _logger.error("Trying to check input age without having input!") - return -1 + return NetworkHistoryServer.get_input_age_for(_input_properties.get_subjects(), NetworkRollback.tick) ## Check if the current tick is predicted. ## [br][br] @@ -212,15 +221,24 @@ func get_input_age() -> int: ## simulated and recorded, but will not be broadcast, nor considered ## authoritative. func is_predicting() -> bool: - return _is_predicted_tick + 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. ## [br][br] ## 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) + # 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] @@ -234,11 +252,7 @@ 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 + return NetworkHistoryServer.get_latest_input_for(_input_properties.get_subjects(), NetworkTime.tick) ## Get the tick of the last known state. ## [br][br] @@ -247,10 +261,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: - # 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 NetworkHistoryServer.get_state_age_for(_state_properties.get_subjects(), NetworkTime.tick) func _ready() -> void: if Engine.is_editor_hint(): @@ -261,60 +272,17 @@ func _ready() -> void: await NetworkTime.after_sync 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) - -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() - -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() - -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() + multiplayer.connected_to_server.connect(process_settings) 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: @@ -347,131 +315,14 @@ 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: - 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 _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 f5fa24a5..2e1a0881 100644 --- a/addons/netfox/schemas/network-schema.gd +++ b/addons/netfox/schemas/network-schema.gd @@ -1,15 +1,36 @@ 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 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) + +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 062ceca5..9918f020 100644 --- a/addons/netfox/schemas/network-schemas.gd +++ b/addons/netfox/schemas/network-schemas.gd @@ -65,6 +65,9 @@ static func uint64() -> NetworkSchemaSerializer: static func varuint() -> NetworkSchemaSerializer: return _VaruintSerializer.instance +static func _varbits() -> NetworkSchemaSerializer: + return _VariableBitsetSerializer.instance + ## Serialize signed integers as 8 bits. ## [br][br] ## Final size is 1 byte. @@ -509,6 +512,22 @@ class _Uint64Serializer extends NetworkSchemaSerializer: func encode(v: Variant, b: StreamPeerBuffer) -> void: b.put_u64(v) func decode(b: StreamPeerBuffer) -> Variant: return b.get_u64() +class _Int8Serializer extends NetworkSchemaSerializer: + func encode(v: Variant, b: StreamPeerBuffer) -> void: b.put_8(v) + func decode(b: StreamPeerBuffer) -> Variant: return b.get_8() + +class _Int16Serializer extends NetworkSchemaSerializer: + func encode(v: Variant, b: StreamPeerBuffer) -> void: b.put_16(v) + func decode(b: StreamPeerBuffer) -> Variant: return b.get_16() + +class _Int32Serializer extends NetworkSchemaSerializer: + func encode(v: Variant, b: StreamPeerBuffer) -> void: b.put_32(v) + func decode(b: StreamPeerBuffer) -> Variant: return b.get_32() + +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: static var instance := _VaruintSerializer.new() @@ -538,21 +557,51 @@ class _VaruintSerializer extends NetworkSchemaSerializer: break return value -class _Int8Serializer extends NetworkSchemaSerializer: - func encode(v: Variant, b: StreamPeerBuffer) -> void: b.put_8(v) - func decode(b: StreamPeerBuffer) -> Variant: return b.get_8() +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 -class _Int16Serializer extends NetworkSchemaSerializer: - func encode(v: Variant, b: StreamPeerBuffer) -> void: b.put_16(v) - func decode(b: StreamPeerBuffer) -> Variant: return b.get_16() + b.put_u8(byte) -class _Int32Serializer extends NetworkSchemaSerializer: - func encode(v: Variant, b: StreamPeerBuffer) -> void: b.put_32(v) - func decode(b: StreamPeerBuffer) -> Variant: return b.get_32() + 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 -class _Int64Serializer extends NetworkSchemaSerializer: - func encode(v: Variant, b: StreamPeerBuffer) -> void: b.put_64(v) - func decode(b: StreamPeerBuffer) -> Variant: return b.get_64() + return _Bitset.of_bools(bools) class _Float16Serializer extends NetworkSchemaSerializer: func encode(v: Variant, b: StreamPeerBuffer) -> void: diff --git a/addons/netfox/serializers/base-snapshot-serializer.gd b/addons/netfox/serializers/base-snapshot-serializer.gd new file mode 100644 index 00000000..b7a154b5 --- /dev/null +++ b/addons/netfox/serializers/base-snapshot-serializer.gd @@ -0,0 +1,37 @@ +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, 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) + +func _read_property(node: Node, property: NodePath, buffer: StreamPeerBuffer) -> Variant: + return _schemas.decode(node, property, buffer) + +func _write_identifier(subject: Object, peer: int, buffer: StreamPeerBuffer) -> Error: + var netref := NetworkSchemas._netref() + 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 + + 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/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 b/addons/netfox/serializers/dense-snapshot-serializer.gd new file mode 100644 index 00000000..5db0b834 --- /dev/null +++ b/addons/netfox/serializers/dense-snapshot-serializer.gd @@ -0,0 +1,87 @@ +extends _BaseSnapshotSerializer +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: + if buffer == null: + buffer = StreamPeerBuffer.new() + + var netref := NetworkSchemas._netref() + var varuint := NetworkSchemas.varuint() + + var node_buffer := StreamPeerBuffer.new() + + var has_data := false + + # Write tick + buffer.put_u32(snapshot.tick) + + # 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: + 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.has_property(node, property), "Trying to serialize missing property %s on subject %s!" % [property, node]) + + 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) + + 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() + 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, data size, and data + 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 := _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]) + continue + var node := identifier.get_subject() as Node + + # Read properties + for property in properties.get_properties_of(node): + if node_buffer.get_available_bytes() == 0: break + + var value := _read_property(node, property, node_buffer) + snapshot.set_property(node, property, value) + snapshot.set_auth(node, is_auth) + + return snapshot 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 b/addons/netfox/serializers/redundant-snapshot-serializer.gd new file mode 100644 index 00000000..b8a8fc45 --- /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: _NetworkSchema, p_identity_server: _NetworkIdentityServer = null): + 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: + var varuint := NetworkSchemas.varuint() + + if buffer == null: + buffer = StreamPeerBuffer.new() + + # 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) + + # 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/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 b/addons/netfox/serializers/sparse-snapshot-serializer.gd new file mode 100644 index 00000000..1705ce8c --- /dev/null +++ b/addons/netfox/serializers/sparse-snapshot-serializer.gd @@ -0,0 +1,92 @@ +extends _BaseSnapshotSerializer +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: + 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) + + # 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: + continue + + node_buffer.clear() + + var node_props := properties.get_properties_of(node) + 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): + continue + + 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 + 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() + 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 := _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]) + 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) + snapshot.set_auth(node, is_auth) + return snapshot 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 b/addons/netfox/servers/data/network-commands.gd deleted file mode 100644 index 283d587e..00000000 --- a/addons/netfox/servers/data/network-commands.gd +++ /dev/null @@ -1,9 +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 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 210af03a..00000000 --- a/addons/netfox/servers/data/network-commands.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cptij4jrx07ix diff --git a/addons/netfox/servers/data/network-identifier.gd.uid b/addons/netfox/servers/data/network-identifier.gd.uid index 5da8ab3c..eab41887 100644 --- a/addons/netfox/servers/data/network-identifier.gd.uid +++ b/addons/netfox/servers/data/network-identifier.gd.uid @@ -1 +1 @@ -uid://b60v3eakkgkey +uid://gnyygflpr78h diff --git a/addons/netfox/servers/data/network-identity-reference.gd.uid b/addons/netfox/servers/data/network-identity-reference.gd.uid index 4202d0d7..006cf3f9 100644 --- a/addons/netfox/servers/data/network-identity-reference.gd.uid +++ b/addons/netfox/servers/data/network-identity-reference.gd.uid @@ -1 +1 @@ -uid://ctxdpacwcjgon +uid://c6eyaau5r8g51 diff --git a/addons/netfox/servers/data/object-snapshot.gd b/addons/netfox/servers/data/object-snapshot.gd new file mode 100644 index 00000000..4119df85 --- /dev/null +++ b/addons/netfox/servers/data/object-snapshot.gd @@ -0,0 +1,47 @@ +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 = {} + +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/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 b/addons/netfox/servers/data/per-object-history.gd new file mode 100644 index 00000000..6f0f9f84 --- /dev/null +++ b/addons/netfox/servers/data/per-object-history.gd @@ -0,0 +1,103 @@ +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 + +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)) + 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, "Failed to ensure 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 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) + + 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/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 b/addons/netfox/servers/data/property-pool.gd new file mode 100644 index 00000000..63db467f --- /dev/null +++ b/addons/netfox/servers/data/property-pool.gd @@ -0,0 +1,64 @@ +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: + 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 + + 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 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, [])) + return properties + +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() + +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/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 b/addons/netfox/servers/data/snapshot.gd new file mode 100644 index 00000000..12963efb --- /dev/null +++ b/addons/netfox/servers/data/snapshot.gd @@ -0,0 +1,171 @@ +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() + +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 + 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 + +# 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 + +func duplicate() -> _Snapshot: + var result := _Snapshot.new(tick) + result._data = _data.duplicate(true) + result._auth_subjects = _auth_subjects.duplicate() + return result + +func set_auth(subject: Object, is_auth: bool) -> void: + 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): + _data[subject] = { property: value } + else: + _data[subject][property] = value + +func record_property(subject: Object, property: NodePath) -> void: + var value := subject.get_indexed(property) + set_property(subject, property, value) + +func get_property(subject: Object, property: NodePath) -> Variant: + return _data.get(subject, {}).get(property) + +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 + +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]: + 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 + if require_auth and not is_auth(subject): + return false + return true + +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_subjects() -> Array: + return _data.keys() + +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() + +func size() -> int: + var result := 0 + for subject in _data: + result += (_data[subject] as Dictionary).size() + return result + +func is_auth(subject: Object) -> bool: + return _auth_subjects.has(subject) + +func equals(other) -> bool: + 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] + for subject in _data: + for property in _data[subject]: + var value = _data[subject][property] + result += ", %s:%s(%s): %s" % [subject, property, is_auth(subject), value] + result += ")" + return result + +func _to_vest(): + return { + "tick": tick, + "data": _data, + "auth_subjects": _auth_subjects + } 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 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-command-server.gd.uid b/addons/netfox/servers/network-command-server.gd.uid index c567c9a3..8636d32b 100644 --- a/addons/netfox/servers/network-command-server.gd.uid +++ b/addons/netfox/servers/network-command-server.gd.uid @@ -1 +1 @@ -uid://d1mbg8wkqq7ko +uid://mkgjb8gdl0fp diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd new file mode 100644 index 00000000..e3ae09bd --- /dev/null +++ b/addons/netfox/servers/network-history-server.gd @@ -0,0 +1,273 @@ +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() + +var _rb_history_size := NetworkRollback.history_limit +var _sync_history_size := ProjectSettings.get_setting("netfox/state_synchronizer/history_limit", 64) as int + +# 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") + +## Register a rollback state property +func register_rollback_state(node: Node, property: NodePath) -> void: + _rb_state_properties.add(node, property) + +## Deregister a rollback state property +func deregister_rollback_state(node: Node, property: NodePath) -> void: + _rb_state_properties.erase(node, property) + +## Register a rollback input property +func register_rollback_input(node: Node, property: NodePath) -> void: + _rb_input_properties.add(node, property) + +## 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) + _sync_state_properties.erase_subject(node) + + _rb_state_history.erase_subject(node) + _rb_input_history.erase_subject(node) + _sync_history.erase_subject(node) + +## 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_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(): + return false + if RollbackSimulationServer._is_predicting(input_snapshot, subject): + return false + return true + ) + +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: + return _restore_latest(tick, _rb_input_history) + +func _restore_rollback_state(tick: int) -> bool: + return _restore_latest(tick, _rb_state_history) + +func _restore_synchronizer_state(tick: int) -> bool: + return _restore_latest(tick, _sync_history) + +func _get_rollback_input_snapshot(tick: int) -> _Snapshot: + return _rb_input_snapshots.get_at(tick) + +func _get_rollback_state_snapshot(tick: int) -> _Snapshot: + return _rb_state_snapshots.get_at(tick) + +func _get_synchronizer_state_snapshot(tick: int) -> _Snapshot: + return _sync_state_snapshots.get_at(tick) + +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: + _merge_snapshot(snapshot, _rb_state_snapshots, true) + return _merge_history(snapshot, _rb_state_history) + +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 + 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!") + + 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 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): + 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: + _logger.trace("Recorded input @%d: %s", [tick, snapshot]) + _rb_state_history: + _logger.trace("Recorded state @%d: %s", [tick, snapshot]) + +func _restore_latest(tick: int, history: _PerObjectHistory) -> bool: + var any_applied := false + + 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() + any_applied = true + + match history: + _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 + +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 + 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 _merge_history(snapshot: _Snapshot, history: _PerObjectHistory, reverse: bool = false) -> bool: + var tick := snapshot.tick + var has_updated := false + + if tick < NetworkRollback.history_start: + _logger.warning("Snapshot being merged is too old! (@%d)", [tick]) + return false + + for subject in snapshot.get_subjects(): + var object_snapshot := history.ensure_snapshot(tick, subject, not reverse) + + # 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): + 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)) + + return has_updated + +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 + + # Should we track `mini()` instead? + latest = maxi(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/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 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-identity-server.gd.uid b/addons/netfox/servers/network-identity-server.gd.uid index 9432423a..1b19aa73 100644 --- a/addons/netfox/servers/network-identity-server.gd.uid +++ b/addons/netfox/servers/network-identity-server.gd.uid @@ -1 +1 @@ -uid://ladwepofotqc +uid://pc8gwg1lbusp diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd new file mode 100644 index 00000000..f150b9bc --- /dev/null +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -0,0 +1,381 @@ +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() +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 + +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) 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 +var _sync_full_scheduler := _IntervalScheduler.new(_sync_full_interval) + +var _schemas := _NetworkSchema.new() + +var _dense_serializer: _DenseSnapshotSerializer +var _sparse_serializer: _SparseSnapshotSerializer +var _redundant_serializer: _RedundantSnapshotSerializer + +var _cmd_full_state: NetworkCommandServer.Command +var _cmd_diff_state: NetworkCommandServer.Command +var _cmd_input: NetworkCommandServer.Command + +var _cmd_full_sync: NetworkCommandServer.Command +var _cmd_diff_sync: NetworkCommandServer.Command + +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) + _rb_owned_state_properties.erase_subject(node) + _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 + if not filter: + return true + else: + return filter.get_visible_peers().has(peer) + +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: + # 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 + var controlled_nodes := RollbackSimulationServer._get_controlled_by(input_subject) + + # Notify peers owning nodes about the input + 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()) + + # Prepare snapshot package + for offset in _input_redundancy: + # Grab snapshot from NetworkHistoryServer + var snapshot := NetworkHistoryServer._get_rollback_input_snapshot(tick - offset) + if not snapshot: + break + + _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_owned_input_properties) + _cmd_input.send(data, peer) + +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 NetworkHistoryServer + var snapshot := NetworkHistoryServer._get_rollback_state_snapshot(tick) + if not snapshot: + # No data for tick + return + + if snapshot.is_empty(): + # Nothing to send + return + + # Figure out whether to send full- or diff state + 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 := NetworkHistoryServer._get_rollback_state_snapshot(tick - 1) + if not reference_snapshot: + 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) + + 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 + + _cmd_full_state.send(data, peer) + + NetworkPerformance.push_full_state_props(snapshot.size()) + NetworkPerformance.push_sent_state_props(snapshot.size()) + else: + 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(): + 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 + + _cmd_diff_state.send(data, peer) + + NetworkPerformance.push_full_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 NetworkHistoryServer + var snapshot := NetworkHistoryServer._get_synchronizer_state_snapshot(tick) + if not snapshot: + return + + # Figure out whether to send full- or diff state + 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) + + 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 + + _cmd_full_sync.send(data, peer) + + 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) + + # Send diffs + 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, _sync_owned_state_properties, filter) + if data.is_empty(): + # Peer can't see anything, send nothing + continue + + _cmd_diff_sync.send(data, peer) + + NetworkPerformance.push_full_state_props(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 + _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(_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(_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() + buffer.data_array = data + + var snapshots := _redundant_serializer.read_from(sender, _rb_input_properties, buffer, true) + + for snapshot in snapshots: + snapshot.sanitize(sender) + + _logger.trace("Ingesting input: %s", [snapshot]) + if NetworkHistoryServer._merge_rollback_input(snapshot): + _on_input.emit(snapshot) + +func _handle_full_state(sender: int, data: PackedByteArray): + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + + var snapshot := _dense_serializer.read_from(sender, _rb_state_properties, buffer, true) + + _ingest_state(sender, snapshot) + +func _handle_diff_state(sender: int, data: PackedByteArray): + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + + var diff := _sparse_serializer.read_from(sender, _rb_state_properties, buffer) + _logger.trace("Received diff state for @%d", [diff.tick]) + + _ingest_state(sender, diff) + +func _handle_full_sync(sender: int, data: PackedByteArray): + var buffer := StreamPeerBuffer.new() + 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]) + +func _handle_diff_sync(sender: int, data: PackedByteArray): + var buffer := StreamPeerBuffer.new() + 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: + snapshot.sanitize(sender) + + NetworkHistoryServer._merge_rollback_state(snapshot) + _logger.trace("Ingested state: %s", [snapshot]) + + _on_state.emit(snapshot) 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 b/addons/netfox/servers/rollback-simulation-server.gd new file mode 100644 index 00000000..e6ae999b --- /dev/null +++ b/addons/netfox/servers/rollback-simulation-server.gd @@ -0,0 +1,201 @@ +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 +var _simulated_ticks := {} # node to array of 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 +var _predicted_nodes := _Set.new() + +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!") + return + + assert(callback.get_object() is Node, "Only nodes supported for now!") + + _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 + + var object := callback.get_object() + + if not is_instance_valid(object): return + if _callbacks[object] != callback: return + + _callbacks.erase(object) + _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) + +## 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 [] + + var tick := input_snapshot.tick + for node in _callbacks.keys(): + 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 + + 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 + + result.append(node) + + return result + +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 := input_nodes.is_empty() + var has_input := false + + if not is_inputless and input_snapshot: + 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 + return false + if not is_owned: + # We don't own the node, so we can only guess - i.e. predict + return true + if is_owned and is_inputless: + # We own the node, node doesn't depend on input, we're sure + return false + 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 + return false + +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: + 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 _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 + +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/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 diff --git a/addons/netfox/state-synchronizer.gd b/addons/netfox/state-synchronizer.gd index 7588803f..7dbd5675 100644 --- a/addons/netfox/state-synchronizer.gd +++ b/addons/netfox/state-synchronizer.gd @@ -22,45 +22,20 @@ class_name StateSynchronizer ## 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 # 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] -## 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 # 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() -var _property_cache: PropertyCache -var _property_config: _PropertyConfig = _PropertyConfig.new() var _properties_dirty: bool = false - -var _schema: _NetworkSchema = _NetworkSchema.new({}) - -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 _properties := _PropertyPool.new() +var _schema_nodes := _Set.new() var _is_initialized: bool = false @@ -70,24 +45,21 @@ 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 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 + _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) _is_initialized = true @@ -123,13 +95,26 @@ 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 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_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: @@ -144,14 +129,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,27 +138,13 @@ 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 _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: @@ -189,100 +152,3 @@ func _reprocess_settings() -> void: _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/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. 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 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/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: 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..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 = move_toward(velocity.x, 0, speed) - velocity.z = 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 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")] diff --git a/examples/visibility-filtering/visibility-filtering.tscn b/examples/visibility-filtering/visibility-filtering.tscn index 2461c112..5cc9cab7 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,16 @@ offset_left = -180.0 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("6_lqh40") + +[node name="Label" type="Label" parent="UI/VBoxContainer"] +layout_mode = 2 +script = SubResource("GDScript_fdk23") diff --git a/project.godot b/project.godot index f55ce66d..f91573cd 100644 --- a/project.godot +++ b/project.godot @@ -30,6 +30,10 @@ 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" +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" +NetworkIdentityServer="*res://addons/netfox/servers/network-identity-server.gd" NetworkCommandServer="*res://addons/netfox/servers/network-command-server.gd" [display] diff --git a/test/netfox.internals/bitset.test.gd b/test/netfox.internals/bitset.test.gd new file mode 100644 index 00000000..3b84d509 --- /dev/null +++ b/test/netfox.internals/bitset.test.gd @@ -0,0 +1,40 @@ +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)) + ) + + test("get_set_indices()", func(): + var bits := _Bitset.of_bools([0, 1, 1, 0]) + expect_equal(bits.get_set_indices(), [1, 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.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 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.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 b/test/netfox.internals/graph.test.gd new file mode 100644 index 00000000..ff5b54a8 --- /dev/null +++ b/test/netfox.internals/graph.test.gd @@ -0,0 +1,48 @@ +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_linked(graph, "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_unlinked(graph, "foo", "bar") + expect_linked(graph, "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.link("oof", "foo") + + graph.erase("foo") + + 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]) 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/history-buffer.test.gd b/test/netfox.internals/history-buffer.test.gd index ee3883b3..c2d41211 100644 --- a/test/netfox.internals/history-buffer.test.gd +++ b/test/netfox.internals/history-buffer.test.gd @@ -3,56 +3,67 @@ 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 set behind tail", func(): + var buffer := filled_buffer.duplicate() + 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(): + 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) + ) + ) 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.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/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 diff --git a/test/netfox/rollback-synchronizer.test.gd b/test/netfox/rollback-synchronizer.test.gd new file mode 100644 index 00000000..b0e8c02e --- /dev/null +++ b/test/netfox/rollback-synchronizer.test.gd @@ -0,0 +1,22 @@ +extends VestTest + +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()) + 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()) + ) 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/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/schemas/network-schemas.test.gd b/test/netfox/schemas/network-schemas.test.gd index d55f7a68..0e0cb12f 100644 --- a/test/netfox/schemas/network-schemas.test.gd +++ b/test/netfox/schemas/network-schemas.test.gd @@ -84,6 +84,9 @@ func suite() -> void: ["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] ] for case in cases: 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..9ed52621 --- /dev/null +++ b/test/netfox/serializers/dense-snapshot-serializer.test.gd @@ -0,0 +1,102 @@ +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 := 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 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()]) + ) + + 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/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 b/test/netfox/serializers/redundant-snapshot-serializer.test.gd new file mode 100644 index 00000000..5b77fce5 --- /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/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 b/test/netfox/serializers/snapshot-serializer-test.gd new file mode 100644 index 00000000..d13ce204 --- /dev/null +++ b/test/netfox/serializers/snapshot-serializer-test.gd @@ -0,0 +1,19 @@ +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 + +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/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 b/test/netfox/serializers/sparse-snapshot-serializer.test.gd new file mode 100644 index 00000000..cc0d8dd3 --- /dev/null +++ b/test/netfox/serializers/sparse-snapshot-serializer.test.gd @@ -0,0 +1,72 @@ +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()]) + ) + + 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/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 b/test/netfox/servers/data/snapshot.perf.gd new file mode 100644 index 00000000..478ae674 --- /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/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 b/test/netfox/servers/data/snapshot.test.gd new file mode 100644 index 00000000..115e9dbd --- /dev/null +++ b/test/netfox/servers/data/snapshot.test.gd @@ -0,0 +1,147 @@ +extends VestTest + +func get_suite_name() -> String: + return "Snapshot" + +func suite() -> void: + var node := Node3D.new() + var other_node := Node3D.new() + + define("make_patch()", func(): + test("should return empty on same", func(): + 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.of(0, [ + [node, "position", Vector3.ZERO], + [node, "scale", Vector3.ONE] + ], [node]) + + var to := _Snapshot.of(0, [ + [node, "position", Vector3.ONE], + [node, "scale", Vector3.ONE] + ], [node]) + + 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.of(0, [ + [node, "position", Vector3.ZERO] + ], [node]) + + var to := _Snapshot.of(0, [ + [node, "position", Vector3.ZERO], + [node, "scale", Vector3.ONE] + ], [node]) + + 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.of(0, [ + [node, "position", Vector3.ZERO], + [node, "scale", Vector3.ONE] + ], [node]) + + 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() + applied.tick = to.tick + applied.merge(patch) + + expect_equal(applied, to) + ) + ) + + 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])) + ) + + 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/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 b/test/netfox/servers/network-history-server.perf.gd new file mode 100644 index 00000000..3b860dca --- /dev/null +++ b/test/netfox/servers/network-history-server.perf.gd @@ -0,0 +1,61 @@ +extends VestTest + +func get_suite_name() -> String: + return "NetworkHistoryServer" + +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(__): + NetworkHistoryServer.register_rollback_state(nodes[idx], "name") + idx += 1 + ).with_iterations(count).with_batch_size(count).run() + + # Record + benchmark("record()", func(__): + NetworkHistoryServer._record_rollback_state(0) + ).with_duration(1.).with_batch_size(16).run() + + # Restore + benchmark("restore()", func(__): + NetworkHistoryServer._restore_rollback_state(0) + ).with_duration(1.).with_batch_size(16).run() + + # Deregister nodes + idx = 0 + benchmark("deregister()", func(__): + NetworkHistoryServer.deregister_rollback_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() 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-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) ) 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..f2da6c67 --- /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_rollback_input(owned_node, "position") + servers.history_server().register_rollback_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_rollback_input(0) + servers.synchronization_server()._synchronize_input(0) + + skip() # 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/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 b/test/netfox/servers/rollback-simulation-server.test.gd new file mode 100644 index 00000000..007df60a --- /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_rollback_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_rollback_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_rollback_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_rollback_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_rollback_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_rollback_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/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 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]) 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