Files
tactical-shooter/addons/netfox/rollback/composite/rollback-history-transmitter.gd
T
shawn e7299b17e9 Phase 7: netfox + godot-jolt stack upgrade
Stack installed:
- netfox v1.35.3 (core + extras + noray + internals)
- godot-jolt v0.16.0-stable

Architecture:
- Server: ENet transport (works headless, no netfox deps)
- Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator)

New/modified:
- docs/migration-netfox-plan.md — migration architecture
- scripts/network/network_manager.gd — netfox-aware ENet fallback
- scripts/network/player.gd — clean base player
- client/characters/player_netfox.gd — rollback player w/ WeaponManager
- client/characters/input/player_net_input.gd — BaseNetInput subclass
- client/characters/character/fps_character_controller.gd — netfox input feed
- client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager
- client/scripts/round_replicator.gd — client-side round state bridge
- server/scripts/round_manager.gd — improved state machine
- server/scripts/plugin_api/plugin_manager.gd — refined plugin system
- config: enemy_tag, ally_tag for meatball targeting

Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
2026-07-02 17:39:22 -04:00

283 lines
9.9 KiB
GDScript

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()