Fresh start: replace with naxIO/netfox-cs-sample foundation

Complete replacement of the tactical-shooter project with the
netfox-cs-sample (MIT) — a CS 1.6 inspired multiplayer FPS built
with Godot 4 and netfox.

## What's new
- Full CS-style gameplay: teams (T/CT), rounds, economy, buy menu
- 6 weapons: Knife, Glock, USP, AK-47, M4A1, AWP
- Bomb plant/defuse with 2 bombsites
- Flashbang & smoke grenades
- Proper netfox rollback netcode at 64 tick
- Network popup UI for host/join
- HUD, crosshair, round timer, scoreboard
- All netfox singletons registered as autoloads (works in exported builds)

## Architecture
- Listen-server (host from client, no dedicated server binary)
- Multiplayer-fps game lives at examples/multiplayer-fps/
- Netfox addons registered as autoloads for exported build compat
- Godot 4.7 with Forward+ renderer

## Removed
- Old headless-server architecture (client_main, server_main, player.gd, etc.)
- Custom netfox bootstrap with ENet fallback
- Old ChaffGames FPS template (2,420 lines, 844 KB)
- SimulationServer GDExtension stub
- Godot-jolt physics (netfox sample uses default Godot physics)
- Duplicate weapon_data.gd, anti_cheat.gd, round_manager.gd, etc.
- Server browser API Python venv (87 MB)
- test_range map and modular assets

## Preserved
- Git history
- Server config at config/default_server_config.cfg
- Windows export preset
- Build directory (gitignored)

Co-authored-by: naxIO <naxIO@users.noreply.github.com>
This commit is contained in:
2026-07-02 20:55:20 -04:00
parent ce39b237c3
commit b0c83af092
4416 changed files with 57418 additions and 902676 deletions
@@ -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()
@@ -1 +0,0 @@
uid://bn6fsqxbfhihk
@@ -1,282 +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
# 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
) -> 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
_input_encoder = _RedundantHistoryEncoder.new(_input_history, _property_cache)
_full_state_encoder = _SnapshotHistoryEncoder.new(_state_history, _property_cache)
_diff_state_encoder = _DiffHistoryEncoder.new(_state_history, _property_cache)
_is_initialized = true
reset()
func reset() -> void:
_ackd_state.clear()
_latest_state_tick = NetworkTime.tick - 1
_earliest_input_tick = NetworkTime.tick
_next_full_state_tick = NetworkTime.tick
_next_diff_ack_tick = NetworkTime.tick
# Scatter full state sends, so not all nodes send at the same tick
if is_inside_tree():
_next_full_state_tick += hash(root.get_path()) % maxi(1, full_state_interval)
_next_diff_ack_tick += hash(root.get_path()) % maxi(1, diff_ack_interval)
else:
_next_full_state_tick += hash(root.name) % maxi(1, full_state_interval)
_next_diff_ack_tick += hash(root.name) % maxi(1, diff_ack_interval)
_diff_state_encoder.add_properties(_state_property_config.get_properties())
_full_state_encoder.set_properties(_get_owned_state_props())
_input_encoder.set_properties(_get_owned_input_props())
func conclude_tick_loop() -> void:
_earliest_input_tick = NetworkTime.tick
func transmit_input(tick: int) -> void:
if not _get_owned_input_props().is_empty():
var input_tick: int = tick + NetworkRollback.input_delay
var input_data := _input_encoder.encode(input_tick, _get_owned_input_props())
var state_owning_peer := root.get_multiplayer_authority()
NetworkRollback.register_input_submission(root, tick)
if enable_input_broadcast:
for peer in _visibility_filter.get_rpc_target_peers():
_submit_input.rpc_id(peer, input_tick, input_data)
elif state_owning_peer != multiplayer.get_unique_id():
_submit_input.rpc_id(state_owning_peer, input_tick, input_data)
func transmit_state(tick: int) -> void:
if _get_owned_state_props().is_empty():
# We don't own state, don't transmit anything
return
if _is_predicted_tick and not _input_property_config.get_properties().is_empty():
# Don't transmit anything if we're predicting
# EXCEPT when we're running inputless
return
# Include properties we own
var full_state := _PropertySnapshot.new()
for property in _get_owned_state_props():
if _should_broadcast(property, tick):
full_state.set_value(property.to_string(), property.get_value())
_on_transmit_state.emit(full_state, tick)
# No properties to send?
if full_state.is_empty():
return
_latest_state_tick = max(_latest_state_tick, tick)
var is_sending_diffs := NetworkRollback.enable_diff_states
var is_full_state_tick := not is_sending_diffs or (full_state_interval > 0 and tick > _next_full_state_tick)
if is_full_state_tick:
# Broadcast new full state
for peer in _visibility_filter.get_rpc_target_peers():
_send_full_state(tick, peer)
# Adjust next full state if sending diffs
if is_sending_diffs:
_next_full_state_tick = tick + full_state_interval
else:
# Send diffs to each peer
for peer in _visibility_filter.get_visible_peers():
var reference_tick := _ackd_state.get(peer, -1) as int
if reference_tick < 0 or not _state_history.has(reference_tick):
# Peer hasn't ack'd any tick, or we don't have the ack'd tick
# Send full state
_send_full_state(tick, peer)
continue
# Prepare diff
var diff_state_data := _diff_state_encoder.encode(tick, reference_tick, _get_owned_state_props())
if _diff_state_encoder.get_full_snapshot().size() == _diff_state_encoder.get_encoded_snapshot().size():
# State is completely different, send full state
_send_full_state(tick, peer)
else:
# Send only diff
_submit_diff_state.rpc_id(peer, diff_state_data, tick, reference_tick)
# Push metrics
NetworkPerformance.push_full_state(_diff_state_encoder.get_full_snapshot())
NetworkPerformance.push_sent_state(_diff_state_encoder.get_encoded_snapshot())
func _should_broadcast(property: PropertyEntry, tick: int) -> bool:
# Only broadcast if we've simulated the node
# NOTE: _can_simulate checks mutations, but to override _skipset
# we check first
if NetworkRollback.is_mutated(property.node, tick - 1):
return true
if _skipset.has(property.node):
return false
if NetworkRollback.is_rollback_aware(property.node):
return NetworkRollback.is_simulated(property.node)
# Node is not rollback-aware, broadcast updates only if we own it
return property.node.is_multiplayer_authority()
func _send_full_state(tick: int, peer: int = 0) -> void:
var full_state_snapshot := _state_history.get_snapshot(tick).as_dictionary()
var full_state_data := _full_state_encoder.encode(tick, _get_owned_state_props())
_submit_full_state.rpc_id(peer, full_state_data, tick)
if peer <= 0:
NetworkPerformance.push_full_state_broadcast(full_state_snapshot)
NetworkPerformance.push_sent_state_broadcast(full_state_snapshot)
else:
NetworkPerformance.push_full_state(full_state_snapshot)
NetworkPerformance.push_sent_state(full_state_snapshot)
func _notification(what):
if what == NOTIFICATION_PREDELETE:
NetworkRollback.free_input_submission_data_for(root)
@rpc("any_peer", "unreliable", "call_remote")
func _submit_input(tick: int, data: Array) -> 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: Array, 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()
@@ -1 +0,0 @@
uid://ofadsxmvd3e3
@@ -0,0 +1 @@
uid://bc61m7m28c1k7
+67 -17
View File
@@ -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))
@@ -0,0 +1,121 @@
@tool
extends Node
class_name PredictiveSynchronizer
## Similar to [RollbackSynchronizer], this class manages local variables in a
## rollback context for predictive simulation without networking.
##
## This is a simplified version that focuses on local state management.
## [br][br]
## Like [RollbackSynchronizer], it automatically discovers nodes
## with a [code]_rollback_tick(delta: float, tick: int)[/code]
## method and calls them during the prediction phase.
## The root node for resolving node paths in properties. Defaults to the parent
## node.
@export var root: Node = get_parent()
@export_group("State")
## Properties that define the game state.
## [br][br]
## State properties are recorded for each tick and restored during rollback.
## State is restored before every rollback tick, and recorded after simulating
## the tick.
@export var state_properties: Array[String]
var _state_properties := _PropertyPool.new()
var _sim_nodes: Array[Node] = []
var _properties_dirty: bool = false
## Process settings.
##
## Call this after any change to configuration.
func process_settings() -> void:
# Deregister all nodes we've registered previously
for subject in _state_properties.get_subjects():
NetworkHistoryServer.deregister(subject)
for node in _sim_nodes:
RollbackSimulationServer.deregister_node(node)
# Gather all prediction-aware nodes to call during prediction ticks
_sim_nodes = root.find_children("*")
_sim_nodes.push_front(root)
_sim_nodes = _sim_nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it))
_sim_nodes.erase(self)
# Keep history of state properties
_state_properties.set_from_paths(root, state_properties)
for subject in _state_properties.get_subjects():
for property in _state_properties.get_properties_of(subject):
NetworkHistoryServer.register_rollback_state(subject, property)
# Simulated notes to participate in rollback
for node in _sim_nodes:
RollbackSimulationServer.register(NetworkRollback._get_rollback_method(node))
func _ready() -> void:
if Engine.is_editor_hint():
return
if not NetworkTime.is_initial_sync_done():
# Wait for time sync to complete
await NetworkTime.after_sync
process_settings.call_deferred()
func _enter_tree() -> void:
if Engine.is_editor_hint():
return
if not NetworkTime.is_initial_sync_done():
# Wait for time sync to complete
await NetworkTime.after_sync
process_settings.call_deferred()
func _reprocess_settings() -> void:
if not _properties_dirty or Engine.is_editor_hint():
return
_properties_dirty = false
process_settings()
## Add a state property.
## [br][br]
## Settings will be automatically updated. The [param node] may be a string or
## [NodePath] pointing to a node, or an actual [Node] instance. If the given
## property is already tracked, this method does nothing.
func add_state(node: Variant, property: String):
var property_path := PropertyEntry.make_path(root, node, property)
if not property_path or state_properties.has(property_path):
return
state_properties.push_back(property_path)
_properties_dirty = true
_reprocess_settings.call_deferred()
func _notification(what: int) -> void:
if what == NOTIFICATION_EDITOR_PRE_SAVE:
update_configuration_warnings()
if what == NOTIFICATION_PREDELETE:
for node in _sim_nodes:
RollbackSimulationServer.deregister_node(node)
for subject in _state_properties.get_subjects():
NetworkHistoryServer.deregister(subject)
func _get_configuration_warnings() -> PackedStringArray:
if not root:
root = get_parent()
# Explore state properties
if not root:
return ["No valid root node found!"]
var result := PackedStringArray()
result.append_array(_NetfoxEditorUtils.gather_properties(root, "_get_rollback_state_properties",
func(node, prop):
add_state(node, prop)
))
return result
@@ -0,0 +1 @@
uid://ct51w7rsjcxvw
@@ -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()
+120 -243
View File
@@ -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,74 +50,95 @@ 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 _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)
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)
_history_transmitter.sync_settings(root, enable_input_broadcast, full_state_interval, diff_ack_interval)
_history_transmitter.configure(_states, _inputs, _state_property_config, _input_property_config, visibility_filter, _property_cache, _skipset)
_history_recorder.configure(_states, _inputs, _state_property_config, _input_property_config, _property_cache, _skipset)
# 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]
## Call this whenever the authority of any of the nodes managed by
## 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()
# 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)
_state_property_config.set_properties_from_paths(state_properties, _property_cache)
_input_property_config.set_properties_from_paths(input_properties, _property_cache)
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]
@@ -159,42 +168,77 @@ func add_input(node: Variant, property: String) -> void:
_properties_dirty = true
_reprocess_settings.call_deferred()
## Set the schema for transmitting properties over the network.
## [br][br]
## The [param schema] must be a dictionary, with the keys being property path
## strings, and the values are the associated [NetworkSchemaSerializer] objects.
## Properties are interpreted relative to the [member root] node. The schema can
## contain both state and input properties. Properties not specified in the
## schema will use a generic fallback serializer. By using the right serializer
## for the right property, bandwidth usage can be lowered.
## [br][br]
## See [NetworkSchemas] for many common serializers.
## [br][br]
## Example:
## [codeblock]
## rollback_synchronizer.set_schema({
## ":transform": NetworkSchemas.transform3f32(),
## ":velocity": NetworkSchemas.vec3f32(),
## "Input:movement": NetworkSchemas.vec3f32()
## })
## [/codeblock]
func set_schema(schema: Dictionary) -> void:
# 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]
## This input is not always current, it may be from multiple ticks ago.
## [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]
## A tick becomes predicted if there's no up-to-date input available. It will be
## 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]
@@ -208,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]
@@ -221,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():
@@ -235,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:
@@ -321,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()