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