From 59972f556b9b0f572f954074310c808ea250ce52 Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Sun, 25 Jan 2026 05:35:00 -0700 Subject: [PATCH 01/13] Add .DS_Store (macOS folder metadata) to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 145ce0e0..98a40ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /target node_modules/ *.zst +.DS_Store From 02e9fa6b7f766bf5a16d20832cd70b027730feb3 Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Sun, 25 Jan 2026 05:53:45 -0700 Subject: [PATCH 02/13] Add transfer packet specification --- crates/minecraft_packets/src/play/mod.rs | 1 + .../src/play/transfer_packet.rs | 16 ++++++++++++++++ pico_limbo/src/server/packet_registry.rs | 8 ++++++++ 3 files changed, 25 insertions(+) create mode 100644 crates/minecraft_packets/src/play/transfer_packet.rs 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..d875efab --- /dev/null +++ b/crates/minecraft_packets/src/play/transfer_packet.rs @@ -0,0 +1,16 @@ +use minecraft_protocol::prelude::*; + +#[derive(PacketOut)] +pub struct TransferPacket { // TODO should this be named PlayTransferPacket since there are also configuration phase transfers? + host: String, + port: VarInt, +} + +impl TransferPacket { + pub fn new(host: &String, port: &VarInt) -> Self { + Self { + host: host.clone(), + port: port.clone(), + } + } +} diff --git a/pico_limbo/src/server/packet_registry.rs b/pico_limbo/src/server/packet_registry.rs index 61ad3500..2f02bd71 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,13 @@ pub enum PacketRegistry { )] SetActionBarText(SetActionBarTextPacket), + #[protocol_id( + state = "play", + bound = "clientbound", + name = "minecraft:transfer" + )] + Transfer(TransferPacket), + #[protocol_id( state = "play", bound = "clientbound", From e2a075592498b53bf4162992acd3f7332a3661d2 Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Sun, 25 Jan 2026 05:55:55 -0700 Subject: [PATCH 03/13] Add accept_transfers config boolean --- pico_limbo/src/configuration/config.rs | 3 +++ 1 file changed, 3 insertions(+) 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(), } } From c5213775af39efabe3ae26e9f69adf5ae6f4016f Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:07:47 -0700 Subject: [PATCH 04/13] Add transfer command --- .../src/play/transfer_packet.rs | 4 ++-- pico_limbo/src/configuration/commands.rs | 2 ++ pico_limbo/src/handlers/play/commands.rs | 20 +++++++++++++++++++ .../src/server_state/server_commands.rs | 6 ++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/minecraft_packets/src/play/transfer_packet.rs b/crates/minecraft_packets/src/play/transfer_packet.rs index d875efab..508c095a 100644 --- a/crates/minecraft_packets/src/play/transfer_packet.rs +++ b/crates/minecraft_packets/src/play/transfer_packet.rs @@ -2,8 +2,8 @@ use minecraft_protocol::prelude::*; #[derive(PacketOut)] pub struct TransferPacket { // TODO should this be named PlayTransferPacket since there are also configuration phase transfers? - host: String, - port: VarInt, + pub host: String, + pub port: VarInt, } impl TransferPacket { 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/handlers/play/commands.rs b/pico_limbo/src/handlers/play/commands.rs index f90b146f..2518b7db 100644 --- a/pico_limbo/src/handlers/play/commands.rs +++ b/pico_limbo/src/handlers/play/commands.rs @@ -7,6 +7,8 @@ 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::VarInt; use thiserror::Error; use tracing::info; @@ -76,6 +78,14 @@ fn run_command( batch.queue(|| PacketRegistry::ClientBoundPlayerAbilities(packet)); client_state.set_flying_speed(speed); } + Command::Transfer(host, port) => { + let packet = TransferPacket { + host: host, + port: VarInt::from(port), + }; + + batch.queue(|| PacketRegistry::Transfer(packet)); + } } } } @@ -88,12 +98,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 +123,11 @@ 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_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 From c9182ec7ec54af1b083f2fd9a257a93680fa0506 Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Sun, 25 Jan 2026 16:55:55 -0700 Subject: [PATCH 05/13] Add transfer command description to commands packet --- .../src/play/commands_packet.rs | 63 +++++++++++++++++++ pico_limbo/src/handlers/configuration.rs | 11 +++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/minecraft_packets/src/play/commands_packet.rs b/crates/minecraft_packets/src/play/commands_packet.rs index ede641db..dd189585 100644 --- a/crates/minecraft_packets/src/play/commands_packet.rs +++ b/crates/minecraft_packets/src/play/commands_packet.rs @@ -1,3 +1,5 @@ +use std::i8; + use minecraft_protocol::prelude::*; const ROOT_NODE: i8 = NodeFlagsBuilder::new().node_type(NodeType::Root).build(); @@ -23,8 +25,19 @@ 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 { name: String, argument_type: CommandArgumentType, @@ -37,6 +50,20 @@ 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 { @@ -81,6 +108,8 @@ 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)); @@ -192,18 +221,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 +257,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 +289,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/pico_limbo/src/handlers/configuration.rs b/pico_limbo/src/handlers/configuration.rs index 47ca3534..fe344aac 100644 --- a/pico_limbo/src/handlers/configuration.rs +++ b/pico_limbo/src/handlers/configuration.rs @@ -11,7 +11,7 @@ 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; @@ -398,6 +398,15 @@ fn send_commands_packet(batch: &mut Batch, server_state: &Server vec![CommandArgument::float("speed", 0.0, 1.0)], )); } + if let ServerCommand::Enabled { alias } = server_state.server_commands().transfer() { + commands.push(Command::new( + alias, + vec![ + CommandArgument::string("hostname", StringBehavior::SingleWord), + CommandArgument::integer("port", 0, 65535), + ], + )); + } let packet = CommandsPacket::new(commands); batch.queue(|| PacketRegistry::Commands(packet)); } From d07e0b9cd7170a8a0a611ab548fb3968dfd20c32 Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:18:03 -0700 Subject: [PATCH 06/13] Connect accept_transfers config option to server state --- pico_limbo/src/handlers/handshake.rs | 3 ++- pico_limbo/src/handlers/play/commands.rs | 2 +- pico_limbo/src/server/start_server.rs | 1 + pico_limbo/src/server_state/mod.rs | 14 ++++++++++---- 4 files changed, 14 insertions(+), 6 deletions(-) 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 2518b7db..f0f1885a 100644 --- a/pico_limbo/src/handlers/play/commands.rs +++ b/pico_limbo/src/handlers/play/commands.rs @@ -79,11 +79,11 @@ fn run_command( client_state.set_flying_speed(speed); } Command::Transfer(host, port) => { + info!("Transferring {} to {}:{}", client_state.get_username(), host, port); let packet = TransferPacket { host: host, port: VarInt::from(port), }; - batch.queue(|| PacketRegistry::Transfer(packet)); } } 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, }) } From 1f872f01de27b202b165d3decb2e59bcac87e9d0 Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Sun, 25 Jan 2026 18:19:16 -0700 Subject: [PATCH 07/13] Run cargo fmt and implement fixes from Clippy --- .../minecraft_packets/src/play/commands_packet.rs | 13 ++++++------- .../minecraft_packets/src/play/transfer_packet.rs | 7 ++++--- pico_limbo/src/handlers/configuration.rs | 6 ++++-- pico_limbo/src/handlers/play/commands.rs | 14 +++++++++++--- pico_limbo/src/server/packet_registry.rs | 6 +----- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/crates/minecraft_packets/src/play/commands_packet.rs b/crates/minecraft_packets/src/play/commands_packet.rs index dd189585..eac15740 100644 --- a/crates/minecraft_packets/src/play/commands_packet.rs +++ b/crates/minecraft_packets/src/play/commands_packet.rs @@ -1,5 +1,3 @@ -use std::i8; - use minecraft_protocol::prelude::*; const ROOT_NODE: i8 = NodeFlagsBuilder::new().node_type(NodeType::Root).build(); @@ -37,7 +35,6 @@ pub enum StringBehavior { GreedyPhrase = 2, } - pub struct CommandArgument { name: String, argument_type: CommandArgumentType, @@ -52,8 +49,8 @@ impl CommandArgument { } pub fn integer(name: impl ToString, min: i32, max: i32) -> Self { - Self { - name: name.to_string(), + Self { + name: name.to_string(), argument_type: CommandArgumentType::Integer { min, max }, } } @@ -108,7 +105,9 @@ 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::Integer { min, max } => { + ParserProperties::integer(min, max) + } CommandArgumentType::String { behavior } => ParserProperties::string(behavior), }; @@ -230,7 +229,7 @@ enum ParserProperties { }, String { behavior: StringBehavior, - } + }, } impl ParserProperties { diff --git a/crates/minecraft_packets/src/play/transfer_packet.rs b/crates/minecraft_packets/src/play/transfer_packet.rs index 508c095a..ab8aa418 100644 --- a/crates/minecraft_packets/src/play/transfer_packet.rs +++ b/crates/minecraft_packets/src/play/transfer_packet.rs @@ -1,15 +1,16 @@ use minecraft_protocol::prelude::*; #[derive(PacketOut)] -pub struct TransferPacket { // TODO should this be named PlayTransferPacket since there are also configuration phase transfers? +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: &String, port: &VarInt) -> Self { + pub fn new(host: &str, port: &VarInt) -> Self { Self { - host: host.clone(), + host: host.to_owned(), port: port.clone(), } } diff --git a/pico_limbo/src/handlers/configuration.rs b/pico_limbo/src/handlers/configuration.rs index fe344aac..ef3bb77d 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, StringBehavior}; +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; @@ -404,7 +406,7 @@ fn send_commands_packet(batch: &mut Batch, server_state: &Server vec![ CommandArgument::string("hostname", StringBehavior::SingleWord), CommandArgument::integer("port", 0, 65535), - ], + ], )); } let packet = CommandsPacket::new(commands); diff --git a/pico_limbo/src/handlers/play/commands.rs b/pico_limbo/src/handlers/play/commands.rs index f0f1885a..43596639 100644 --- a/pico_limbo/src/handlers/play/commands.rs +++ b/pico_limbo/src/handlers/play/commands.rs @@ -79,9 +79,14 @@ fn run_command( client_state.set_flying_speed(speed); } Command::Transfer(host, port) => { - info!("Transferring {} to {}:{}", client_state.get_username(), host, port); + info!( + "Transferring {} to {}:{}", + client_state.get_username(), + host, + port + ); let packet = TransferPacket { - host: host, + host, port: VarInt::from(port), }; batch.queue(|| PacketRegistry::Transfer(packet)); @@ -124,7 +129,10 @@ impl Command { 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 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)) diff --git a/pico_limbo/src/server/packet_registry.rs b/pico_limbo/src/server/packet_registry.rs index 2f02bd71..e2140409 100644 --- a/pico_limbo/src/server/packet_registry.rs +++ b/pico_limbo/src/server/packet_registry.rs @@ -326,11 +326,7 @@ pub enum PacketRegistry { )] SetActionBarText(SetActionBarTextPacket), - #[protocol_id( - state = "play", - bound = "clientbound", - name = "minecraft:transfer" - )] + #[protocol_id(state = "play", bound = "clientbound", name = "minecraft:transfer")] Transfer(TransferPacket), #[protocol_id( From a651386ac94ff0f41a5888c0b1e02fa119c8875d Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:18:29 -0700 Subject: [PATCH 08/13] Check protocol version before sending transfer command or packet --- pico_limbo/src/handlers/configuration.rs | 12 ++++++-- pico_limbo/src/handlers/play/commands.rs | 37 +++++++++++++++--------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/pico_limbo/src/handlers/configuration.rs b/pico_limbo/src/handlers/configuration.rs index ef3bb77d..246e0c18 100644 --- a/pico_limbo/src/handlers/configuration.rs +++ b/pico_limbo/src/handlers/configuration.rs @@ -177,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 @@ -386,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)); @@ -400,7 +404,9 @@ fn send_commands_packet(batch: &mut Batch, server_state: &Server vec![CommandArgument::float("speed", 0.0, 1.0)], )); } - if let ServerCommand::Enabled { alias } = server_state.server_commands().transfer() { + if protocol_version.is_after_inclusive(ProtocolVersion::V1_20_5) + && let ServerCommand::Enabled { alias } = server_state.server_commands().transfer() + { commands.push(Command::new( alias, vec![ diff --git a/pico_limbo/src/handlers/play/commands.rs b/pico_limbo/src/handlers/play/commands.rs index 43596639..766940ac 100644 --- a/pico_limbo/src/handlers/play/commands.rs +++ b/pico_limbo/src/handlers/play/commands.rs @@ -8,9 +8,9 @@ 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::VarInt; +use minecraft_protocol::prelude::{ProtocolVersion, VarInt}; use thiserror::Error; -use tracing::info; +use tracing::{info, warn}; impl PacketHandler for ChatCommandPacket { fn handle( @@ -79,17 +79,28 @@ fn run_command( client_state.set_flying_speed(speed); } Command::Transfer(host, port) => { - info!( - "Transferring {} to {}:{}", - client_state.get_username(), - host, - port - ); - let packet = TransferPacket { - host, - port: VarInt::from(port), - }; - batch.queue(|| PacketRegistry::Transfer(packet)); + 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() + ) + } } } } From 588f4614be06243146200289a8afde86525b8c19 Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:36:19 -0700 Subject: [PATCH 09/13] Add transfer packet to V1_20_5 packets.json --- data/generated/V1_20_5/reports/packets.json | 6 ++++++ 1 file changed, 6 insertions(+) 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": { From b25a99fea87a7c5b569a29b05931129ee4664f79 Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:51:26 -0700 Subject: [PATCH 10/13] Document accept_transfers and the /transfer command --- docs/about/faq.md | 6 +----- docs/config/commands.md | 15 +++++++++++++++ docs/config/server-settings.md | 10 ++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/about/faq.md b/docs/about/faq.md index 4c5efc75..019716f3 100644 --- a/docs/about/faq.md +++ b/docs/about/faq.md @@ -27,11 +27,7 @@ If you need authenticated players, you should handle authentication at the proxy PicoLimbo cannot load existing worlds or generate terrain. Players connect to a void environment by default. 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. - +s ## 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..bdfb15ee 100644 --- a/docs/config/commands.md +++ b/docs/config/commands.md @@ -35,6 +35,19 @@ 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 that 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 +58,7 @@ Any command can be disabled by setting its value to an empty string `""`. This p spawn = "" fly = "fly" fly_speed = "" +transfer = "" ``` ::: @@ -58,5 +72,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 +``` +::: From b001119846742595e182f213d4aa76e5041cd4da Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:03:10 -0700 Subject: [PATCH 11/13] Use note callout in docs --- docs/config/commands.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/config/commands.md b/docs/config/commands.md index bdfb15ee..879bca0d 100644 --- a/docs/config/commands.md +++ b/docs/config/commands.md @@ -39,7 +39,8 @@ fly_speed = "flyspeed" 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 that the destination server must have [accepts-transfers](https://minecraft.wiki/w/Server.properties#Keys) set to true in its server.properties. +> [!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} From 74a294e232b3f056108da1a35089fe7529331f40 Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:19:38 -0700 Subject: [PATCH 12/13] Remove extra s in docs --- docs/about/faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/about/faq.md b/docs/about/faq.md index 019716f3..c9def662 100644 --- a/docs/about/faq.md +++ b/docs/about/faq.md @@ -27,7 +27,7 @@ If you need authenticated players, you should handle authentication at the proxy PicoLimbo cannot load existing worlds or generate terrain. Players connect to a void environment by default. However, PicoLimbo includes experimental support for loading small structures using `.schem` files. See the [Experimental World Loading](/config/world.html) section for configuration details. -s + ## 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. From 4f7d9b71de7078d038f11051374343c02f0bc591 Mon Sep 17 00:00:00 2001 From: Caden Kriese <13630061+cadenkriese@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:58:04 -0700 Subject: [PATCH 13/13] Mark hostname argument on transfer command as required --- .../src/play/commands_packet.rs | 50 +++++++++++++------ pico_limbo/src/handlers/configuration.rs | 3 +- pico_limbo/src/handlers/play/commands.rs | 2 +- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/crates/minecraft_packets/src/play/commands_packet.rs b/crates/minecraft_packets/src/play/commands_packet.rs index eac15740..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 { @@ -66,6 +56,7 @@ impl CommandArgument { pub struct Command { alias: String, arguments: Vec, + required_argument_count: i32, } impl Command { @@ -73,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, } } @@ -80,6 +84,7 @@ impl Command { Self { alias: alias.to_string(), arguments: Vec::new(), + required_argument_count: 0, } } } @@ -94,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; @@ -111,7 +117,9 @@ impl CommandsPacket { 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; } @@ -154,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(), @@ -164,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(), diff --git a/pico_limbo/src/handlers/configuration.rs b/pico_limbo/src/handlers/configuration.rs index 246e0c18..ac0c482e 100644 --- a/pico_limbo/src/handlers/configuration.rs +++ b/pico_limbo/src/handlers/configuration.rs @@ -407,12 +407,13 @@ fn send_commands_packet( if protocol_version.is_after_inclusive(ProtocolVersion::V1_20_5) && let ServerCommand::Enabled { alias } = server_state.server_commands().transfer() { - commands.push(Command::new( + commands.push(Command::with_required_arguments( alias, vec![ CommandArgument::string("hostname", StringBehavior::SingleWord), CommandArgument::integer("port", 0, 65535), ], + 1, )); } let packet = CommandsPacket::new(commands); diff --git a/pico_limbo/src/handlers/play/commands.rs b/pico_limbo/src/handlers/play/commands.rs index 766940ac..8ce6fb11 100644 --- a/pico_limbo/src/handlers/play/commands.rs +++ b/pico_limbo/src/handlers/play/commands.rs @@ -99,7 +99,7 @@ fn run_command( "{} tried to transfer servers on unsupported version {}", client_state.get_username(), client_state.protocol_version().humanize() - ) + ); } } }