diff --git a/apps/amethyst/lib/amethyst.ex b/apps/amethyst/lib/amethyst.ex index 9daeeea..5ec724c 100644 --- a/apps/amethyst/lib/amethyst.ex +++ b/apps/amethyst/lib/amethyst.ex @@ -24,7 +24,7 @@ defmodule Amethyst.Application do @impl true def start(_type, _args) do children = [ - {Task.Supervisor, name: Amethyst.ConnectionSupervisor}, + {DynamicSupervisor, name: Amethyst.ConnectionSupervisor}, {Amethyst.Keys, 1024}, {Amethyst.GameCoordinator, %Amethyst.GameCoordinator.State{games: %{}, gid: 0}}, {PartitionSupervisor, diff --git a/apps/amethyst/lib/apps/connection_handler.ex b/apps/amethyst/lib/apps/connection_handler.ex new file mode 100644 index 0000000..95089be --- /dev/null +++ b/apps/amethyst/lib/apps/connection_handler.ex @@ -0,0 +1,110 @@ +# Amethyst - An experimental Minecraft server written in Elixir. +# Copyright (C) 2024 KodiCraft +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +defmodule Amethyst.ConnectionHandler do + @moduledoc """ + This module is responsible for handling incoming packets and sending outgoing packets. It keeps track of what state the connection is in and which game should + receive the packets. + """ +alias ElixirSense.Log + require Logger + + @spec child_spec(:gen_tcp.socket()) :: Supervisor.child_spec() + def child_spec(socket) do + %{ + id: __MODULE__, + start: {__MODULE__, :start, [socket, Amethyst.ConnectionState.Handshake, 0]} + } + end + + @spec start(:gen_tcp.socket(), atom(), pos_integer()) :: no_return() + def start(socket, connstate, version) do + {:ok, spawn(fn -> + Process.set_label("ConnectionHandler for #{inspect(socket)}") + loop(socket, connstate, version) + end)} + end + + @spec start_link(:gen_tcp.socket(), atom(), pos_integer()) :: no_return() + def start_link(socket, connstate, version) do + {:ok, spawn_link(fn -> + Process.set_label("ConnectionHandler for #{inspect(socket)}") + loop(socket, connstate, version) + end)} + end + + @spec loop(:gen_tcp.socket(), atom(), pos_integer()) :: no_return() + defp loop(socket, connstate, version) do + receive do + :closed -> + Logger.info("Connection #{inspect(socket)} closed.") + Process.exit(self(), :normal) + {:disconnect, reason} -> + disconnect(socket, reason, connstate, version) + Process.exit(self(), :normal) + {:set_state, newstate} -> + Logger.debug("Switching to state #{newstate} from #{connstate}") + loop(socket, newstate, version) + {:set_version, newversion} -> + Logger.debug("Switching to version #{newversion} from #{version}") + loop(socket, connstate, newversion) + {:send_packet, packet} -> + Logger.debug("Sending packet #{inspect(packet)}") + send_packet(socket, connstate, packet, version) + loop(socket, connstate, version) + after 0 -> + receive do + {:packet, id, data} -> + handle_packet(id, data, connstate, version) + loop(socket, connstate, version) + end + end + end + + defp handle_packet(id, data, connstate, version) do + try do + packet = connstate.deserialize(id, version, data) + case connstate.handle(packet, version) do + :ok -> :ok + {:error, reason} -> + Logger.error("Error handling packet with ID #{id} in state #{connstate}: #{reason}") + send(self(), {:disconnect, "Error handling packet #{id}: #{reason}"}) + _ -> + Logger.warn("Unknown return value from handle_packet for packet #{id} in state #{connstate}") + end + rescue + e -> + Logger.error("Error handling packet with ID #{id} in state #{connstate}: #{Exception.format(:error, e, __STACKTRACE__)}") + send(self(), {:disconnect, "Error handling packet #{id}: #{Exception.format(:error, e, __STACKTRACE__)}"}) + end + end + + defp send_packet(socket, connstate, packet, version) do + data = connstate.serialize(packet, version) + length = byte_size(data) |> Amethyst.Minecraft.Write.varint() + :gen_tcp.send(socket, length <> data) + end + + defp disconnect(socket, reason, connstate, version) do + Logger.info("Disconnecting connection #{inspect(socket)}") + Logger.debug("Disconnecting connection #{inspect(socket)}: #{reason}") + case connstate.disconnect(reason) do + nil -> nil + packet -> send_packet(socket, connstate, packet, version) + end + :gen_tcp.close(socket) + end +end diff --git a/apps/amethyst/lib/apps/connection_receiver.ex b/apps/amethyst/lib/apps/connection_receiver.ex new file mode 100644 index 0000000..ea11a77 --- /dev/null +++ b/apps/amethyst/lib/apps/connection_receiver.ex @@ -0,0 +1,87 @@ +# Amethyst - An experimental Minecraft server written in Elixir. +# Copyright (C) 2024 KodiCraft +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +defmodule Amethyst.ConnectionReceiver do + @moduledoc """ + This module waits for data incoming through a TCP connection, reads entire packets at a time and sends them to the handler. + """ + + require Logger + alias Amethyst.Minecraft.Read + + @spec child_spec(:gen_tcp.socket()) :: Supervisor.child_spec() + def child_spec(socket) do + %{ + id: __MODULE__, + start: {__MODULE__, :start, [socket]} + } + end + + @spec start(:gen_tcp.socket()) :: no_return() + def start(socket) do + {:ok, spawn(fn -> + Process.set_label("ConnectionReceiver for #{inspect(socket)}") + {:ok, pid} = Amethyst.ConnectionHandler.start_link(socket, Amethyst.ConnectionState.Handshake, 0) + receive(socket, pid) + end)} + end + + @spec start_link(:gen_tcp.socket()) :: no_return() + def start_link(socket) do + {:ok, spawn_link(fn -> + Process.set_label("ConnectionReceiver for #{inspect(socket)}") + {:ok, pid} = Amethyst.ConnectionHandler.start_link(socket, Amethyst.ConnectionState.Handshake, 0) + receive(socket, pid) + end)} + end + + @spec receive(:gen_tcp.socket(), pid()) :: no_return() + def receive(socket, sender) do + case get_packet(socket) do + :closed -> send(sender, :closed) + Process.exit(self(), :normal) + {:error, error} -> Logger.error("Error reading packet: #{error}") + {id, data} -> send(sender, {:packet, id, data}) + end + receive(socket, sender) + end + + def get_packet(client) do + case get_varint(client, "") do + :closed -> :closed + {:error, error} -> {:error, error} + {[length], ""} -> + recv = :gen_tcp.recv(client, length) + case recv do + {:ok, full_packet} -> ({[id], data} = Read.start(full_packet) |> Read.varint() |> Read.stop() + {id, data}) + {:error, :closed} -> :closed + {:error, error} -> {:error, error} + end + end + end + + defp get_varint(client, acc) do + case :gen_tcp.recv(client, 1) do + {:ok, byte} -> case byte do + <<0::1, _::7>> -> Read.start(acc <> byte) |> Read.varint() |> Read.stop() + <<1::1, _::7>> -> get_varint(client, acc <> byte) + end + {:error, :closed} -> :closed + {:error, error} -> {:error, error} + end + end +end diff --git a/apps/amethyst/lib/apps/tcp_listener.ex b/apps/amethyst/lib/apps/tcp_listener.ex index ccc9d2f..7696ee0 100644 --- a/apps/amethyst/lib/apps/tcp_listener.ex +++ b/apps/amethyst/lib/apps/tcp_listener.ex @@ -29,7 +29,7 @@ defmodule Amethyst.TCPListener do @spec loop_acceptor(socket :: :gen_tcp.socket()) :: no_return() defp loop_acceptor(socket) do {:ok, client} = :gen_tcp.accept(socket) - {:ok, pid} = Task.Supervisor.start_child(Amethyst.ConnectionSupervisor, fn -> Amethyst.Server.Handshake.serve(client) end) + {:ok, pid} = DynamicSupervisor.start_child(Amethyst.ConnectionSupervisor, {Amethyst.ConnectionReceiver, client}) :ok = :gen_tcp.controlling_process(client, pid) Logger.info("Received connection from #{inspect(client)}, assigned to PID #{inspect(pid)}") loop_acceptor(socket) diff --git a/apps/amethyst/lib/data.ex b/apps/amethyst/lib/data.ex index 8a8aedb..b330aa2 100644 --- a/apps/amethyst/lib/data.ex +++ b/apps/amethyst/lib/data.ex @@ -35,6 +35,10 @@ defmodule Amethyst.Minecraft.Write do end end + def raw(value) do + value + end + def byte(value) when value in -128..127 do <> end @@ -98,6 +102,14 @@ defmodule Amethyst.Minecraft.Write do <> end + def json(value) do + string(Jason.encode!(value)) + end + + def byte_array(value) do + <> + end + def position({x, y, z}) do <> end @@ -243,4 +255,8 @@ defmodule Amethyst.Minecraft.Read do <> = rest {[value | acc], rest, :reversed} end + def json({acc, data, :reversed}) do + {[value], rest, :reversed} = string({[], data, :reversed}) + {[Jason.decode!(value) | acc], rest, :reversed} + end end diff --git a/apps/amethyst/lib/servers/configuration.ex b/apps/amethyst/lib/servers/configuration.ex deleted file mode 100644 index 2411237..0000000 --- a/apps/amethyst/lib/servers/configuration.ex +++ /dev/null @@ -1,361 +0,0 @@ -# Amethyst - An experimental Minecraft server written in Elixir. -# Copyright (C) 2024 KodiCraft -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -defmodule Amethyst.Server.Configuration do - @moduledoc """ - This module contains the logic for the Configuration stage of the server. - """ - require Logger - use Amethyst.Server - - alias Amethyst.Minecraft.Read - alias Amethyst.Minecraft.Write - - @impl true - def init(state) do - state - end - - ## DESERIALIZATION - @impl true - # Client Information https://wiki.vg/Protocol#Client_Information - def deserialize(0x00, data) do - {[locale, view_dist, chat_mode, chat_colors, displayed_skin_parts, main_hand, text_filtering, allow_listing], ""} = - Read.start(data) |> Read.string |> Read.byte |> Read.varint |> Read.bool |> Read.ubyte |> Read.varint |> Read.bool |> Read.bool |> Read.stop - chat_mode = case chat_mode do - 0 -> :enabled - 1 -> :commands_only - 2 -> :hidden - _ -> raise RuntimeError, "Unknown chat mode #{chat_mode}" - end - main_hand = case main_hand do - 0 -> :left - 1 -> :right - _ -> raise RuntimeError, "Unknown main hand #{main_hand}" - end - {:client_information, locale, view_dist, chat_mode, chat_colors, displayed_skin_parts, main_hand, text_filtering, allow_listing} - end - # Cookie Response https://wiki.vg/Protocol#Cookie_Response_(configuration) - def deserialize(0x01, data) do - {[key, exists], rest} = Read.start(data) |> Read.string |> Read.bool |> Read.stop - if exists do - {[length], rest} = Read.start(rest) |> Read.varint |> Read.stop - {[data], _} = Read.start(rest) |> Read.raw(length) |> Read.stop - {:cookie_response, key, data} - else - {:cookie_response, key, nil} - end - end - # Serverbound Plugin Message https://wiki.vg/Protocol#Serverbound_Plugin_Message_(configuration) - def deserialize(0x02, data) do - {[channel], rest} = Read.start(data) |> Read.string |> Read.stop - {:serverbound_plugin_message, channel, rest} - end - # Acknowledge Finish Configuration https://wiki.vg/Protocol#Acknowledge_Finish_Configuration - def deserialize(0x03, "") do - {:acknowledge_finish_configuration} - end - # Serverbound Keep Alive https://wiki.vg/Protocol#Serverbound_Keep_Alive_(configuration) - def deserialize(0x04, data) do - {[id], ""} = Read.start(data) |> Read.long |> Read.stop - {:serverbound_keep_alive, id} - end - # Pong https://wiki.vg/Protocol#Pong_(configuration) - def deserialize(0x05, data) do - {[id], ""} = Read.start(data) |> Read.int |> Read.stop - {:pong, id} - end - # Resource Pack Response https://wiki.vg/Protocol#Resource_Pack_Response_(configuration) - def deserialize(0x06, data) do - {[uuid, result], ""} = Read.start(data) |> Read.uuid |> Read.varint |> Read.stop - result = case result do - 0 -> :successfully_downloaded - 1 -> :declined - 2 -> :failed_to_download - 3 -> :accepted - 4 -> :downloaded - 5 -> :invalid_url - 6 -> :failed_to_reload - 7 -> :discarded - _ -> raise RuntimeError, "Unknown resource pack response #{result}" - end - {:resource_pack_response, uuid, result} - end - # Serverbound Known Packs https://wiki.vg/Protocol#Serverbound_Known_Packs - def deserialize(0x07, data) do - {[count], rest} = Read.start(data) |> Read.varint |> Read.stop - {packs, _} = Enum.reduce(1..count, {[], rest}, fn _, {acc, rest} -> - {[namespace, id, version], rest} = Read.start(rest) |> Read.string |> Read.string |> Read.string |> Read.stop - {[{namespace, id, version} | acc], rest} - end) - {:serverbound_known_packs, packs} - end - def deserialize(type, _) do - raise RuntimeError, "Got unknown packet type #{type}!" - end - - ## SERIALIZATION - @impl true - # Cookie Request https://wiki.vg/Protocol#Cookie_Request_(configuration) - def serialize({:cookie_request, id}) do - Write.varint(0x00) <> Write.string(id) - end - # Clientbound Plugin Message https://wiki.vg/Protocol#Clientbound_Plugin_Message_(configuration) - def serialize({:clientbound_plugin_message, channel, data}) do - Write.varint(0x01) <> Write.string(channel) <> data - end - # Disconnect https://wiki.vg/Protocol#Disconnect_(configuration) - def serialize({:disconnect, reason}) do - Write.varint(0x02) <> Write.string(reason) - end - # Finish Configuration https://wiki.vg/Protocol#Finish_Configuration - def serialize({:finish_configuration}) do - Write.varint(0x03) - end - # Clientbound Keep Alive https://wiki.vg/Protocol#Clientbound_Keep_Alive_(configuration) - def serialize({:clientbound_keep_alive, id}) do - Write.varint(0x04) <> <> - end - # Ping https://wiki.vg/Protocol#Ping_(configuration) - def serialize({:ping, id}) do - Write.varint(0x05) <> <> - end - # Reset Chat https://wiki.vg/Protocol#Reset_Chat - def serialize({:reset_chat}) do - Write.varint(0x06) - end - # Registry Data https://wiki.vg/Protocol#Registry_Data - def serialize({:registry_data, id, entries}) do - Write.varint(0x07) <> Write.string(id) <> Write.varint(length(entries)) <> Enum.map_join(entries, "", fn {name, nbt} -> - Write.string(name) <> - if nbt == nil do - Write.bool(false) - else - Write.bool(true) <> Amethyst.NBT.Write.write_net(nbt) - end - end) - end - # Remove Resource Pack https://wiki.vg/Protocol#Remove_Resource_Pack_(configuration) - def serialize({:remove_resource_pack, id}) do - Write.option(id, &Write.string/1) - end - # Add Resource Pack https://wiki.vg/Protocol#Add_Resource_Pack_(configuration) - def serialize({:add_resource_pack, id, url, hash, forced, msg}) do - Write.varint(0x09) <> Write.string(id) <> Write.string(url) <> Write.string(hash) <> - Write.bool(forced) <> Write.option(msg, &Write.string/1) - end - # Store Cookie https://wiki.vg/Protocol#Store_Cookie_(configuration) - def serialize({:store_cookie, id, data}) do - Write.varint(0x0A) <> Write.string(id) <> Write.string(data) - end - # Transfer https://wiki.vg/Protocol#Transfer_(configuration) - def serialize({:transfer, addr, port}) do - Write.varint(0x0B) <> Write.string(addr) <> Write.varint(port) - end - # Feature Flags https://wiki.vg/Protocol#Feature_Flags - def serialize({:feature_flags, flags}) do - Write.varint(0x0C) <> Write.varint(length(flags)) <> Write.list(flags, &Write.string/1) - end - # Update Tags https://wiki.vg/Protocol#Update_Tags - def serialize({:update_tags, tags}) do - Write.varint(0x0D) <> Write.varint(length(tags)) <> - Enum.reduce(tags, "", &serialize_tag/2) - end - # Clientbound Known Packs https://wiki.vg/Protocol#Clientbound_Known_Packs - def serialize({:clientbound_known_packs, packs}) do - Write.varint(0x0E) <> Write.varint(length(packs)) <> - Write.list(packs, fn {namespace, id, version} -> Write.string(namespace) <> Write.string(id) <> Write.string(version) end) - end - # Custom Report Details https://wiki.vg/Protocol#Custom_Report_Details - def serialize({:custom_report_details, details}) do - Write.varint(0x0F) <> Write.varint(length(details)) <> - Write.list(details, fn {id, data} -> Write.string(id) <> Write.string(data) end) - end - # Server Links https://wiki.vg/Protocol#Server_Links_(configuration) - def serialize({:server_links, links}) do - Write.varint(0x10) <> Write.varint(length(links)) <> - Write.list(links, fn {label, url} -> serialize_link_label(label) <> Write.string(url) end) - end - def serialize(packet) do - raise ArgumentError, "Tried serializing unknown packet #{inspect(packet)}" - end - - defp serialize_tag({id, elements}, acc) do - acc <> Write.string(id) <> Write.varint(length(elements)) <> serialize_elements(elements) - end - defp serialize_elements(elements) do - Write.list(elements, fn {id, ids} -> - Write.string(id) <> Write.varint(length(ids)) <> - Write.list(ids, &Write.varint/1) - end) - end - defp serialize_link_label(:bug_report) do - <<0x01>> <> Write.varint(0x00) - end - defp serialize_link_label(:community_guidelines) do - <<0x01>> <> Write.varint(0x01) - end - defp serialize_link_label(:support) do - <<0x01>> <> Write.varint(0x02) - end - defp serialize_link_label(:status) do - <<0x01>> <> Write.varint(0x03) - end - defp serialize_link_label(:feedback) do - <<0x01>> <> Write.varint(0x04) - end - defp serialize_link_label(:community) do - <<0x01>> <> Write.varint(0x05) - end - defp serialize_link_label(:website) do - <<0x01>> <> Write.varint(0x06) - end - defp serialize_link_label(:forums) do - <<0x01>> <> Write.varint(0x07) - end - defp serialize_link_label(:news) do - <<0x01>> <> Write.varint(0x08) - end - defp serialize_link_label(:announcements) do - <<0x01>> <> Write.varint(0x09) - end - defp serialize_link_label(other) do - <<0x00>> <> Write.string(other) - end - - ## HANDLING - @impl true - # Client Information https://wiki.vg/Protocol#Client_Information - def handle({:client_information, locale, v_dist, chat_mode, chat_colors, displayed_skin_parts, main_hand, text_filtering, allow_listing}, client, state) do - state = state |> Keyword.put(:locale, locale) |> Keyword.put(:view_dist, v_dist) |> Keyword.put(:chat_mode, chat_mode) |> - Keyword.put(:chat_colors, chat_colors) |> Keyword.put(:displayed_skin_parts, displayed_skin_parts) |> Keyword.put(:main_hand, main_hand) |> - Keyword.put(:text_filtering, text_filtering) |> Keyword.put(:allow_listing, allow_listing) - # TODO: Here we should create the game handling task for this player and give it - # this data. - transmit({:clientbound_plugin_message, "minecraft:brand", Write.string("amethyst")}, client) - transmit({:clientbound_known_packs, [{"minecraft", "core", "1.21"}, {"minecraft", "dimension_type", "1.21"}]}, client) - {:ok, state} - end - # Serverbound Known Packs https://wiki.vg/Protocol#Serverbound_Known_Packs - def handle({:serverbound_known_packs, _packs}, client, state) do - # L + ratio + don't care + didn't ask + finish configuration - import Amethyst.NBT.Write - # TODO: This shouldn't be hard-coded but we obviously don't know what we will need until we have game handling - # This can at least be followed as a "minimum" of what we need to send for the client not to complain - transmit({:registry_data, "minecraft:dimension_type", [{"amethyst:basic", compound(%{ - "has_skylight" => byte(1), - "has_ceiling" => byte(0), - "ultrawarm" => byte(0), - "natural" => byte(1), - "coordinate_scale" => float(1.0), - "bed_works" => byte(1), - "respawn_anchor_works" => byte(1), - "min_y" => int(0), - "height" => int(256), - "logical_height" => int(256), - "infiniburn" => string("#"), - "effects" => string("minecraft:overworld"), - "ambient_light" => float(0.0), - "piglin_safe" => byte(0), - "has_raids" => byte(1), - "monster_spawn_light_level" => int(0), - "monster_spawn_block_light_limit" => int(0) - })}]}, client) - transmit({:registry_data, "minecraft:painting_variant", [{"minecraft:kebab", compound(%{ - "asset_id" => string("minecraft:kebab"), - "height" => int(1), - "width" => int(1), - })}]}, client) - transmit({:registry_data, "minecraft:wolf_variant", [{"minecraft:wolf_ashen", compound(%{ - "wild_texture" => string("minecraft:entity/wolf/wolf_ashen"), - "tame_texture" => string("minecraft:entity/wolf/wolf_ashen_tame"), - "angry_texture" => string("minecraft:entity/wolf/wolf_ashen_angry"), - "biomes" => string("amethyst:basic"), - })}]}, client) - # https://gist.github.com/WinX64/ab8c7a8df797c273b32d3a3b66522906 minecraft:plains - basic_biome = compound(%{ - "effects" => compound(%{ - "sky_color" => int(7907327), - "water_fog_color" => int(329011), - "fog_color" => int(12638463), - "water_color" => int(4159204), - "mood_sound" => compound(%{ - "tick_delay" => int(6000), - "offset" => float(2.0), - "sound" => string("minecraft:ambient.cave"), - "block_search_extent" => int(8) - }), - }), - "has_precipitation" => byte(1), - "temperature" => float(0.8), - "downfall" => float(0.4), - }) - transmit({:registry_data, "minecraft:worldgen/biome", [ - {"amethyst:basic", basic_biome}, {"minecraft:plains", basic_biome} - ]}, client) - # holy fucking shit - generic_damage = compound(%{ - "scaling" => string("when_caused_by_living_non_player"), - "exhaustion" => float(0.0), - "message_id" => string("generic") - }) - transmit({:registry_data, "minecraft:damage_type", [ - {"minecraft:in_fire", generic_damage}, {"minecraft:campfire", generic_damage}, {"minecraft:lightning_bolt", generic_damage}, - {"minecraft:on_fire", generic_damage}, {"minecraft:lava", generic_damage}, {"minecraft:hot_floor", generic_damage}, - {"minecraft:in_wall", generic_damage}, {"minecraft:cramming", generic_damage}, {"minecraft:drown", generic_damage}, - {"minecraft:starve", generic_damage}, {"minecraft:cactus", generic_damage}, {"minecraft:fall", generic_damage}, - {"minecraft:fly_into_wall", generic_damage}, {"minecraft:out_of_world", generic_damage}, {"minecraft:generic", generic_damage}, - {"minecraft:magic", generic_damage}, {"minecraft:wither", generic_damage}, {"minecraft:dragon_breath", generic_damage}, - {"minecraft:dry_out", generic_damage}, {"minecraft:sweet_berry_bush", generic_damage}, {"minecraft:freeze", generic_damage}, - {"minecraft:stalagmite", generic_damage}, {"minecraft:outside_border", generic_damage}, {"minecraft:generic_kill", generic_damage}, - ]}, client) - transmit({:finish_configuration}, client) - {:ok, state} - end - # Acknowledge Finish Configuration https://wiki.vg/Protocol#Acknowledge_Finish_Configuration - def handle({:acknowledge_finish_configuration}, client, state) do - game = Application.fetch_env!(:amethyst, :default_game) |> Amethyst.GameCoordinator.find_or_create() - state = Keyword.put(state, :game, game) - if Amethyst.API.Game.login(game, state) == :reject do - raise RuntimeError, "Default game rejected login!" - else - # TODO: All of this stuff should obviously not be hardcoded here - Amethyst.Server.Play.transmit({:login, - 0, false, ["minecraft:overworld"], 0, 16, 16, false, true, true, 0, - "minecraft:overworld", <<0::64>>, :spectator, nil, false, true, nil, 0, false - }, client) - Amethyst.Server.Play.serve(client, state) - end - end - # Serverbound Plugin Message https://wiki.vg/Protocol#Serverbound_Plugin_Message_(configuration) - def handle({:serverbound_plugin_message, channel, data}, client, state) do - handle_plugin_message(channel, data, client, state) - end - def handle(tuple, state) do - Logger.error("Unhandled but known packet #{elem(tuple, 0)}") - {:unhandled, state} - end - defp handle_plugin_message("minecraft:brand", data, _client, state) do - {[brand], ""} = Read.start(data) |> Read.string |> Read.stop - Logger.info("Client using brand: #{brand}") - {:ok, Keyword.put(state, :brand, brand)} - end - defp handle_plugin_message("amethyst:hello", _data, client, state) do - Logger.info("Client is Amethyst aware! Hello!") - transmit({:clientbound_plugin_message, "amethyst:hello", ""}, client) - {:ok, Keyword.put(state, :knows_amethyst, true)} - end -end diff --git a/apps/amethyst/lib/servers/generic.ex b/apps/amethyst/lib/servers/generic.ex deleted file mode 100644 index 9ac91f9..0000000 --- a/apps/amethyst/lib/servers/generic.ex +++ /dev/null @@ -1,87 +0,0 @@ -# Amethyst - An experimental Minecraft server written in Elixir. -# Copyright (C) 2024 KodiCraft -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -defmodule Amethyst.Server.Generic do - @moduledoc """ - This module includes generic logic which may be used by all stages of the server, including, - for instance, listening for packets. - """ - - alias Amethyst.Minecraft.Read - - def get_packet(client) do - {[length], ""} = get_varint(client, "") - recv = :gen_tcp.recv(client, length) - case recv do - {:ok, full_packet} -> ({[id], data} = Read.start(full_packet) |> Read.varint() |> Read.stop() - {id, data}) - {:error, :closed} -> raise RuntimeError, "TODO: Handle disconnections reasonably" - {:error, error} -> raise RuntimeError, "An error has occured while waiting on a packet: #{error}" - end - end - - defp get_varint(client, acc) do - {:ok, byte} = :gen_tcp.recv(client, 1) - case byte do - <<0::1, _::7>> -> Read.start(acc <> byte) |> Read.varint() |> Read.stop() - <<1::1, _::7>> -> get_varint(client, acc <> byte) - end - end -end - -defmodule Amethyst.Server do - @moduledoc """ - This module includes shared boilerplate code for all stages of the server. - """ - require Logger - - @callback init(any()) :: any() - @callback deserialize(integer(), binary()) :: any() - @callback serialize(any()) :: binary() - @callback handle(any(), :gen_tcp.socket(), any()) :: {:ok, any()} | {:unhandled, any()} - - defmacro __using__(_opts) do - quote do - @behaviour Amethyst.Server - - @spec serve(:gen_tcp.socket(), any()) :: no_return() - def serve(client, state \\ []) do - Logger.debug("#{__MODULE__} serving client #{inspect(client)}") - serve_loop(client, init(state)) - end - - defp serve_loop(client, state) do - {id, data} = Amethyst.Server.Generic.get_packet(client) - Logger.debug("State: #{inspect(state)}") - packet = deserialize(id, data) - Logger.debug("Got packet #{inspect(packet)}") - {result, state} = handle(packet, client, state) - if result != :ok do - Logger.warning("Handler returned result #{result}") - end - serve_loop(client, state) - end - - def transmit(packet, client) do - Logger.debug("Transmitting #{inspect(packet)}") - data = serialize(packet) - length = byte_size(data) |> Amethyst.Minecraft.Write.varint() - Logger.debug("Sending #{inspect(length <> data)}") - :gen_tcp.send(client, length <> data) - end - end - end -end diff --git a/apps/amethyst/lib/servers/handhsake.ex b/apps/amethyst/lib/servers/handhsake.ex deleted file mode 100644 index 499d2cd..0000000 --- a/apps/amethyst/lib/servers/handhsake.ex +++ /dev/null @@ -1,72 +0,0 @@ -# Amethyst - An experimental Minecraft server written in Elixir. -# Copyright (C) 2024 KodiCraft -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -defmodule Amethyst.Server.Handshake do - @moduledoc """ - This module contains the logic for the Handshake stage of the server. - """ - require Logger - use Amethyst.Server - - alias Amethyst.Minecraft.Read - - @impl true - def init(state) do - state - end - - ## DESERIALIZATION - @impl true - # Handshake https://wiki.vg/Protocol#Handshake - @spec deserialize(0, binary()) :: - {:handshake, any(), any(), any(), :login | :status | :transfer} - def deserialize(0x00, <>) do - {[ver, addr, port, next], ""} = Read.start(data) |> Read.varint() |> Read.string() |> Read.ushort() |> Read.varint() |> Read.stop() - next = case next do - 1 -> :status - 2 -> :login - 3 -> :transfer - _ -> raise RuntimeError, "Client requested moving to an unknown state!" - end - {:handshake, ver, addr, port, next} - end - def deserialize(type, _) do - raise RuntimeError, "Got unknown packet type #{type}!" - end - - ## SERIALIZATION - @impl true - def serialize(_) do - raise RuntimeError, "No packets can be transmitted while still in the handshake stage!" - end - - ## HANDLING - @impl true - # Handshake https://wiki.vg/Protocol#Handshake - @spec handle(any(), any(), any()) :: no_return() - def handle({:handshake, 767, addr, port, next}, client, state) do - Logger.info("Got handshake, version 767 on #{addr}:#{port}. Wants to move to #{next}") - case next do - :status -> Amethyst.Server.Status.serve(client, state) - :login -> Amethyst.Server.Login.serve(client, state) - _ -> raise RuntimeError, "Unhandled move to next mode #{next}" - end - end - def handle(tuple, _, state) do - Logger.error("Unhandled but known packet #{elem(tuple, 0)}") - {:unhandled, state} - end -end diff --git a/apps/amethyst/lib/servers/login.ex b/apps/amethyst/lib/servers/login.ex deleted file mode 100644 index 596bd0d..0000000 --- a/apps/amethyst/lib/servers/login.ex +++ /dev/null @@ -1,136 +0,0 @@ -# Amethyst - An experimental Minecraft server written in Elixir. -# Copyright (C) 2024 KodiCraft -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -defmodule Amethyst.Server.Login do - @moduledoc """ - This module contains the logic for the Login stage of the server. - """ - require Logger - use Amethyst.Server - - alias Amethyst.Minecraft.Read - alias Amethyst.Minecraft.Write - - @impl true - def init(state) do - state - end - - ## DESERIALIZATION - @impl true - # Login Start https://wiki.vg/Protocol#Login_Start - def deserialize(0x00, data) do - {[name, uuid], ""} = Read.start(data) |> Read.string() |> Read.uuid() |> Read.stop() - {:login_start, name, uuid} - end - # Encryption Response https://wiki.vg/Protocol#Encryption_Response - def deserialize(0x01, data) do - {[secret_length], rest} = Read.start(data) |> Read.varint() |> Read.stop() - {[secret, verify_token_length], rest} = Read.start(rest) |> Read.raw(secret_length) |> Read.varint() |> Read.stop() - {[verify_token], ""} = Read.start(rest) |> Read.raw(verify_token_length) |> Read.stop() - {:encryption_response, secret, verify_token} - end - # Login Plugin Response https://wiki.vg/Protocol#Login_Plugin_Response - def deserialize(0x02, data) do - {[message_id, success], rest} = Read.start(data) |> Read.varint() |> Read.bool() |> Read.stop() - if success do - {:login_plugin_response, message_id, rest} - else - {:login_plugin_response, message_id, nil} - end - end - # Login Acknowledged https://wiki.vg/Protocol#Login_Acknowledged - def deserialize(0x03, "") do - {:login_acknowledged} - end - # Cookie Response https://wiki.vg/Protocol#Cookie_Response_(login) - def deserialize(0x04, data) do - {[key, exists], rest} = Read.start(data) |> Read.string() |> Read.bool() |> Read.stop() - if exists do - {[length], rest} = Read.start(rest) |> Read.varint() |> Read.stop() - {[data], _} = Read.start(rest) |> Read.raw(length) |> Read.stop() - {:cookie_response, key, data} - else - {:cookie_response, key, nil} - end - end - def deserialize(type, _) do - raise RuntimeError, "Got unknown packet type #{type}!" - end - - ## SERIALIZATION - @impl true - # Disconnect (login) https://wiki.vg/Protocol#Disconnect_(login) - def serialize({:disconnect, reason}) do - Write.varint(0x00) <> Write.string(reason) - end - # Encryption Request https://wiki.vg/Protocol#Encryption_Request - def serialize({:encryption_request, server_id, pubkey, verify_token, auth}) do - Write.varint(0x01) <> - Write.string(server_id) <> - Write.varint(byte_size(pubkey)) <> pubkey <> - Write.varint(byte_size(verify_token)) <> verify_token <> - Write.bool(auth) - end - # Login Success https://wiki.vg/Protocol#Login_Success - def serialize({:login_success, uuid, username, props, strict}) do - Write.varint(0x02) <> Write.uuid(uuid) <> Write.string(username) <> Write.varint(length(props)) <> - Enum.reduce(props, "", fn {name, value, signature}, acc -> acc <> Write.string(name) <> Write.string(value) <> case signature do - nil -> <<0x00>> - signature -> <<0x01>> <> Write.string(signature) - end end) <> Write.bool(strict) - end - # Set Compression https://wiki.vg/Protocol#Set_Compression - def serialize({:set_compression, threshold}) do - Write.varint(0x03) <> Write.varint(threshold) - end - # Login Plugin Request https://wiki.vg/Protocol#Login_Plugin_Request - def serialize({:login_plugin_request, id, channel, data}) do - Write.varint(0x04) <> Write.varint(id) <> Write.string(channel) <> data - end - # Cookie Request (login) https://wiki.vg/Protocol#Cookie_Request_(login) - def serialize({:cookie_request_login, id}) do - Write.varint(0x05) <> Write.string(id) - end - def serialize(packet) do - raise ArgumentError, "Tried serializing unknown packet #{inspect(packet)}" - end - - ## HANDLING - @impl true - # Login Start https://wiki.vg/Protocol#Login_Start - def handle({:login_start, name, uuid}, client, state) do - Logger.info("Logging in #{name} (#{uuid})") - if Application.fetch_env!(:amethyst, :encryption) do - raise RuntimeError, "Encryption is currently unsupported." # TODO: Implement encryption - # verify_token = :crypto.strong_rand_bytes(4) - # pubkey = Amethyst.Keys.get_pub() - # auth = Application.fetch_env!(:amethyst, :auth) - # transmit({:encryption_request, "amethyst", pubkey, verify_token, auth}, client) # This is broken for some reason? java.lang.IllegalStateException: Protocol Error - else - transmit({:login_success, uuid, name, [], false}, client) - end - {:ok, state} - end - def handle({:login_acknowledged}, client, state) do - Amethyst.Server.Configuration.serve(client, state) - {:ok, state} - end - def handle(tuple, _, state) do - Logger.error("Unhandled but known packet #{elem(tuple, 0)}") - {:unhandled, state} - end -end diff --git a/apps/amethyst/lib/servers/play.ex b/apps/amethyst/lib/servers/play.ex deleted file mode 100644 index 9f3b409..0000000 --- a/apps/amethyst/lib/servers/play.ex +++ /dev/null @@ -1,127 +0,0 @@ -# Amethyst - An experimental Minecraft server written in Elixir. -# Copyright (C) 2024 KodiCraft -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -defmodule Amethyst.Server.Play do - @moduledoc """ - This module contains the logic for the Play stage of the server. - """ - require Logger - use Amethyst.Server - - alias Amethyst.Minecraft.Read - alias Amethyst.Minecraft.Write - - @impl true - def init(state) do - state - end - - ## DESERIALIZATION - @impl true - def deserialize(0x12, data) do - {[channel], data} = Read.start(data) |> Read.string |> Read.stop - {:serverbound_plugin_message, channel, data} - end - @impl true - def deserialize(0x1A, data) do - {[x, feet_y, z, on_ground], ""} = Read.start(data) |> Read.double |> Read.double |> Read.double |> Read.bool |> Read.stop - {:set_player_position, x, feet_y, z, on_ground} - end - @impl true - def deserialize(0x1B, data) do - {[x, feet_y, z, yaw, pitch, on_ground], ""} = Read.start(data) |> Read.double |> Read.double |> Read.double |> Read.float |> Read.float |> Read.bool |> Read.stop - {:set_position_position_and_rotation, x, feet_y, z, yaw, pitch, on_ground} - end - @impl true - def deserialize(0x1C, data) do - {[yaw, pitch, on_ground], ""} = Read.start(data) |> Read.float |> Read.float |> Read.bool |> Read.stop() - {:set_player_rotation, yaw, pitch, on_ground} - end - @impl true - def deserialize(0x1D, data) do - {[on_ground], ""} = Read.start(data) |> Read.bool |> Read.stop() - {:set_player_on_ground, on_ground} - end - def deserialize(type, _) do - raise RuntimeError, "Got unknown packet type #{type}!" - end - - ## SERIALIZATION - @impl true - # Login https://wiki.vg/Protocol#Login_(play) - def serialize({:login, eid, hardcore, dimensions, - max_players, view_distance, simulation_distance, - reduce_debug, enable_respawn_screen, limited_crafting, - dim_type, dim_name, hashed_seed, gamemode, prev_gm, - is_debug, is_flat, death_loc, portal_cooldown, enforce_chat}) when byte_size(hashed_seed) == 8 do - # TODO: This is a big unreadable slab of serialization which makes bugs really hard to catch, it needs a proper rework at some point - Write.varint(0x2B) <> - Write.int(eid) <> - Write.bool(hardcore) <> - Write.varint(length(dimensions)) <> - Write.list(dimensions, &Write.string/1) <> - Write.varint(max_players) <> - Write.varint(view_distance) <> - Write.varint(simulation_distance) <> - Write.bool(reduce_debug) <> - Write.bool(enable_respawn_screen) <> - Write.bool(limited_crafting) <> - Write.varint(dim_type) <> - Write.string(dim_name) <> - hashed_seed <> - Write.ubyte(gamemode_id(gamemode)) <> - Write.byte(gamemode_id(prev_gm)) <> - Write.bool(is_debug) <> - Write.bool(is_flat) <> - if(death_loc == nil, do: Write.bool(false), else: Write.bool(true) <> Write.string(elem(death_loc, 0)) <> Write.position(elem(death_loc, 1))) <> - Write.varint(portal_cooldown) <> - Write.bool(enforce_chat) - end - def serialize(packet) do - raise ArgumentError, "Tried serializing unknown packet #{inspect(packet)}" - end - - ## HANDLING - @impl true - def handle({:serverbound_plugin_message, channel, data}, _client, state) do - Logger.debug("Got plugin message #{channel} with data #{inspect(data)}") - {:ok, state} - end - def handle({:set_player_position, x, y, z, _on_ground}, _client, state) do - # We do not accept movement packets until we get a :confirm_teleportation - if Keyword.fetch(state, :awaiting_tp_confirm) != :error do - Logger.warning("Got :set_player_position packet while waiting for :confirm_teleportation!") - {:ok, state} - else - game = Keyword.fetch!(state, :game) - {Amethyst.API.Game.player_position(game, {x, y, z}), state} - end - end - def handle(tuple, _, state) do # TODO: These error cases should be somehow moved into some shared area? Maybe even moved out of the modules themselves - Logger.error("Unhandled but known packet #{inspect(tuple)}") - {:unhandled, state} - end - - defp gamemode_id(gm) do - case gm do - nil -> -1 - :survival -> 0 - :creative -> 1 - :adventure -> 2 - :spectator -> 3 - end - end -end diff --git a/apps/amethyst/lib/servers/status.ex b/apps/amethyst/lib/servers/status.ex deleted file mode 100644 index d49150a..0000000 --- a/apps/amethyst/lib/servers/status.ex +++ /dev/null @@ -1,85 +0,0 @@ -# Amethyst - An experimental Minecraft server written in Elixir. -# Copyright (C) 2024 KodiCraft -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -defmodule Amethyst.Server.Status do - @moduledoc """ - This module contains the logic for the Status stage of the server. - """ - require Logger - use Amethyst.Server - - alias Amethyst.Minecraft.Read - alias Amethyst.Minecraft.Write - - @impl true - def init(state) do - state - end - - ## DESERIALIZATION - @impl true - # Status Request https://wiki.vg/Protocol#Status_Request - def deserialize(0x00, _) do - {:status_request} - end - # Ping Request https://wiki.vg/Protocol#Ping_Request - def deserialize(0x01, <>) do - {[payload], ""} = Read.start(data) |> Read.long() |> Read.stop() - {:ping_request, payload} - end - def deserialize(type, _) do - raise RuntimeError, "Got unknown packet type #{type}!" - end - - ## SERIALIZATION - @impl true - # Status Response https://wiki.vg/Protocol#Status_Response - def serialize({:status_response, data}) do - Write.varint(0x00) <> Write.string(data) - end - def serialize({:ping_response, payload}) do - Write.varint(0x01) <> Write.long(payload) - end - def serialize(packet) do - raise ArgumentError, "Tried serializing unknown packet #{inspect(packet)}" - end - - ## HANDLING - @impl true - # Status Request https://wiki.vg/Protocol#Status_Request - def handle({:status_request}, client, state) do - # We want to make this more dynamic in the future, but this works for now - packet = {:status_response, ~s({ -"version": {"name": "1.21", "protocol": 767}, -"players": {"max": -1, "online": 69, "sample": [{"name": "§dAmethyst§r", "id": "4566e69f-c907-48ee-8d71-d7ba5aa00d20"}]}, -"description": {"text":"Amethyst is an experimental server written in Elixir"}, -"enforcesSecureChat": false, -"previewsChat": false, -"preventsChatReports": true -})} - transmit(packet, client) - {:ok, state} - end - def handle({:ping_request, payload}, client, state) do - packet = {:ping_response, payload} - transmit(packet, client) - {:ok, state} - end - def handle(tuple, _, state) do - Logger.error("Unhandled but known packet #{elem(tuple, 0)}") - {:unhandled, state} - end -end diff --git a/apps/amethyst/lib/states/handhsake.ex b/apps/amethyst/lib/states/handhsake.ex new file mode 100644 index 0000000..73f7270 --- /dev/null +++ b/apps/amethyst/lib/states/handhsake.ex @@ -0,0 +1,56 @@ +# Amethyst - An experimental Minecraft server written in Elixir. +# Copyright (C) 2024 KodiCraft +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +defmodule Amethyst.ConnectionState.Handshake do + require Amethyst.ConnectionState.Macros + alias Amethyst.ConnectionState.Macros + + require Logger + + @moduledoc """ + This module contains the packets and logic for the Handshake state. + """ + # Notice that in the Handshake state, our version is always 0. + Macros.defpacket_serverbound :handshake, 0x00, 0, [ + version: :varint, + address: :string, + port: :ushort, + next: :varint + ] + + def handle(%{packet_type: :handshake, version: 767, address: address, port: port, next: next}, 0) do + Logger.debug("Received handshake for #{address}:#{port} with version 767") + case next do + 1 -> + send(self(), {:set_state, Amethyst.ConnectionState.Status}) + send(self(), {:set_version, 767}) + :ok + 2 -> + send(self(), {:set_state, Amethyst.ConnectionState.Login}) + send(self(), {:set_version, 767}) + :ok + 3 -> + send(self(), {:set_state, Amethyst.ConnectionState.Transfer}) + send(self(), {:set_version, 767}) + :ok + _ -> {:error, "Invalid next state"} + end + end + + def disconnect(_reason) do + # When disconnecting from the handshake state, we can't send any sort of reason. + end +end diff --git a/apps/amethyst/lib/states/login.ex b/apps/amethyst/lib/states/login.ex new file mode 100644 index 0000000..e3bc961 --- /dev/null +++ b/apps/amethyst/lib/states/login.ex @@ -0,0 +1,69 @@ +# Amethyst - An experimental Minecraft server written in Elixir. +# Copyright (C) 2024 KodiCraft +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +defmodule Amethyst.ConnectionState.Login do + require Amethyst.ConnectionState.Macros + alias Amethyst.ConnectionState.Macros + + require Logger + + @moduledoc """ + This module contains the packets and logic for the Login state. + """ + Macros.defpacket_clientbound :disconnect, 0x00, 767, [reason: :json] + Macros.defpacket_clientbound :encryption_request, 0x01, 767, [ + server_id: :string, + public_key: :byte_array, + verify_token: :byte_array, + should_authenticate: :bool + ] + Macros.defpacket_clientbound :login_success, 0x02, 767, [ + uuid: :uuid, + username: :string, + properties: {:array, [ + name: :string, + value: :string, + signature: {:optional, :string} + ]}, + strict_error_handling: :bool + ] + Macros.defpacket_clientbound :set_compression, 0x03, 767, [threshold: :varint] + Macros.defpacket_clientbound :login_plugin_request, 0x04, 767, [ + message_id: :varint, + channel: :string, + data: :raw + ] + Macros.defpacket_clientbound :cookie_request, 0x05, 767, [identifier: :string] + + Macros.defpacket_serverbound :login_start, 0x00, 767, [name: :string, player_uuid: :uuid] + Macros.defpacket_serverbound :encryption_response, 0x01, 767, [ + shared_secret: :byte_array, + verify_token: :byte_array + ] + Macros.defpacket_serverbound :login_plugin_response, 0x02, 767, [ + message_id: :varint, + data: {:optional, :raw} + ] + Macros.defpacket_serverbound :login_acknowledged, 0x03, 767, [] + Macros.defpacket_serverbound :cookie_response, 0x04, 767, [identifier: :string, payload: {:optional, :byte_array}] + + def disconnect(reason) do + %{packet_type: :disconnect, reason: %{ + "text" => "You have been disconnected:\n#{reason}", + "color" => "red" + }} + end +end diff --git a/apps/amethyst/lib/states/macros.ex b/apps/amethyst/lib/states/macros.ex new file mode 100644 index 0000000..7aed042 --- /dev/null +++ b/apps/amethyst/lib/states/macros.ex @@ -0,0 +1,81 @@ +# Amethyst - An experimental Minecraft server written in Elixir. +# Copyright (C) 2024 KodiCraft +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +defmodule Amethyst.ConnectionState.Macros do + defmacro defpacket_serverbound(name, id, version, signature) do + quote do + def deserialize(unquote(id), unquote(version), data) do + {packet, ""} = Amethyst.ConnectionState.Macros.read_signature(data, unquote(signature)) + packet |> Map.put(:packet_type, unquote(name)) + end + end + end + + defmacro defpacket_clientbound(name, id, version, signature) do + quote do + def serialize(%{packet_type: unquote(name)} = packet, unquote(version)) do + Amethyst.Minecraft.Write.varint(unquote(id)) <> Amethyst.ConnectionState.Macros.write_signature(packet, unquote(signature)) + end + end + end + + alias Amethyst.Minecraft.Read + alias Amethyst.Minecraft.Write + + def read_signature(data, signature) do + names = Enum.map(signature, fn {name, _type} -> name end) + {got, rest} = Enum.reduce(signature, Read.start(data), fn {_name, type}, {acc, rest, :reversed} -> + case type do + {:optional, t} -> + {[exists], rest} = Read.start(rest) |> Read.bool() |> Read.stop() + if exists do + apply(Read, t, [{acc, rest, :reversed}]) + else + {[nil | acc], rest, :reversed} + end + {:array, signature} -> + {[count], rest} = Read.start(rest) |> Read.varint() |> Read.stop() + {items, rest} = Enum.reduce(1..count, {[], rest}, fn _, {acc, rest} -> + {item, rest} = read_signature(rest, signature) + {[item | acc], rest} + end) + {[Enum.reverse(items) | acc], rest, :reversed} + t -> apply(Read, t, [{acc, rest, :reversed}]) + end + end) |> Read.stop() + + {Enum.zip(names, got) |> Map.new(), rest} + end + + def write_signature(packet, signature) do + data = Enum.reduce(signature, "", fn {name, type}, acc -> + #acc <> apply(Write, type, [Map.get(packet, name)]) + case type do + {:optional, t} -> + case Map.get(packet, name) do + nil -> acc <> Write.bool(false) + _ -> acc <> Write.bool(true) <> apply(Write, t, [Map.get(packet, name)]) + end + {:array, signature} -> + acc <> Write.varint(Enum.count(Map.get(packet, name))) <> + Enum.reduce(Map.get(packet, name), "", fn item, acc -> + acc <> write_signature(item, signature) + end) + t -> acc <> apply(Write, t, [Map.get(packet, name)]) + end + end) + end +end diff --git a/apps/amethyst/lib/states/status.ex b/apps/amethyst/lib/states/status.ex new file mode 100644 index 0000000..eaa7158 --- /dev/null +++ b/apps/amethyst/lib/states/status.ex @@ -0,0 +1,60 @@ +# Amethyst - An experimental Minecraft server written in Elixir. +# Copyright (C) 2024 KodiCraft +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +defmodule Amethyst.ConnectionState.Status do + require Amethyst.ConnectionState.Macros + alias Amethyst.ConnectionState.Macros + + require Logger + + @moduledoc """ + This module contains the packets and logic for the Status state. + """ + Macros.defpacket_clientbound :status_response, 0x00, 767, [json_response: :string] + Macros.defpacket_clientbound :pong_response, 0x01, 767, [payload: :long] + + Macros.defpacket_serverbound :status_request, 0x00, 767, [] + Macros.defpacket_serverbound :ping_request, 0x01, 767, [payload: :long] + + def handle(%{packet_type: :status_request}, 767) do + Logger.debug("Received status request") + send(self(), {:send_packet, %{ + packet_type: :status_response, + json_response: ~s({ +"version": {"name": "1.21", "protocol": 767}, +"players": {"max": -1, "online": 69, "sample": [{"name": "§dAmethyst§r", "id": "4566e69f-c907-48ee-8d71-d7ba5aa00d20"}]}, +"description": {"text":"Amethyst is an experimental server written in Elixir"}, +"enforcesSecureChat": false, +"previewsChat": false, +"preventsChatReports": true +}) + }}) + :ok + end + + def handle(%{packet_type: :ping_request, payload: payload}, 767) do + Logger.debug("Received ping request") + send(self(), {:send_packet, %{ + packet_type: :pong_response, + payload: payload + }}) + :ok + end + + def disconnect(_reason) do + # When disconnecting from the status state, we can't send any sort of reason. + end +end diff --git a/apps/amethyst/mix.exs b/apps/amethyst/mix.exs index b38a215..6f5a605 100644 --- a/apps/amethyst/mix.exs +++ b/apps/amethyst/mix.exs @@ -38,7 +38,8 @@ defmodule Amethyst.MixProject do [ {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:uuid, "~> 1.1"}, - {:ex_doc, "~> 0.22", only: :dev, runtime: false} + {:ex_doc, "~> 0.22", only: :dev, runtime: false}, + {:jason, "~> 1.4"} ] end end