Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Input delay #261

Closed
10 changes: 9 additions & 1 deletion addons/netfox/netfox.gd
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ var SETTINGS = [
{
"name": "netfox/rollback/input_redundancy",
"value": 3,
"type": TYPE_INT
"type": TYPE_INT,
"hint_string": "When an input is sent, which previous tick inputs should we send with it? By having this above 1, inputs are batched together so if a packet is lost which contains an input, the next packets will provide it"
},
{
"name": "netfox/rollback/display_offset",
Expand All @@ -76,6 +77,13 @@ var SETTINGS = [
"name": "netfox/events/enabled",
"value": true,
"type": TYPE_BOOL
},
# Serialization
{
"name": "netfox/serialization/enable_input_serialization",
"value": true,
"type": TYPE_BOOL,
"hint_string": "Enabling this, the input is serialized before sending it, instead of sending a dictionary of string properties and its values. Enabling this is recommended to save bandwidth, at the slight cost of CPU."
}
]

Expand Down
2 changes: 1 addition & 1 deletion addons/netfox/properties/property-cache.gd
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ func _init(p_root: Node):

func get_entry(path: String) -> PropertyEntry:
if not _cache.has(path):
var parsed = PropertyEntry.parse(root, path)
var parsed: PropertyEntry = PropertyEntry.parse(root, path)
if not parsed.is_valid():
_logger.warning("Invalid property path: %s" % path)
_cache[path] = parsed
Expand Down
10 changes: 10 additions & 0 deletions addons/netfox/properties/property-entry.gd
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class_name PropertyEntry
var _path: String
var node: Node
var property: String
var type: Variant.Type ## See typeof()

func get_value() -> Variant:
return node.get_indexed(property)
Expand All @@ -28,4 +29,13 @@ static func parse(root: Node, path: String) -> PropertyEntry:
result.node = root.get_node(NodePath(path))
result.property = path.erase(0, path.find(":") + 1)
result._path = path
result.type = typeof(result.get_value())
return result

## This is the only property which doesnt have a node.
#static func get_tick_entry() -> PropertyEntry:
#var tick_property_entry: PropertyEntry = PropertyEntry.new()
#tick_property_entry.property = "tick"
#tick_property_entry._path = "tick"
#tick_property_entry.type = TYPE_INT
#return tick_property_entry
4 changes: 2 additions & 2 deletions addons/netfox/properties/property-snapshot.gd
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ extends Object
class_name PropertySnapshot

static func extract(properties: Array[PropertyEntry]) -> Dictionary:
var result = {}
var result: Dictionary = {}
for property in properties:
result[property.to_string()] = property.get_value()
result.make_read_only()
return result

static func apply(properties: Dictionary, cache: PropertyCache):
for property in properties:
var pe = cache.get_entry(property)
var pe: PropertyEntry = cache.get_entry(property)
var value = properties[property]
pe.set_value(value)

Expand Down
12 changes: 7 additions & 5 deletions addons/netfox/rollback/network-rollback.gd
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ var tick: int:
set(v):
push_error("Trying to set read-only variable tick")

var enable_input_serialization: bool = ProjectSettings.get_setting("netfox/serialization/enable_input_serialization", true)

## Event emitted before running the network rollback loop
signal before_loop()

Expand All @@ -73,7 +75,7 @@ signal on_record_tick(tick: int)
signal after_loop()

var _tick: int = 0
var _resim_from: int
var _resimulate_from_tick: int

var _is_rollback: bool = false
var _simulated_nodes: Dictionary = {}
Expand All @@ -82,7 +84,7 @@ var _simulated_nodes: Dictionary = {}
##
## This is used to determine the resimulation range during each loop.
func notify_resimulation_start(tick: int):
_resim_from = min(_resim_from, tick)
_resimulate_from_tick = min(_resimulate_from_tick, tick)

## Submit node for simulation.
##
Expand Down Expand Up @@ -136,14 +138,14 @@ func _rollback():
_is_rollback = true

# Ask all rewindables to submit their earliest inputs
_resim_from = NetworkTime.tick
_resimulate_from_tick = NetworkTime.tick
before_loop.emit()

# from = Earliest input amongst all rewindables
var from = _resim_from
var from: int = _resimulate_from_tick

# to = Current tick
var to = NetworkTime.tick
var to: int = NetworkTime.tick

# for tick in from .. to:
for tick in range(from, to):
Expand Down
174 changes: 132 additions & 42 deletions addons/netfox/rollback/rollback-synchronizer.gd
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ class_name RollbackSynchronizer
@export var input_properties: Array[String]

## 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 bandwith and reduce cheating risks.
## Turning this off is recommended to save bandwidth and reduce cheating risks.
@export var enable_input_broadcast: bool = true

## This is measured in ticks. So if your game's tickrate (project settings > Time) is 60 ticks, 4 ticks are 68 ms (bad but acceptable)
## If your game's tickrate is 30 ticks, 4 ticks are 134 ms (laggy)
## The lesser the value, the tighter the controls feel locally, but the less time for other players to catch up to this node's input
@export var input_delay: int = 0


var _record_state_props: Array[PropertyEntry] = []
var _record_input_props: Array[PropertyEntry] = []
var _auth_state_props: Array[PropertyEntry] = []
Expand All @@ -22,6 +28,8 @@ var _nodes: Array[Node] = []

var _states: Dictionary = {}
var _inputs: Dictionary = {}
var _serialized_inputs: Dictionary = {} #<tick, PackedByteArray>
var serialized_inputs_to_send: Array[PackedByteArray] = []
var _latest_state: int = -1
var _earliest_input: int

Expand All @@ -45,10 +53,10 @@ func process_settings():
_inputs.clear()
_latest_state = NetworkTime.tick - 1
_earliest_input = NetworkTime.tick

# Gather state props - all state props are recorded
for property in state_properties:
var pe = _property_cache.get_entry(property)
var pe: PropertyEntry = _property_cache.get_entry(property)
_record_state_props.push_back(pe)

process_authority()
Expand All @@ -71,18 +79,20 @@ func process_authority():

# Gather state properties that we own
# i.e. it's the state of a node that belongs to the local peer
for property in state_properties:
var pe = _property_cache.get_entry(property)
if pe.node.is_multiplayer_authority():
_auth_state_props.push_back(pe)
var picked_property_entry: PropertyEntry
for picked_property in state_properties:
picked_property_entry = _property_cache.get_entry(picked_property)
if picked_property_entry.node.is_multiplayer_authority():
_auth_state_props.push_back(picked_property_entry)

# Gather input properties that we own
# Only record input that is our own
for property in input_properties:
var pe = _property_cache.get_entry(property)
if pe.node.is_multiplayer_authority():
_record_input_props.push_back(pe)
_auth_input_props.push_back(pe)
for picked_property in input_properties:
picked_property_entry = _property_cache.get_entry(picked_property)
_record_input_props.push_back(picked_property_entry)
if picked_property_entry.node.is_multiplayer_authority():
_auth_input_props.push_back(picked_property_entry)


func _ready():
process_settings()
Expand All @@ -92,8 +102,15 @@ func _ready():
await NetworkTime.after_sync
_latest_state = NetworkTime.tick - 1

#dummy states to parse for first inputs before our first real input
#The code: PropertySnapshot.apply(input, _property_cache) -> does literally nothing if input == {}
#So the default input stays in its default values for the first input delay ticks.
for i in input_delay:
_inputs[NetworkTime.tick + i] = {}

NetworkTime.before_tick.connect(_before_tick)
NetworkTime.after_tick.connect(_after_tick)

NetworkRollback.before_loop.connect(_before_loop)
NetworkRollback.on_prepare_tick.connect(_prepare_tick)
NetworkRollback.on_process_tick.connect(_process_tick)
Expand All @@ -112,8 +129,8 @@ func _prepare_tick(tick: int):
# Prepare state
# Done individually by Rewindables ( usually Rollback Synchronizers )
# Restore input and state for tick
var state = _get_history(_states, tick)
var input = _get_history(_inputs, tick)
var state: Dictionary = _get_history(_states, tick)
var input: Dictionary = _get_history(_inputs, tick)

PropertySnapshot.apply(state, _property_cache)
PropertySnapshot.apply(input, _property_cache)
Expand Down Expand Up @@ -142,7 +159,7 @@ func _process_tick(tick: int):
# If not: Latest input >= tick >= Earliest input
for node in _nodes:
if NetworkRollback.is_simulated(node):
var is_fresh = _freshness_store.is_fresh(node, tick)
var is_fresh: bool = _freshness_store.is_fresh(node, tick)
NetworkRollback.process_rollback(node, NetworkTime.ticktime, tick, is_fresh)
_freshness_store.notify_processed(node, tick)

Expand All @@ -168,46 +185,79 @@ func _record_tick(tick: int):

func _after_loop():
_earliest_input = NetworkTime.tick

# Apply display state
var display_state = _get_history(_states, NetworkTime.tick - NetworkRollback.display_offset)
var display_state: Dictionary = _get_history(_states, NetworkTime.tick - NetworkRollback.display_offset)
PropertySnapshot.apply(display_state, _property_cache)

func _before_tick(_delta, tick):
func _before_tick(_delta: float, tick: int):
# Apply state for tick
var state = _get_history(_states, tick)
var state: Dictionary = _get_history(_states, tick)
PropertySnapshot.apply(state, _property_cache)

func _after_tick(_delta, _tick):
func _after_tick(_delta: float, _tick: int):
if not _auth_input_props.is_empty():
var input = PropertySnapshot.extract(_auth_input_props)
_inputs[NetworkTime.tick] = input

#Send the last n inputs for each property
var inputs = {}
for i in range(0, NetworkRollback.input_redundancy):
var tick_input = _inputs.get(NetworkTime.tick - i, {})
for property in tick_input:
if not inputs.has(property):
inputs[property] = []
inputs[property].push_back(tick_input[property])

_attempt_submit_input(inputs)

var local_input: Dictionary = PropertySnapshot.extract(_auth_input_props)
var delayed_input_tick: int = _tick + input_delay
_inputs[delayed_input_tick] = local_input

if (NetworkRollback.enable_input_serialization):
var serialized_current_input: PackedByteArray = PropertiesSerializer.serialize_multiple_properties(_auth_input_props, delayed_input_tick)
_serialized_inputs[delayed_input_tick] = serialized_current_input

if (serialized_inputs_to_send.size() == NetworkRollback.input_redundancy):
serialized_inputs_to_send.remove_at(0)
serialized_inputs_to_send.append(serialized_current_input)

if (serialized_inputs_to_send.is_empty() == false):
var merged_serialized_inputs: PackedByteArray
merged_serialized_inputs.resize(0)
for picked_serialized_input in serialized_inputs_to_send:
merged_serialized_inputs.append_array(picked_serialized_input)

_attempt_submit_serialized_inputs(merged_serialized_inputs)
else:
#Send the last n inputs for each property
var inputs = {}
for i in range(0, NetworkRollback.input_redundancy):
var tick_input: Dictionary = _inputs.get(delayed_input_tick - i, {})
for property in tick_input:
if not inputs.has(property):
inputs[property] = []
inputs[property].push_back(tick_input[property])

_attempt_submit_raw_input(inputs, delayed_input_tick)

history_cleanup()
func history_cleanup() -> void:
while _states.size() > NetworkRollback.history_limit:
_states.erase(_states.keys().min())

while _inputs.size() > NetworkRollback.history_limit:
_inputs.erase(_inputs.keys().min())

if (NetworkRollback.enable_input_serialization):
while _serialized_inputs.size() > NetworkRollback.history_limit:
_serialized_inputs.erase(_serialized_inputs.keys().min())

_freshness_store.trim()

func _attempt_submit_input(input: Dictionary):
## Sends batched inputs to all other players (not local!)
func _attempt_submit_raw_input(batched_inputs: Dictionary, tick: int):
# TODO: Default to input broadcast in mesh network setups
if enable_input_broadcast:
_submit_input.rpc(input, NetworkTime.tick)
for picked_peer_id in multiplayer.get_peers():
_submit_raw_input.rpc_id(picked_peer_id, batched_inputs, tick)
elif not multiplayer.is_server():
_submit_input.rpc_id(1, input, NetworkTime.tick)
_submit_raw_input.rpc_id(1, batched_inputs, tick)

## Sends serialized batched inputs to all other players (not local!)
func _attempt_submit_serialized_inputs(serialized_inputs: PackedByteArray):
# TODO: Default to input broadcast in mesh network setups
if enable_input_broadcast:
for picked_peer_id in multiplayer.get_peers():
_submit_serialized_inputs.rpc_id(picked_peer_id, serialized_inputs)
elif not multiplayer.is_server():
_submit_serialized_inputs.rpc_id(1, serialized_inputs)

func _get_history(buffer: Dictionary, tick: int) -> Dictionary:
if buffer.has(tick):
Expand All @@ -219,26 +269,66 @@ func _get_history(buffer: Dictionary, tick: int) -> Dictionary:
var earliest = buffer.keys().min()
var latest = buffer.keys().max()


if tick < earliest:
return buffer[earliest]

#For inputs, this is extrapolation aka prediction
#For example, if you move to the right (at latest)
#at tick, you will still be moving to the right ;)
if tick > latest:
return buffer[latest]

var before = buffer.keys() \
var before: int = buffer.keys() \
.filter(func (key): return key < tick) \
.max()

return buffer[before]

@rpc("any_peer", "unreliable", "call_remote")
func _submit_input(input: Dictionary, tick: int):
var sender = multiplayer.get_remote_sender_id()
func _submit_serialized_inputs(serialized_inputs: PackedByteArray):
var sender: int = multiplayer.get_remote_sender_id()

#TODO: Security check to ensure no other client sent this (when enable_input_broadcast == false), see sanitization in submit_raw_inputs

var picked_tick: int
var picked_input_values_size: int #The size of the serialized input containing all properties (excluding tick timestamp[0,1,2,3] and the size itself on byte[4])
var picked_single_input: PackedByteArray
var picked_byte_index: int = 0
while (picked_byte_index < serialized_inputs.size()):
picked_tick = serialized_inputs.decode_u32(picked_byte_index)
picked_byte_index += 4
picked_input_values_size = serialized_inputs.decode_u8(picked_byte_index)
picked_byte_index += 1

if (_inputs.has(picked_tick) == false): #New input!
picked_single_input = serialized_inputs.slice(picked_byte_index, picked_byte_index + picked_input_values_size)
var received_properties: Dictionary
if (_auth_input_props.is_empty()):
received_properties = PropertiesSerializer.deserialize_multiple_properties(picked_single_input, _record_input_props)
else:
received_properties = PropertiesSerializer.deserialize_multiple_properties(picked_single_input, _auth_input_props)

_earliest_input = min(_earliest_input, picked_tick)

if (_inputs.has(picked_tick) == false):
_inputs[picked_tick] = received_properties
else:
for picked_property_path in received_properties:
_inputs[picked_tick][picked_property_path] = received_properties[picked_property_path]


picked_byte_index += picked_input_values_size

@rpc("any_peer", "unreliable", "call_remote")
func _submit_raw_input(input: Dictionary, tick: int):
var sender: int = multiplayer.get_remote_sender_id()

var sanitized = {}
for property in input:
var pe = _property_cache.get_entry(property)
var pe: PropertyEntry = _property_cache.get_entry(property)
var value = input[property]
var input_owner = pe.node.get_multiplayer_authority()
var input_owner: int = pe.node.get_multiplayer_authority()

if input_owner != sender:
_logger.warning("Received input for node owned by %s from %s, sender has no authority!" \
Expand Down
Loading