diff --git a/.gitignore b/.gitignore index ea8c4bf..f182a35 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +config.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7503b8 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Quectocraft + +Quectocraft is a minimal, extensible, efficient Minecraft server implementation written in Rust and Lua. + +## Goals + +- Minimal: By default, Quectocraft does very little by itself. It accepts connections, encodes and decodes packets, and handles the login sequence for you, but everything else must be done via plugins. +- Extensible: Via its Lua plugin system, Quectocraft can be configured to do a variety of things. +- Efficient: The vanilla Minecraft server, and even more efficient servers like Spigot and Paper, all use significant amounts of CPU even while idling with no players connected. Due to its low CPU and memory usage, Quectocraft is suitable for running on lower-end systems, or alongside another server without causing additional lag. + +## Why? + +I'm mostly just writing this for fun, but here are some potential applications: +- A lobby for a server network +- A queue that players have to wait in before joining another server +- A server to send players to if they are AFK for too long diff --git a/plugins/testcmd.lua b/plugins/testcmd.lua new file mode 100644 index 0000000..52771ba --- /dev/null +++ b/plugins/testcmd.lua @@ -0,0 +1,23 @@ +local plugin = { + id = "testcmd", + name = "TestCmd", + description = "eufdahjklfhjakl", + authors = { "trimill" }, + version = "0.1.0", +} + +local logger = nil + +function plugin.init() + logger = server.initLogger(plugin) +end + +function plugin.registerCommands(registry) + registry.addCommand("test") +end + +function plugin.command(command, args, name, uuid) + logger.info("player " .. name .. " ran /" .. command .. " " .. args) +end + +return plugin diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1273139 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,14 @@ +use std::{net::IpAddr, fs::OpenOptions}; + +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Config { + pub addr: IpAddr, + pub port: u16, +} + +pub fn load_config() -> Result> { + let config = serde_json::from_reader(OpenOptions::new().read(true).open("./config.json")?)?; + Ok(config) +} diff --git a/src/main.rs b/src/main.rs index e6ea2ab..2473004 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::net::SocketAddr; use std::time::Duration; use std::io::Write; @@ -9,11 +10,14 @@ use mlua::Lua; use network::NetworkServer; use plugins::Plugins; +use crate::config::load_config; + +mod config; mod plugins; mod protocol; mod network; -pub const VERSION: &'static str = std::env!("CARGO_PKG_VERSION"); +pub const VERSION: &str = std::env!("CARGO_PKG_VERSION"); fn main() { env_logger::Builder::from_env( @@ -37,6 +41,8 @@ fn main() { writeln!(buf, "\x1b[90m[\x1b[37m{} {color}{}\x1b[37m {}\x1b[90m]\x1b[0m {}", now, record.level(), target, record.args()) }).init(); + let config = load_config().expect("Failed to load config"); + let addr = SocketAddr::new(config.addr, config.port); info!("Starting Quectocraft version {}", VERSION); @@ -45,7 +51,7 @@ fn main() { std::fs::create_dir_all("plugins").expect("Couldn't create the plugins directory"); plugins.load_plugins(); - let mut server = NetworkServer::new("127.0.0.1:25565".to_owned(), plugins); + let mut server = NetworkServer::new(addr, plugins); let sleep_dur = Duration::from_millis(5); let mut i = 0; loop { diff --git a/src/network/server.rs b/src/network/server.rs index 2fcb0da..95e67a5 100644 --- a/src/network/server.rs +++ b/src/network/server.rs @@ -1,32 +1,37 @@ -use std::{net::TcpListener, thread, sync::mpsc::{Receiver, Sender, channel}, collections::HashSet}; +use std::{net::{TcpListener, SocketAddr}, thread, sync::mpsc::{Receiver, Sender, channel}, collections::HashSet}; use log::{info, warn, debug}; use serde_json::json; -use crate::{protocol::{data::PacketEncoder, serverbound::*, clientbound::*}, plugins::{Plugins, Response}, VERSION}; +use crate::{protocol::{data::PacketEncoder, serverbound::*, clientbound::*, command::{Commands, CommandNodeType}}, plugins::{Plugins, Response}, VERSION}; use super::{client::NetworkClient, Player}; pub struct NetworkServer<'lua> { plugins: Plugins<'lua>, + commands: Commands, new_clients: Receiver, clients: Vec, } impl <'lua> NetworkServer<'lua> { - pub fn new(addr: String, plugins: Plugins<'lua>) -> Self { + pub fn new(addr: SocketAddr, mut plugins: Plugins<'lua>) -> Self { let (send, recv) = channel(); info!("Initializing plugins"); plugins.init(); + let mut commands = Commands::new(); + commands.create_simple_cmd("qc"); + let commands = plugins.register_commands(commands).unwrap(); thread::spawn(move || Self::listen(&addr, send)); Self { plugins, + commands, new_clients: recv, clients: Vec::new(), } } - fn listen(addr: &str, send_clients: Sender) { + fn listen(addr: &SocketAddr, send_clients: Sender) { info!("Listening on {}", addr); let listener = TcpListener::bind(addr).unwrap(); for (id, stream) in listener.incoming().enumerate() { @@ -57,7 +62,8 @@ impl <'lua> NetworkServer<'lua> { let mut closed = Vec::new(); for client in self.clients.iter_mut() { if client.play { - if let Err(_) = client.send_packet(ClientBoundPacket::KeepAlive(0)) { + let result = client.send_packet(ClientBoundPacket::KeepAlive(0)); + if result.is_err() { client.close(); if let Some(pl) = &client.player { self.plugins.player_leave(pl); @@ -77,7 +83,8 @@ impl <'lua> NetworkServer<'lua> { }; let mut alive = true; while let Some(packet) = client.recv_packet(&mut alive) { - if let Err(_) = self.handle_packet(client, packet) { + let result = self.handle_packet(client, packet); + if result.is_err() { alive = false; break } @@ -162,6 +169,20 @@ impl <'lua> NetworkServer<'lua> { ServerBoundPacket::ChatMessage(msg) => { self.plugins.chat_message(client.player.as_ref().unwrap(), &msg.message); } + ServerBoundPacket::ChatCommand(msg) => { + let mut parts = msg.message.splitn(1, " "); + if let Some(cmd) = parts.next() { + if cmd == "qc" { + client.send_packet(ClientBoundPacket::SystemChatMessage(json!({ + "text": format!("QuectoCraft version {}", VERSION), + "color": "green" + }), false))?; + } else { + let args = parts.next().unwrap_or_default(); + self.plugins.command(client.player.as_ref().unwrap(), cmd, args); + } + } + } } Ok(()) } @@ -173,11 +194,11 @@ impl <'lua> NetworkServer<'lua> { gamemode: 1, prev_gamemode: 1, dimensions: vec![ - "minecraft:world".to_owned(), + "qc:world".to_owned(), ], registry_codec: include_bytes!("../resources/registry_codec.nbt").to_vec(), dimension_type: "minecraft:the_end".to_owned(), - dimension_name: "minecraft:world".to_owned(), + dimension_name: "qc:world".to_owned(), seed_hash: 0, max_players: 0, view_distance: 8, @@ -196,6 +217,7 @@ impl <'lua> NetworkServer<'lua> { data } }))?; + client.send_packet(ClientBoundPacket::Commands(self.commands.clone()))?; let mut chunk_data: Vec = Vec::new(); for _ in 0..(384 / 16) { // number of non-air blocks diff --git a/src/plugins/init.lua b/src/plugins/init.lua new file mode 100644 index 0000000..2166d97 --- /dev/null +++ b/src/plugins/init.lua @@ -0,0 +1,38 @@ +server = { players = {} } +_qc = { responses = {} } + +local function to_chat(message, default) + if message == nil then + if default ~= nil then + return default + else + error("message must be a string or table") + end + elseif type(message) == "table" then + return message + elseif type(message) == "string" then + return { text = message } + elseif default == nil then + error("message must be a string or table") + else + error("message must be a string, table, or nil for the default message") + end +end + +function server.sendMessage(player, message) + if type(player) ~= "string" then + error("player must be a string") + end + local message = assert(to_chat(message)) + table.insert(_qc.responses, {type = "message", player = player, message = message}) +end + +function server.broadcast(message) + local message = assert(to_chat(message)) + table.insert(_qc.responses, { type = "broadcast", message = message }) +end + +function server.disconnect(player, reason) + local reason = assert(to_chat(reason, { translate = "multiplayer.disconnect.generic" })) + table.insert(_qc.responses, { type = "disconnect", player = player, reason = reason }) +end diff --git a/src/plugins/init_lua.rs b/src/plugins/init_lua.rs index 0a115a2..3e8c260 100644 --- a/src/plugins/init_lua.rs +++ b/src/plugins/init_lua.rs @@ -1,8 +1,9 @@ use log::{info, warn, trace, error, debug}; use mlua::{Lua, chunk}; +use crate::VERSION; -pub fn init<'lua>(lua: &'lua Lua) -> Result<(), mlua::Error> { +pub fn init(lua: &Lua) -> Result<(), mlua::Error> { macro_rules! log_any { ($level:tt) => { lua.create_function(|_, args: (String, String)| { @@ -16,9 +17,8 @@ pub fn init<'lua>(lua: &'lua Lua) -> Result<(), mlua::Error> { let log_info = log_any!(info)?; let log_warn = log_any!(warn)?; let log_error = log_any!(error)?; + lua.load(include_str!("init.lua")).exec()?; lua.load(chunk!{ - server = { players = {} } - _qc = { responses = {} } function server.initLogger(plugin) local id = "pl::" .. assert(plugin["id"]) return { @@ -29,42 +29,9 @@ pub fn init<'lua>(lua: &'lua Lua) -> Result<(), mlua::Error> { error = function(msg) $log_error(id, msg) end, } end - - local function to_chat(message, default) - if message == nil then - if default ~= nil then - return default - else - error("message must be a string or table") - end - elseif type(message) == "table" then - return message - elseif type(message) == "string" then - return { text = message } - elseif default == nil then - error("message must be a string or table") - else - error("message must be a string, table, or nil for the default message") - end - end - function server.sendMessage(player, message) - if type(player) ~= "string" then - error("player must be a string") - end - local message = assert(to_chat(message)) - table.insert(_qc.responses, {type = "message", player = player, message = message}) - end - - function server.broadcast(message) - local message = assert(to_chat(message)) - table.insert(_qc.responses, { type = "broadcast", message = message }) - end - - function server.disconnect(player, reason) - local reason = assert(to_chat(reason, { translate = "multiplayer.disconnect.generic" })) - table.insert(_qc.responses, { type = "disconnect", player = player, reason = reason }) - end + server.version = $VERSION }).exec()?; + Ok(()) } diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 0dad724..fe36c16 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -1,11 +1,11 @@ -use std::fs::read_dir; +use std::{fs::read_dir, rc::Rc, cell::RefCell, collections::HashMap}; use log::{warn, info}; use mlua::{Lua, Table, LuaSerdeExt}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::network::Player; +use crate::{network::Player, protocol::command::Commands}; use self::plugin::Plugin; @@ -26,7 +26,8 @@ pub enum Response { pub struct Plugins<'lua> { lua: &'lua Lua, - plugins: Vec> + plugins: Vec>, + cmd_owners: HashMap, } impl <'lua> Plugins<'lua> { @@ -35,6 +36,7 @@ impl <'lua> Plugins<'lua> { Ok(Self { lua, plugins: Vec::new(), + cmd_owners: HashMap::new(), }) } @@ -49,7 +51,7 @@ impl <'lua> Plugins<'lua> { } else { file.path() }; - let pl = Plugin::load(&path, &self.lua).expect("error loading plugin"); + let pl = Plugin::load(&path, self.lua).expect("error loading plugin"); self.plugins.push(pl); info!("Loaded plugin '{}'", file.file_name().to_string_lossy()); } @@ -82,6 +84,38 @@ impl <'lua> Plugins<'lua> { } } + pub fn register_commands(&mut self, commands: Commands) -> Result { + let commands = Rc::new(RefCell::new(commands)); + let cmd_owners = Rc::new(RefCell::new(HashMap::new())); + for (i, pl) in self.plugins.iter().enumerate() { + let commands_2 = commands.clone(); + let cmd_owners_2 = cmd_owners.clone(); + let pl_id = pl.id.clone(); + let add_command = self.lua.create_function(move |_, name: String| { + let scoped_name = format!("{}:{}", pl_id, name); + let mut cmds = commands_2.borrow_mut(); + let id1 = cmds.create_simple_cmd(&name); + let id2 = cmds.create_simple_cmd(&scoped_name); + if id1.is_none() || id2.is_none() { + return Ok(mlua::Nil) + } + cmd_owners_2.borrow_mut().insert(name, i); + cmd_owners_2.borrow_mut().insert(scoped_name, i); + Ok(mlua::Nil) + })?; + let registry = self.lua.create_table()?; + registry.set("addCommand", add_command)?; + if let Some(init) = &pl.event_handlers.register_commands { + if let Err(e) = init.call::<_, ()>((registry.clone(),)) { + warn!("Error in plugin {}: {}", pl.name, e); + } + } + } + let cb = commands.borrow(); + self.cmd_owners = (*cmd_owners.borrow()).clone(); + Ok((*cb).clone()) + } + pub fn player_join(&self, player: &Player) { if let Err(e) = self.add_player(player) { warn!("Error adding player: {}", e); @@ -133,4 +167,17 @@ impl <'lua> Plugins<'lua> { } } } + + pub fn command(&self, player: &Player, command: &str, args: &str) { + if let Some(owner) = self.cmd_owners.get(command) { + let pl = &self.plugins[*owner]; + if let Some(func) = &pl.event_handlers.command { + if let Err(e) = func.call::<_, ()>((command, args, player.name.as_str(), player.uuid.to_string())) { + warn!("Error in plugin {}: {}", pl.name, e); + } + } else { + warn!("Plugin {} registered a command but no command handler was found", pl.id); + } + } + } } diff --git a/src/plugins/plugin.rs b/src/plugins/plugin.rs index 1ab508f..3e248d9 100644 --- a/src/plugins/plugin.rs +++ b/src/plugins/plugin.rs @@ -4,9 +4,11 @@ use mlua::{Function, Table, Lua}; pub struct EventHandlers<'lua> { pub init: Option>, + pub register_commands: Option>, pub player_join: Option>, pub player_leave: Option>, pub chat_message: Option>, + pub command: Option>, } pub struct Plugin<'lua> { @@ -26,11 +28,13 @@ impl <'lua> Plugin<'lua> { let version: String = module.get("version").unwrap_or_else(|_| "?".to_owned()); let init: Option> = module.get("init").ok(); + let register_commands: Option> = module.get("registerCommands").ok(); let player_join: Option> = module.get("playerJoin").ok(); let player_leave: Option> = module.get("playerLeave").ok(); let chat_message: Option> = module.get("chatMessage").ok(); + let command: Option> = module.get("command").ok(); - let event_handlers = EventHandlers { init, player_join, player_leave, chat_message }; + let event_handlers = EventHandlers { init, register_commands, player_join, player_leave, chat_message, command }; Ok(Plugin { id, name, version, event_handlers }) } } diff --git a/src/protocol/clientbound.rs b/src/protocol/clientbound.rs index c9740c5..795ffe2 100644 --- a/src/protocol/clientbound.rs +++ b/src/protocol/clientbound.rs @@ -1,6 +1,6 @@ use uuid::Uuid; -use super::{data::{PacketEncoder, finalize_packet}, Position}; +use super::{data::{PacketEncoder, finalize_packet}, Position, command::Commands}; #[derive(Debug)] pub struct LoginSuccess { @@ -147,8 +147,9 @@ pub enum ClientBoundPacket { // play LoginPlay(LoginPlay), PluginMessage(PluginMessage), - SyncPlayerPosition(SyncPlayerPosition), + Commands(Commands), ChunkData(ChunkData), + SyncPlayerPosition(SyncPlayerPosition), KeepAlive(i64), PlayerAbilities(i8, f32, f32), Disconnect(serde_json::Value), @@ -190,14 +191,18 @@ impl ClientBoundPacket { plugin_message.encode(&mut packet); finalize_packet(packet, 22) } - Self::SyncPlayerPosition(sync_player_position) => { - sync_player_position.encode(&mut packet); - finalize_packet(packet, 57) + Self::Commands(commands) => { + commands.encode(&mut packet); + finalize_packet(packet, 15) } Self::ChunkData(chunk_data) => { chunk_data.encode(&mut packet); finalize_packet(packet, 33) } + Self::SyncPlayerPosition(sync_player_position) => { + sync_player_position.encode(&mut packet); + finalize_packet(packet, 57) + } Self::KeepAlive(n) => { packet.write_long(n); finalize_packet(packet, 32) diff --git a/src/protocol/command.rs b/src/protocol/command.rs new file mode 100644 index 0000000..4a81a6a --- /dev/null +++ b/src/protocol/command.rs @@ -0,0 +1,191 @@ +use super::data::PacketEncoder; + +#[derive(Debug, Clone)] +pub struct Commands { + nodes: Vec, +} + +impl Commands { + pub fn new() -> Self { + let root = CommandNode { + executable: false, + redirect: None, + children: Vec::new(), + suggestion: None, + type_data: CommandNodeType::Root + }; + let simple_cmd_arg = CommandNode { + executable: true, + redirect: None, + children: Vec::new(), + suggestion: None, + type_data: CommandNodeType::Argument { name: "[args]".to_owned(), parser: Parser::String { kind: StringKind::Greedy } } + }; + Self { + nodes: vec![root, simple_cmd_arg], + } + } + + pub fn create_node( + &mut self, + parent: i32, + type_data: CommandNodeType, + executable: bool, + redirect: Option, + suggestion: Option + ) -> Option { + if parent < 0 || parent >= self.nodes.len() as i32 { + return None + } + if let Some(redirect) = redirect { + if redirect < 0 || redirect >= self.nodes.len() as i32 { + return None + } + } + if let CommandNodeType::Root = type_data { + return None + } + let id = self.nodes.len() as i32; + self.nodes.push(CommandNode { + executable, + redirect, + children: Vec::new(), + suggestion, + type_data, + }); + self.nodes[parent as usize].children.push(id); + Some(id) + } + + pub fn create_simple_cmd(&mut self, name: &str) -> Option { + let id = self.create_node(0, CommandNodeType::Literal { name: name.to_owned() }, true, None, None)?; + self.add_child(id, 1); + Some(id) + } + + pub fn add_child(&mut self, node: i32, child: i32) { + self.nodes[node as usize].children.push(child); + } + + pub fn encode(&self, encoder: &mut impl PacketEncoder) { + encoder.write_varint(self.nodes.len() as i32); + for node in &self.nodes { + node.encode(encoder); + } + // root node + encoder.write_varint(0); + } +} + +pub enum NodeError { + InvalidParent(i32), + InvalidRedirect(i32), + BadTypeData, +} + +#[derive(Debug, Clone)] +pub struct CommandNode { + executable: bool, + redirect: Option, + children: Vec, + suggestion: Option, + type_data: CommandNodeType, +} + +#[derive(Debug, Clone)] +pub enum CommandNodeType { + Root, + Literal { name: String }, + Argument { name: String, parser: Parser } +} + +impl CommandNode { + pub fn encode(&self, encoder: &mut impl PacketEncoder) { + let mut flags = match self.type_data { + CommandNodeType::Root => 0, + CommandNodeType::Literal{..} => 1, + CommandNodeType::Argument{..} => 2, + }; + if self.executable { flags |= 4; } + if self.redirect.is_some() { flags |= 8; } + if self.suggestion.is_some() { flags |= 16; } + encoder.write_byte(flags); + encoder.write_varint(self.children.len() as i32); + for child in &self.children { + encoder.write_varint(*child); + } + if let Some(redirect) = &self.redirect { + encoder.write_varint(*redirect); + } + match &self.type_data { + CommandNodeType::Root => (), + CommandNodeType::Literal { name } => { + encoder.write_string(32767, &name); + } + CommandNodeType::Argument { name, parser } => { + encoder.write_string(32767, &name); + parser.encode(encoder); + } + } + if let Some(suggestion) = &self.suggestion { + encoder.write_string(32767, &suggestion); + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum Parser { + Bool, + Float { min: Option, max: Option, }, + Double { min: Option, max: Option, }, + Int { min: Option, max: Option, }, + Long { min: Option, max: Option, }, + String { kind: StringKind }, +} + +impl Parser { + pub fn encode(&self, encoder: &mut impl PacketEncoder) { + match self { + Self::Bool => encoder.write_varint(0), + Self::Float{ min, max } => { + encoder.write_varint(1); + encoder.write_byte( if min.is_some() { 1 } else { 0 } + if max.is_some() { 2 } else { 0 } ); + if let Some(min) = min { encoder.write_float(*min) }; + if let Some(max) = max { encoder.write_float(*max) }; + }, + Self::Double{ min, max } => { + encoder.write_varint(2); + encoder.write_byte( if min.is_some() { 1 } else { 0 } + if max.is_some() { 2 } else { 0 } ); + if let Some(min) = min { encoder.write_double(*min) }; + if let Some(max) = max { encoder.write_double(*max) }; + }, + Self::Int{ min, max } => { + encoder.write_varint(3); + encoder.write_byte( if min.is_some() { 1 } else { 0 } + if max.is_some() { 2 } else { 0 } ); + if let Some(min) = min { encoder.write_int(*min) }; + if let Some(max) = max { encoder.write_int(*max) }; + }, + Self::Long{ min, max } => { + encoder.write_varint(4); + encoder.write_byte( if min.is_some() { 1 } else { 0 } + if max.is_some() { 2 } else { 0 } ); + if let Some(min) = min { encoder.write_long(*min) }; + if let Some(max) = max { encoder.write_long(*max) }; + }, + Self::String{ kind } => { + encoder.write_varint(5); + encoder.write_varint(match kind { + StringKind::Single => 0, + StringKind::Quoted => 1, + StringKind::Greedy => 2, + }) + }, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum StringKind { + Single, + Quoted, + Greedy, +} diff --git a/src/protocol/data.rs b/src/protocol/data.rs index 1e35605..346c919 100644 --- a/src/protocol/data.rs +++ b/src/protocol/data.rs @@ -53,7 +53,7 @@ pub trait PacketEncoder: Write { fn write_varint(&mut self, mut data: i32) { loop { let mut byte = (data & 0b11111111) as u8; - data = data >> 7; + data >>= 7; if data != 0 { byte |= 0b10000000; } @@ -67,7 +67,7 @@ pub trait PacketEncoder: Write { fn write_varlong(&mut self, mut data: i64) { loop { let mut byte = (data & 0b11111111) as u8; - data = data >> 7; + data >>= 7; if data != 0 { byte |= 0b10000000; } @@ -109,7 +109,7 @@ fn encode_varint(mut data: i32) -> Vec { let mut res = Vec::new(); loop { let mut byte = (data & 0b11111111) as u8; - data = data >> 7; + data >>= 7; if data != 0 { byte |= 0b10000000; } @@ -148,11 +148,11 @@ impl PacketDecoder { packet_id: 0 }; decoder.packet_id = decoder.read_varint(); - return Ok(decoder); + Ok(decoder) } pub fn packet_id(&self) -> i32 { - return self.packet_id; + self.packet_id } pub fn read_bytes(&mut self, n: usize) -> &[u8] { diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index be49b58..d2467fa 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -1,3 +1,5 @@ + +pub mod command; pub mod data; pub mod serverbound; pub mod clientbound; diff --git a/src/protocol/serverbound.rs b/src/protocol/serverbound.rs index 0c105a3..d5de560 100644 --- a/src/protocol/serverbound.rs +++ b/src/protocol/serverbound.rs @@ -85,6 +85,7 @@ pub enum ServerBoundPacket { LoginStart(LoginStart), // play ChatMessage(ChatMessage), + ChatCommand(ChatMessage), } impl ServerBoundPacket { @@ -108,6 +109,7 @@ impl ServerBoundPacket { *state = NS::Play; ServerBoundPacket::LoginStart(LoginStart::decode(decoder)) }, + (NS::Play, 4) => ServerBoundPacket::ChatCommand(ChatMessage::decode(decoder)), (NS::Play, 5) => ServerBoundPacket::ChatMessage(ChatMessage::decode(decoder)), (NS::Play, id @ (18 | 20 | 21 | 22 | 30)) => ServerBoundPacket::Ignored(id), (_, id) => ServerBoundPacket::Unknown(id),