2026-07-29 15:01:46 -04:00
|
|
|
class_name NetworkProtocol
|
|
|
|
|
extends RefCounted
|
|
|
|
|
|
2026-07-29 22:18:14 -04:00
|
|
|
const PROTOCOL_VERSION: int = 3
|
2026-07-29 15:01:46 -04:00
|
|
|
const GAME_BUILD: String = "prealpha"
|
2026-07-29 19:26:46 -04:00
|
|
|
const MAX_DISPLAY_NAME_LENGTH: int = 24
|
2026-07-29 15:01:46 -04:00
|
|
|
const MAX_PROFILE_ID_LENGTH: int = 96
|
|
|
|
|
const MAX_NONCE_LENGTH: int = 96
|
2026-07-29 22:18:14 -04:00
|
|
|
const MAX_PUBLIC_KEY_LENGTH: int = 8192
|
|
|
|
|
const MAX_SIGNATURE_LENGTH: int = 2048
|
2026-07-29 18:31:00 -04:00
|
|
|
# ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement
|
2026-07-29 18:51:51 -04:00
|
|
|
# snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales,
|
2026-07-29 19:26:46 -04:00
|
|
|
# 6 reliable shop transactions, 7 reliable item/equipment lifecycle,
|
2026-07-29 20:10:44 -04:00
|
|
|
# 8 reliable ordered session chat, 9 reliable private session mail.
|
2026-07-29 18:31:00 -04:00
|
|
|
const SALE_RELIABLE_CHANNEL: int = 5
|
2026-07-29 18:51:51 -04:00
|
|
|
const SHOP_RELIABLE_CHANNEL: int = 6
|
2026-07-29 19:26:46 -04:00
|
|
|
const ITEM_RELIABLE_CHANNEL: int = 7
|
|
|
|
|
const CHAT_RELIABLE_CHANNEL: int = 8
|
2026-07-29 20:10:44 -04:00
|
|
|
const MAIL_RELIABLE_CHANNEL: int = 9
|
|
|
|
|
const ENET_CHANNEL_COUNT: int = 10
|
2026-07-29 15:01:46 -04:00
|
|
|
|
|
|
|
|
enum RejectionCode {
|
|
|
|
|
NONE,
|
|
|
|
|
MALFORMED_HANDSHAKE,
|
|
|
|
|
PROTOCOL_MISMATCH,
|
|
|
|
|
SERVER_FULL,
|
|
|
|
|
DUPLICATE_PROFILE,
|
2026-07-29 22:18:14 -04:00
|
|
|
DUPLICATE_IDENTITY,
|
|
|
|
|
INVALID_IDENTITY_PROOF,
|
2026-07-29 15:01:46 -04:00
|
|
|
AUTHENTICATION_TIMEOUT,
|
|
|
|
|
SERVER_SHUTTING_DOWN,
|
|
|
|
|
UNSUPPORTED_CLIENT,
|
2026-07-29 23:10:51 -04:00
|
|
|
BANNED,
|
2026-07-29 15:01:46 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 22:18:14 -04:00
|
|
|
static func make_identity_hello(
|
|
|
|
|
public_key: String,
|
|
|
|
|
fingerprint: String,
|
|
|
|
|
client_nonce: String,
|
|
|
|
|
attempt_id: String,
|
|
|
|
|
) -> Dictionary:
|
|
|
|
|
return {
|
|
|
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
|
|
|
"public_key": public_key,
|
|
|
|
|
"fingerprint": fingerprint,
|
|
|
|
|
"client_nonce": client_nonce,
|
|
|
|
|
"attempt_id": attempt_id,
|
|
|
|
|
"capability_flags": PackedStringArray(["identity_v1"]),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func validate_identity_hello(data: Variant) -> bool:
|
|
|
|
|
if typeof(data) != TYPE_DICTIONARY:
|
|
|
|
|
return false
|
|
|
|
|
var value: Dictionary = data
|
|
|
|
|
return (
|
|
|
|
|
typeof(value.get("protocol_version")) == TYPE_INT
|
|
|
|
|
and typeof(value.get("public_key")) == TYPE_STRING
|
|
|
|
|
and str(value["public_key"]).to_utf8_buffer().size() <= MAX_PUBLIC_KEY_LENGTH
|
|
|
|
|
and NetworkIdentityCrypto.valid_fingerprint(value.get("fingerprint"))
|
|
|
|
|
and typeof(value.get("client_nonce")) == TYPE_STRING
|
|
|
|
|
and str(value["client_nonce"]).length() == 64
|
|
|
|
|
and typeof(value.get("attempt_id")) == TYPE_STRING
|
|
|
|
|
and str(value["attempt_id"]).length() == 32
|
|
|
|
|
and typeof(value.get("capability_flags")) in [
|
|
|
|
|
TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 15:01:46 -04:00
|
|
|
static func make_client_hello(
|
|
|
|
|
profile_id: String,
|
|
|
|
|
display_name: String,
|
|
|
|
|
client_nonce: String,
|
2026-07-29 21:31:42 -04:00
|
|
|
cosmetic_snapshot: Dictionary = {},
|
2026-07-29 22:18:14 -04:00
|
|
|
identity_fingerprint: String = "",
|
|
|
|
|
identity_signature: PackedByteArray = PackedByteArray(),
|
2026-07-29 15:01:46 -04:00
|
|
|
) -> Dictionary:
|
|
|
|
|
return {
|
|
|
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
|
|
|
"game_build": GAME_BUILD,
|
|
|
|
|
"local_profile_id": profile_id,
|
|
|
|
|
"display_name": display_name,
|
|
|
|
|
"client_nonce": client_nonce,
|
|
|
|
|
"capability_flags": PackedStringArray(),
|
2026-07-29 21:31:42 -04:00
|
|
|
"cosmetic_snapshot": cosmetic_snapshot,
|
2026-07-29 22:18:14 -04:00
|
|
|
"identity_fingerprint": identity_fingerprint,
|
|
|
|
|
"identity_signature": identity_signature,
|
2026-07-29 15:01:46 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func validate_client_hello(data: Variant) -> String:
|
|
|
|
|
if typeof(data) != TYPE_DICTIONARY:
|
|
|
|
|
return "Handshake payload is not a dictionary."
|
|
|
|
|
var payload: Dictionary = data
|
|
|
|
|
for key: String in [
|
|
|
|
|
"protocol_version",
|
|
|
|
|
"game_build",
|
|
|
|
|
"local_profile_id",
|
|
|
|
|
"display_name",
|
|
|
|
|
"client_nonce",
|
|
|
|
|
"capability_flags",
|
|
|
|
|
"cosmetic_snapshot",
|
2026-07-29 22:18:14 -04:00
|
|
|
"identity_fingerprint",
|
|
|
|
|
"identity_signature",
|
2026-07-29 15:01:46 -04:00
|
|
|
]:
|
|
|
|
|
if not payload.has(key):
|
|
|
|
|
return "Handshake is missing %s." % key
|
|
|
|
|
if typeof(payload["protocol_version"]) != TYPE_INT:
|
|
|
|
|
return "Protocol version is invalid."
|
|
|
|
|
if typeof(payload["game_build"]) != TYPE_STRING:
|
|
|
|
|
return "Game build is invalid."
|
|
|
|
|
if typeof(payload["local_profile_id"]) != TYPE_STRING:
|
|
|
|
|
return "Profile ID is invalid."
|
|
|
|
|
if typeof(payload["display_name"]) != TYPE_STRING:
|
|
|
|
|
return "Display name is invalid."
|
|
|
|
|
if typeof(payload["client_nonce"]) != TYPE_STRING:
|
|
|
|
|
return "Client nonce is invalid."
|
|
|
|
|
if typeof(payload["capability_flags"]) not in [
|
|
|
|
|
TYPE_PACKED_STRING_ARRAY,
|
|
|
|
|
TYPE_ARRAY,
|
|
|
|
|
]:
|
|
|
|
|
return "Capabilities are invalid."
|
|
|
|
|
if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY:
|
|
|
|
|
return "Cosmetic snapshot is invalid."
|
2026-07-29 22:18:14 -04:00
|
|
|
if (
|
|
|
|
|
not NetworkIdentityCrypto.valid_fingerprint(
|
|
|
|
|
payload["identity_fingerprint"]
|
|
|
|
|
)
|
|
|
|
|
or typeof(payload["identity_signature"]) != TYPE_PACKED_BYTE_ARRAY
|
|
|
|
|
):
|
|
|
|
|
return "Profile identity proof is invalid."
|
2026-07-29 15:01:46 -04:00
|
|
|
var profile_id: String = payload["local_profile_id"]
|
|
|
|
|
var display_name: String = payload["display_name"]
|
|
|
|
|
var nonce: String = payload["client_nonce"]
|
|
|
|
|
if (
|
|
|
|
|
profile_id.is_empty()
|
|
|
|
|
or profile_id.length() > MAX_PROFILE_ID_LENGTH
|
|
|
|
|
or display_name.is_empty()
|
|
|
|
|
or display_name.length() > MAX_DISPLAY_NAME_LENGTH
|
|
|
|
|
or nonce.is_empty()
|
|
|
|
|
or nonce.length() > MAX_NONCE_LENGTH
|
|
|
|
|
):
|
|
|
|
|
return "Handshake strings are outside allowed limits."
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 22:18:14 -04:00
|
|
|
static func client_profile_fields(data: Dictionary) -> Array:
|
|
|
|
|
var appearance: Dictionary = data.get("cosmetic_snapshot", {})
|
|
|
|
|
return [
|
|
|
|
|
str(data.get("client_nonce", "")),
|
|
|
|
|
str(data.get("identity_fingerprint", "")),
|
|
|
|
|
str(data.get("local_profile_id", "")),
|
|
|
|
|
str(data.get("display_name", "")),
|
|
|
|
|
str(appearance.get("species", "")),
|
|
|
|
|
str(appearance.get("fur_pattern", "")),
|
|
|
|
|
str(appearance.get("ears", "")),
|
|
|
|
|
str(appearance.get("eyes", "")),
|
|
|
|
|
str(appearance.get("nose", "")),
|
|
|
|
|
str(appearance.get("mouth", "")),
|
|
|
|
|
str(appearance.get("tail", "")),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 15:01:46 -04:00
|
|
|
static func make_server_hello(
|
|
|
|
|
accepted: bool,
|
|
|
|
|
rejection_code: RejectionCode,
|
|
|
|
|
session_id: String,
|
|
|
|
|
assigned_peer_id: int,
|
|
|
|
|
player_count: int,
|
|
|
|
|
max_players: int,
|
|
|
|
|
) -> Dictionary:
|
|
|
|
|
return {
|
|
|
|
|
"accepted": accepted,
|
|
|
|
|
"rejection_code": int(rejection_code),
|
|
|
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
|
|
|
"session_id": session_id,
|
|
|
|
|
"assigned_peer_id": assigned_peer_id,
|
|
|
|
|
"server_display_name": "NETFISHING",
|
|
|
|
|
"player_count": player_count,
|
|
|
|
|
"max_players": max_players,
|
2026-07-29 16:29:25 -04:00
|
|
|
"capability_flags": PackedStringArray([
|
|
|
|
|
"movement_v1",
|
|
|
|
|
"fishing_v1",
|
2026-07-29 18:31:00 -04:00
|
|
|
"sale_v1",
|
2026-07-29 18:51:51 -04:00
|
|
|
"shop_v1",
|
2026-07-29 19:26:46 -04:00
|
|
|
"item_use_v1",
|
|
|
|
|
"equipment_v1",
|
|
|
|
|
"chat_v1",
|
2026-07-29 20:10:44 -04:00
|
|
|
"mail_v1",
|
2026-07-29 21:31:42 -04:00
|
|
|
"profile_v1",
|
2026-07-29 22:18:14 -04:00
|
|
|
"identity_v1",
|
2026-07-29 16:29:25 -04:00
|
|
|
]),
|
2026-07-29 15:01:46 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func rejection_text(code: int) -> String:
|
|
|
|
|
match code:
|
|
|
|
|
RejectionCode.PROTOCOL_MISMATCH:
|
|
|
|
|
return "The server uses a different network protocol."
|
|
|
|
|
RejectionCode.SERVER_FULL:
|
|
|
|
|
return "The server is full."
|
|
|
|
|
RejectionCode.DUPLICATE_PROFILE:
|
2026-07-29 22:18:14 -04:00
|
|
|
return "This legacy local profile is already connected."
|
|
|
|
|
RejectionCode.DUPLICATE_IDENTITY:
|
|
|
|
|
return "This player identity is already connected."
|
|
|
|
|
RejectionCode.INVALID_IDENTITY_PROOF:
|
|
|
|
|
return "Player identity authentication failed."
|
2026-07-29 15:01:46 -04:00
|
|
|
RejectionCode.AUTHENTICATION_TIMEOUT:
|
|
|
|
|
return "The server did not finish authentication."
|
|
|
|
|
RejectionCode.SERVER_SHUTTING_DOWN:
|
|
|
|
|
return "The server is shutting down."
|
|
|
|
|
RejectionCode.UNSUPPORTED_CLIENT:
|
|
|
|
|
return "This game build is not supported by the server."
|
2026-07-29 23:10:51 -04:00
|
|
|
RejectionCode.BANNED:
|
|
|
|
|
return "You are not permitted to join this server."
|
2026-07-29 15:01:46 -04:00
|
|
|
_:
|
|
|
|
|
return "The server rejected the connection."
|