Begin complete rewrite of communication system
All checks were successful
Build & Test / nix-build (push) Successful in 1m31s

This commit is contained in:
Kodi Craft 2024-09-05 19:06:35 +02:00
parent 9842195b8e
commit 5413708b29
Signed by: kodi
GPG Key ID: 69D9EED60B242822
16 changed files with 483 additions and 871 deletions

View File

@ -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,

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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)

View File

@ -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
<<value::8-signed-big>>
end
@ -98,6 +102,14 @@ defmodule Amethyst.Minecraft.Write do
<<varint(byte_size(value))::binary, value::binary>>
end
def json(value) do
string(Jason.encode!(value))
end
def byte_array(value) do
<<varint(byte_size(value))::binary, value::binary>>
end
def position({x, y, z}) do
<<x::signed-big-26, z::signed-big-26, y::signed-big-12>>
end
@ -243,4 +255,8 @@ defmodule Amethyst.Minecraft.Read do
<<value::binary-size(length), rest::binary>> = 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

View File

@ -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 <https://www.gnu.org/licenses/>.
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) <> <<id::64-big-signed>>
end
# Ping https://wiki.vg/Protocol#Ping_(configuration)
def serialize({:ping, id}) do
Write.varint(0x05) <> <<id::32-big-signed>>
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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, <<data::binary>>) 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

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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, <<data::binary>>) 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

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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 <https://www.gnu.org/licenses/>.
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

View File

@ -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