diff --git a/.gitignore b/.gitignore index 145ce0e0..98a40ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /target node_modules/ *.zst +.DS_Store diff --git a/crates/minecraft_packets/src/play/commands_packet.rs b/crates/minecraft_packets/src/play/commands_packet.rs index ede641db..5dbd7a91 100644 --- a/crates/minecraft_packets/src/play/commands_packet.rs +++ b/crates/minecraft_packets/src/play/commands_packet.rs @@ -2,16 +2,6 @@ use minecraft_protocol::prelude::*; const ROOT_NODE: i8 = NodeFlagsBuilder::new().node_type(NodeType::Root).build(); -const LITERAL_NODE: i8 = NodeFlagsBuilder::new() - .node_type(NodeType::Literal) - .executable(true) - .build(); - -const ARGUMENT_NODE: i8 = NodeFlagsBuilder::new() - .node_type(NodeType::Argument) - .executable(true) - .build(); - /// This packet is sent since 1.13 #[derive(PacketOut)] pub struct CommandsPacket { @@ -23,6 +13,16 @@ pub struct CommandsPacket { pub enum CommandArgumentType { Float { min: f32, max: f32 }, + Integer { min: i32, max: i32 }, + String { behavior: StringBehavior }, +} + +#[repr(i8)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum StringBehavior { + SingleWord = 0, + QuotablePhrase = 1, + GreedyPhrase = 2, } pub struct CommandArgument { @@ -37,11 +37,26 @@ impl CommandArgument { argument_type: CommandArgumentType::Float { min, max }, } } + + pub fn integer(name: impl ToString, min: i32, max: i32) -> Self { + Self { + name: name.to_string(), + argument_type: CommandArgumentType::Integer { min, max }, + } + } + + pub fn string(name: impl ToString, behavior: StringBehavior) -> Self { + Self { + name: name.to_string(), + argument_type: CommandArgumentType::String { behavior }, + } + } } pub struct Command { alias: String, arguments: Vec, + required_argument_count: i32, } impl Command { @@ -49,6 +64,19 @@ impl Command { Self { alias: alias.to_string(), arguments, + required_argument_count: 0, + } + } + + pub fn with_required_arguments( + alias: impl ToString, + arguments: Vec, + required_argument_count: i32, + ) -> Self { + Self { + alias: alias.to_string(), + arguments, + required_argument_count, } } @@ -56,6 +84,7 @@ impl Command { Self { alias: alias.to_string(), arguments: Vec::new(), + required_argument_count: 0, } } } @@ -70,7 +99,8 @@ impl CommandsPacket { let mut current_node_index = nodes.len() as i32; root_children_indices.push(current_node_index); - nodes.push(Node::literal(command.alias)); + let executable = command.required_argument_count < 1; + nodes.push(Node::literal(command.alias, executable)); for argument in command.arguments { let argument_node_index = nodes.len() as i32; @@ -81,9 +111,15 @@ impl CommandsPacket { let properties = match argument.argument_type { CommandArgumentType::Float { min, max } => ParserProperties::float(min, max), + CommandArgumentType::Integer { min, max } => { + ParserProperties::integer(min, max) + } + CommandArgumentType::String { behavior } => ParserProperties::string(behavior), }; - nodes.push(Node::argument(argument.name, properties)); + let executable = + argument_node_index - current_node_index >= command.required_argument_count; + nodes.push(Node::argument(argument.name, executable, properties)); current_node_index = argument_node_index; } @@ -126,9 +162,12 @@ impl Node { } } - fn literal(name: impl ToString) -> Self { + fn literal(name: impl ToString, executable: bool) -> Self { Node { - flags: LITERAL_NODE, + flags: NodeFlagsBuilder::new() + .node_type(NodeType::Literal) + .executable(executable) + .build(), children: LengthPaddedVec::default(), data: NodeData::Literal { name: name.to_string(), @@ -136,9 +175,16 @@ impl Node { } } - fn argument(name: impl ToString, parser_properties: ParserProperties) -> Self { + fn argument( + name: impl ToString, + executable: bool, + parser_properties: ParserProperties, + ) -> Self { Node { - flags: ARGUMENT_NODE, + flags: NodeFlagsBuilder::new() + .node_type(NodeType::Argument) + .executable(executable) + .build(), children: LengthPaddedVec::default(), data: NodeData::Argument { name: name.to_string(), @@ -192,18 +238,32 @@ enum ParserProperties { /// Only if flags & 0x02. If not specified, defaults to Float.MAX_VALUE (≈ 3.4028235E38) max: Omitted, }, + Integer { + flags: i8, + /// Only if flags & 0x01. If not specified, defaults to Integer.MIN_VALUE (2147483648) + min: Omitted, + /// Only if flags & 0x02. If not specified, defaults to Integer.MAX_VALUE (-2147483647) + max: Omitted, + }, + String { + behavior: StringBehavior, + }, } impl ParserProperties { fn id(&self) -> VarInt { match self { Self::Float { .. } => VarInt::new(1), + Self::Integer { .. } => VarInt::new(3), + Self::String { .. } => VarInt::new(5), } } fn identifier(&self) -> Identifier { match self { ParserProperties::Float { .. } => Identifier::new("brigadier", "float"), + ParserProperties::Integer { .. } => Identifier::new("brigadier", "integer"), + ParserProperties::String { .. } => Identifier::new("brigadier", "string"), } } @@ -214,6 +274,18 @@ impl ParserProperties { max: Omitted::Some(max), } } + + fn integer(min: i32, max: i32) -> Self { + Self::Integer { + flags: 0x01 | 0x02, + min: Omitted::Some(min), + max: Omitted::Some(max), + } + } + + fn string(behavior: StringBehavior) -> Self { + Self::String { behavior } + } } impl EncodePacket for ParserProperties { @@ -234,6 +306,14 @@ impl EncodePacket for ParserProperties { min.encode(writer, protocol_version)?; max.encode(writer, protocol_version)?; } + ParserProperties::Integer { flags, min, max } => { + flags.encode(writer, protocol_version)?; + min.encode(writer, protocol_version)?; + max.encode(writer, protocol_version)?; + } + ParserProperties::String { behavior } => { + (*behavior as i8).encode(writer, protocol_version)?; + } } Ok(()) } diff --git a/crates/minecraft_packets/src/play/mod.rs b/crates/minecraft_packets/src/play/mod.rs index 26b59e6e..60489824 100644 --- a/crates/minecraft_packets/src/play/mod.rs +++ b/crates/minecraft_packets/src/play/mod.rs @@ -27,6 +27,7 @@ pub mod set_titles_animation; pub mod synchronize_player_position_packet; pub mod system_chat_message_packet; pub mod tab_list_packet; +pub mod transfer_packet; pub mod update_time_packet; pub use data::chunk_context::{VoidChunkContext, WorldContext}; diff --git a/crates/minecraft_packets/src/play/transfer_packet.rs b/crates/minecraft_packets/src/play/transfer_packet.rs new file mode 100644 index 00000000..ab8aa418 --- /dev/null +++ b/crates/minecraft_packets/src/play/transfer_packet.rs @@ -0,0 +1,17 @@ +use minecraft_protocol::prelude::*; + +#[derive(PacketOut)] +pub struct TransferPacket { + // TODO should this be named PlayTransferPacket since there are also configuration phase transfers? + pub host: String, + pub port: VarInt, +} + +impl TransferPacket { + pub fn new(host: &str, port: &VarInt) -> Self { + Self { + host: host.to_owned(), + port: port.clone(), + } + } +} diff --git a/data/generated/V1_20_5/reports/packets.json b/data/generated/V1_20_5/reports/packets.json index bbd560dd..819b844d 100644 --- a/data/generated/V1_20_5/reports/packets.json +++ b/data/generated/V1_20_5/reports/packets.json @@ -13,6 +13,9 @@ "minecraft:registry_data": { "protocol_id": 7 }, + "minecraft:transfer": { + "protocol_id": 11 + }, "minecraft:select_known_packs": { "protocol_id": 14 }, @@ -127,6 +130,9 @@ }, "minecraft:player_abilities": { "protocol_id": 56 + }, + "minecraft:transfer": { + "protocol_id": 115 } }, "serverbound": { diff --git a/docs/about/faq.md b/docs/about/faq.md index 4c5efc75..c9def662 100644 --- a/docs/about/faq.md +++ b/docs/about/faq.md @@ -28,10 +28,6 @@ PicoLimbo cannot load existing worlds or generate terrain. Players connect to a However, PicoLimbo includes experimental support for loading small structures using `.schem` files. See the [Experimental World Loading](/config/world.html) section for configuration details. -## Does PicoLimbo support transfer packets? - -Transfer packet support is not currently implemented in PicoLimbo, but this is a planned feature for a future release. - ## Does PicoLimbo support Bedrock players? While it is not planned in the near future, I understand the need for such a feature. In the meantime, you can probably install Geyser as a plugin on a Velocity proxy. diff --git a/docs/config/commands.md b/docs/config/commands.md index a7e70013..879bca0d 100644 --- a/docs/config/commands.md +++ b/docs/config/commands.md @@ -35,6 +35,20 @@ fly_speed = "flyspeed" ``` ::: +## Transfer Command + +The `/transfer` command allows players to transfer to another server by specifying its `hostname` and optionally a `port`. If a port is not specified the Minecraft default of 25565 is used. + +> [!NOTE] +> The destination server must have [accepts-transfers](https://minecraft.wiki/w/Server.properties#Keys) set to `true` in its server.properties. + +:::code-group +```toml [server.toml] {2} +[commands] +transfer = "transfer" +``` +::: + ## Disabling Commands Any command can be disabled by setting its value to an empty string `""`. This prevents players from using that command entirely. @@ -45,6 +59,7 @@ Any command can be disabled by setting its value to an empty string `""`. This p spawn = "" fly = "fly" fly_speed = "" +transfer = "" ``` ::: @@ -58,5 +73,6 @@ You can rename any command to a custom alias by changing its value. For example, spawn = "home" fly = "soar" fly_speed = "speed" +transfer = "server" ``` ::: \ No newline at end of file diff --git a/docs/config/server-settings.md b/docs/config/server-settings.md index 18ddcbac..167f83e5 100644 --- a/docs/config/server-settings.md +++ b/docs/config/server-settings.md @@ -141,3 +141,13 @@ fetch_player_skins = true > [!WARNING] > If you expect a large amount of player to connect to your limbo server instance, your server's IP may get black listed from Mojang API. + +## Accept Transfers + +Whether players who have been transferred from another server via a [transfer packet](https://minecraft.wiki/w/Commands/transfer) should be accepted. + +:::code-group +```toml [server.toml] +accept_transfers = false +``` +::: diff --git a/pico_limbo/src/configuration/commands.rs b/pico_limbo/src/configuration/commands.rs index f728205f..21e4611f 100644 --- a/pico_limbo/src/configuration/commands.rs +++ b/pico_limbo/src/configuration/commands.rs @@ -6,6 +6,7 @@ pub struct CommandsConfig { pub spawn: String, pub fly: String, pub fly_speed: String, + pub transfer: String, } impl Default for CommandsConfig { @@ -14,6 +15,7 @@ impl Default for CommandsConfig { spawn: "spawn".to_string(), fly: "fly".to_string(), fly_speed: "flyspeed".to_string(), + transfer: "transfer".to_string(), } } } diff --git a/pico_limbo/src/configuration/config.rs b/pico_limbo/src/configuration/config.rs index bb5df6dd..3a2672b1 100644 --- a/pico_limbo/src/configuration/config.rs +++ b/pico_limbo/src/configuration/config.rs @@ -66,6 +66,8 @@ pub struct Config { pub allow_flight: bool, + pub accept_transfers: bool, + pub boss_bar: BossBarConfig, pub title: TitleConfig, @@ -92,6 +94,7 @@ impl Default for Config { title: TitleConfig::default(), allow_unsupported_versions: false, allow_flight: false, + accept_transfers: false, commands: CommandsConfig::default(), } } diff --git a/pico_limbo/src/handlers/configuration.rs b/pico_limbo/src/handlers/configuration.rs index 47ca3534..ac0c482e 100644 --- a/pico_limbo/src/handlers/configuration.rs +++ b/pico_limbo/src/handlers/configuration.rs @@ -11,7 +11,9 @@ use minecraft_packets::login::Property; use minecraft_packets::play::boss_bar_packet::BossBarPacket; use minecraft_packets::play::client_bound_player_abilities_packet::ClientBoundPlayerAbilitiesPacket; use minecraft_packets::play::client_bound_plugin_message_packet::PlayClientBoundPluginMessagePacket; -use minecraft_packets::play::commands_packet::{Command, CommandArgument, CommandsPacket}; +use minecraft_packets::play::commands_packet::{ + Command, CommandArgument, CommandsPacket, StringBehavior, +}; use minecraft_packets::play::game_event_packet::GameEventPacket; use minecraft_packets::play::legacy_chat_message_packet::LegacyChatMessagePacket; use minecraft_packets::play::legacy_set_title_packet::LegacySetTitlePacket; @@ -175,7 +177,7 @@ pub fn send_play_packets( client_state.set_feet_position(y); if protocol_version.is_after_inclusive(ProtocolVersion::V1_13) { - send_commands_packet(batch, server_state); + send_commands_packet(batch, protocol_version, server_state); } // The brand is not visible for clients prior to 1.13, no need to send it @@ -384,7 +386,11 @@ fn send_skin_packets( } } -fn send_commands_packet(batch: &mut Batch, server_state: &ServerState) { +fn send_commands_packet( + batch: &mut Batch, + protocol_version: ProtocolVersion, + server_state: &ServerState, +) { let mut commands = vec![]; if let ServerCommand::Enabled { alias } = server_state.server_commands().spawn() { commands.push(Command::no_arguments(alias)); @@ -398,6 +404,18 @@ fn send_commands_packet(batch: &mut Batch, server_state: &Server vec![CommandArgument::float("speed", 0.0, 1.0)], )); } + if protocol_version.is_after_inclusive(ProtocolVersion::V1_20_5) + && let ServerCommand::Enabled { alias } = server_state.server_commands().transfer() + { + commands.push(Command::with_required_arguments( + alias, + vec![ + CommandArgument::string("hostname", StringBehavior::SingleWord), + CommandArgument::integer("port", 0, 65535), + ], + 1, + )); + } let packet = CommandsPacket::new(commands); batch.queue(|| PacketRegistry::Commands(packet)); } diff --git a/pico_limbo/src/handlers/handshake.rs b/pico_limbo/src/handlers/handshake.rs index 1ec4e65d..9e85c109 100644 --- a/pico_limbo/src/handlers/handshake.rs +++ b/pico_limbo/src/handlers/handshake.rs @@ -41,7 +41,8 @@ impl PacketHandler for HandshakePacket { Ok(batch) } State::Transfer => { - if server_state.accepts_transfers() { + if server_state.accept_transfers() { + client_state.set_state(State::Login); begin_login(client_state, server_state, &self.hostname)?; Ok(batch) } else { diff --git a/pico_limbo/src/handlers/play/commands.rs b/pico_limbo/src/handlers/play/commands.rs index f90b146f..8ce6fb11 100644 --- a/pico_limbo/src/handlers/play/commands.rs +++ b/pico_limbo/src/handlers/play/commands.rs @@ -7,8 +7,10 @@ use crate::server_state::{ServerCommand, ServerCommands, ServerState}; use minecraft_packets::play::chat_command_packet::ChatCommandPacket; use minecraft_packets::play::chat_message_packet::ChatMessagePacket; use minecraft_packets::play::client_bound_player_abilities_packet::ClientBoundPlayerAbilitiesPacket; +use minecraft_packets::play::transfer_packet::TransferPacket; +use minecraft_protocol::prelude::{ProtocolVersion, VarInt}; use thiserror::Error; -use tracing::info; +use tracing::{info, warn}; impl PacketHandler for ChatCommandPacket { fn handle( @@ -76,6 +78,30 @@ fn run_command( batch.queue(|| PacketRegistry::ClientBoundPlayerAbilities(packet)); client_state.set_flying_speed(speed); } + Command::Transfer(host, port) => { + if client_state + .protocol_version() + .is_after_inclusive(ProtocolVersion::V1_20_5) + { + info!( + "Transferring {} to {}:{}", + client_state.get_username(), + host, + port + ); + let packet = TransferPacket { + host, + port: VarInt::from(port), + }; + batch.queue(|| PacketRegistry::Transfer(packet)); + } else { + warn!( + "{} tried to transfer servers on unsupported version {}", + client_state.get_username(), + client_state.protocol_version().humanize() + ); + } + } } } } @@ -88,12 +114,17 @@ pub enum ParseCommandError { Unknown, #[error("invalid speed value")] InvalidSpeed(#[from] std::num::ParseFloatError), + #[error("invalid hostname")] + InvalidHost, + #[error("invalid port")] + InvalidPort(#[from] std::num::ParseIntError), } enum Command { Spawn, Fly, FlySpeed(f32), + Transfer(String, i32), } impl Command { @@ -108,6 +139,14 @@ impl Command { let speed_str = parts.next().unwrap_or("0.05"); let speed = speed_str.parse::()?.clamp(0.0, 1.0); Ok(Self::FlySpeed(speed)) + } else if Self::is_command(server_commands.transfer(), cmd) { + let host = parts + .next() + .ok_or(ParseCommandError::InvalidHost)? + .to_string(); + let port_str = parts.next().unwrap_or("25565"); + let port = port_str.parse::()?; + Ok(Self::Transfer(host, port)) } else { Err(ParseCommandError::Unknown) } diff --git a/pico_limbo/src/server/packet_registry.rs b/pico_limbo/src/server/packet_registry.rs index 61ad3500..e2140409 100644 --- a/pico_limbo/src/server/packet_registry.rs +++ b/pico_limbo/src/server/packet_registry.rs @@ -43,6 +43,7 @@ use minecraft_packets::play::set_titles_animation::SetTitlesAnimationPacket; use minecraft_packets::play::synchronize_player_position_packet::SynchronizePlayerPositionPacket; use minecraft_packets::play::system_chat_message_packet::SystemChatMessagePacket; use minecraft_packets::play::tab_list_packet::TabListPacket; +use minecraft_packets::play::transfer_packet::TransferPacket; use minecraft_packets::play::update_time_packet::UpdateTimePacket; use minecraft_packets::status::ping_request_packet::PingRequestPacket; use minecraft_packets::status::ping_response_packet::PongResponsePacket; @@ -325,6 +326,9 @@ pub enum PacketRegistry { )] SetActionBarText(SetActionBarTextPacket), + #[protocol_id(state = "play", bound = "clientbound", name = "minecraft:transfer")] + Transfer(TransferPacket), + #[protocol_id( state = "play", bound = "clientbound", diff --git a/pico_limbo/src/server/start_server.rs b/pico_limbo/src/server/start_server.rs index 66e5ae88..6a3add3e 100644 --- a/pico_limbo/src/server/start_server.rs +++ b/pico_limbo/src/server/start_server.rs @@ -124,6 +124,7 @@ fn build_state(cfg: Config) -> Result { .set_reply_to_status(cfg.server_list.reply_to_status) .set_allow_unsupported_versions(cfg.allow_unsupported_versions) .set_allow_flight(cfg.allow_flight) + .set_accept_transfers(cfg.accept_transfers) .server_commands(cfg.commands); server_state_builder.build() diff --git a/pico_limbo/src/server_state/mod.rs b/pico_limbo/src/server_state/mod.rs index 4948093c..82d5e928 100644 --- a/pico_limbo/src/server_state/mod.rs +++ b/pico_limbo/src/server_state/mod.rs @@ -107,7 +107,7 @@ pub struct ServerState { reduced_debug_info: bool, is_player_listed: bool, reply_to_status: bool, - accepts_transfers: bool, + accept_transfers: bool, allow_unsupported_versions: bool, allow_flight: bool, server_commands: ServerCommands, @@ -254,8 +254,8 @@ impl ServerState { self.allow_flight } - pub const fn accepts_transfers(&self) -> bool { - self.accepts_transfers + pub const fn accept_transfers(&self) -> bool { + self.accept_transfers } pub const fn server_commands(&self) -> &ServerCommands { @@ -301,6 +301,7 @@ pub struct ServerStateBuilder { reply_to_status: bool, allow_unsupported_versions: bool, allow_flight: bool, + accept_transfers: bool, server_commands: ServerCommands, } @@ -430,6 +431,11 @@ impl ServerStateBuilder { self } + pub const fn set_accept_transfers(&mut self, accept_transfers: bool) -> &mut Self { + self.accept_transfers = accept_transfers; + self + } + pub const fn hardcore(&mut self, hardcore: bool) -> &mut Self { self.hardcore = hardcore; self @@ -611,7 +617,7 @@ impl ServerStateBuilder { reply_to_status: self.reply_to_status, allow_unsupported_versions: self.allow_unsupported_versions, allow_flight: self.allow_flight, - accepts_transfers: false, + accept_transfers: self.accept_transfers, server_commands: self.server_commands, }) } diff --git a/pico_limbo/src/server_state/server_commands.rs b/pico_limbo/src/server_state/server_commands.rs index 98000e2f..cd32d688 100644 --- a/pico_limbo/src/server_state/server_commands.rs +++ b/pico_limbo/src/server_state/server_commands.rs @@ -5,6 +5,7 @@ pub struct ServerCommands { spawn: String, fly: String, fly_speed: String, + transfer: String, } impl From for ServerCommands { @@ -13,6 +14,7 @@ impl From for ServerCommands { spawn: config.spawn, fly: config.fly, fly_speed: config.fly_speed, + transfer: config.transfer, } } } @@ -35,6 +37,10 @@ impl ServerCommands { Self::server_command(self.fly_speed.clone()) } + pub fn transfer(&self) -> ServerCommand { + Self::server_command(self.transfer.clone()) + } + fn server_command(alias: String) -> ServerCommand { if alias.is_empty() { ServerCommand::Disabled