diff --git a/crates/shared/src/protocol.rs b/crates/shared/src/protocol.rs index a2b25b2..4b68e06 100644 --- a/crates/shared/src/protocol.rs +++ b/crates/shared/src/protocol.rs @@ -1,198 +1,11 @@ +// SPDX-License-Identifier: AGPL-3.0-only + //! Network protocol types and constants. +//! +//! The module is split by stream purpose, starting with the [`control`]-stream handshake and disconnect messages. Control-stream types are re-exported here so callers continue to refer to `shared::protocol::` regardless of the internal layout. -use serde::{Deserialize, Serialize}; +mod control; -/// Wire-protocol version. Incremented on any breaking change to the message layout below. -pub const PROTOCOL_VERSION: u32 = 1; - -/// Messages carried on the control stream (stream 0): handshake and disconnect. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum ControlMessage { - /// First message a client sends after the QUIC/TLS handshake. - ClientHello(ClientHello), - /// Server acceptance carrying negotiated session parameters. - HandshakeAck(HandshakeAck), - /// Server refusal with a machine-readable reason. - HandshakeReject(HandshakeReject), - /// Orderly session teardown initiated by either side. - Disconnect(Disconnect), -} - -/// First message a client sends after the QUIC/TLS handshake. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ClientHello { - /// Protocol version the client was built against; compared to `PROTOCOL_VERSION`. - pub protocol_version: u32, - /// Human-readable client build string (e.g. crate version + git hash). - pub client_build: String, - /// Identity the player presents. Minimal for M1. - pub player_identity: PlayerIdentity, - /// Content packs the client has installed. Empty in M1; validated later. - pub installed_packs: Vec, - /// Optional protocol feature bits the client requests. Zero in M1. - pub requested_features: FeatureFlags, -} - -/// Server acceptance carrying negotiated session parameters. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct HandshakeAck { - /// Server's protocol version (equal to the client's on success). - pub protocol_version: u32, - /// Human-readable server build string. - pub server_build: String, - /// Packs the world requires, each with an optional download source. May include `PackTier::Resource` entries (a server resource pack), which are delivered one-way and applied client-side rather than strict-matched; a consumer must branch on tier (or `PackTier::requires_strict_match`) before treating an entry as a match requirement. - pub world_packs: Vec, - /// Packs the client is missing relative to the server, each with an optional download source. As with `world_packs`, `PackTier::Resource` entries are delivered, not matched. - pub missing_packs: Vec, - /// Which stream carries which purpose for this session. - pub stream_layout: StreamLayout, - /// Advisory server tick rate in Hz, for client clock setup. - pub tick_rate_hint: u16, -} - -/// Server refusal with a machine-readable reason. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct HandshakeReject { - /// Machine-readable rejection category. - pub reason: RejectReason, - /// Human-readable detail for logs and UI. - pub detail: String, - /// Optional URL directing the user to a compatible build or pack, when the rejection is recoverable (e.g. `ProtocolMismatch`, `PackMismatch`). - pub upgrade_url: Option, -} - -/// Orderly session teardown initiated by either side. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct Disconnect { - /// Human-readable reason shown to the peer and logged. - pub reason: String, -} - -/// Machine-readable categories for handshake rejection. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RejectReason { - /// Client protocol version does not match the server's. - ProtocolMismatch, - /// Client is missing required packs or has incompatible versions. - PackMismatch, - /// Client declined or failed to fetch a server resource pack the server marked required. - ResourcePackDeclined, - /// Client failed to authenticate. - AuthFailed, - /// Client is banned from the server. - Banned, - /// Server is full. - Full, - /// Server encountered an internal error during handshake. - ServerError, -} - -/// Identity presented by the player to the server. -// TODO: use authenticated identity once the Account system exists. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct PlayerIdentity { - /// Human-readable display name. - pub display_name: String, -} - -/// Reference to a content pack (resource pack, data pack, or Lua mod) as it appears in a modlist exchanged during the handshake. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PackRef { - /// Namespaced content identifier of the pack (`namespace:id`). Charset validation is deferred to the modlist-matching concept (out of M1 scope). - pub id: String, - /// Human-readable semantic version. Informational only; not the match key. - pub version: String, - /// Canonical hash of the pack contents; the authoritative match key. - // TODO: pin the canonical hashing procedure (traversal order, newline normalization) so independent builds of one pack hash identically. - pub content_hash: [u8; 32], - /// Tier the pack was classified into, which governs whether a client/server mismatch on this pack is fatal or tolerated. Inferred by the owner from the pack's folder contents (see Load order), never self-declared. - pub tier: PackTier, -} - -/// Classification of a content pack, determining the handshake matching rule applied to it. Inferred from folder contents, not self-declared: `assets/`-only is a resource pack, `data/`-only is a data pack, presence of `scripts/` is a Lua mod. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum PackTier { - /// Client-side asset overlay (`assets/` only). Never strict-matched between peers. A server may push one server resource pack of its own, delivered one-way and applied on top of the client's local pack stack; enforcement of a `required` server pack is apply-or-reject at the client, not a peer hash-match. - Resource, - /// Declarative content (`data/` only). Must match exactly between peers. - Data, - /// Lua mod (`scripts/`, optionally `data/` and `assets/`); full API access. - Mod { - /// Set when the mod ships no `data/` and every system is `scope = "client"`, so a client/server mismatch on it cannot desync authoritative state and is therefore tolerated. Not trusted blindly by the server for packs carrying data or server-scoped systems. - client_only: bool, - }, -} - -impl PackTier { - /// Returns whether a pack of this tier must match byte-for-byte between client and server for the connection to be accepted. Resource packs are never matched; data packs and non-`client_only` mods must match exactly. A `false` here does not imply the server never sends the pack, a server resource pack is delivered one-way despite not being part of bidirectional matching. - #[must_use] - pub fn requires_strict_match(self) -> bool { - match self { - PackTier::Resource => false, - PackTier::Data => true, - PackTier::Mod { client_only } => !client_only, - } - } -} - -/// A pack the server's world requires, paired with an optional out-of-band download source. Sent server → client in the handshake; the client fetches any it lacks via the URL when present, otherwise over the QUIC asset stream. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RequiredPack { - /// Identity and tier of the required pack. - pub pack: PackRef, - /// Optional HTTP(S) URL to fetch the pack from, bypassing the QUIC asset stream for large downloads. `None` means fetch over the asset stream. - pub download_url: Option, - /// Whether the connection is rejected if the client cannot obtain and apply this pack. For data/mod tiers this is always `true` (they are mandatory for a correct session). For a `PackTier::Resource` entry (a server resource pack) it distinguishes an *optional* overlay the client may decline and keep playing (`false`) from a *required* one whose decline or fetch failure rejects the connection (`true`). - pub required: bool, -} - -/// Optional protocol feature bits. -#[repr(transparent)] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] -pub struct FeatureFlags(pub u32); - -/// Mapping of logical purposes to QUIC stream IDs. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct StreamLayout { - /// Stream ID for control messages (handshake, disconnect). - pub control: u8, - /// Stream ID for client input to server. - pub input: u8, - /// Stream ID for server authoritative state updates. - pub authority: u8, - /// Stream ID for highest detail chunk updates (LOD0). - pub chunk_lod0: u8, - /// Stream ID for chunk updates (LOD1). - pub chunk_lod1: u8, - /// Stream ID for chunk updates (LOD2). - pub chunk_lod2: u8, - /// Stream ID for chunk updates (LOD3). - pub chunk_lod3: u8, - /// Stream ID for lowest detail chunk updates (LOD4). - pub chunk_lod4: u8, - /// Stream ID for downloading assets. - pub asset: u8, - /// Stream ID for downloading mod scripts. - pub mod_data: u8, -} - -impl Default for StreamLayout { - fn default() -> Self { - Self { - control: 0, - input: 1, - authority: 2, - chunk_lod0: 3, - chunk_lod1: 4, - chunk_lod2: 5, - chunk_lod3: 6, - chunk_lod4: 7, - asset: 8, - mod_data: 9, - } - } -} - -#[cfg(test)] -#[path = "tests/protocol.rs"] -mod tests; +pub use control::{ + ClientHello, ControlMessage, Disconnect, FeatureFlags, HandshakeAck, HandshakeReject, PROTOCOL_VERSION, PackRef, PackTier, PlayerIdentity, RejectReason, RequiredPack, StreamLayout, +}; diff --git a/crates/shared/src/protocol/control.rs b/crates/shared/src/protocol/control.rs new file mode 100644 index 0000000..54a9338 --- /dev/null +++ b/crates/shared/src/protocol/control.rs @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Control-stream messages: handshake negotiation and orderly disconnect. + +use serde::{Deserialize, Serialize}; + +/// Wire-protocol version. Incremented on any breaking change to the message layout below. +pub const PROTOCOL_VERSION: u32 = 1; + +/// Messages carried on the control stream (stream 0): handshake and disconnect. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ControlMessage { + /// First message a client sends after the QUIC/TLS handshake. + ClientHello(ClientHello), + /// Server acceptance carrying negotiated session parameters. + HandshakeAck(HandshakeAck), + /// Server refusal with a machine-readable reason. + HandshakeReject(HandshakeReject), + /// Orderly session teardown initiated by either side. + Disconnect(Disconnect), +} + +/// First message a client sends after the QUIC/TLS handshake. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ClientHello { + /// Protocol version the client was built against; compared to `PROTOCOL_VERSION`. + pub protocol_version: u32, + /// Human-readable client build string (e.g. crate version + git hash). + pub client_build: String, + /// Identity the player presents. Minimal for M1. + pub player_identity: PlayerIdentity, + /// Content packs the client has installed. Empty in M1; validated later. + pub installed_packs: Vec, + /// Optional protocol feature bits the client requests. Zero in M1. + pub requested_features: FeatureFlags, +} + +/// Server acceptance carrying negotiated session parameters. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HandshakeAck { + /// Server's protocol version (equal to the client's on success). + pub protocol_version: u32, + /// Human-readable server build string. + pub server_build: String, + /// Packs the world requires, each with an optional download source. May include `PackTier::Resource` entries (a server resource pack), which are delivered one-way and applied client-side rather than strict-matched; a consumer must branch on tier (or `PackTier::requires_strict_match`) before treating an entry as a match requirement. + pub world_packs: Vec, + /// Packs the client is missing relative to the server, each with an optional download source. As with `world_packs`, `PackTier::Resource` entries are delivered, not matched. + pub missing_packs: Vec, + /// Which stream carries which purpose for this session. + pub stream_layout: StreamLayout, + /// Advisory server tick rate in Hz, for client clock setup. + pub tick_rate_hint: u16, +} + +/// Server refusal with a machine-readable reason. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HandshakeReject { + /// Machine-readable rejection category. + pub reason: RejectReason, + /// Human-readable detail for logs and UI. + pub detail: String, + /// Optional URL directing the user to a compatible build or pack, when the rejection is recoverable (e.g. `ProtocolMismatch`, `PackMismatch`). + pub upgrade_url: Option, +} + +/// Orderly session teardown initiated by either side. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Disconnect { + /// Human-readable reason shown to the peer and logged. + pub reason: String, +} + +/// Machine-readable categories for handshake rejection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RejectReason { + /// Client protocol version does not match the server's. + ProtocolMismatch, + /// Client is missing required packs or has incompatible versions. + PackMismatch, + /// Client declined or failed to fetch a server resource pack the server marked required. + ResourcePackDeclined, + /// Client failed to authenticate. + AuthFailed, + /// Client is banned from the server. + Banned, + /// Server is full. + Full, + /// Server encountered an internal error during handshake. + ServerError, +} + +/// Identity presented by the player to the server. +// TODO: use authenticated identity once the Account system exists. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PlayerIdentity { + /// Human-readable display name. + pub display_name: String, +} + +/// Reference to a content pack (resource pack, data pack, or Lua mod) as it appears in a modlist exchanged during the handshake. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PackRef { + /// Namespaced content identifier of the pack (`namespace:id`). Charset validation is deferred to the modlist-matching concept (out of M1 scope). + pub id: String, + /// Human-readable semantic version. Informational only; not the match key. + pub version: String, + /// Canonical hash of the pack contents; the authoritative match key. + // TODO: pin the canonical hashing procedure (traversal order, newline normalization) so independent builds of one pack hash identically. + pub content_hash: [u8; 32], + /// Tier the pack was classified into, which governs whether a client/server mismatch on this pack is fatal or tolerated. Inferred by the owner from the pack's folder contents (see Load order), never self-declared. + pub tier: PackTier, +} + +/// Classification of a content pack, determining the handshake matching rule applied to it. Inferred from folder contents, not self-declared: `assets/`-only is a resource pack, `data/`-only is a data pack, presence of `scripts/` is a Lua mod. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum PackTier { + /// Client-side asset overlay (`assets/` only). Never strict-matched between peers. A server may push one server resource pack of its own, delivered one-way and applied on top of the client's local pack stack; enforcement of a `required` server pack is apply-or-reject at the client, not a peer hash-match. + Resource, + /// Declarative content (`data/` only). Must match exactly between peers. + Data, + /// Lua mod (`scripts/`, optionally `data/` and `assets/`); full API access. + Mod { + /// Set when the mod ships no `data/` and every system is `scope = "client"`, so a client/server mismatch on it cannot desync authoritative state and is therefore tolerated. Not trusted blindly by the server for packs carrying data or server-scoped systems. + client_only: bool, + }, +} + +impl PackTier { + /// Returns whether a pack of this tier must match byte-for-byte between client and server for the connection to be accepted. Resource packs are never matched; data packs and non-`client_only` mods must match exactly. A `false` here does not imply the server never sends the pack, a server resource pack is delivered one-way despite not being part of bidirectional matching. + #[must_use] + pub fn requires_strict_match(self) -> bool { + match self { + PackTier::Resource => false, + PackTier::Data => true, + PackTier::Mod { client_only } => !client_only, + } + } +} + +/// A pack the server's world requires, paired with an optional out-of-band download source. Sent server → client in the handshake; the client fetches any it lacks via the URL when present, otherwise over the QUIC asset stream. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RequiredPack { + /// Identity and tier of the required pack. + pub pack: PackRef, + /// Optional HTTP(S) URL to fetch the pack from, bypassing the QUIC asset stream for large downloads. `None` means fetch over the asset stream. + pub download_url: Option, + /// Whether the connection is rejected if the client cannot obtain and apply this pack. For data/mod tiers this is always `true` (they are mandatory for a correct session). For a `PackTier::Resource` entry (a server resource pack) it distinguishes an *optional* overlay the client may decline and keep playing (`false`) from a *required* one whose decline or fetch failure rejects the connection (`true`). + pub required: bool, +} + +/// Optional protocol feature bits. +#[repr(transparent)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct FeatureFlags(pub u32); + +/// Mapping of logical purposes to QUIC stream IDs. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StreamLayout { + /// Stream ID for control messages (handshake, disconnect). + pub control: u8, + /// Stream ID for client input to server. + pub input: u8, + /// Stream ID for server authoritative state updates. + pub authority: u8, + /// Stream ID for highest detail chunk updates (LOD0). + pub chunk_lod0: u8, + /// Stream ID for chunk updates (LOD1). + pub chunk_lod1: u8, + /// Stream ID for chunk updates (LOD2). + pub chunk_lod2: u8, + /// Stream ID for chunk updates (LOD3). + pub chunk_lod3: u8, + /// Stream ID for lowest detail chunk updates (LOD4). + pub chunk_lod4: u8, + /// Stream ID for downloading assets. + pub asset: u8, + /// Stream ID for downloading mod scripts. + pub mod_data: u8, +} + +impl Default for StreamLayout { + fn default() -> Self { + Self { + control: 0, + input: 1, + authority: 2, + chunk_lod0: 3, + chunk_lod1: 4, + chunk_lod2: 5, + chunk_lod3: 6, + chunk_lod4: 7, + asset: 8, + mod_data: 9, + } + } +} + +#[cfg(test)] +#[path = "../tests/protocol.rs"] +mod tests;