netfishing/network/network_session.gd

2073 lines
60 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name NetworkSession
extends Node
const DEFAULT_PORT: int = 7777
const DEFAULT_PRIVATE_HOST_PORT_ATTEMPTS: int = 16
const DEFAULT_SESSION_MAX_PLAYERS: int = 8
const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31
const CONNECTION_TIMEOUT_SECONDS: float = 10.0
const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0
const INPUT_INTERVAL: float = 1.0 / 30.0
const SNAPSHOT_INTERVAL: float = 1.0 / 30.0
const MAX_MOVEMENT_INPUT_SEQUENCE: int = 2147483647
signal state_changed(state: State)
signal status_message_changed(message: String)
signal connection_error(message: String)
signal peer_authenticated(peer_id: int, display_name: String)
signal peer_removed(peer_id: int)
signal host_openness_changed(is_open: bool)
signal session_display_name_changed(display_name: String)
signal peer_count_changed(player_count: int, max_players: int)
signal peer_display_name_changed(peer_id: int, display_name: String)
signal peer_profile_changed(
peer_id: int,
display_name: String,
appearance: Dictionary,
)
signal join_authenticated
signal server_trust_required(
endpoint: String,
expected_fingerprint: String,
received_fingerprint: String,
is_changed: bool,
)
signal peer_identity_observed(peer_id: int, status: String)
signal operator_status_changed(peer_id: int, is_operator: bool)
signal server_lost
signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
signal remote_recovery_presentation_changed(
peer_id: int,
active: bool,
attempt_id: String,
)
enum State {
INACTIVE,
STARTING_PRIVATE_HOST,
PRIVATE_HOST,
OPEN_HOST,
CONNECTING,
AUTHENTICATING,
VERIFYING_SERVER_IDENTITY,
JOINED_CLIENT,
DISCONNECTING,
CONNECTION_FAILED,
SERVER_LOST,
}
@export_range(2, 128, 1) var session_max_players: int = (
DEFAULT_SESSION_MAX_PLAYERS
)
@export_range(1, 256, 1) var transport_max_clients: int = (
DEFAULT_TRANSPORT_MAX_CLIENTS
)
var state: State = State.INACTIVE
var _transport: DirectEnetTransport
var _profile: NetworkProfilePreferences
var _saved_servers: SavedServerStore
var _spawn_service: PlayerSpawnService
var _registry := PeerRegistry.new()
var _pending_authentication: Dictionary[int, float] = {}
var _session_id: String = ""
var _operation_generation: int = 0
var _connection_deadline: float = 0.0
var _client_nonce: String = ""
var _current_route: ConnectionRoute
var _input_sequence: int = 0
var _input_accumulator: float = 0.0
var _snapshot_accumulator: float = 0.0
var _last_server_max_players: int = DEFAULT_SESSION_MAX_PLAYERS
var _last_server_player_count: int = 0
var _last_server_display_name: String = ""
var _last_server_protocol_version: int = 0
var _server_capabilities: PackedStringArray = PackedStringArray()
var _profile_ready: bool = false
var _player_identity: PlayerIdentityStore
var _host_identity: HostIdentityStore
var _known_players: KnownPlayerStore
var _server_trust: ServerTrustStore
var _host_bans: HostBanStore
var _pending_identity_challenges: Dictionary[int, Dictionary] = {}
var _authenticated_identity_cache: Dictionary[int, Dictionary] = {}
var _client_identity_attempt: Dictionary = {}
var _pending_server_proof: Dictionary = {}
var _recovery_attempts: Dictionary[int, String] = {}
var _server_identity_fingerprint: String = ""
var _server_identity_public_key: String = ""
var _session_identity_keys: Dictionary[String, String] = {}
var _local_appearance_snapshot: Dictionary = (
CharacterCustomizationCatalog.default_snapshot()
)
var _moderation_disconnect_message := ""
var _host_port: int = 0
var _session_display_name: String = "NETfishing Room"
var _dedicated_host: bool = false
var _configured_operator_fingerprints: Dictionary[String, bool] = {}
var _session_operator_fingerprints: Dictionary[String, bool] = {}
var _operator_peer_ids: Dictionary[int, bool] = {}
var _local_operator: bool = false
func _ready() -> void:
transport_max_clients = maxi(
transport_max_clients,
session_max_players - 1
)
_connect_multiplayer_signals()
func setup(
profile: NetworkProfilePreferences,
saved_servers: SavedServerStore,
spawn_service: PlayerSpawnService,
player_identity: PlayerIdentityStore,
host_identity: HostIdentityStore,
known_players: KnownPlayerStore,
server_trust: ServerTrustStore,
host_bans: HostBanStore,
dedicated: bool = false,
) -> void:
_profile = profile
_saved_servers = saved_servers
_spawn_service = spawn_service
_player_identity = player_identity
_host_identity = host_identity
_known_players = known_players
_server_trust = server_trust
_host_bans = host_bans
if not dedicated and _profile != null:
_profile_ready = _profile.load_or_create()
if not dedicated and _player_identity != null:
_profile_ready = _profile_ready and _player_identity.load_or_create()
func configure_dedicated_operators(
fingerprints: PackedStringArray,
) -> bool:
if state != State.INACTIVE:
return false
var configured: Dictionary[String, bool] = {}
for fingerprint: String in fingerprints:
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
return false
configured[fingerprint] = true
_configured_operator_fingerprints = configured
return true
func start_private_host(
port: int = DEFAULT_PORT,
port_attempts: int = 1,
) -> bool:
if (
state != State.INACTIVE
or not _profile_ready
or _profile == null
or _spawn_service == null
or _host_identity == null
):
return false
return _start_host(port, port_attempts, false)
func start_dedicated_host(
port: int = DEFAULT_PORT,
max_players: int = DEFAULT_SESSION_MAX_PLAYERS,
bind_address: String = "*",
) -> bool:
if (
state != State.INACTIVE
or _spawn_service == null
or _host_identity == null
or max_players < 1
or max_players > 128
or bind_address.is_empty()
):
return false
session_max_players = max_players
transport_max_clients = maxi(transport_max_clients, max_players)
return _start_host(port, 1, true, bind_address)
func _start_host(
port: int,
port_attempts: int,
dedicated: bool,
bind_address: String = "*",
) -> bool:
if not _host_identity.load_or_create():
_fail(_host_identity.error_message)
return false
if port < 1 or port > 65535 or port_attempts < 1:
_fail("The hosting port must be from 1 to 65535.")
return false
_operation_generation += 1
_set_state(
State.STARTING_PRIVATE_HOST,
"Starting dedicated server..." if dedicated else "Starting private game...",
)
var selected_port: int = 0
var final_port: int = mini(port + port_attempts - 1, 65535)
for candidate_port: int in range(port, final_port + 1):
if not _can_bind_udp_port(candidate_port, bind_address):
continue
_replace_transport()
var error: Error = _transport.start_host(
candidate_port,
transport_max_clients,
bind_address,
)
if error == OK:
selected_port = candidate_port
break
if selected_port == 0:
_fail(
"Could not start a private game on UDP ports %d%d."
% [port, final_port]
)
return false
_host_port = selected_port
_dedicated_host = dedicated
var peer: MultiplayerPeer = _transport.get_multiplayer_peer()
peer.refuse_new_connections = true
multiplayer.multiplayer_peer = peer
_session_id = Crypto.new().generate_random_bytes(16).hex_encode()
_registry.clear()
_session_operator_fingerprints.clear()
_operator_peer_ids.clear()
_local_operator = false
_spawn_service.clear_remote_players()
if not dedicated:
_register_player_host()
_set_state(
State.PRIVATE_HOST,
(
"Dedicated server • UDP %d"
if dedicated else "Private game • UDP %d"
) % selected_port,
)
host_openness_changed.emit(false)
_emit_peer_count()
return true
func _register_player_host() -> void:
_registry.add_peer(
1,
_profile.profile_id,
_profile.display_name,
NetworkProtocol.PROTOCOL_VERSION,
_player_identity.fingerprint,
_player_identity.public_pem,
PackedStringArray([
NetworkProtocol.FISH_QUALITY_CAPABILITY,
NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.JOBS_CAPABILITY,
]),
)
_registry.update_appearance(1, _local_appearance_snapshot)
var host_profile_hello := NetworkProtocol.make_client_hello(
_profile.profile_id,
_profile.display_name,
NetworkIdentityCrypto.secure_id(32),
_local_appearance_snapshot,
_player_identity.fingerprint,
)
host_profile_hello["identity_signature"] = _player_identity.sign(
"handshake_client_profile",
NetworkProtocol.client_profile_fields(host_profile_hello),
)
var host_record := _registry.get_peer(1)
if host_record != null:
host_record.profile_authorization = {
"domain": "handshake_client_profile",
"signature": host_profile_hello["identity_signature"],
"client_nonce": host_profile_hello["client_nonce"],
}
_archive_authenticated_identity(host_record)
_spawn_service.register_local_player(1)
var host_avatar := _spawn_service.get_avatar(1)
if host_avatar != null:
host_avatar.apply_appearance_snapshot(_local_appearance_snapshot)
func get_host_port() -> int:
return _host_port if is_host() else 0
func send_traversal_packet(
destination_address: String,
destination_port: int,
packet: PackedByteArray,
) -> bool:
return (
_transport != null
and state in [
State.PRIVATE_HOST,
State.OPEN_HOST,
State.CONNECTING,
State.AUTHENTICATING,
]
and _transport.send_raw_packet(
destination_address,
destination_port,
packet,
)
)
func is_dedicated_host() -> bool:
return is_host() and _dedicated_host
func is_local_operator() -> bool:
return state == State.JOINED_CLIENT and _local_operator
func can_local_moderate() -> bool:
return is_host() or is_local_operator()
func can_manage_operators() -> bool:
return is_host() and not _dedicated_host
func is_peer_operator(peer_id: int) -> bool:
return bool(_operator_peer_ids.get(peer_id, false))
func set_peer_operator(
peer_id: int,
fingerprint: String,
enabled: bool,
) -> bool:
if not can_manage_operators() or peer_id <= 1:
return false
var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
if (
record == null
or not record.identity_authenticated
or record.identity_fingerprint != fingerprint
):
return false
if enabled:
_session_operator_fingerprints[fingerprint] = true
else:
_session_operator_fingerprints.erase(fingerprint)
_set_operator_status(peer_id, enabled)
return true
func set_session_display_name(value: String) -> void:
var cleaned: String = value.strip_edges().left(48)
if cleaned.is_empty():
cleaned = "NETfishing Room"
if cleaned == _session_display_name:
return
_session_display_name = cleaned
session_display_name_changed.emit(_session_display_name)
func get_session_display_name() -> String:
return _session_display_name
func get_local_display_name() -> String:
return _profile.display_name if _profile != null else ""
static func _can_bind_udp_port(port: int, bind_address: String = "*") -> bool:
var probe := PacketPeerUDP.new()
var error: Error = probe.bind(port, bind_address)
probe.close()
return error == OK
func join_direct(endpoint_text: String) -> bool:
if (
state != State.INACTIVE
or not _profile_ready
or _profile == null
or _spawn_service == null
):
return false
var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text)
if not endpoint.is_valid():
_fail(endpoint.error_message)
return false
_operation_generation += 1
var generation: int = _operation_generation
_current_route = ConnectionRoute.direct(endpoint)
_set_state(
State.CONNECTING,
"Connecting to %s..." % endpoint.normalized_display
)
_replace_transport()
var error: Error = _transport.connect_to_route(_current_route)
if error != OK:
_fail("Could not begin the direct connection.")
return false
multiplayer.multiplayer_peer = _transport.get_multiplayer_peer()
_connection_deadline = (
Time.get_ticks_msec() / 1000.0 + CONNECTION_TIMEOUT_SECONDS
)
# The generation is checked by the process timeout and all state-gated
# multiplayer callbacks.
if generation != _operation_generation:
return false
return true
func cancel_connection() -> void:
if state not in [
State.CONNECTING,
State.AUTHENTICATING,
State.VERIFYING_SERVER_IDENTITY,
]:
return
_operation_generation += 1
_teardown_peer()
_set_state(State.INACTIVE, "Connection cancelled.")
func reset_failure() -> void:
if state not in [State.CONNECTION_FAILED, State.SERVER_LOST]:
return
_teardown_peer()
_set_state(State.INACTIVE, "Ready for a direct connection.")
func disconnect_session(message: String = "Disconnected.") -> void:
if state == State.INACTIVE:
return
if state in [State.CONNECTION_FAILED, State.SERVER_LOST]:
_teardown_peer()
_set_state(State.INACTIVE, message)
return
_operation_generation += 1
_set_state(State.DISCONNECTING, "Disconnecting...")
_teardown_peer()
_set_state(State.INACTIVE, message)
func set_host_open(is_open: bool) -> bool:
if state not in [State.PRIVATE_HOST, State.OPEN_HOST]:
return false
var peer: MultiplayerPeer = multiplayer.multiplayer_peer
if peer == null:
return false
peer.refuse_new_connections = not is_open
_set_state(
State.OPEN_HOST if is_open else State.PRIVATE_HOST,
(
"Dedicated server • UDP %d%d / %d players"
if _dedicated_host
else "Open game • UDP %d%d / %d players"
if is_open
else "Private game • UDP %d%d / %d players"
) % [_host_port, _registry.size(), session_max_players]
)
host_openness_changed.emit(is_open)
return true
func is_host() -> bool:
return state in [State.PRIVATE_HOST, State.OPEN_HOST]
func is_open_host() -> bool:
return state == State.OPEN_HOST
func is_joined_client() -> bool:
return state == State.JOINED_CLIENT
func is_session_active() -> bool:
return is_host() or is_joined_client()
func get_player_count() -> int:
return _registry.size()
func get_session_max_players() -> int:
return _last_server_max_players if is_joined_client() else session_max_players
func get_current_route_display() -> String:
return (
_current_route.display_description
if _current_route != null
else _transport.get_route_description() if _transport != null else ""
)
func get_current_endpoint() -> ConnectionEndpoint:
return (
_current_route.direct_endpoint
if _current_route != null
and _current_route.kind == ConnectionRoute.Kind.DIRECT
else null
)
func get_last_server_metadata() -> Dictionary:
return {
"server_display_name": _last_server_display_name,
"protocol_version": _last_server_protocol_version,
"player_count": _last_server_player_count,
"max_players": _last_server_max_players,
}
func can_use_host_gameplay() -> bool:
return is_host()
func is_gameplay_session_active() -> bool:
return state in [State.PRIVATE_HOST, State.OPEN_HOST, State.JOINED_CLIENT]
func is_authenticated_peer(peer_id: int) -> bool:
return _registry.has_peer(peer_id)
func get_session_id() -> String:
return _session_id
func get_operation_generation() -> int:
return _operation_generation
func supports_server_capability(capability: StringName) -> bool:
if is_host():
return str(capability) in PackedStringArray([
"movement_v1", "fishing_v1", "sale_v1", "shop_v1",
NetworkProtocol.ART_SHOP_CAPABILITY,
"item_use_v1", "equipment_v1", "fish_showcase_v1",
NetworkProtocol.FISH_QUALITY_CAPABILITY,
NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",
"identity_v1",
])
return str(capability) in _server_capabilities
func get_peer_record(peer_id: int) -> PeerRegistry.PeerRecord:
return _registry.get_peer(peer_id)
func peer_supports_capability(
peer_id: int,
capability: StringName,
) -> bool:
var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
return record != null and str(capability) in record.capability_flags
func get_authenticated_peer_ids() -> Array[int]:
return _registry.get_peer_ids()
func get_peer_rtt_ms(peer_id: int) -> int:
if peer_id == 1:
return 0
var enet := multiplayer.multiplayer_peer as ENetMultiplayerPeer
if (
enet == null
or enet.get_connection_status()
!= MultiplayerPeer.CONNECTION_CONNECTED
):
return -1
var measurable := is_host() or (
is_joined_client() and peer_id == get_local_peer_id()
)
if not measurable:
return -1
if is_joined_client() and not _registry.has_peer(1):
return -1
var packet_peer: ENetPacketPeer = enet.get_peer(peer_id if is_host() else 1)
if packet_peer == null:
return -1
return int(packet_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME))
func kick_authenticated_peer(
peer_id: int,
fingerprint: String,
banned: bool = false,
) -> bool:
if not is_host() or peer_id <= 1:
return false
var record := _registry.get_peer(peer_id)
if record == null or record.identity_fingerprint != fingerprint:
return false
receive_moderation_disconnect.rpc_id(
peer_id,
(
"You are not permitted to join this server."
if banned else "You were removed by the host."
),
)
call_deferred("_disconnect_rejected_peer", peer_id)
return true
func get_local_identity_fingerprint() -> String:
return _player_identity.fingerprint if _player_identity != null else ""
func sign_local_action(domain: String, fields: Array) -> PackedByteArray:
return (
_player_identity.sign(domain, fields)
if _player_identity != null else PackedByteArray()
)
func verify_peer_action(
peer_id: int,
domain: String,
fields: Array,
signature: PackedByteArray,
) -> bool:
var record := _registry.get_peer(peer_id)
if record == null or not record.identity_authenticated:
return false
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(record.identity_public_key),
domain,
fields,
signature,
)
func matches_authenticated_session_identity(
fingerprint: String, public_pem: String
) -> bool:
var normalized := NetworkIdentityCrypto.normalize_public_pem(public_pem)
return (
NetworkIdentityCrypto.valid_fingerprint(fingerprint)
and NetworkIdentityCrypto.fingerprint_public_pem(normalized)
== fingerprint
and _session_identity_keys.get(fingerprint, "") == normalized
)
func sign_host_action(domain: String, fields: Array) -> PackedByteArray:
return (
_host_identity.sign(domain, fields)
if is_host() and _host_identity != null else PackedByteArray()
)
func get_host_identity_fingerprint() -> String:
return (
_host_identity.fingerprint
if is_host() and _host_identity != null
else _server_identity_fingerprint
)
func verify_host_action(
domain: String, fields: Array, signature: PackedByteArray
) -> bool:
var public_pem: String = (
_host_identity.public_pem
if is_host() and _host_identity != null
else _server_identity_public_key
)
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
domain,
fields,
signature,
)
func update_local_display_name(value: String) -> bool:
if _profile == null or not _profile.set_display_name(value):
return false
var peer_id := get_local_peer_id()
if is_host():
_apply_display_name(peer_id, _profile.display_name)
receive_display_name.rpc(peer_id, _profile.display_name)
elif is_joined_client() and supports_server_capability(&"chat_v1"):
submit_display_name.rpc_id(1, _profile.display_name)
return true
func set_local_appearance_snapshot(snapshot: Dictionary) -> void:
if CharacterCustomizationCatalog.validate_snapshot(snapshot):
_local_appearance_snapshot = snapshot.duplicate(true)
var local_id := get_local_peer_id()
if local_id > 0:
_registry.update_appearance(local_id, _local_appearance_snapshot)
func apply_canonical_profile(
peer_id: int,
display_name: String,
appearance: Dictionary,
) -> bool:
if (
not NetworkProfilePreferences.is_valid_display_name(display_name)
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
):
return false
var name_changed := _registry.update_display_name(peer_id, display_name)
var appearance_changed := _registry.update_appearance(peer_id, appearance)
if name_changed:
peer_display_name_changed.emit(peer_id, display_name)
if appearance_changed:
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar != null:
avatar.apply_appearance_snapshot(appearance)
if name_changed or appearance_changed:
peer_profile_changed.emit(peer_id, display_name, appearance.duplicate(true))
return name_changed or appearance_changed
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_display_name(value: String) -> void:
var sender_id := multiplayer.get_remote_sender_id()
if (
is_host()
and is_authenticated_peer(sender_id)
and NetworkProfilePreferences.is_valid_display_name(value)
):
_apply_display_name(sender_id, value.strip_edges())
receive_display_name.rpc(sender_id, value.strip_edges())
@rpc("authority", "call_remote", "reliable", 0)
func receive_display_name(peer_id: int, value: String) -> void:
if NetworkProfilePreferences.is_valid_display_name(value):
_apply_display_name(peer_id, value.strip_edges())
func _apply_display_name(peer_id: int, value: String) -> void:
if _registry.update_display_name(peer_id, value):
peer_display_name_changed.emit(peer_id, value)
func get_local_peer_id() -> int:
if is_dedicated_host():
return 0
return multiplayer.get_unique_id() if is_gameplay_session_active() else 0
func _process(delta: float) -> void:
var now: float = Time.get_ticks_msec() / 1000.0
if (
state in [
State.CONNECTING,
State.AUTHENTICATING,
State.VERIFYING_SERVER_IDENTITY,
]
and _connection_deadline > 0.0
and now >= _connection_deadline
):
_fail("Connection timed out.")
_teardown_peer()
return
if is_host():
_expire_pending_authentication(now)
if state not in [State.OPEN_HOST, State.PRIVATE_HOST, State.JOINED_CLIENT]:
return
_input_accumulator += delta
_snapshot_accumulator += delta
if state == State.JOINED_CLIENT and _input_accumulator >= INPUT_INTERVAL:
_input_accumulator = fmod(_input_accumulator, INPUT_INTERVAL)
_send_local_input()
if is_host() and _snapshot_accumulator >= SNAPSHOT_INTERVAL:
_snapshot_accumulator = fmod(_snapshot_accumulator, SNAPSHOT_INTERVAL)
_broadcast_movement_snapshots()
func _connect_multiplayer_signals() -> void:
if not multiplayer.peer_connected.is_connected(_on_peer_connected):
multiplayer.peer_connected.connect(_on_peer_connected)
if not multiplayer.peer_disconnected.is_connected(_on_peer_disconnected):
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
if not multiplayer.connected_to_server.is_connected(
_on_connected_to_server
):
multiplayer.connected_to_server.connect(_on_connected_to_server)
if not multiplayer.connection_failed.is_connected(_on_connection_failed):
multiplayer.connection_failed.connect(_on_connection_failed)
if not multiplayer.server_disconnected.is_connected(
_on_server_disconnected
):
multiplayer.server_disconnected.connect(_on_server_disconnected)
func _replace_transport() -> void:
if _transport != null:
_transport.disconnect_transport()
_transport.queue_free()
_transport = DirectEnetTransport.new()
add_child(_transport)
_transport.transport_error.connect(connection_error.emit)
func _on_peer_connected(peer_id: int) -> void:
if not is_host():
return
if state != State.OPEN_HOST:
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
return
_pending_authentication[peer_id] = (
Time.get_ticks_msec() / 1000.0 + AUTHENTICATION_TIMEOUT_SECONDS
)
func _on_connected_to_server() -> void:
if state != State.CONNECTING:
return
_set_state(State.AUTHENTICATING, "Authenticating...")
_client_nonce = NetworkIdentityCrypto.secure_id(32)
_client_identity_attempt = NetworkProtocol.make_identity_hello(
_player_identity.public_pem,
_player_identity.fingerprint,
_client_nonce,
NetworkIdentityCrypto.secure_id(16),
)
submit_identity_hello.rpc_id(1, _client_identity_attempt)
func _on_connection_failed() -> void:
if state not in [
State.CONNECTING,
State.AUTHENTICATING,
State.VERIFYING_SERVER_IDENTITY,
]:
return
_teardown_peer()
_fail("Could not connect. The server may be private, unavailable, or unreachable.")
func _on_server_disconnected() -> void:
if state in [State.INACTIVE, State.DISCONNECTING]:
return
_operation_generation += 1
_teardown_peer()
if state in [
State.CONNECTING,
State.AUTHENTICATING,
State.VERIFYING_SERVER_IDENTITY,
]:
_fail("The server rejected or ended authentication.")
return
var message := (
_moderation_disconnect_message
if not _moderation_disconnect_message.is_empty()
else "The server connection was lost."
)
_moderation_disconnect_message = ""
_set_state(State.SERVER_LOST, message)
server_lost.emit()
connection_error.emit(message)
func _on_peer_disconnected(peer_id: int) -> void:
_pending_authentication.erase(peer_id)
_pending_identity_challenges.erase(peer_id)
_authenticated_identity_cache.erase(peer_id)
_operator_peer_ids.erase(peer_id)
var recovery_attempt: String = _recovery_attempts.get(peer_id, "")
if not recovery_attempt.is_empty():
_recovery_attempts.erase(peer_id)
remote_recovery_presentation_changed.emit(
peer_id, false, recovery_attempt
)
if _registry.has_peer(peer_id):
_registry.remove_peer(peer_id)
_spawn_service.remove_peer(peer_id)
if is_host():
receive_peer_despawn.rpc(peer_id)
peer_removed.emit(peer_id)
_emit_peer_count()
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_identity_hello(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not is_host() or sender_id <= 1 or not _pending_authentication.has(sender_id):
return
var client_game_version: String = str(data.get("game_version", ""))
var version_rejection: NetworkProtocol.RejectionCode = (
NetworkProtocol.game_version_rejection(
client_game_version,
NetworkProtocol.game_version(),
)
)
if version_rejection != NetworkProtocol.RejectionCode.NONE:
_reject_peer(sender_id, version_rejection)
return
if (
not NetworkProtocol.validate_identity_hello(data)
or int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION
):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.PROTOCOL_MISMATCH
)
return
var public_pem := NetworkIdentityCrypto.normalize_public_pem(data["public_key"])
var fingerprint := NetworkIdentityCrypto.fingerprint_public_pem(public_pem)
if (
fingerprint != str(data["fingerprint"])
or NetworkIdentityCrypto.load_public_key(public_pem) == null
):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF
)
return
if _registry.has_fingerprint(fingerprint):
_reject_peer(sender_id, NetworkProtocol.RejectionCode.DUPLICATE_IDENTITY)
return
var server_nonce := NetworkIdentityCrypto.secure_id(32)
var challenge := {
"peer_id": sender_id,
"attempt_id": str(data["attempt_id"]),
"client_nonce": str(data["client_nonce"]),
"client_fingerprint": fingerprint,
"client_public_key": public_pem,
"client_game_version": client_game_version,
"server_nonce": server_nonce,
"server_fingerprint": _host_identity.fingerprint,
"server_game_version": NetworkProtocol.game_version(),
"session_id": _session_id,
"generation": _operation_generation,
"expires_at_msec": Time.get_ticks_msec() + 60000,
}
var fields := _identity_proof_fields(challenge)
var proof := challenge.duplicate(true)
proof["server_public_key"] = _host_identity.public_pem
proof["server_signature"] = _host_identity.sign(
"handshake_server_proof", fields
)
_pending_identity_challenges[sender_id] = challenge
receive_server_identity_proof.rpc_id(sender_id, proof)
@rpc("authority", "call_remote", "reliable", 0)
func receive_server_identity_proof(data: Dictionary) -> void:
if state != State.AUTHENTICATING:
return
var server_game_version: String = str(data.get("server_game_version", ""))
if server_game_version.is_empty():
_fail_identity(NetworkProtocol.rejection_text(
NetworkProtocol.RejectionCode.SERVER_OUTDATED
))
return
var version_rejection: NetworkProtocol.RejectionCode = (
NetworkProtocol.game_version_rejection(
NetworkProtocol.game_version(),
server_game_version,
)
)
if version_rejection != NetworkProtocol.RejectionCode.NONE:
_fail_identity(NetworkProtocol.rejection_text(version_rejection))
return
if not _valid_server_proof_shape(data):
_fail_identity("The server sent an invalid identity proof.")
return
if (
str(data["attempt_id"]) != str(_client_identity_attempt.get("attempt_id", ""))
or str(data["client_nonce"]) != _client_nonce
):
_fail_identity("The server identity proof did not match this connection.")
return
var public_pem := NetworkIdentityCrypto.normalize_public_pem(data["server_public_key"])
var fingerprint := NetworkIdentityCrypto.fingerprint_public_pem(public_pem)
var public_key := NetworkIdentityCrypto.load_public_key(public_pem)
if (
fingerprint != str(data["server_fingerprint"])
or not NetworkIdentityCrypto.verify_fields(
public_key,
"handshake_server_proof",
_identity_proof_fields(data),
data["server_signature"],
)
):
_fail_identity("The server identity proof was invalid.")
return
_pending_server_proof = data.duplicate(true)
_server_identity_fingerprint = fingerprint
_server_identity_public_key = public_pem
var verification := _server_trust.verify(
get_current_endpoint(), fingerprint
)
if verification == ServerTrustStore.Verification.MATCH:
_server_trust.touch(get_current_endpoint())
_send_client_identity_proof()
return
_set_state(
State.VERIFYING_SERVER_IDENTITY,
"Confirm this server identity before continuing.",
)
_connection_deadline = 0.0
var expected := str(
_server_trust.get_record(get_current_endpoint()).get("fingerprint", "")
)
server_trust_required.emit(
get_current_route_display(),
expected,
fingerprint,
verification == ServerTrustStore.Verification.CHANGED,
)
func resolve_server_trust(accepted: bool) -> void:
if state != State.VERIFYING_SERVER_IDENTITY:
return
if not accepted:
cancel_connection()
return
if not _server_trust.trust(
get_current_endpoint(),
str(_pending_server_proof["server_fingerprint"]),
"NETfishing",
):
_fail_identity("The server identity could not be pinned.")
return
_set_state(State.AUTHENTICATING, "Authenticating identity...")
_connection_deadline = (
Time.get_ticks_msec() / 1000.0 + CONNECTION_TIMEOUT_SECONDS
)
_send_client_identity_proof()
func _send_client_identity_proof() -> void:
var proof := {
"attempt_id": _pending_server_proof["attempt_id"],
"session_id": _pending_server_proof["session_id"],
"client_fingerprint": _player_identity.fingerprint,
"client_signature": _player_identity.sign(
"handshake_client_proof",
_identity_proof_fields(_pending_server_proof),
),
}
submit_client_identity_proof.rpc_id(1, proof)
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_client_identity_proof(data: Dictionary) -> void:
var sender_id := multiplayer.get_remote_sender_id()
var challenge: Dictionary = _pending_identity_challenges.get(sender_id, {})
if (
not is_host()
or challenge.is_empty()
or Time.get_ticks_msec() > int(challenge["expires_at_msec"])
or data.get("attempt_id") != challenge["attempt_id"]
or data.get("session_id") != _session_id
or data.get("client_fingerprint") != challenge["client_fingerprint"]
or typeof(data.get("client_signature")) != TYPE_PACKED_BYTE_ARRAY
):
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
return
var verified := NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(challenge["client_public_key"]),
"handshake_client_proof",
_identity_proof_fields(challenge),
data["client_signature"],
)
_pending_identity_challenges.erase(sender_id)
if not verified:
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
return
_authenticated_identity_cache[sender_id] = {
"fingerprint": challenge["client_fingerprint"],
"public_key": challenge["client_public_key"],
"game_version": challenge["client_game_version"],
}
if (
_host_bans != null
and _host_bans.is_banned(
_host_identity.fingerprint,
str(challenge["client_fingerprint"]),
)
):
_reject_peer(sender_id, NetworkProtocol.RejectionCode.BANNED)
return
request_client_profile.rpc_id(sender_id)
@rpc("authority", "call_remote", "reliable", 0)
func receive_moderation_disconnect(message: String) -> void:
_moderation_disconnect_message = message.left(120)
connection_error.emit(_moderation_disconnect_message)
@rpc("authority", "call_remote", "reliable", 0)
func request_client_profile() -> void:
if state != State.AUTHENTICATING:
return
var hello := NetworkProtocol.make_client_hello(
_profile.profile_id,
_profile.display_name,
_client_nonce,
_local_appearance_snapshot,
_player_identity.fingerprint,
)
hello["identity_signature"] = _player_identity.sign(
"handshake_client_profile",
NetworkProtocol.client_profile_fields(hello),
)
submit_client_hello.rpc_id(1, hello)
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_client_hello(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not is_host() or sender_id <= 1 or not _pending_authentication.has(sender_id):
return
var validation_error: String = NetworkProtocol.validate_client_hello(data)
if not validation_error.is_empty():
_reject_peer(sender_id, NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE)
return
var client_game_version: String = str(data.get("game_version", ""))
var version_rejection: NetworkProtocol.RejectionCode = (
NetworkProtocol.game_version_rejection(
client_game_version,
NetworkProtocol.game_version(),
)
)
if version_rejection != NetworkProtocol.RejectionCode.NONE:
_reject_peer(sender_id, version_rejection)
return
var client_capabilities: PackedStringArray = _sanitized_capabilities(
data.get("capability_flags", [])
)
if NetworkProtocol.FISH_QUALITY_CAPABILITY not in client_capabilities:
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {})
if identity.is_empty():
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
return
if (
data["identity_fingerprint"] != identity["fingerprint"]
or client_game_version != str(identity.get("game_version", ""))
or not NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(identity["public_key"]),
"handshake_client_profile",
NetworkProtocol.client_profile_fields(data),
data["identity_signature"],
)
):
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
return
if _registry.size() >= session_max_players:
_reject_peer(sender_id, NetworkProtocol.RejectionCode.SERVER_FULL)
return
var profile_id: String = data["local_profile_id"]
var display_name: String = data["display_name"]
if not NetworkProfilePreferences.is_valid_display_name(display_name):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
)
return
if not _registry.add_peer(
sender_id,
profile_id,
display_name,
NetworkProtocol.PROTOCOL_VERSION,
identity["fingerprint"],
identity["public_key"],
client_capabilities,
):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
)
return
var operator_enabled: bool = _operator_for_fingerprint(
str(identity["fingerprint"])
)
if operator_enabled:
_operator_peer_ids[sender_id] = true
var submitted_appearance := CharacterCustomizationCatalog.sanitized_snapshot(
data["cosmetic_snapshot"]
)
_registry.update_appearance(sender_id, submitted_appearance)
var peer_record := _registry.get_peer(sender_id)
if peer_record != null:
peer_record.profile_authorization = {
"domain": "handshake_client_profile",
"signature": data["identity_signature"],
"client_nonce": data["client_nonce"],
}
_archive_authenticated_identity(peer_record)
var identity_status := _known_players.observe(
str(identity["fingerprint"]), display_name
)
peer_identity_observed.emit(sender_id, identity_status)
_pending_authentication.erase(sender_id)
_authenticated_identity_cache.erase(sender_id)
var spawn_index: int = _registry.get_peer_ids().find(sender_id)
var spawn_transform: Transform3D = (
_spawn_service.get_spawn_transform_for_index(spawn_index)
)
_spawn_service.spawn_remote_player(sender_id, spawn_transform, true)
var spawned_avatar: Player = _spawn_service.get_avatar(sender_id)
if spawned_avatar != null:
spawned_avatar.apply_appearance_snapshot(submitted_appearance)
peer_profile_changed.emit(
sender_id,
display_name,
submitted_appearance.duplicate(true),
)
receive_server_hello.rpc_id(
sender_id,
NetworkProtocol.make_server_hello(
true,
NetworkProtocol.RejectionCode.NONE,
_session_id,
sender_id,
_registry.size(),
session_max_players,
_session_display_name,
)
)
receive_spawn_list.rpc_id(sender_id, _build_spawn_list())
receive_operator_snapshot.rpc_id(sender_id, _operator_peer_id_snapshot())
receive_peer_spawn.rpc(
_make_spawn_entry(sender_id, display_name, spawn_transform)
)
if operator_enabled:
receive_operator_status.rpc(sender_id, true)
peer_authenticated.emit(sender_id, display_name)
_emit_peer_count()
func _reject_peer(
peer_id: int,
code: NetworkProtocol.RejectionCode,
) -> void:
receive_server_hello.rpc_id(
peer_id,
NetworkProtocol.make_server_hello(
false,
code,
_session_id,
peer_id,
_registry.size(),
session_max_players
)
)
_pending_authentication.erase(peer_id)
call_deferred("_disconnect_rejected_peer", peer_id)
func _disconnect_rejected_peer(peer_id: int) -> void:
await get_tree().create_timer(0.15).timeout
if (
is_host()
and multiplayer.multiplayer_peer != null
and multiplayer.multiplayer_peer.get_connection_status()
== MultiplayerPeer.CONNECTION_CONNECTED
and peer_id in multiplayer.get_peers()
):
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
@rpc("authority", "call_remote", "reliable", 0)
func receive_server_hello(data: Dictionary) -> void:
if state != State.AUTHENTICATING:
return
if (
typeof(data.get("accepted")) != TYPE_BOOL
or typeof(data.get("protocol_version")) != TYPE_INT
or typeof(data.get("game_version")) != TYPE_STRING
or typeof(data.get("rejection_code")) != TYPE_INT
):
_teardown_peer()
_fail("The server sent an invalid handshake response.")
return
if not bool(data["accepted"]):
var message: String = NetworkProtocol.rejection_text(
int(data["rejection_code"])
)
_teardown_peer()
_fail(message)
return
var version_rejection: NetworkProtocol.RejectionCode = (
NetworkProtocol.game_version_rejection(
NetworkProtocol.game_version(),
str(data["game_version"]),
)
)
if version_rejection != NetworkProtocol.RejectionCode.NONE:
_teardown_peer()
_fail(NetworkProtocol.rejection_text(version_rejection))
return
if int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION:
_teardown_peer()
_fail("The server uses a different network protocol.")
return
_session_id = str(data.get("session_id", ""))
_last_server_max_players = int(data.get(
"max_players",
DEFAULT_SESSION_MAX_PLAYERS
))
_last_server_player_count = int(data.get("player_count", 1))
_last_server_display_name = str(data.get("server_display_name", ""))
_last_server_protocol_version = int(data.get(
"protocol_version", NetworkProtocol.PROTOCOL_VERSION
))
_server_capabilities = PackedStringArray()
var advertised_capabilities: Variant = data.get(
"capability_flags", PackedStringArray()
)
if typeof(advertised_capabilities) in [
TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY
]:
for value: Variant in advertised_capabilities:
if typeof(value) in [TYPE_STRING, TYPE_STRING_NAME]:
_server_capabilities.append(str(value))
if NetworkProtocol.FISH_QUALITY_CAPABILITY not in _server_capabilities:
_teardown_peer()
_fail("This server does not support fish quality data.")
return
var local_peer_id: int = multiplayer.get_unique_id()
_registry.clear()
_registry.add_peer(
local_peer_id,
_profile.profile_id,
_profile.display_name,
NetworkProtocol.PROTOCOL_VERSION,
_player_identity.fingerprint,
_player_identity.public_pem,
PackedStringArray([
NetworkProtocol.FISH_QUALITY_CAPABILITY,
NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
]),
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)
var local_record := _registry.get_peer(local_peer_id)
if local_record != null:
_archive_authenticated_identity(local_record)
_spawn_service.clear_remote_players()
_spawn_service.register_local_player(local_peer_id)
var local_avatar := _spawn_service.get_avatar(local_peer_id)
if local_avatar != null:
local_avatar.apply_appearance_snapshot(_local_appearance_snapshot)
_connection_deadline = 0.0
_set_state(
State.JOINED_CLIENT,
"Connected • %d / %d players" % [
int(data.get("player_count", 1)),
_last_server_max_players,
]
)
join_authenticated.emit()
@rpc("authority", "call_remote", "reliable", 0)
func receive_spawn_list(entries: Array) -> void:
if state != State.JOINED_CLIENT:
return
for value: Variant in entries:
if typeof(value) == TYPE_DICTIONARY:
_apply_spawn_entry(value)
_emit_peer_count()
@rpc("authority", "call_remote", "reliable", 0)
func receive_peer_spawn(entry: Dictionary) -> void:
if state != State.JOINED_CLIENT:
return
_apply_spawn_entry(entry)
_emit_peer_count()
@rpc("authority", "call_remote", "reliable", 0)
func receive_peer_despawn(peer_id: int) -> void:
if state != State.JOINED_CLIENT:
return
_operator_peer_ids.erase(peer_id)
_registry.remove_peer(peer_id)
_spawn_service.remove_peer(peer_id)
peer_removed.emit(peer_id)
_emit_peer_count()
@rpc("authority", "call_remote", "reliable", 0)
func receive_operator_snapshot(peer_ids: PackedInt32Array) -> void:
if (
state != State.JOINED_CLIENT
or peer_ids.size() > get_session_max_players()
):
return
_operator_peer_ids.clear()
for peer_id: int in peer_ids:
if peer_id > 1:
_operator_peer_ids[peer_id] = true
_update_local_operator()
for peer_id: int in _operator_peer_ids:
operator_status_changed.emit(peer_id, true)
@rpc("authority", "call_remote", "reliable", 0)
func receive_operator_status(peer_id: int, enabled: bool) -> void:
if state != State.JOINED_CLIENT or peer_id <= 1:
return
if enabled:
_operator_peer_ids[peer_id] = true
else:
_operator_peer_ids.erase(peer_id)
_update_local_operator()
operator_status_changed.emit(peer_id, enabled)
func _apply_spawn_entry(entry: Dictionary) -> void:
if (
typeof(entry.get("peer_id")) != TYPE_INT
or typeof(entry.get("profile_id")) != TYPE_STRING
or typeof(entry.get("display_name")) != TYPE_STRING
or typeof(entry.get("position")) != TYPE_ARRAY
or typeof(entry.get("yaw")) not in [TYPE_FLOAT, TYPE_INT]
):
return
if not _verify_spawn_identity(entry):
return
var peer_id: int = entry["peer_id"]
if peer_id == multiplayer.get_unique_id():
var own_position: Array = entry["position"]
if own_position.size() == 3:
var own_avatar: Player = _spawn_service.get_avatar(peer_id)
if own_avatar != null:
var own_snapshot: Dictionary = own_avatar.make_network_snapshot(
peer_id
)
own_snapshot["position"] = own_position
own_snapshot["visual_yaw"] = float(entry["yaw"])
own_avatar.apply_network_teleport(own_snapshot)
return
if not _registry.has_peer(peer_id):
_registry.add_peer(
peer_id,
entry["profile_id"],
entry["display_name"],
NetworkProtocol.PROTOCOL_VERSION,
str(entry.get("identity_fingerprint", "")),
str(entry.get("identity_public_key", "")),
_sanitized_capabilities(entry.get("capability_flags", [])),
)
var added_record := _registry.get_peer(peer_id)
if added_record != null and added_record.identity_authenticated:
_archive_authenticated_identity(added_record)
var status := _known_players.observe(
added_record.identity_fingerprint,
added_record.display_name,
)
peer_identity_observed.emit(peer_id, status)
if typeof(entry.get("appearance")) == TYPE_DICTIONARY:
var appearance := CharacterCustomizationCatalog.sanitized_snapshot(
entry["appearance"]
)
_registry.update_appearance(peer_id, appearance)
var transform: Transform3D = _spawn_service.get_spawn_transform_for_index(0)
var spawn_position: Array = entry["position"]
if spawn_position.size() != 3:
return
transform.origin = Vector3(
float(spawn_position[0]),
float(spawn_position[1]),
float(spawn_position[2])
)
transform.basis = Basis(Vector3.UP, float(entry["yaw"]))
var avatar := _spawn_service.spawn_remote_player(peer_id, transform, false)
var record := _registry.get_peer(peer_id)
if avatar != null and record != null:
avatar.apply_appearance_snapshot(record.appearance_snapshot)
func _build_spawn_list() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for peer_id: int in _registry.get_peer_ids():
var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
var avatar: Player = _spawn_service.get_avatar(peer_id)
if record != null and avatar != null:
entries.append(_make_spawn_entry(
peer_id,
record.display_name,
avatar.global_transform
))
return entries
func _make_spawn_entry(
peer_id: int,
display_name: String,
transform: Transform3D,
) -> Dictionary:
var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
return {
"peer_id": peer_id,
"profile_id": record.profile_id if record != null else "",
"display_name": display_name,
"position": [
transform.origin.x,
transform.origin.y,
transform.origin.z,
],
"yaw": transform.basis.get_euler().y,
"appearance": (
record.appearance_snapshot.duplicate(true)
if record != null
else CharacterCustomizationCatalog.default_snapshot()
),
"identity_fingerprint": (
record.identity_fingerprint if record != null else ""
),
"identity_public_key": (
record.identity_public_key if record != null else ""
),
"profile_authorization": (
record.profile_authorization.duplicate(true)
if record != null else {}
),
"capability_flags": (
record.capability_flags.duplicate()
if record != null else PackedStringArray()
),
}
func _sanitized_capabilities(value: Variant) -> PackedStringArray:
var result := PackedStringArray()
if typeof(value) not in [TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY]:
return result
for capability: Variant in value:
if (
typeof(capability) in [TYPE_STRING, TYPE_STRING_NAME]
and not str(capability).is_empty()
and str(capability).length() <= 64
and str(capability) not in result
):
result.append(str(capability))
return result
func _operator_for_fingerprint(fingerprint: String) -> bool:
return bool((
_configured_operator_fingerprints
if _dedicated_host
else _session_operator_fingerprints
).get(fingerprint, false))
func _operator_peer_id_snapshot() -> PackedInt32Array:
var result: PackedInt32Array = PackedInt32Array()
for peer_id: int in _operator_peer_ids:
result.append(peer_id)
result.sort()
return result
func _set_operator_status(peer_id: int, enabled: bool) -> void:
if enabled:
_operator_peer_ids[peer_id] = true
else:
_operator_peer_ids.erase(peer_id)
operator_status_changed.emit(peer_id, enabled)
receive_operator_status.rpc(peer_id, enabled)
func _update_local_operator() -> void:
var local_peer_id: int = multiplayer.get_unique_id()
_local_operator = bool(_operator_peer_ids.get(local_peer_id, false))
func _verify_spawn_identity(entry: Dictionary) -> bool:
var fingerprint := str(entry.get("identity_fingerprint", ""))
var public_pem := NetworkIdentityCrypto.normalize_public_pem(
str(entry.get("identity_public_key", ""))
)
if (
NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint
or typeof(entry.get("profile_authorization")) != TYPE_DICTIONARY
):
return false
var authorization: Dictionary = entry["profile_authorization"]
if authorization.is_empty():
return int(entry.get("peer_id", 0)) == 1
if authorization.get("domain") == "handshake_client_profile":
var hello := NetworkProtocol.make_client_hello(
str(entry.get("profile_id", "")),
str(entry.get("display_name", "")),
str(authorization.get("client_nonce", "")),
Dictionary(entry.get("appearance", {})),
fingerprint,
authorization.get("signature", PackedByteArray()),
)
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
"handshake_client_profile",
NetworkProtocol.client_profile_fields(hello),
hello["identity_signature"],
)
if authorization.has("sender_signature"):
var signed := authorization.duplicate(true)
signed["display_name"] = entry.get("display_name", "")
signed["appearance"] = entry.get("appearance", {})
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
"profile_update",
NetworkProfileProtocol.signature_fields(signed),
signed["sender_signature"],
)
return false
func _identity_proof_fields(data: Dictionary) -> Array:
return [
NetworkProtocol.PROTOCOL_VERSION,
str(data.get("client_game_version", "")),
str(data.get("server_game_version", "")),
str(data.get("attempt_id", "")),
str(data.get("client_fingerprint", "")),
str(data.get("client_nonce", "")),
str(data.get("server_fingerprint", "")),
str(data.get("server_nonce", "")),
str(data.get("session_id", "")),
int(data.get("peer_id", 0)),
int(data.get("generation", 0)),
int(data.get("expires_at_msec", 0)),
]
func _archive_authenticated_identity(record: PeerRegistry.PeerRecord) -> void:
if (
record != null
and record.identity_authenticated
and NetworkIdentityCrypto.fingerprint_public_pem(
record.identity_public_key
) == record.identity_fingerprint
):
_session_identity_keys[record.identity_fingerprint] = (
NetworkIdentityCrypto.normalize_public_pem(
record.identity_public_key
)
)
func _valid_server_proof_shape(data: Variant) -> bool:
if typeof(data) != TYPE_DICTIONARY:
return false
var value: Dictionary = data
return (
typeof(value.get("attempt_id")) == TYPE_STRING
and str(value["attempt_id"]).length() == 32
and typeof(value.get("client_nonce")) == TYPE_STRING
and str(value["client_nonce"]).length() == 64
and typeof(value.get("server_nonce")) == TYPE_STRING
and str(value["server_nonce"]).length() == 64
and NetworkIdentityCrypto.valid_fingerprint(value.get("client_fingerprint"))
and NetworkIdentityCrypto.valid_fingerprint(value.get("server_fingerprint"))
and typeof(value.get("client_game_version")) == TYPE_STRING
and str(value["client_game_version"]) == NetworkProtocol.game_version()
and typeof(value.get("server_game_version")) == TYPE_STRING
and str(value["server_game_version"]) == NetworkProtocol.game_version()
and typeof(value.get("server_public_key")) == TYPE_STRING
and str(value["server_public_key"]).to_utf8_buffer().size()
<= NetworkProtocol.MAX_PUBLIC_KEY_LENGTH
and typeof(value.get("server_signature")) == TYPE_PACKED_BYTE_ARRAY
and PackedByteArray(value["server_signature"]).size()
<= NetworkIdentityCrypto.MAX_SIGNATURE_BYTES
and typeof(value.get("session_id")) == TYPE_STRING
and typeof(value.get("peer_id")) == TYPE_INT
and typeof(value.get("generation")) == TYPE_INT
and typeof(value.get("expires_at_msec")) == TYPE_INT
)
func _fail_identity(message: String) -> void:
_teardown_peer()
_fail(message)
func _send_local_input() -> void:
var peer_id: int = multiplayer.get_unique_id()
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
return
_input_sequence += 1
var input: Dictionary = avatar.capture_network_input(_input_sequence)
submit_movement_input.rpc_id(1, input)
func submit_neutral_local_movement() -> void:
if state != State.JOINED_CLIENT:
return
_send_local_input()
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
func submit_movement_input(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not is_host() or not _registry.has_peer(sender_id):
return
var avatar: Player = _spawn_service.get_avatar(sender_id)
if avatar == null or not _is_valid_movement_input(data):
return
avatar.apply_authoritative_network_input(data)
func _is_valid_movement_input(data: Dictionary) -> bool:
if (
typeof(data.get("sequence")) != TYPE_INT
or typeof(data.get("axis")) != TYPE_ARRAY
or typeof(data.get("jump")) != TYPE_BOOL
or typeof(data.get("sprint")) != TYPE_BOOL
or typeof(data.get("sneak")) != TYPE_BOOL
or typeof(data.get("slow_walk")) != TYPE_BOOL
or typeof(data.get("sitting")) != TYPE_BOOL
or typeof(data.get("casting")) != TYPE_BOOL
or not NetworkPlayerAnimationProtocol.validate_action_state(
data.get("animation_action")
)
or typeof(data.get("camera_yaw")) not in [TYPE_FLOAT, TYPE_INT]
):
return false
var axis: Array = data["axis"]
if axis.size() != 2:
return false
if (
typeof(axis[0]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(axis[1]) not in [TYPE_FLOAT, TYPE_INT]
):
return false
var x: float = float(axis[0])
var y: float = float(axis[1])
var camera_yaw: float = float(data["camera_yaw"])
return (
int(data["sequence"]) > 0
and int(data["sequence"]) <= MAX_MOVEMENT_INPUT_SEQUENCE
and is_finite(x)
and is_finite(y)
and is_finite(camera_yaw)
and absf(x) <= 1.01
and absf(y) <= 1.01
and absf(camera_yaw) <= TAU * 100.0
)
func _broadcast_movement_snapshots() -> void:
var snapshots: Array[Dictionary] = []
for peer_id: int in _registry.get_peer_ids():
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
continue
snapshots.append(avatar.make_network_snapshot(peer_id))
receive_movement_snapshots.rpc(snapshots)
@rpc("authority", "call_remote", "unreliable_ordered", 2)
func receive_movement_snapshots(snapshots: Array) -> void:
if state != State.JOINED_CLIENT:
return
var local_peer_id: int = multiplayer.get_unique_id()
for value: Variant in snapshots:
if typeof(value) != TYPE_DICTIONARY:
continue
var snapshot: Dictionary = value
if typeof(snapshot.get("peer_id")) != TYPE_INT:
continue
var peer_id: int = snapshot["peer_id"]
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
continue
if peer_id == local_peer_id:
avatar.apply_local_prediction_correction(snapshot)
else:
avatar.push_network_snapshot(snapshot)
func publish_authoritative_teleport(peer_id: int) -> void:
if not is_host():
return
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar == null:
return
receive_authoritative_teleport.rpc(
avatar.make_network_snapshot(peer_id)
)
func request_recovery_presentation(
active: bool,
attempt_id: String,
) -> void:
if (
attempt_id.is_empty()
or attempt_id.length() > 96
or not is_gameplay_session_active()
):
return
if is_host():
_set_authoritative_recovery_presentation(
get_local_peer_id(), active, attempt_id
)
elif state == State.JOINED_CLIENT:
submit_recovery_presentation.rpc_id(
1, _session_id, active, attempt_id
)
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_recovery_presentation(
session_id: String,
active: bool,
attempt_id: String,
) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
not is_host()
or not _registry.has_peer(sender_id)
or session_id != _session_id
or attempt_id.is_empty()
or attempt_id.length() > 96
):
return
_set_authoritative_recovery_presentation(sender_id, active, attempt_id)
func _set_authoritative_recovery_presentation(
peer_id: int,
active: bool,
attempt_id: String,
) -> void:
var current_attempt: String = _recovery_attempts.get(peer_id, "")
if active:
if not current_attempt.is_empty() and current_attempt != attempt_id:
return
elif current_attempt != attempt_id:
return
var data := {
"session_id": _session_id,
"generation": _operation_generation,
"peer_id": peer_id,
"active": active,
"attempt_id": attempt_id,
}
_apply_recovery_presentation(data)
receive_recovery_presentation.rpc(data)
@rpc("authority", "call_remote", "reliable", 0)
func receive_recovery_presentation(data: Dictionary) -> void:
_apply_recovery_presentation(data)
func _apply_recovery_presentation(data: Dictionary) -> void:
if (
typeof(data.get("session_id")) != TYPE_STRING
or str(data["session_id"]) != _session_id
or typeof(data.get("generation")) != TYPE_INT
or int(data["generation"]) < 1
or typeof(data.get("peer_id")) != TYPE_INT
or typeof(data.get("active")) != TYPE_BOOL
or typeof(data.get("attempt_id")) != TYPE_STRING
or str(data["attempt_id"]).is_empty()
or str(data["attempt_id"]).length() > 96
):
return
var peer_id: int = data["peer_id"]
var attempt_id: String = data["attempt_id"]
var active: bool = data["active"]
var current_attempt: String = _recovery_attempts.get(peer_id, "")
if active:
if not current_attempt.is_empty() and current_attempt != attempt_id:
return
_recovery_attempts[peer_id] = attempt_id
elif current_attempt != attempt_id:
return
else:
_recovery_attempts.erase(peer_id)
remote_recovery_presentation_changed.emit(peer_id, active, attempt_id)
func request_safe_respawn(entry_position: Vector3) -> void:
if not entry_position.is_finite():
return
if is_host():
remote_recovery_requested.emit(1, entry_position)
elif state == State.JOINED_CLIENT:
submit_safe_respawn_request.rpc_id(
1,
[entry_position.x, entry_position.y, entry_position.z]
)
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_safe_respawn_request(position_data: Array) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
not is_host()
or not _registry.has_peer(sender_id)
or position_data.size() != 3
):
return
var entry_position := Vector3(
float(position_data[0]),
float(position_data[1]),
float(position_data[2])
)
if not entry_position.is_finite():
return
remote_recovery_requested.emit(sender_id, entry_position)
@rpc("authority", "call_remote", "reliable", 0)
func receive_authoritative_teleport(snapshot: Dictionary) -> void:
if state != State.JOINED_CLIENT:
return
var peer_id: int = int(snapshot.get("peer_id", 0))
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar != null:
avatar.apply_network_teleport(snapshot)
func _expire_pending_authentication(now: float) -> void:
for peer_id: int in _pending_authentication.keys():
if now >= float(_pending_authentication[peer_id]):
_reject_peer(
peer_id,
NetworkProtocol.RejectionCode.AUTHENTICATION_TIMEOUT
)
func _emit_peer_count() -> void:
peer_count_changed.emit(_registry.size(), session_max_players)
func _set_state(new_state: State, message: String) -> void:
if not _is_transition_allowed(state, new_state):
push_warning(
"Rejected invalid network state transition %s -> %s."
% [State.keys()[state], State.keys()[new_state]]
)
return
state = new_state
state_changed.emit(state)
status_message_changed.emit(message)
func _is_transition_allowed(from_state: State, to_state: State) -> bool:
if from_state == to_state:
return true
var allowed: Dictionary[State, Array] = {
State.INACTIVE: [
State.STARTING_PRIVATE_HOST,
State.CONNECTING,
State.CONNECTION_FAILED,
],
State.STARTING_PRIVATE_HOST: [
State.PRIVATE_HOST,
State.CONNECTION_FAILED,
State.DISCONNECTING,
],
State.PRIVATE_HOST: [
State.OPEN_HOST,
State.DISCONNECTING,
],
State.OPEN_HOST: [
State.PRIVATE_HOST,
State.DISCONNECTING,
],
State.CONNECTING: [
State.AUTHENTICATING,
State.CONNECTION_FAILED,
State.DISCONNECTING,
State.INACTIVE,
],
State.AUTHENTICATING: [
State.VERIFYING_SERVER_IDENTITY,
State.JOINED_CLIENT,
State.CONNECTION_FAILED,
State.DISCONNECTING,
State.INACTIVE,
],
State.VERIFYING_SERVER_IDENTITY: [
State.AUTHENTICATING,
State.CONNECTION_FAILED,
State.DISCONNECTING,
State.INACTIVE,
],
State.JOINED_CLIENT: [
State.DISCONNECTING,
State.SERVER_LOST,
],
State.DISCONNECTING: [
State.INACTIVE,
State.CONNECTING,
],
State.CONNECTION_FAILED: [
State.INACTIVE,
State.CONNECTING,
State.DISCONNECTING,
],
State.SERVER_LOST: [
State.INACTIVE,
State.CONNECTING,
State.DISCONNECTING,
],
}
return to_state in allowed.get(from_state, [])
func _fail(message: String) -> void:
_set_state(State.CONNECTION_FAILED, message)
connection_error.emit(message)
func _teardown_peer() -> void:
for peer_id: int in _recovery_attempts.keys():
remote_recovery_presentation_changed.emit(
peer_id, false, _recovery_attempts[peer_id]
)
_recovery_attempts.clear()
_pending_authentication.clear()
_pending_identity_challenges.clear()
_authenticated_identity_cache.clear()
_client_identity_attempt.clear()
_pending_server_proof.clear()
_registry.clear()
if _spawn_service != null:
_spawn_service.clear_remote_players()
if _transport != null:
_transport.disconnect_transport()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
_connection_deadline = 0.0
_input_accumulator = 0.0
_snapshot_accumulator = 0.0
_server_capabilities = PackedStringArray()
_server_identity_fingerprint = ""
_server_identity_public_key = ""
_session_identity_keys.clear()
_session_operator_fingerprints.clear()
_operator_peer_ids.clear()
_local_operator = false
_host_port = 0
_dedicated_host = false