From f2d32c244aab9f29610bbafa5727b1db09bd4623 Mon Sep 17 00:00:00 2001
From: Iota <47987888+IotaBread@users.noreply.github.com>
Date: Fri, 26 Jan 2024 02:01:15 -0300
Subject: [PATCH] Enigma server cleanup (#175)

* Convert packet classes to records where possible

* Remove Packet#read and no-args packet constructors

* Fix assumption for swing in enigma server

* Separate packets by c2s/s2c

* Don't send disconnect messages for logging-in clients

* Add a dev option to log client packets

* Update dev menu with the new option

* Reorder imports

* Add an explanation for the protocol version

* Handle logins after validating the packet

* Reject changes from unapproved clients

* Log actual port on server start

* Only notify disconnections of approved clients

* Fix some server logging

* Set up a network test

* Validate usernames

* Add a test for the username validation

* Update protocol.md

* More login tests

* Add a test plugin

* Ignore messages from unapproved clients

* Fix invalid packets crashing the packet handling thread

* Add a username field to the server creation dialog

* Update protocol file

* Don't send packets to unapproved clients

* Reset the mapping to the server state on invalid changes

* Improve invalid username message

* Change protocol version
---
 enigma-server/build.gradle                    |   2 +
 enigma-server/docs/protocol.md                |  91 +++++------
 .../enigma/network/DedicatedEnigmaServer.java |  10 +-
 .../quiltmc/enigma/network/EnigmaClient.java  |  36 +++--
 .../quiltmc/enigma/network/EnigmaServer.java  | 113 ++++++++++----
 .../quiltmc/enigma/network/ServerAddress.java |   2 +-
 .../quiltmc/enigma/network/ServerMessage.java |   6 +-
 .../enigma/network/ServerPacketHandler.java   |   9 ++
 .../enigma/network/packet/KickS2CPacket.java  |  33 ----
 .../quiltmc/enigma/network/packet/Packet.java |   3 -
 .../enigma/network/packet/PacketRegistry.java |  53 ++++---
 .../{ => c2s}/ConfirmChangeC2SPacket.java     |  19 +--
 .../{ => c2s}/EntryChangeC2SPacket.java       |  23 +--
 .../packet/{ => c2s}/LoginC2SPacket.java      |  42 ++---
 .../packet/{ => c2s}/MessageC2SPacket.java    |  26 ++--
 .../{ => s2c}/EntryChangeS2CPacket.java       |  26 +---
 .../network/packet/s2c/KickS2CPacket.java     |  25 +++
 .../packet/{ => s2c}/MessageS2CPacket.java    |  19 +--
 .../{ => s2c}/SyncMappingsS2CPacket.java      |  30 ++--
 .../packet/{ => s2c}/UserListS2CPacket.java   |  12 +-
 .../network/DummyClientPacketHandler.java     |  49 ++++++
 .../quiltmc/enigma/network/NetworkTest.java   | 146 ++++++++++++++++++
 .../enigma/network/TestEnigmaClient.java      |  27 ++++
 .../enigma/network/TestEnigmaServer.java      |  70 +++++++++
 enigma-swing/build.gradle                     |  21 ++-
 .../org/quiltmc/enigma/gui/GuiController.java |  15 +-
 .../quiltmc/enigma/gui/config/DevSection.java |  10 ++
 .../gui/dialog/ConnectToServerDialog.java     |   2 +
 .../enigma/gui/dialog/CreateServerDialog.java |   9 +-
 .../enigma/gui/docker/CollabDocker.java       |   2 +-
 .../quiltmc/enigma/gui/element/DevMenu.java   | 132 ++++++++++++++++
 .../quiltmc/enigma/gui/element/MenuBar.java   |  89 +----------
 .../gui/network/IntegratedEnigmaClient.java   |  20 +++
 .../gui}/network/IntegratedEnigmaServer.java  |   3 +-
 .../api/translation/mapping/EntryMapping.java |   4 +-
 .../org/quiltmc/enigma/util/EntryUtil.java    |  26 ++++
 .../enigma/util/validation/Message.java       |   1 +
 enigma/src/main/resources/lang/en_us.json     |   3 +
 .../enigma/test/plugin/TestEnigmaPlugin.java  |  58 +++++++
 .../org.quiltmc.enigma.api.EnigmaPlugin       |   1 +
 .../src/testFixtures/resources/profile.json   |  23 +++
 41 files changed, 930 insertions(+), 361 deletions(-)
 delete mode 100644 enigma-server/src/main/java/org/quiltmc/enigma/network/packet/KickS2CPacket.java
 rename enigma-server/src/main/java/org/quiltmc/enigma/network/packet/{ => c2s}/ConfirmChangeC2SPacket.java (51%)
 rename enigma-server/src/main/java/org/quiltmc/enigma/network/packet/{ => c2s}/EntryChangeC2SPacket.java (80%)
 rename enigma-server/src/main/java/org/quiltmc/enigma/network/packet/{ => c2s}/LoginC2SPacket.java (65%)
 rename enigma-server/src/main/java/org/quiltmc/enigma/network/packet/{ => c2s}/MessageC2SPacket.java (59%)
 rename enigma-server/src/main/java/org/quiltmc/enigma/network/packet/{ => s2c}/EntryChangeS2CPacket.java (53%)
 create mode 100644 enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/KickS2CPacket.java
 rename enigma-server/src/main/java/org/quiltmc/enigma/network/packet/{ => s2c}/MessageS2CPacket.java (52%)
 rename enigma-server/src/main/java/org/quiltmc/enigma/network/packet/{ => s2c}/SyncMappingsS2CPacket.java (81%)
 rename enigma-server/src/main/java/org/quiltmc/enigma/network/packet/{ => s2c}/UserListS2CPacket.java (76%)
 create mode 100644 enigma-server/src/test/java/org/quiltmc/enigma/network/DummyClientPacketHandler.java
 create mode 100644 enigma-server/src/test/java/org/quiltmc/enigma/network/NetworkTest.java
 create mode 100644 enigma-server/src/test/java/org/quiltmc/enigma/network/TestEnigmaClient.java
 create mode 100644 enigma-server/src/test/java/org/quiltmc/enigma/network/TestEnigmaServer.java
 create mode 100644 enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/DevMenu.java
 create mode 100644 enigma-swing/src/main/java/org/quiltmc/enigma/gui/network/IntegratedEnigmaClient.java
 rename {enigma-server/src/main/java/org/quiltmc/enigma => enigma-swing/src/main/java/org/quiltmc/enigma/gui}/network/IntegratedEnigmaServer.java (82%)
 create mode 100644 enigma/src/testFixtures/java/org/quiltmc/enigma/test/plugin/TestEnigmaPlugin.java
 create mode 100644 enigma/src/testFixtures/resources/META-INF/services/org.quiltmc.enigma.api.EnigmaPlugin
 create mode 100644 enigma/src/testFixtures/resources/profile.json

diff --git a/enigma-server/build.gradle b/enigma-server/build.gradle
index ca4df4023..fe04e9059 100644
--- a/enigma-server/build.gradle
+++ b/enigma-server/build.gradle
@@ -7,6 +7,8 @@ plugins {
 dependencies {
 	shadow(implementation project(':enigma'))
 	implementation libs.jopt
+
+	testImplementation testFixtures(project(':enigma'))
 }
 
 mainClassName = 'org.quiltmc.enigma.network.DedicatedEnigmaServer'
diff --git a/enigma-server/docs/protocol.md b/enigma-server/docs/protocol.md
index 3d84c579c..bb360b9b6 100644
--- a/enigma-server/docs/protocol.md
+++ b/enigma-server/docs/protocol.md
@@ -11,58 +11,61 @@ Strings, see below.
 
 ## Login protocol
 ```
-Client     Server
-|               |
-|     Login     |
-| >>>>>>>>>>>>> |
-|               |
-| SyncMappings  |
-| <<<<<<<<<<<<< |
-|               |
-| ConfirmChange |
-| >>>>>>>>>>>>> |
+Client        Server
+|                  |
+|     LoginC2S     |
+| >>>>>>>>>>>>>>>> |
+|                  |
+| SyncMappingsS2C  |
+| <<<<<<<<<<<<<<<< |
+|                  |
+| ConfirmChangeC2S |
+| >>>>>>>>>>>>>>>> |
 ```
 1. On connect, the client sends a login packet to the server. This allows the server to test the validity of the client,
    as well as allowing the client to declare metadata about itself, such as the username.
-1. After validating the login packet, the server sends all its mappings to the client, and the client will apply them.
-1. Upon receiving the mappings, the client sends a `ConfirmChange` packet with `sync_id` set to 0, to confirm that it
+2. After validating the login packet, the server sends all its mappings to the client, and the client will apply them.
+   - Just before the mappings are sent, the server sends the new user list to every connected client
+3. Upon receiving the mappings, the client sends a `ConfirmChangeC2S` packet with `sync_id` set to 0, to confirm that it
    has received the mappings and is in sync with the server. Once the server receives this packet, the client will be
    allowed to modify mappings.
 
-The server will not accept any other packets from the client until this entire exchange has been completed.
+The server will ignore any other packets from the client until this entire exchange has been completed, and may kick the
+client if any other packet is received during this stage.
 
 ## Kicking clients
 When the server kicks a client, it may optionally send a `Kick` packet immediately before closing the connection, which
 contains the reason why the client was kicked (so the client can display it to the user). This is not required though -
-the server may simply terminate the connection.
+the server may simply terminate the connection. After the connection is closed, the server should send the new user list
+to the other connected clients.
 
 ## Changing mappings
-This section uses the example of renaming, but the same pattern applies to all mapping changes.
 ```
-Client A   Server    Client B
-|           |               |
-| RenameC2S |               |
-| >>>>>>>>> |               |
-|           |               |
-|           |   RenameS2C   |
-|           | >>>>>>>>>>>>> |
-|           |               |
-|           | ConfirmChange |
-|           | <<<<<<<<<<<<< |
+Client A       Server        Client B
+|                |                  |
+| EntryChangeC2S |                  |
+| >>>>>>>>>>>>>> |                  |
+|                |                  |
+|                |  EntryChangeS2C  |
+|                | >>>>>>>>>>>>>>>> |
+|                |                  |
+|                | ConfirmChangeC2S |
+|                | <<<<<<<<<<<<<<<< |
 ```
 
 1. Client A validates the name and updates the mapping client-side to give the impression there is no latency >:)
-1. Client A sends a rename packet to the server, notifying it of the rename.
-1. The server assesses the validity of the rename. If it is invalid for whatever reason (e.g. the mapping was locked or
-   the name contains invalid characters), then the server sends an appropriate packet back to client A to revert the
-   change, with `sync_id` set to 0. The server will ignore any `ConfirmChange` packets it receives in response to this.
-1. If the rename was valid, the server will lock all clients except client A from being able to modify this mapping, and
-   then send an appropriate packet to all clients except client A notifying them of this rename. The `sync_id` will be a
+2. Client A sends an entry change packet to the server, notifying it of the change.
+3. The server assesses the validity of the change. If it is invalid for whatever reason (e.g. the mapping was locked or
+   the name contains invalid characters), then the server sends an appropriate packet back to client A to reset the
+   mapping back to the same state as the server, with `sync_id` set to 0. The server will ignore any `ConfirmChangeC2S`
+   packets it receives in response to this.
+4. If the change was valid, the server will lock all clients except client A from being able to modify this mapping, and
+   then send an appropriate packet to all clients except client A notifying them of this change. The `sync_id` will be a
    unique non-zero value identifying this change.
-1. Each client responds to this packet by updating their mappings locally to reflect this change, then sending a
-   `ConfirmChange` packet with the same `sync_id` as the one in the packet they received, to confirm that they have
+5. Each client responds to this packet by updating their mappings locally to reflect this change, then sending a
+   `ConfirmChangeC2S` packet with the same `sync_id` as the one in the packet they received, to confirm that they have
    received the change.
-1. When the server receives the `ConfirmChange` packet, and another change to that mapping hasn't occurred since, the
+6. When the server receives the `ConfirmChangeC2S` packet, and another change to that mapping hasn't occurred since, the
    server will unlock that mapping for that client and allow them to make changes again.
 
 ## Packets
@@ -130,7 +133,7 @@ struct Entry {
 - `index`: The index of the local variable in the local variable table.
 - `parameter`: Whether the local variable is a parameter.
 
-### The Message struct
+### The ServerMessage struct
 ```c
 enum MessageType {
     MESSAGE_CHAT = 0,
@@ -143,7 +146,7 @@ enum MessageType {
 };
 typedef unsigned byte message_type_t;
 
-struct Message {
+struct ServerMessage {
     message_type_t type;
     union { // Note that the size of this varies depending on type, it is not constant size
         struct {
@@ -198,10 +201,12 @@ typedef enum tristate_change {
     TRISTATE_CHANGE_SET = 2
 } tristate_change_t;
 
-// Contains 2 packed values:
+// Contains 4 packed values:
 // bitmask   type
-// 0011  tristate_change_t deobf_name_change;
-// 1100  tristate_change_t javadoc_change;
+// 00000011  tristate_change_t deobf_name_change;
+// 00001100  tristate_change_t javadoc_change;
+// 00110000  tristate_change_t token_type_change;
+// 11000000  tristate_change_t source_plugin_id_change;
 typedef uint8_t entry_change_flags;
 
 struct entry_change {
@@ -239,9 +244,9 @@ struct LoginC2SPacket {
 }
 ```
 - `protocol_version`: the version of the protocol. If the version does not match on the server, then the client will be
-                      kicked immediately. Currently always equal to 0.
-- `checksum`: the SHA-1 hash of the JAR file the client has open. If this does not match the SHA-1 hash of the JAR file
-              the server has open, the client will be kicked.
+                      kicked immediately.
+- `checksum`: the SHA-1 hash of the sorted class files in the JAR file the client has open. If this does not match the
+              SHA-1 hash of the JAR file the server has open, the client will be kicked.
 - `password`: the password needed to log into the server. Note that each `char` is 2 bytes, as per the Java data type.
               If this password is incorrect, the client will be kicked.
 - `username`: the username of the user logging in. If the username is not unique, the client will be kicked.
@@ -306,7 +311,7 @@ typedef { Entry but without the has_parent or parent fields } NoParentEntry;
 ### Message (server-to-client)
 ```c
 struct MessageS2CPacket {
-    Message message;
+    ServerMessage message;
 }
 ```
 
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/DedicatedEnigmaServer.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/DedicatedEnigmaServer.java
index 4ca97c232..997f4d49e 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/DedicatedEnigmaServer.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/DedicatedEnigmaServer.java
@@ -1,19 +1,19 @@
 package org.quiltmc.enigma.network;
 
+import joptsimple.OptionParser;
+import joptsimple.OptionSet;
+import joptsimple.OptionSpec;
+import joptsimple.ValueConverter;
 import org.quiltmc.enigma.api.Enigma;
 import org.quiltmc.enigma.api.EnigmaProfile;
 import org.quiltmc.enigma.api.EnigmaProject;
 import org.quiltmc.enigma.api.ProgressListener;
 import org.quiltmc.enigma.api.class_provider.ClasspathClassProvider;
-import org.quiltmc.enigma.api.translation.mapping.serde.MappingParseException;
 import org.quiltmc.enigma.api.translation.mapping.EntryRemapper;
 import org.quiltmc.enigma.api.translation.mapping.serde.MappingFormat;
+import org.quiltmc.enigma.api.translation.mapping.serde.MappingParseException;
 import org.quiltmc.enigma.api.translation.mapping.tree.HashEntryTree;
 import org.quiltmc.enigma.util.Utils;
-import joptsimple.OptionParser;
-import joptsimple.OptionSet;
-import joptsimple.OptionSpec;
-import joptsimple.ValueConverter;
 import org.tinylog.Logger;
 
 import java.io.IOException;
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/EnigmaClient.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/EnigmaClient.java
index 3012af3e4..616efe6a9 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/EnigmaClient.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/EnigmaClient.java
@@ -4,7 +4,6 @@
 import org.quiltmc.enigma.network.packet.PacketRegistry;
 import org.tinylog.Logger;
 
-import javax.swing.SwingUtilities;
 import java.io.DataInput;
 import java.io.DataInputStream;
 import java.io.DataOutput;
@@ -14,16 +13,18 @@
 import java.net.Socket;
 import java.net.SocketException;
 
-public class EnigmaClient {
-	private final ClientPacketHandler controller;
+public abstract class EnigmaClient {
+	protected boolean logPackets = false;
+
+	private final ClientPacketHandler handler;
 
 	private final String ip;
 	private final int port;
 	private Socket socket;
 	private DataOutput output;
 
-	public EnigmaClient(ClientPacketHandler controller, String ip, int port) {
-		this.controller = controller;
+	public EnigmaClient(ClientPacketHandler handler, String ip, int port) {
+		this.handler = handler;
 		this.ip = ip;
 		this.port = port;
 	}
@@ -42,16 +43,25 @@ public void connect() throws IOException {
 						break;
 					}
 
-					Packet<ClientPacketHandler> packet = PacketRegistry.createS2CPacket(packetId);
+					Packet<ClientPacketHandler> packet = PacketRegistry.readS2CPacket(packetId, input);
 					if (packet == null) {
 						throw new IOException("Received invalid packet id " + packetId);
 					}
 
-					packet.read(input);
-					SwingUtilities.invokeLater(() -> packet.handle(this.controller));
+					if (this.logPackets) {
+						Logger.info("Received packet {} (id {})", packet, packetId);
+					}
+
+					this.runOnThread(() -> {
+						try {
+							packet.handle(this.handler);
+						} catch (Exception e) {
+							Logger.error(e, "Failed to handle packet!");
+						}
+					});
 				}
 			} catch (IOException e) {
-				this.controller.disconnectIfConnected(e.toString());
+				this.handler.disconnectIfConnected(e.toString());
 			}
 		});
 		thread.setName("Client I/O thread");
@@ -73,8 +83,14 @@ public void sendPacket(Packet<ServerPacketHandler> packet) {
 		try {
 			this.output.writeByte(PacketRegistry.getC2SId(packet));
 			packet.write(this.output);
+
+			if (this.logPackets) {
+				Logger.info("Sent packet {}", packet);
+			}
 		} catch (IOException e) {
-			this.controller.disconnectIfConnected(e.toString());
+			this.handler.disconnectIfConnected(e.toString());
 		}
 	}
+
+	protected abstract void runOnThread(Runnable task);
 }
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/EnigmaServer.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/EnigmaServer.java
index 10eb511d6..e3698e22a 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/EnigmaServer.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/EnigmaServer.java
@@ -1,15 +1,17 @@
 package org.quiltmc.enigma.network;
 
-import org.quiltmc.enigma.network.packet.EntryChangeS2CPacket;
-import org.quiltmc.enigma.network.packet.KickS2CPacket;
-import org.quiltmc.enigma.network.packet.MessageS2CPacket;
-import org.quiltmc.enigma.network.packet.Packet;
-import org.quiltmc.enigma.network.packet.PacketRegistry;
-import org.quiltmc.enigma.network.packet.UserListS2CPacket;
+import com.google.common.annotations.VisibleForTesting;
 import org.quiltmc.enigma.api.translation.mapping.EntryChange;
 import org.quiltmc.enigma.api.translation.mapping.EntryMapping;
 import org.quiltmc.enigma.api.translation.mapping.EntryRemapper;
 import org.quiltmc.enigma.api.translation.representation.entry.Entry;
+import org.quiltmc.enigma.network.packet.Packet;
+import org.quiltmc.enigma.network.packet.PacketRegistry;
+import org.quiltmc.enigma.network.packet.s2c.EntryChangeS2CPacket;
+import org.quiltmc.enigma.network.packet.s2c.KickS2CPacket;
+import org.quiltmc.enigma.network.packet.s2c.MessageS2CPacket;
+import org.quiltmc.enigma.network.packet.s2c.UserListS2CPacket;
+import org.quiltmc.enigma.util.EntryUtil;
 import org.tinylog.Logger;
 
 import java.io.DataInput;
@@ -29,17 +31,22 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.regex.Pattern;
 
 public abstract class EnigmaServer {
 	public static final int DEFAULT_PORT = 34712;
-	public static final int PROTOCOL_VERSION = 1;
+	// Testing protocol versions are in hex: 0xMmVV => Major (4 bits), minor (4 bits), sub-Version (8 bits)
+	// Components are independent of the enigma version, i.e. enigma 2.1.0 isn't protocol 0x2100
+	public static final int PROTOCOL_VERSION = 2;
 	public static final int CHECKSUM_SIZE = 20;
 	public static final int MAX_PASSWORD_LENGTH = 255; // length is written as a byte in the login packet
+	public static final Pattern USERNAME_REGEX = Pattern.compile("^[A-Za-z_][^(;:\"<>*+=\\\\|?,)]{2,31}$");
 
 	private final int port;
 	private ServerSocket socket;
 	private final List<Socket> clients = new CopyOnWriteArrayList<>();
 	private final Map<Socket, String> usernames = new HashMap<>();
+	// Clients are only approved once they finish the login exchange by confirming the mapping sync
 	private final Set<Socket> unapprovedClients = new HashSet<>();
 
 	private final byte[] jarChecksum;
@@ -63,7 +70,7 @@ public EnigmaServer(byte[] jarChecksum, char[] password, EntryRemapper remapper,
 
 	public void start() throws IOException {
 		this.socket = new ServerSocket(this.port);
-		this.log("Server started on " + this.socket.getInetAddress() + ":" + this.port);
+		this.log("Server started on " + this.socket.getInetAddress() + ":" + this.socket.getLocalPort()); // Port 0 is automatically allocated
 		Thread thread = new Thread(() -> {
 			try {
 				while (!this.socket.isClosed()) {
@@ -72,7 +79,7 @@ public void start() throws IOException {
 			} catch (SocketException e) {
 				Logger.info("Server closed");
 			} catch (IOException e) {
-				Logger.error("Failed to accept client!", e);
+				Logger.error(e, "Failed to accept client!");
 			}
 		});
 		thread.setName("Server client listener");
@@ -83,6 +90,8 @@ public void start() throws IOException {
 	private void acceptClient() throws IOException {
 		Socket client = this.socket.accept();
 		this.clients.add(client);
+		this.unapprovedClients.add(client);
+
 		Thread thread = new Thread(() -> {
 			try {
 				DataInput input = new DataInputStream(client.getInputStream());
@@ -94,17 +103,22 @@ private void acceptClient() throws IOException {
 						break;
 					}
 
-					Packet<ServerPacketHandler> packet = PacketRegistry.createC2SPacket(packetId);
+					Packet<ServerPacketHandler> packet = PacketRegistry.readC2SPacket(packetId, input);
 					if (packet == null) {
 						throw new IOException("Received invalid packet id " + packetId);
 					}
 
-					packet.read(input);
-					this.runOnThread(() -> packet.handle(new ServerPacketHandler(client, this)));
+					this.runOnThread(() -> {
+						try {
+							packet.handle(new ServerPacketHandler(client, this));
+						} catch (Exception e) {
+							Logger.error(e, "Failed to handle packet!");
+						}
+					});
 				}
 			} catch (IOException e) {
 				this.kick(client, e.toString());
-				Logger.error("Failed to read packet from client!", e);
+				Logger.error(e, "Failed to read packet from client!");
 				return;
 			}
 
@@ -132,6 +146,10 @@ public void stop() {
 	}
 
 	public void kick(Socket client, String reason) {
+		this.kick(client, reason, this.isClientApproved(client)); // Notify others only if the client logged in
+	}
+
+	public void kick(Socket client, String reason, boolean notifyOthers) {
 		if (!this.clients.remove(client)) {
 			return;
 		}
@@ -147,21 +165,31 @@ public void kick(Socket client, String reason) {
 		try {
 			client.close();
 		} catch (IOException e) {
-			Logger.error("Failed to close server client socket!", e);
+			Logger.error(e, "Failed to close server client socket!");
 		}
 
 		if (username != null) {
-			Logger.info("Kicked " + username + " because " + reason);
-			this.sendMessage(ServerMessage.disconnect(username));
-		}
+			this.log("Kicked " + username + " because " + reason);
+			if (notifyOthers) {
+				this.sendMessage(ServerMessage.disconnect(username));
+			}
 
-		this.sendUsernamePacket();
+			this.sendUsernamePacket();
+		}
 	}
 
 	public boolean isUsernameTaken(String username) {
 		return this.usernames.containsValue(username);
 	}
 
+	public boolean isUsernameValid(String username) {
+		return usernameMatchesRegex(username);
+	}
+
+	public static boolean usernameMatchesRegex(String username) {
+		return USERNAME_REGEX.matcher(username).matches();
+	}
+
 	public void setUsername(Socket client, String username) {
 		this.usernames.put(client, username);
 		this.sendUsernamePacket();
@@ -177,6 +205,10 @@ public String getUsername(Socket client) {
 		return this.usernames.get(client);
 	}
 
+	public boolean isClientApproved(Socket client) {
+		return !this.unapprovedClients.contains(client);
+	}
+
 	public void sendPacket(Socket client, Packet<ClientPacketHandler> packet) {
 		if (!client.isClosed()) {
 			int packetId = PacketRegistry.getS2CId(packet);
@@ -187,7 +219,7 @@ public void sendPacket(Socket client, Packet<ClientPacketHandler> packet) {
 			} catch (IOException e) {
 				if (!(packet instanceof KickS2CPacket)) {
 					this.kick(client, e.toString());
-					Logger.error("Failed to send packet to client!", e);
+					Logger.error(e, "Failed to send packet to client!");
 				}
 			}
 		}
@@ -195,20 +227,22 @@ public void sendPacket(Socket client, Packet<ClientPacketHandler> packet) {
 
 	public void sendToAll(Packet<ClientPacketHandler> packet) {
 		for (Socket client : this.clients) {
-			this.sendPacket(client, packet);
+			if (this.isClientApproved(client)) {
+				this.sendPacket(client, packet);
+			}
 		}
 	}
 
 	public void sendToAllExcept(Socket excluded, Packet<ClientPacketHandler> packet) {
 		for (Socket client : this.clients) {
-			if (client != excluded) {
+			if (client != excluded && this.isClientApproved(client)) {
 				this.sendPacket(client, packet);
 			}
 		}
 	}
 
 	public boolean canModifyEntry(Socket client, Entry<?> entry) {
-		if (this.unapprovedClients.contains(client)) {
+		if (!this.isClientApproved(client)) {
 			return false;
 		}
 
@@ -238,11 +272,13 @@ public int lockEntry(Socket exception, Entry<?> entry) {
 		this.inverseSyncIds.put(syncId, entry);
 		Set<Socket> clients = new HashSet<>(this.clients);
 		clients.remove(exception);
+		clients.removeAll(this.unapprovedClients);
 		this.clientsNeedingConfirmation.put(syncId, clients);
 		return syncId;
 	}
 
 	public void confirmChange(Socket client, int syncId) {
+		// If a client has a username, it has been approved
 		if (this.usernames.containsKey(client)) {
 			this.unapprovedClients.remove(client);
 		}
@@ -258,13 +294,10 @@ public void confirmChange(Socket client, int syncId) {
 	}
 
 	public void sendCorrectMapping(Socket client, Entry<?> entry) {
-		EntryMapping oldMapping = this.remapper.getMapping(entry);
-		String oldName = oldMapping.targetName();
-		if (oldName == null) {
-			this.sendPacket(client, new EntryChangeS2CPacket(DUMMY_SYNC_ID, EntryChange.modify(entry).clearDeobfName()));
-		} else {
-			this.sendPacket(client, new EntryChangeS2CPacket(0, EntryChange.modify(entry).withDeobfName(oldName)));
-		}
+		EntryMapping correctMapping = this.remapper.getMapping(entry);
+		EntryChange<Entry<?>> change = EntryUtil.changeFromMapping(entry, correctMapping);
+
+		this.sendPacket(client, new EntryChangeS2CPacket(DUMMY_SYNC_ID, change));
 	}
 
 	protected abstract void runOnThread(Runnable task);
@@ -289,8 +322,28 @@ public EntryRemapper getRemapper() {
 		return this.remapper;
 	}
 
+	@VisibleForTesting
+	int getPort() {
+		return this.port;
+	}
+
+	@VisibleForTesting
+	int getActualPort() {
+		return this.socket != null ? this.socket.getLocalPort() : this.getPort();
+	}
+
+	@VisibleForTesting
+	List<Socket> getClients() {
+		return this.clients;
+	}
+
+	@VisibleForTesting
+	public Set<Socket> getUnapprovedClients() {
+		return this.unapprovedClients;
+	}
+
 	public void sendMessage(ServerMessage message) {
-		Logger.info("[chat] {}", message.translate());
+		this.log("[chat] " + message.translate());
 		this.sendToAll(new MessageS2CPacket(message));
 	}
 }
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/ServerAddress.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/ServerAddress.java
index 0f07b2daa..61a0c08e9 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/ServerAddress.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/ServerAddress.java
@@ -1,7 +1,7 @@
 package org.quiltmc.enigma.network;
 
-import java.util.Objects;
 import javax.annotation.Nullable;
+import java.util.Objects;
 
 public class ServerAddress {
 	public final String address;
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/ServerMessage.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/ServerMessage.java
index 0f195995b..be9c0161c 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/ServerMessage.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/ServerMessage.java
@@ -1,7 +1,7 @@
 package org.quiltmc.enigma.network;
 
-import org.quiltmc.enigma.network.packet.PacketHelper;
 import org.quiltmc.enigma.api.translation.representation.entry.Entry;
+import org.quiltmc.enigma.network.packet.PacketHelper;
 import org.quiltmc.enigma.util.I18n;
 
 import java.io.DataInput;
@@ -52,6 +52,8 @@ public static ServerMessage read(DataInput input) throws IOException {
 
 		Type type = Type.values()[typeId];
 		String user = PacketHelper.readString(input);
+
+		Entry<?> entry;
 		switch (type) {
 			case CHAT:
 				String message = PacketHelper.readString(input);
@@ -61,7 +63,7 @@ public static ServerMessage read(DataInput input) throws IOException {
 			case DISCONNECT:
 				return disconnect(user);
 			case EDIT_DOCS:
-				Entry<?> entry = PacketHelper.readEntry(input);
+				entry = PacketHelper.readEntry(input);
 				return editDocs(user, entry);
 			case MARK_DEOBF:
 				entry = PacketHelper.readEntry(input);
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/ServerPacketHandler.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/ServerPacketHandler.java
index aa8ffa21b..866066b05 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/ServerPacketHandler.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/ServerPacketHandler.java
@@ -1,6 +1,15 @@
 package org.quiltmc.enigma.network;
 
+import org.quiltmc.enigma.network.packet.Packet;
+
 import java.net.Socket;
 
 public record ServerPacketHandler(Socket client, EnigmaServer server) {
+	public boolean isClientApproved() {
+		return this.server.isClientApproved(this.client);
+	}
+
+	public void sendPacket(Packet<ClientPacketHandler> packet) {
+		this.server.sendPacket(this.client, packet);
+	}
 }
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/KickS2CPacket.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/KickS2CPacket.java
deleted file mode 100644
index ac29e545b..000000000
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/KickS2CPacket.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package org.quiltmc.enigma.network.packet;
-
-import org.quiltmc.enigma.network.ClientPacketHandler;
-
-import java.io.DataInput;
-import java.io.DataOutput;
-import java.io.IOException;
-
-public class KickS2CPacket implements Packet<ClientPacketHandler> {
-	private String reason;
-
-	KickS2CPacket() {
-	}
-
-	public KickS2CPacket(String reason) {
-		this.reason = reason;
-	}
-
-	@Override
-	public void read(DataInput input) throws IOException {
-		this.reason = PacketHelper.readString(input);
-	}
-
-	@Override
-	public void write(DataOutput output) throws IOException {
-		PacketHelper.writeString(output, this.reason);
-	}
-
-	@Override
-	public void handle(ClientPacketHandler controller) {
-		controller.disconnectIfConnected(this.reason);
-	}
-}
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/Packet.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/Packet.java
index 91f066645..de8cac73b 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/Packet.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/Packet.java
@@ -1,12 +1,9 @@
 package org.quiltmc.enigma.network.packet;
 
-import java.io.DataInput;
 import java.io.DataOutput;
 import java.io.IOException;
 
 public interface Packet<H> {
-	void read(DataInput input) throws IOException;
-
 	void write(DataOutput output) throws IOException;
 
 	void handle(H handler);
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/PacketRegistry.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/PacketRegistry.java
index c5ad89a21..b3cae5c4a 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/PacketRegistry.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/PacketRegistry.java
@@ -2,25 +2,35 @@
 
 import org.quiltmc.enigma.network.ClientPacketHandler;
 import org.quiltmc.enigma.network.ServerPacketHandler;
+import org.quiltmc.enigma.network.packet.c2s.ConfirmChangeC2SPacket;
+import org.quiltmc.enigma.network.packet.c2s.EntryChangeC2SPacket;
+import org.quiltmc.enigma.network.packet.c2s.LoginC2SPacket;
+import org.quiltmc.enigma.network.packet.c2s.MessageC2SPacket;
+import org.quiltmc.enigma.network.packet.s2c.EntryChangeS2CPacket;
+import org.quiltmc.enigma.network.packet.s2c.KickS2CPacket;
+import org.quiltmc.enigma.network.packet.s2c.MessageS2CPacket;
+import org.quiltmc.enigma.network.packet.s2c.SyncMappingsS2CPacket;
+import org.quiltmc.enigma.network.packet.s2c.UserListS2CPacket;
 
+import java.io.DataInput;
+import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.function.Supplier;
 
 public class PacketRegistry {
-	private static final Map<Class<? extends Packet<ServerPacketHandler>>, Integer> c2sPacketIds = new HashMap<>();
-	private static final Map<Integer, Supplier<? extends Packet<ServerPacketHandler>>> c2sPacketCreators = new HashMap<>();
-	private static final Map<Class<? extends Packet<ClientPacketHandler>>, Integer> s2cPacketIds = new HashMap<>();
-	private static final Map<Integer, Supplier<? extends Packet<ClientPacketHandler>>> s2cPacketCreators = new HashMap<>();
+	private static final Map<Class<? extends Packet<ServerPacketHandler>>, Integer> C2S_PACKET_IDS = new HashMap<>();
+	private static final Map<Integer, DataFunction<? extends Packet<ServerPacketHandler>>> C2S_PACKET_READERS = new HashMap<>();
+	private static final Map<Class<? extends Packet<ClientPacketHandler>>, Integer> S2C_PACKET_IDS = new HashMap<>();
+	private static final Map<Integer, DataFunction<? extends Packet<ClientPacketHandler>>> S2C_PACKET_READERS = new HashMap<>();
 
-	private static <T extends Packet<ServerPacketHandler>> void registerC2S(int id, Class<T> clazz, Supplier<T> creator) {
-		c2sPacketIds.put(clazz, id);
-		c2sPacketCreators.put(id, creator);
+	private static <T extends Packet<ServerPacketHandler>> void registerC2S(int id, Class<T> clazz, DataFunction<T> reader) {
+		C2S_PACKET_IDS.put(clazz, id);
+		C2S_PACKET_READERS.put(id, reader);
 	}
 
-	private static <T extends Packet<ClientPacketHandler>> void registerS2C(int id, Class<T> clazz, Supplier<T> creator) {
-		s2cPacketIds.put(clazz, id);
-		s2cPacketCreators.put(id, creator);
+	private static <T extends Packet<ClientPacketHandler>> void registerS2C(int id, Class<T> clazz, DataFunction<T> reader) {
+		S2C_PACKET_IDS.put(clazz, id);
+		S2C_PACKET_READERS.put(id, reader);
 	}
 
 	static {
@@ -37,20 +47,25 @@ private static <T extends Packet<ClientPacketHandler>> void registerS2C(int id,
 	}
 
 	public static int getC2SId(Packet<ServerPacketHandler> packet) {
-		return c2sPacketIds.get(packet.getClass());
+		return C2S_PACKET_IDS.get(packet.getClass());
 	}
 
-	public static Packet<ServerPacketHandler> createC2SPacket(int id) {
-		Supplier<? extends Packet<ServerPacketHandler>> creator = c2sPacketCreators.get(id);
-		return creator == null ? null : creator.get();
+	public static Packet<ServerPacketHandler> readC2SPacket(int id, DataInput input) throws IOException {
+		DataFunction<? extends Packet<ServerPacketHandler>> reader = C2S_PACKET_READERS.get(id);
+		return reader == null ? null : reader.apply(input);
 	}
 
 	public static int getS2CId(Packet<ClientPacketHandler> packet) {
-		return s2cPacketIds.get(packet.getClass());
+		return S2C_PACKET_IDS.get(packet.getClass());
 	}
 
-	public static Packet<ClientPacketHandler> createS2CPacket(int id) {
-		Supplier<? extends Packet<ClientPacketHandler>> creator = s2cPacketCreators.get(id);
-		return creator == null ? null : creator.get();
+	public static Packet<ClientPacketHandler> readS2CPacket(int id, DataInput input) throws IOException {
+		DataFunction<? extends Packet<ClientPacketHandler>> reader = S2C_PACKET_READERS.get(id);
+		return reader == null ? null : reader.apply(input);
+	}
+
+	@FunctionalInterface
+	private interface DataFunction<R> {
+		R apply(DataInput input) throws IOException;
 	}
 }
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/ConfirmChangeC2SPacket.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/c2s/ConfirmChangeC2SPacket.java
similarity index 51%
rename from enigma-server/src/main/java/org/quiltmc/enigma/network/packet/ConfirmChangeC2SPacket.java
rename to enigma-server/src/main/java/org/quiltmc/enigma/network/packet/c2s/ConfirmChangeC2SPacket.java
index f2a5e1297..e64e90f14 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/ConfirmChangeC2SPacket.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/c2s/ConfirmChangeC2SPacket.java
@@ -1,24 +1,15 @@
-package org.quiltmc.enigma.network.packet;
+package org.quiltmc.enigma.network.packet.c2s;
 
 import org.quiltmc.enigma.network.ServerPacketHandler;
+import org.quiltmc.enigma.network.packet.Packet;
 
 import java.io.DataInput;
 import java.io.DataOutput;
 import java.io.IOException;
 
-public class ConfirmChangeC2SPacket implements Packet<ServerPacketHandler> {
-	private int syncId;
-
-	ConfirmChangeC2SPacket() {
-	}
-
-	public ConfirmChangeC2SPacket(int syncId) {
-		this.syncId = syncId;
-	}
-
-	@Override
-	public void read(DataInput input) throws IOException {
-		this.syncId = input.readUnsignedShort();
+public record ConfirmChangeC2SPacket(int syncId) implements Packet<ServerPacketHandler> {
+	public ConfirmChangeC2SPacket(DataInput input) throws IOException {
+		this(input.readUnsignedShort());
 	}
 
 	@Override
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/EntryChangeC2SPacket.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/c2s/EntryChangeC2SPacket.java
similarity index 80%
rename from enigma-server/src/main/java/org/quiltmc/enigma/network/packet/EntryChangeC2SPacket.java
rename to enigma-server/src/main/java/org/quiltmc/enigma/network/packet/c2s/EntryChangeC2SPacket.java
index 9f8f71c46..290061752 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/EntryChangeC2SPacket.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/c2s/EntryChangeC2SPacket.java
@@ -1,8 +1,11 @@
-package org.quiltmc.enigma.network.packet;
+package org.quiltmc.enigma.network.packet.c2s;
 
+import org.quiltmc.enigma.api.translation.mapping.EntryChange;
 import org.quiltmc.enigma.network.ServerMessage;
 import org.quiltmc.enigma.network.ServerPacketHandler;
-import org.quiltmc.enigma.api.translation.mapping.EntryChange;
+import org.quiltmc.enigma.network.packet.Packet;
+import org.quiltmc.enigma.network.packet.PacketHelper;
+import org.quiltmc.enigma.network.packet.s2c.EntryChangeS2CPacket;
 import org.quiltmc.enigma.util.EntryUtil;
 import org.quiltmc.enigma.util.validation.ValidationContext;
 
@@ -10,19 +13,9 @@
 import java.io.DataOutput;
 import java.io.IOException;
 
-public class EntryChangeC2SPacket implements Packet<ServerPacketHandler> {
-	private EntryChange<?> change;
-
-	EntryChangeC2SPacket() {
-	}
-
-	public EntryChangeC2SPacket(EntryChange<?> change) {
-		this.change = change;
-	}
-
-	@Override
-	public void read(DataInput input) throws IOException {
-		this.change = PacketHelper.readEntryChange(input);
+public record EntryChangeC2SPacket(EntryChange<?> change) implements Packet<ServerPacketHandler> {
+	public EntryChangeC2SPacket(DataInput input) throws IOException {
+		this(PacketHelper.readEntryChange(input));
 	}
 
 	@Override
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/LoginC2SPacket.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/c2s/LoginC2SPacket.java
similarity index 65%
rename from enigma-server/src/main/java/org/quiltmc/enigma/network/packet/LoginC2SPacket.java
rename to enigma-server/src/main/java/org/quiltmc/enigma/network/packet/c2s/LoginC2SPacket.java
index a664b8b57..fa39893b6 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/LoginC2SPacket.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/c2s/LoginC2SPacket.java
@@ -1,8 +1,11 @@
-package org.quiltmc.enigma.network.packet;
+package org.quiltmc.enigma.network.packet.c2s;
 
 import org.quiltmc.enigma.network.EnigmaServer;
-import org.quiltmc.enigma.network.ServerPacketHandler;
 import org.quiltmc.enigma.network.ServerMessage;
+import org.quiltmc.enigma.network.ServerPacketHandler;
+import org.quiltmc.enigma.network.packet.Packet;
+import org.quiltmc.enigma.network.packet.PacketHelper;
+import org.quiltmc.enigma.network.packet.s2c.SyncMappingsS2CPacket;
 
 import java.io.DataInput;
 import java.io.DataOutput;
@@ -10,12 +13,9 @@
 import java.util.Arrays;
 
 public class LoginC2SPacket implements Packet<ServerPacketHandler> {
-	private byte[] jarChecksum;
-	private char[] password;
-	private String username;
-
-	LoginC2SPacket() {
-	}
+	private final byte[] jarChecksum;
+	private final char[] password;
+	private final String username;
 
 	public LoginC2SPacket(byte[] jarChecksum, char[] password, String username) {
 		this.jarChecksum = jarChecksum;
@@ -23,8 +23,7 @@ public LoginC2SPacket(byte[] jarChecksum, char[] password, String username) {
 		this.username = username;
 	}
 
-	@Override
-	public void read(DataInput input) throws IOException {
+	public LoginC2SPacket(DataInput input) throws IOException {
 		if (input.readUnsignedShort() != EnigmaServer.PROTOCOL_VERSION) {
 			throw new IOException("Mismatching protocol");
 		}
@@ -53,26 +52,33 @@ public void write(DataOutput output) throws IOException {
 
 	@Override
 	public void handle(ServerPacketHandler handler) {
-		boolean usernameTaken = handler.server().isUsernameTaken(this.username);
-		handler.server().setUsername(handler.client(), this.username);
-		handler.server().log(this.username + " logged in with IP " + handler.client().getInetAddress().toString() + ":" + handler.client().getPort());
+		if (!handler.server().isUsernameValid(this.username)) {
+			handler.server().log("Client connected with invalid username, with IP " + handler.client().getInetAddress().toString() + ":" + handler.client().getPort());
+			handler.server().kick(handler.client(), "disconnect.invalid_username", false);
+			return;
+		}
+
+		handler.server().log(this.username + " connected with IP " + handler.client().getInetAddress().toString() + ":" + handler.client().getPort());
 
 		if (!Arrays.equals(this.password, handler.server().getPassword())) {
-			handler.server().kick(handler.client(), "disconnect.wrong_password");
+			handler.server().kick(handler.client(), "disconnect.wrong_password", false);
 			return;
 		}
 
-		if (usernameTaken) {
-			handler.server().kick(handler.client(), "disconnect.username_taken");
+		if (handler.server().isUsernameTaken(this.username)) {
+			handler.server().kick(handler.client(), "disconnect.username_taken", false);
 			return;
 		}
 
 		if (!Arrays.equals(this.jarChecksum, handler.server().getJarChecksum())) {
-			handler.server().kick(handler.client(), "disconnect.wrong_jar");
+			handler.server().kick(handler.client(), "disconnect.wrong_jar", false);
 			return;
 		}
 
-		handler.server().sendPacket(handler.client(), new SyncMappingsS2CPacket(handler.server().getRemapper().getDeobfMappings()));
+		handler.server().setUsername(handler.client(), this.username);
+		handler.server().log(this.username + " successfully logged in");
+
+		handler.sendPacket(new SyncMappingsS2CPacket(handler.server().getRemapper().getDeobfMappings()));
 		handler.server().sendMessage(ServerMessage.connect(this.username));
 	}
 }
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/MessageC2SPacket.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/c2s/MessageC2SPacket.java
similarity index 59%
rename from enigma-server/src/main/java/org/quiltmc/enigma/network/packet/MessageC2SPacket.java
rename to enigma-server/src/main/java/org/quiltmc/enigma/network/packet/c2s/MessageC2SPacket.java
index 26320f0ad..c787dc251 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/MessageC2SPacket.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/c2s/MessageC2SPacket.java
@@ -1,25 +1,17 @@
-package org.quiltmc.enigma.network.packet;
+package org.quiltmc.enigma.network.packet.c2s;
 
-import org.quiltmc.enigma.network.ServerPacketHandler;
 import org.quiltmc.enigma.network.ServerMessage;
+import org.quiltmc.enigma.network.ServerPacketHandler;
+import org.quiltmc.enigma.network.packet.Packet;
+import org.quiltmc.enigma.network.packet.PacketHelper;
 
 import java.io.DataInput;
 import java.io.DataOutput;
 import java.io.IOException;
 
-public class MessageC2SPacket implements Packet<ServerPacketHandler> {
-	private String message;
-
-	MessageC2SPacket() {
-	}
-
-	public MessageC2SPacket(String message) {
-		this.message = message;
-	}
-
-	@Override
-	public void read(DataInput input) throws IOException {
-		this.message = PacketHelper.readString(input);
+public record MessageC2SPacket(String message) implements Packet<ServerPacketHandler> {
+	public MessageC2SPacket(DataInput input) throws IOException {
+		this(PacketHelper.readString(input));
 	}
 
 	@Override
@@ -29,6 +21,10 @@ public void write(DataOutput output) throws IOException {
 
 	@Override
 	public void handle(ServerPacketHandler handler) {
+		if (!handler.isClientApproved()) {
+			return;
+		}
+
 		String trimmedMessage = this.message.trim();
 		if (!trimmedMessage.isEmpty()) {
 			handler.server().sendMessage(ServerMessage.chat(handler.server().getUsername(handler.client()), trimmedMessage));
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/EntryChangeS2CPacket.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/EntryChangeS2CPacket.java
similarity index 53%
rename from enigma-server/src/main/java/org/quiltmc/enigma/network/packet/EntryChangeS2CPacket.java
rename to enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/EntryChangeS2CPacket.java
index a0b69e6d3..170774c5f 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/EntryChangeS2CPacket.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/EntryChangeS2CPacket.java
@@ -1,28 +1,18 @@
-package org.quiltmc.enigma.network.packet;
+package org.quiltmc.enigma.network.packet.s2c;
 
-import org.quiltmc.enigma.network.ClientPacketHandler;
 import org.quiltmc.enigma.api.translation.mapping.EntryChange;
+import org.quiltmc.enigma.network.ClientPacketHandler;
+import org.quiltmc.enigma.network.packet.Packet;
+import org.quiltmc.enigma.network.packet.PacketHelper;
+import org.quiltmc.enigma.network.packet.c2s.ConfirmChangeC2SPacket;
 
 import java.io.DataInput;
 import java.io.DataOutput;
 import java.io.IOException;
 
-public class EntryChangeS2CPacket implements Packet<ClientPacketHandler> {
-	private int syncId;
-	private EntryChange<?> change;
-
-	public EntryChangeS2CPacket(int syncId, EntryChange<?> change) {
-		this.syncId = syncId;
-		this.change = change;
-	}
-
-	EntryChangeS2CPacket() {
-	}
-
-	@Override
-	public void read(DataInput input) throws IOException {
-		this.syncId = input.readUnsignedShort();
-		this.change = PacketHelper.readEntryChange(input);
+public record EntryChangeS2CPacket(int syncId, EntryChange<?> change) implements Packet<ClientPacketHandler> {
+	public EntryChangeS2CPacket(DataInput input) throws IOException {
+		this(input.readUnsignedShort(), PacketHelper.readEntryChange(input));
 	}
 
 	@Override
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/KickS2CPacket.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/KickS2CPacket.java
new file mode 100644
index 000000000..007afde90
--- /dev/null
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/KickS2CPacket.java
@@ -0,0 +1,25 @@
+package org.quiltmc.enigma.network.packet.s2c;
+
+import org.quiltmc.enigma.network.ClientPacketHandler;
+import org.quiltmc.enigma.network.packet.Packet;
+import org.quiltmc.enigma.network.packet.PacketHelper;
+
+import java.io.DataInput;
+import java.io.DataOutput;
+import java.io.IOException;
+
+public record KickS2CPacket(String reason) implements Packet<ClientPacketHandler> {
+	public KickS2CPacket(DataInput input) throws IOException {
+		this(PacketHelper.readString(input));
+	}
+
+	@Override
+	public void write(DataOutput output) throws IOException {
+		PacketHelper.writeString(output, this.reason);
+	}
+
+	@Override
+	public void handle(ClientPacketHandler handler) {
+		handler.disconnectIfConnected(this.reason);
+	}
+}
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/MessageS2CPacket.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/MessageS2CPacket.java
similarity index 52%
rename from enigma-server/src/main/java/org/quiltmc/enigma/network/packet/MessageS2CPacket.java
rename to enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/MessageS2CPacket.java
index 8fb29a852..b444f30b5 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/MessageS2CPacket.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/MessageS2CPacket.java
@@ -1,25 +1,16 @@
-package org.quiltmc.enigma.network.packet;
+package org.quiltmc.enigma.network.packet.s2c;
 
 import org.quiltmc.enigma.network.ClientPacketHandler;
 import org.quiltmc.enigma.network.ServerMessage;
+import org.quiltmc.enigma.network.packet.Packet;
 
 import java.io.DataInput;
 import java.io.DataOutput;
 import java.io.IOException;
 
-public class MessageS2CPacket implements Packet<ClientPacketHandler> {
-	private ServerMessage message;
-
-	MessageS2CPacket() {
-	}
-
-	public MessageS2CPacket(ServerMessage message) {
-		this.message = message;
-	}
-
-	@Override
-	public void read(DataInput input) throws IOException {
-		this.message = ServerMessage.read(input);
+public record MessageS2CPacket(ServerMessage message) implements Packet<ClientPacketHandler> {
+	public MessageS2CPacket(DataInput input) throws IOException {
+		this(ServerMessage.read(input));
 	}
 
 	@Override
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/SyncMappingsS2CPacket.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/SyncMappingsS2CPacket.java
similarity index 81%
rename from enigma-server/src/main/java/org/quiltmc/enigma/network/packet/SyncMappingsS2CPacket.java
rename to enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/SyncMappingsS2CPacket.java
index 2434219ce..84707938d 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/SyncMappingsS2CPacket.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/SyncMappingsS2CPacket.java
@@ -1,13 +1,16 @@
-package org.quiltmc.enigma.network.packet;
+package org.quiltmc.enigma.network.packet.s2c;
 
 import org.quiltmc.enigma.api.source.TokenType;
-import org.quiltmc.enigma.network.ClientPacketHandler;
-import org.quiltmc.enigma.network.EnigmaServer;
 import org.quiltmc.enigma.api.translation.mapping.EntryMapping;
 import org.quiltmc.enigma.api.translation.mapping.tree.EntryTree;
 import org.quiltmc.enigma.api.translation.mapping.tree.EntryTreeNode;
 import org.quiltmc.enigma.api.translation.mapping.tree.HashEntryTree;
 import org.quiltmc.enigma.api.translation.representation.entry.Entry;
+import org.quiltmc.enigma.network.ClientPacketHandler;
+import org.quiltmc.enigma.network.EnigmaServer;
+import org.quiltmc.enigma.network.packet.Packet;
+import org.quiltmc.enigma.network.packet.PacketHelper;
+import org.quiltmc.enigma.network.packet.c2s.ConfirmChangeC2SPacket;
 
 import java.io.DataInput;
 import java.io.DataOutput;
@@ -15,19 +18,10 @@
 import java.util.Collection;
 import java.util.List;
 
-public class SyncMappingsS2CPacket implements Packet<ClientPacketHandler> {
-	private EntryTree<EntryMapping> mappings;
+public record SyncMappingsS2CPacket(EntryTree<EntryMapping> mappings) implements Packet<ClientPacketHandler> {
+	public SyncMappingsS2CPacket(DataInput input) throws IOException {
+		this(new HashEntryTree<>());
 
-	SyncMappingsS2CPacket() {
-	}
-
-	public SyncMappingsS2CPacket(EntryTree<EntryMapping> mappings) {
-		this.mappings = mappings;
-	}
-
-	@Override
-	public void read(DataInput input) throws IOException {
-		this.mappings = new HashEntryTree<>();
 		int size = input.readInt();
 		for (int i = 0; i < size; i++) {
 			this.readEntryTreeNode(input, null);
@@ -76,8 +70,8 @@ private static void writeEntryTreeNode(DataOutput output, EntryTreeNode<EntryMap
 	}
 
 	@Override
-	public void handle(ClientPacketHandler controller) {
-		controller.openMappings(this.mappings);
-		controller.sendPacket(new ConfirmChangeC2SPacket(EnigmaServer.DUMMY_SYNC_ID));
+	public void handle(ClientPacketHandler handler) {
+		handler.openMappings(this.mappings);
+		handler.sendPacket(new ConfirmChangeC2SPacket(EnigmaServer.DUMMY_SYNC_ID));
 	}
 }
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/UserListS2CPacket.java b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/UserListS2CPacket.java
similarity index 76%
rename from enigma-server/src/main/java/org/quiltmc/enigma/network/packet/UserListS2CPacket.java
rename to enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/UserListS2CPacket.java
index 3308a96cc..f9eb523ff 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/UserListS2CPacket.java
+++ b/enigma-server/src/main/java/org/quiltmc/enigma/network/packet/s2c/UserListS2CPacket.java
@@ -1,6 +1,8 @@
-package org.quiltmc.enigma.network.packet;
+package org.quiltmc.enigma.network.packet.s2c;
 
 import org.quiltmc.enigma.network.ClientPacketHandler;
+import org.quiltmc.enigma.network.packet.Packet;
+import org.quiltmc.enigma.network.packet.PacketHelper;
 
 import java.io.DataInput;
 import java.io.DataOutput;
@@ -9,17 +11,13 @@
 import java.util.List;
 
 public class UserListS2CPacket implements Packet<ClientPacketHandler> {
-	private List<String> users;
-
-	UserListS2CPacket() {
-	}
+	private final List<String> users;
 
 	public UserListS2CPacket(List<String> users) {
 		this.users = users;
 	}
 
-	@Override
-	public void read(DataInput input) throws IOException {
+	public UserListS2CPacket(DataInput input) throws IOException {
 		int len = input.readUnsignedShort();
 		this.users = new ArrayList<>(len);
 		for (int i = 0; i < len; i++) {
diff --git a/enigma-server/src/test/java/org/quiltmc/enigma/network/DummyClientPacketHandler.java b/enigma-server/src/test/java/org/quiltmc/enigma/network/DummyClientPacketHandler.java
new file mode 100644
index 000000000..2e5033edf
--- /dev/null
+++ b/enigma-server/src/test/java/org/quiltmc/enigma/network/DummyClientPacketHandler.java
@@ -0,0 +1,49 @@
+package org.quiltmc.enigma.network;
+
+import org.quiltmc.enigma.api.translation.mapping.EntryChange;
+import org.quiltmc.enigma.api.translation.mapping.EntryMapping;
+import org.quiltmc.enigma.api.translation.mapping.tree.EntryTree;
+import org.quiltmc.enigma.network.packet.Packet;
+
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+public class DummyClientPacketHandler implements ClientPacketHandler {
+	TestEnigmaClient client;
+	CountDownLatch disconnectFromServerLatch = new CountDownLatch(1);
+
+	@Override
+	public void openMappings(EntryTree<EntryMapping> mappings) {
+	}
+
+	@Override
+	public boolean applyChangeFromServer(EntryChange<?> change) {
+		return true;
+	}
+
+	@Override
+	public void disconnectIfConnected(String reason) {
+		if (this.client != null) {
+			this.client.disconnect();
+		}
+
+		if (this.disconnectFromServerLatch != null) {
+			this.disconnectFromServerLatch.countDown();
+		}
+	}
+
+	@Override
+	public void sendPacket(Packet<ServerPacketHandler> packet) {
+		if (this.client != null) {
+			this.client.sendPacket(packet);
+		}
+	}
+
+	@Override
+	public void addMessage(ServerMessage message) {
+	}
+
+	@Override
+	public void updateUserList(List<String> users) {
+	}
+}
diff --git a/enigma-server/src/test/java/org/quiltmc/enigma/network/NetworkTest.java b/enigma-server/src/test/java/org/quiltmc/enigma/network/NetworkTest.java
new file mode 100644
index 000000000..4ca9e8e95
--- /dev/null
+++ b/enigma-server/src/test/java/org/quiltmc/enigma/network/NetworkTest.java
@@ -0,0 +1,146 @@
+package org.quiltmc.enigma.network;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.quiltmc.enigma.TestUtil;
+import org.quiltmc.enigma.api.Enigma;
+import org.quiltmc.enigma.api.EnigmaProject;
+import org.quiltmc.enigma.api.ProgressListener;
+import org.quiltmc.enigma.api.class_provider.ClasspathClassProvider;
+import org.quiltmc.enigma.api.translation.mapping.EntryRemapper;
+import org.quiltmc.enigma.network.packet.c2s.LoginC2SPacket;
+import org.quiltmc.enigma.network.packet.c2s.MessageC2SPacket;
+import org.quiltmc.enigma.util.Utils;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+public class NetworkTest {
+	private static final Path JAR = TestUtil.obfJar("complete");
+	private static final String PASSWORD = "foobar";
+	private static byte[] checksum;
+	private static TestEnigmaServer server;
+	private static EntryRemapper remapper;
+
+	@BeforeAll
+	public static void startServer() throws IOException {
+		Enigma enigma = Enigma.create();
+		EnigmaProject project = enigma.openJar(JAR, new ClasspathClassProvider(), ProgressListener.none());
+
+		checksum = Utils.zipSha1(JAR);
+		remapper = project.getRemapper();
+		server = new TestEnigmaServer(checksum, PASSWORD.toCharArray(), remapper, 0);
+
+		server.start();
+	}
+
+	@AfterAll
+	public static void stopServer() {
+		server.stop();
+	}
+
+	private static TestEnigmaClient connectClient(ClientPacketHandler handler) throws IOException {
+		var client = new TestEnigmaClient(handler, "127.0.0.1", server.getActualPort());
+		client.connect();
+
+		return client;
+	}
+
+	@Test
+	public void testLogin() throws IOException, InterruptedException {
+		var handler = new DummyClientPacketHandler();
+		var client = connectClient(handler);
+		handler.client = client;
+
+		Assertions.assertFalse(server.getClients().isEmpty());
+		Assertions.assertFalse(server.getUnapprovedClients().isEmpty());
+
+		client.sendPacket(new LoginC2SPacket(checksum, PASSWORD.toCharArray(), "alice"));
+		var confirmed = server.waitChangeConfirmation(server.getClients().get(0), 1)
+				.await(3, TimeUnit.SECONDS);
+
+		Assertions.assertNotEquals(0, handler.disconnectFromServerLatch.getCount(), "The client was disconnected by the server");
+		Assertions.assertTrue(confirmed, "Timed out waiting for the change confirmation");
+		client.disconnect();
+	}
+
+	@Test
+	public void testInvalidUsername() throws IOException, InterruptedException {
+		var handler = new DummyClientPacketHandler();
+		var client = connectClient(handler);
+		handler.client = client;
+
+		client.sendPacket(new LoginC2SPacket(checksum, PASSWORD.toCharArray(), "<span style=\"color: lavender\">eve</span>"));
+		var disconnected = handler.disconnectFromServerLatch.await(3, TimeUnit.SECONDS);
+
+		Assertions.assertTrue(disconnected, "Timed out waiting for the server to kick the client");
+		client.disconnect();
+	}
+
+	@Test
+	public void testWrongPassword() throws IOException, InterruptedException {
+		var handler = new DummyClientPacketHandler();
+		var client = connectClient(handler);
+		handler.client = client;
+
+		client.sendPacket(new LoginC2SPacket(checksum, "password".toCharArray(), "eve"));
+		var disconnected = handler.disconnectFromServerLatch.await(3, TimeUnit.SECONDS);
+
+		Assertions.assertTrue(disconnected, "Timed out waiting for the server to kick the client");
+		client.disconnect();
+	}
+
+	@Test
+	public void testTakenUsername() throws IOException, InterruptedException {
+		var packet = new LoginC2SPacket(checksum, PASSWORD.toCharArray(), "alice");
+
+		var handler = new DummyClientPacketHandler();
+		var client = connectClient(handler);
+		handler.client = client;
+		client.sendPacket(packet);
+
+		var handler2 = new DummyClientPacketHandler();
+		var client2 = connectClient(handler2);
+		handler2.client = client2;
+
+		client2.sendPacket(packet);
+		var disconnected = handler2.disconnectFromServerLatch.await(3, TimeUnit.SECONDS);
+
+		Assertions.assertTrue(disconnected, "Timed out waiting for the server to kick the client");
+		client.disconnect();
+		client2.disconnect();
+	}
+
+	@Test
+	public void testWrongChecksum() throws IOException, InterruptedException {
+		var handler = new DummyClientPacketHandler();
+		var client = connectClient(handler);
+		handler.client = client;
+
+		handler.disconnectFromServerLatch = new CountDownLatch(1);
+		client.sendPacket(new LoginC2SPacket(new byte[EnigmaServer.CHECKSUM_SIZE], PASSWORD.toCharArray(), "eve"));
+		var disconnected = handler.disconnectFromServerLatch.await(3, TimeUnit.SECONDS);
+
+		Assertions.assertTrue(disconnected, "Timed out waiting for the server to kick the client");
+		client.disconnect();
+	}
+
+	@Test
+	public void testUnapprovedMessage() throws IOException, InterruptedException {
+		var handler = new DummyClientPacketHandler();
+		var client = connectClient(handler);
+		handler.client = client;
+
+		client.sendPacket(new MessageC2SPacket("I am in your (walls) server :3"));
+		server.sendMessageLatch = new CountDownLatch(1);
+		var sent = client.packetSentLatch.await(1, TimeUnit.SECONDS);
+		Assertions.assertTrue(sent, "Failed to send packet");
+
+		var handled = server.sendMessageLatch.await(2, TimeUnit.SECONDS);
+		Assertions.assertFalse(handled, "The server handled an unapproved message!");
+	}
+}
diff --git a/enigma-server/src/test/java/org/quiltmc/enigma/network/TestEnigmaClient.java b/enigma-server/src/test/java/org/quiltmc/enigma/network/TestEnigmaClient.java
new file mode 100644
index 000000000..25a2760d5
--- /dev/null
+++ b/enigma-server/src/test/java/org/quiltmc/enigma/network/TestEnigmaClient.java
@@ -0,0 +1,27 @@
+package org.quiltmc.enigma.network;
+
+import org.quiltmc.enigma.network.packet.Packet;
+
+import java.util.concurrent.CountDownLatch;
+
+public class TestEnigmaClient extends EnigmaClient {
+	CountDownLatch packetSentLatch = new CountDownLatch(1);
+
+	public TestEnigmaClient(ClientPacketHandler handler, String ip, int port) {
+		super(handler, ip, port);
+		this.logPackets = true;
+	}
+
+	@Override
+	protected void runOnThread(Runnable task) {
+		task.run();
+	}
+
+	@Override
+	public void sendPacket(Packet<ServerPacketHandler> packet) {
+		super.sendPacket(packet);
+		if (this.packetSentLatch != null) {
+			this.packetSentLatch.countDown();
+		}
+	}
+}
diff --git a/enigma-server/src/test/java/org/quiltmc/enigma/network/TestEnigmaServer.java b/enigma-server/src/test/java/org/quiltmc/enigma/network/TestEnigmaServer.java
new file mode 100644
index 000000000..aa163bae3
--- /dev/null
+++ b/enigma-server/src/test/java/org/quiltmc/enigma/network/TestEnigmaServer.java
@@ -0,0 +1,70 @@
+package org.quiltmc.enigma.network;
+
+import org.quiltmc.enigma.api.translation.mapping.EntryRemapper;
+
+import java.io.IOException;
+import java.net.Socket;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.LinkedBlockingDeque;
+
+public class TestEnigmaServer extends EnigmaServer {
+	private final Map<Socket, CountDownLatch> changeConfirmationLatches = new ConcurrentHashMap<>();
+	private final BlockingQueue<Runnable> tasks = new LinkedBlockingDeque<>();
+
+	CountDownLatch sendMessageLatch;
+
+	public TestEnigmaServer(byte[] jarChecksum, char[] password, EntryRemapper remapper, int port) {
+		super(jarChecksum, password, remapper, port);
+	}
+
+	@Override
+	public void start() throws IOException {
+		super.start();
+
+		var tasksThread = new Thread(() -> {
+			while (true) {
+				try {
+					this.tasks.take().run();
+				} catch (InterruptedException e) {
+					break;
+				}
+			}
+		});
+		tasksThread.setName("Test server tasks");
+		tasksThread.setDaemon(true);
+		tasksThread.start();
+	}
+
+	@Override
+	protected void runOnThread(Runnable task) {
+		this.tasks.add(task);
+	}
+
+	@Override
+	public void confirmChange(Socket client, int syncId) {
+		super.confirmChange(client, syncId);
+
+		var latch = this.changeConfirmationLatches.get(client);
+		if (latch != null) {
+			latch.countDown();
+		}
+	}
+
+	public CountDownLatch waitChangeConfirmation(Socket client, int count) {
+		var latch = new CountDownLatch(count);
+		this.changeConfirmationLatches.put(client, latch);
+		return latch;
+	}
+
+	@Override
+	public void sendMessage(ServerMessage message) {
+		if (this.sendMessageLatch != null) {
+			this.sendMessageLatch.countDown();
+		}
+
+		super.sendMessage(message);
+	}
+}
diff --git a/enigma-swing/build.gradle b/enigma-swing/build.gradle
index 1ba502abb..faaa6102e 100644
--- a/enigma-swing/build.gradle
+++ b/enigma-swing/build.gradle
@@ -43,12 +43,29 @@ def registerTestTask(String name) {
 	tasks.register("${taskName}TestGui", JavaExec.class) {
 		group("test")
 		dependsOn(":enigma:${taskName}TestObf")
+		dependsOn(":enigma:processResources")
 		mainClass = mainClassName
-		classpath = sourceSets.main.runtimeClasspath
+		classpath = sourceSets.test.runtimeClasspath
 
 		def jar = project(":enigma").file("build/test-obf/${name}.jar")
+		def profile = project(":enigma").file("build/resources/testFixtures/profile.json")
 		def mappings = file("mappings/${name}")
-		args('-jar', jar, '-mappings', mappings, '--development')
+		args('-jar', jar, '-mappings', mappings, '-profile', profile, '--development')
+		doFirst {
+			mappings.mkdirs()
+		}
+	}
+
+	tasks.register("${taskName}TestGui2", JavaExec.class) {
+		group("test")
+		dependsOn(":enigma:${taskName}TestObf")
+		mainClass = mainClassName
+		classpath = sourceSets.test.runtimeClasspath
+
+		def jar = project(":enigma").file("build/test-obf/${name}.jar")
+		def profile = project(":enigma").file("build/resources/testFixtures/profile.json")
+		def mappings = file("mappings/${name}2")
+		args('-jar', jar, '-mappings', mappings, '-profile', profile, '--development')
 		doFirst {
 			mappings.mkdirs()
 		}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/GuiController.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/GuiController.java
index d97243b51..1857da8a4 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/GuiController.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/GuiController.java
@@ -12,6 +12,7 @@
 import org.quiltmc.enigma.api.analysis.tree.ClassReferenceTreeNode;
 import org.quiltmc.enigma.api.analysis.EntryReference;
 import org.quiltmc.enigma.api.analysis.tree.FieldReferenceTreeNode;
+import org.quiltmc.enigma.gui.network.IntegratedEnigmaClient;
 import org.quiltmc.enigma.impl.analysis.IndexTreeBuilder;
 import org.quiltmc.enigma.api.analysis.tree.MethodImplementationsTreeNode;
 import org.quiltmc.enigma.api.analysis.tree.MethodInheritanceTreeNode;
@@ -30,11 +31,11 @@
 import org.quiltmc.enigma.network.ClientPacketHandler;
 import org.quiltmc.enigma.network.EnigmaClient;
 import org.quiltmc.enigma.network.EnigmaServer;
-import org.quiltmc.enigma.network.IntegratedEnigmaServer;
+import org.quiltmc.enigma.gui.network.IntegratedEnigmaServer;
 import org.quiltmc.enigma.network.ServerMessage;
 import org.quiltmc.enigma.network.ServerPacketHandler;
-import org.quiltmc.enigma.network.packet.EntryChangeC2SPacket;
-import org.quiltmc.enigma.network.packet.LoginC2SPacket;
+import org.quiltmc.enigma.network.packet.c2s.EntryChangeC2SPacket;
+import org.quiltmc.enigma.network.packet.c2s.LoginC2SPacket;
 import org.quiltmc.enigma.network.packet.Packet;
 import org.quiltmc.enigma.api.source.DecompiledClassSource;
 import org.quiltmc.enigma.api.source.DecompilerService;
@@ -607,18 +608,18 @@ public StatsGenerator getStatsGenerator() {
 	}
 
 	public void createClient(String username, String ip, int port, char[] password) throws IOException {
-		this.client = new EnigmaClient(this, ip, port);
+		this.client = new IntegratedEnigmaClient(this, ip, port);
 		this.client.connect();
 		this.client.sendPacket(new LoginC2SPacket(this.project.getJarChecksum(), password, username));
 		this.gui.setConnectionState(ConnectionState.CONNECTED);
 	}
 
-	public void createServer(int port, char[] password) throws IOException {
+	public void createServer(String username, int port, char[] password) throws IOException {
 		this.server = new IntegratedEnigmaServer(this.project.getJarChecksum(), password, EntryRemapper.mapped(this.project.getJarIndex(), this.project.getMappingsIndex(), new HashEntryTree<>(this.project.getRemapper().getJarProposedMappings()), new HashEntryTree<>(this.project.getRemapper().getDeobfMappings()), this.project.getEnigma().getNameProposalServices()), port);
 		this.server.start();
-		this.client = new EnigmaClient(this, "127.0.0.1", port);
+		this.client = new IntegratedEnigmaClient(this, "127.0.0.1", port);
 		this.client.connect();
-		this.client.sendPacket(new LoginC2SPacket(this.project.getJarChecksum(), password, Config.net().username.value()));
+		this.client.sendPacket(new LoginC2SPacket(this.project.getJarChecksum(), password, username));
 		this.gui.setConnectionState(ConnectionState.HOSTING);
 	}
 
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/DevSection.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/DevSection.java
index b521f9474..c7452cd43 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/DevSection.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/DevSection.java
@@ -5,6 +5,7 @@
 import org.quiltmc.config.api.annotations.Processor;
 import org.quiltmc.config.api.values.TrackedValue;
 import org.quiltmc.enigma.api.source.DecompiledClassSource;
+import org.quiltmc.enigma.gui.network.IntegratedEnigmaClient;
 
 public class DevSection extends ReflectiveConfig.Section {
 	@SerializedName("show_mapping_source_plugin")
@@ -14,8 +15,17 @@ public class DevSection extends ReflectiveConfig.Section {
 	@Processor("processDebugTokenHighlights")
 	public final TrackedValue<Boolean> debugTokenHighlights = this.value(false);
 
+	@SerializedName("log_client_packets")
+	@Processor("processLogClientPackets")
+	public final TrackedValue<Boolean> logClientPackets = this.value(false);
+
 	@SuppressWarnings("unused")
 	public void processDebugTokenHighlights(TrackedValue.Builder<Boolean> builder) {
 		builder.callback(trackedValue -> DecompiledClassSource.DEBUG_TOKEN_HIGHLIGHTS = trackedValue.value());
 	}
+
+	@SuppressWarnings("unused")
+	public void processLogClientPackets(TrackedValue.Builder<Boolean> builder) {
+		builder.callback(trackedValue -> IntegratedEnigmaClient.LOG_PACKETS = trackedValue.value());
+	}
 }
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/ConnectToServerDialog.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/ConnectToServerDialog.java
index 257982d43..47b376f3e 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/ConnectToServerDialog.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/ConnectToServerDialog.java
@@ -54,6 +54,8 @@ protected List<Pair<String, Component>> createComponents() {
 	public void validateInputs() {
 		if (StandardValidation.notBlank(this.vc, this.ipField.getText()) && ServerAddress.from(this.ipField.getText(), EnigmaServer.DEFAULT_PORT) == null) {
 			this.vc.raise(Message.INVALID_IP);
+		} else if (StandardValidation.notBlank(this.vc, this.usernameField.getText()) && !EnigmaServer.usernameMatchesRegex(this.usernameField.getText())) {
+			this.vc.raise(Message.INVALID_USERNAME);
 		}
 	}
 
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/CreateServerDialog.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/CreateServerDialog.java
index ffd9f2441..2504ed75d 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/CreateServerDialog.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/CreateServerDialog.java
@@ -17,6 +17,7 @@
 import javax.swing.JTextField;
 
 public class CreateServerDialog extends AbstractDialog {
+	private JTextField usernameField;
 	private JTextField portField;
 	private JPasswordField passwordField;
 
@@ -32,13 +33,16 @@ public CreateServerDialog(Frame owner, Gui gui) {
 
 	@Override
 	protected List<Pair<String, Component>> createComponents() {
+		this.usernameField = new JTextField(Config.net().username.value());
 		this.portField = new JTextField(Integer.toString(Config.net().serverPort.value()));
 		this.passwordField = new JPasswordField(Config.net().serverPassword.value());
 
+		this.usernameField.addActionListener(event -> this.confirm());
 		this.portField.addActionListener(event -> this.confirm());
 		this.passwordField.addActionListener(event -> this.confirm());
 
 		return Arrays.asList(
+				new Pair<>("prompt.connect.username", this.usernameField),
 				new Pair<>("prompt.create_server.port", this.portField),
 				new Pair<>("prompt.password", this.passwordField)
 		);
@@ -49,6 +53,8 @@ public void validateInputs() {
 		StandardValidation.isIntInRange(this.vc, this.portField.getText(), 0, 65535);
 		if (this.passwordField.getPassword().length > EnigmaServer.MAX_PASSWORD_LENGTH) {
 			this.vc.raise(Message.FIELD_LENGTH_OUT_OF_RANGE, EnigmaServer.MAX_PASSWORD_LENGTH);
+		} else if (StandardValidation.notBlank(this.vc, this.usernameField.getText()) && !EnigmaServer.usernameMatchesRegex(this.usernameField.getText())) {
+			this.vc.raise(Message.INVALID_USERNAME);
 		}
 	}
 
@@ -58,6 +64,7 @@ public Result getResult() {
 		this.validateInputs();
 		if (!this.vc.canProceed()) return null;
 		return new Result(
+				this.usernameField.getText(),
 				Integer.parseInt(this.portField.getText()),
 				this.passwordField.getPassword()
 		);
@@ -73,6 +80,6 @@ public static Result show(Gui gui) {
 		return r;
 	}
 
-	public record Result(int port, char[] password) {
+	public record Result(String username, int port, char[] password) {
 	}
 }
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/docker/CollabDocker.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/docker/CollabDocker.java
index 6dfe0e783..abdca1450 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/docker/CollabDocker.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/docker/CollabDocker.java
@@ -2,7 +2,7 @@
 
 import org.quiltmc.enigma.gui.Gui;
 import org.quiltmc.enigma.gui.docker.component.DockerTitleBar;
-import org.quiltmc.enigma.network.packet.MessageC2SPacket;
+import org.quiltmc.enigma.network.packet.c2s.MessageC2SPacket;
 import org.quiltmc.enigma.util.I18n;
 
 import javax.swing.AbstractAction;
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/DevMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/DevMenu.java
new file mode 100644
index 000000000..5b0ef0541
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/DevMenu.java
@@ -0,0 +1,132 @@
+package org.quiltmc.enigma.gui.element;
+
+import org.quiltmc.enigma.gui.Gui;
+import org.quiltmc.enigma.gui.config.Config;
+import org.quiltmc.enigma.gui.util.ScaleUtil;
+import org.quiltmc.enigma.util.EntryTreePrinter;
+import org.quiltmc.enigma.util.I18n;
+import org.tinylog.Logger;
+
+import javax.annotation.Nullable;
+import javax.swing.JButton;
+import javax.swing.JCheckBoxMenuItem;
+import javax.swing.JFileChooser;
+import javax.swing.JFrame;
+import javax.swing.JMenu;
+import javax.swing.JMenuItem;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTextArea;
+import javax.swing.WindowConstants;
+import java.awt.BorderLayout;
+import java.awt.Font;
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.file.Files;
+
+public class DevMenu extends JMenu {
+	private final Gui gui;
+
+	private final JCheckBoxMenuItem showMappingSourcePluginItem = new JCheckBoxMenuItem();
+	private final JCheckBoxMenuItem debugTokenHighlightsItem = new JCheckBoxMenuItem();
+	private final JCheckBoxMenuItem logClientPacketsItem = new JCheckBoxMenuItem();
+	private final JMenuItem printMappingTreeItem = new JMenuItem();
+
+	public DevMenu(Gui gui) {
+		this.gui = gui;
+
+		this.add(this.showMappingSourcePluginItem);
+		this.add(this.debugTokenHighlightsItem);
+		this.add(this.logClientPacketsItem);
+		this.add(this.printMappingTreeItem);
+
+		this.showMappingSourcePluginItem.addActionListener(e -> this.onShowMappingSourcePluginClicked());
+		this.debugTokenHighlightsItem.addActionListener(e -> this.onDebugTokenHighlightsClicked());
+		this.logClientPacketsItem.addActionListener(e -> this.onLogClientPacketsClicked());
+		this.printMappingTreeItem.addActionListener(e -> this.onPrintMappingTreeClicked());
+	}
+
+	public void retranslateUi() {
+		this.setText("Dev");
+
+		this.showMappingSourcePluginItem.setText(I18n.translate("dev.menu.show_mapping_source_plugin"));
+		this.debugTokenHighlightsItem.setText(I18n.translate("dev.menu.debug_token_highlights"));
+		this.logClientPacketsItem.setText(I18n.translate("dev.menu.log_client_packets"));
+		this.printMappingTreeItem.setText(I18n.translate("dev.menu.print_mapping_tree"));
+	}
+
+	public void updateUiState() {
+		boolean jarOpen = this.gui.isJarOpen();
+		this.printMappingTreeItem.setEnabled(jarOpen);
+
+		this.showMappingSourcePluginItem.setState(Config.main().development.showMappingSourcePlugin.value());
+		this.debugTokenHighlightsItem.setState(Config.main().development.debugTokenHighlights.value());
+		this.logClientPacketsItem.setState(Config.main().development.logClientPackets.value());
+	}
+
+	private void showSavableTextAreaDialog(String title, String text, @Nullable String fileName) {
+		var frame = new JFrame(title);
+		var pane = frame.getContentPane();
+		pane.setLayout(new BorderLayout());
+
+		var textArea = new JTextArea(text);
+		textArea.setFont(ScaleUtil.getFont(Font.MONOSPACED, Font.PLAIN, 12));
+		pane.add(new JScrollPane(textArea), BorderLayout.CENTER);
+
+		var buttonPane = new JPanel();
+
+		var saveButton = new JButton(I18n.translate("prompt.save"));
+		saveButton.addActionListener(e -> {
+			var chooser = new JFileChooser();
+			if (fileName != null) {
+				chooser.setSelectedFile(new File(fileName));
+			}
+
+			if (chooser.showSaveDialog(frame) == JFileChooser.APPROVE_OPTION) {
+				try {
+					Files.writeString(chooser.getSelectedFile().toPath(), text);
+				} catch (IOException ex) {
+					Logger.error(ex, "Failed to save the file");
+				}
+			}
+		});
+		buttonPane.add(saveButton);
+
+		var closeButton = new JButton(I18n.translate("prompt.ok"));
+		closeButton.addActionListener(e -> frame.dispose());
+		buttonPane.add(closeButton);
+
+		pane.add(buttonPane, BorderLayout.SOUTH);
+
+		frame.setSize(ScaleUtil.getDimension(1200, 400));
+		frame.setLocationRelativeTo(this.gui.getFrame());
+		frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
+		frame.setVisible(true);
+	}
+
+	private void onShowMappingSourcePluginClicked() {
+		var value = this.showMappingSourcePluginItem.getState();
+		Config.main().development.showMappingSourcePlugin.setValue(value, true);
+	}
+
+	private void onDebugTokenHighlightsClicked() {
+		var value = this.debugTokenHighlightsItem.getState();
+		Config.main().development.debugTokenHighlights.setValue(value, true);
+	}
+
+	private void onLogClientPacketsClicked() {
+		var value = this.logClientPacketsItem.getState();
+		Config.main().development.logClientPackets.setValue(value, true);
+	}
+
+	private void onPrintMappingTreeClicked() {
+		var mappings = this.gui.getController().getProject().getRemapper().getMappings();
+
+		var text = new StringWriter();
+		EntryTreePrinter.print(new PrintWriter(text), mappings);
+
+		this.showSavableTextAreaDialog(I18n.translate("dev.mapping_tree"), text.toString(), "mapping_tree.txt");
+	}
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/MenuBar.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/MenuBar.java
index ba08f7e33..3fb14604a 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/MenuBar.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/MenuBar.java
@@ -21,34 +21,21 @@
 import org.quiltmc.enigma.gui.util.GuiUtil;
 import org.quiltmc.enigma.gui.util.LanguageUtil;
 import org.quiltmc.enigma.gui.util.ScaleUtil;
-import org.quiltmc.enigma.util.EntryTreePrinter;
 import org.quiltmc.enigma.util.I18n;
 import org.quiltmc.enigma.util.Pair;
 import org.quiltmc.enigma.util.validation.Message;
 import org.quiltmc.enigma.util.validation.ParameterizedMessage;
-import org.tinylog.Logger;
 
 import javax.annotation.Nullable;
 import javax.swing.ButtonGroup;
-import javax.swing.JButton;
-import javax.swing.JCheckBoxMenuItem;
 import javax.swing.JFileChooser;
-import javax.swing.JFrame;
 import javax.swing.JMenu;
 import javax.swing.JMenuBar;
 import javax.swing.JMenuItem;
 import javax.swing.JOptionPane;
-import javax.swing.JPanel;
 import javax.swing.JRadioButtonMenuItem;
-import javax.swing.JScrollPane;
-import javax.swing.JTextArea;
-import javax.swing.WindowConstants;
-import java.awt.BorderLayout;
-import java.awt.Font;
 import java.io.File;
 import java.io.IOException;
-import java.io.PrintWriter;
-import java.io.StringWriter;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Arrays;
@@ -105,15 +92,13 @@ public class MenuBar {
 	private final JMenuItem githubItem = new JMenuItem();
 
 	// Enabled with system property "enigma.development" or "--development" flag
-	private final JMenu devMenu = new JMenu();
-	private final JCheckBoxMenuItem showMappingSourcePluginItem = new JCheckBoxMenuItem();
-	private final JCheckBoxMenuItem debugTokenHighlightsItem = new JCheckBoxMenuItem();
-	private final JMenuItem printMappingTreeItem = new JMenuItem();
+	private final DevMenu devMenu;
 
 	private final Gui gui;
 
 	public MenuBar(Gui gui) {
 		this.gui = gui;
+		this.devMenu = new DevMenu(gui);
 
 		JMenuBar ui = gui.getMainWindow().getMenuBar();
 
@@ -179,9 +164,6 @@ public MenuBar(Gui gui) {
 		this.helpMenu.add(this.githubItem);
 		ui.add(this.helpMenu);
 
-		this.devMenu.add(this.showMappingSourcePluginItem);
-		this.devMenu.add(this.debugTokenHighlightsItem);
-		this.devMenu.add(this.printMappingTreeItem);
 		if (System.getProperty("enigma.development", "false").equalsIgnoreCase("true")) {
 			ui.add(this.devMenu);
 		}
@@ -214,9 +196,6 @@ public MenuBar(Gui gui) {
 		this.startServerItem.addActionListener(e -> this.onStartServerClicked());
 		this.aboutItem.addActionListener(e -> AboutDialog.show(this.gui.getFrame()));
 		this.githubItem.addActionListener(e -> this.onGithubClicked());
-		this.showMappingSourcePluginItem.addActionListener(e -> this.onShowMappingSourcePluginClicked());
-		this.debugTokenHighlightsItem.addActionListener(e -> this.onDebugTokenHighlightsClicked());
-		this.printMappingTreeItem.addActionListener(e -> this.onPrintMappingTreeClicked());
 	}
 
 	public void setKeyBinds() {
@@ -251,10 +230,8 @@ public void updateUiState() {
 		this.exportSourceItem.setEnabled(jarOpen);
 		this.exportJarItem.setEnabled(jarOpen);
 		this.statsItem.setEnabled(jarOpen);
-		this.printMappingTreeItem.setEnabled(jarOpen);
 
-		this.showMappingSourcePluginItem.setState(Config.main().development.showMappingSourcePlugin.value());
-		this.debugTokenHighlightsItem.setState(Config.main().development.debugTokenHighlights.value());
+		this.devMenu.updateUiState();
 	}
 
 	public void retranslateUi() {
@@ -303,10 +280,7 @@ public void retranslateUi() {
 		this.aboutItem.setText(I18n.translate("menu.help.about"));
 		this.githubItem.setText(I18n.translate("menu.help.github"));
 
-		this.devMenu.setText("Dev");
-		this.showMappingSourcePluginItem.setText(I18n.translate("dev.menu.show_mapping_source_plugin"));
-		this.debugTokenHighlightsItem.setText(I18n.translate("dev.menu.debug_token_highlights"));
-		this.printMappingTreeItem.setText(I18n.translate("dev.menu.print_mapping_tree"));
+		this.devMenu.retranslateUi();
 	}
 
 	private void onOpenJarClicked() {
@@ -476,11 +450,12 @@ public void onStartServerClicked() {
 
 		this.gui.getController().disconnectIfConnected(null);
 		try {
-			this.gui.getController().createServer(result.port(), result.password());
+			this.gui.getController().createServer(result.username(), result.port(), result.password());
 			if (Config.main().serverNotificationLevel.value() != NotificationManager.ServerNotificationLevel.NONE) {
 				this.gui.getNotificationManager().notify(new ParameterizedMessage(Message.SERVER_STARTED, result.port()));
 			}
 
+			Config.net().username.setValue(result.username(), true);
 			Config.net().serverPort.setValue(result.port(), true);
 			Config.net().serverPassword.setValue(String.valueOf(result.password()), true);
 		} catch (IOException e) {
@@ -493,58 +468,6 @@ private void onGithubClicked() {
 		GuiUtil.openUrl("https://github.com/QuiltMC/Enigma");
 	}
 
-	private void onShowMappingSourcePluginClicked() {
-		var value = this.showMappingSourcePluginItem.getState();
-		Config.main().development.showMappingSourcePlugin.setValue(value, true);
-	}
-
-	private void onDebugTokenHighlightsClicked() {
-		var value = this.debugTokenHighlightsItem.getState();
-		Config.main().development.debugTokenHighlights.setValue(value, true);
-	}
-
-	private void onPrintMappingTreeClicked() {
-		var mappings = this.gui.getController().getProject().getRemapper().getMappings();
-
-		var text = new StringWriter();
-		EntryTreePrinter.print(new PrintWriter(text), mappings);
-
-		var frame = new JFrame(I18n.translate("dev.mapping_tree"));
-		var pane = frame.getContentPane();
-		pane.setLayout(new BorderLayout());
-
-		var textArea = new JTextArea(text.toString());
-		textArea.setFont(ScaleUtil.getFont(Font.MONOSPACED, Font.PLAIN, 12));
-		pane.add(new JScrollPane(textArea), BorderLayout.CENTER);
-
-		var buttonPane = new JPanel();
-
-		var saveButton = new JButton(I18n.translate("prompt.save"));
-		saveButton.addActionListener(e -> {
-			var chooser = new JFileChooser();
-			chooser.setSelectedFile(new File("mapping_tree.txt"));
-			if (chooser.showSaveDialog(frame) == JFileChooser.APPROVE_OPTION) {
-				try {
-					Files.writeString(chooser.getSelectedFile().toPath(), text.toString());
-				} catch (IOException ex) {
-					Logger.error(ex, "Failed to save the mapping tree");
-				}
-			}
-		});
-		buttonPane.add(saveButton);
-
-		var closeButton = new JButton(I18n.translate("prompt.ok"));
-		closeButton.addActionListener(e -> frame.dispose());
-		buttonPane.add(closeButton);
-
-		pane.add(buttonPane, BorderLayout.SOUTH);
-
-		frame.setSize(ScaleUtil.getDimension(1200, 400));
-		frame.setLocationRelativeTo(this.gui.getFrame());
-		frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
-		frame.setVisible(true);
-	}
-
 	private void onOpenMappingsClicked() {
 		this.gui.enigmaMappingsFileChooser.setCurrentDirectory(new File(Config.main().stats.lastSelectedDir.value()));
 		if (this.gui.enigmaMappingsFileChooser.showOpenDialog(this.gui.getFrame()) == JFileChooser.APPROVE_OPTION) {
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/network/IntegratedEnigmaClient.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/network/IntegratedEnigmaClient.java
new file mode 100644
index 000000000..e5f501a48
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/network/IntegratedEnigmaClient.java
@@ -0,0 +1,20 @@
+package org.quiltmc.enigma.gui.network;
+
+import org.quiltmc.enigma.network.ClientPacketHandler;
+import org.quiltmc.enigma.network.EnigmaClient;
+
+import javax.swing.SwingUtilities;
+
+public class IntegratedEnigmaClient extends EnigmaClient {
+	public static boolean LOG_PACKETS = false;
+
+	public IntegratedEnigmaClient(ClientPacketHandler handler, String ip, int port) {
+		super(handler, ip, port);
+		this.logPackets = LOG_PACKETS;
+	}
+
+	@Override
+	protected void runOnThread(Runnable task) {
+		SwingUtilities.invokeLater(task);
+	}
+}
diff --git a/enigma-server/src/main/java/org/quiltmc/enigma/network/IntegratedEnigmaServer.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/network/IntegratedEnigmaServer.java
similarity index 82%
rename from enigma-server/src/main/java/org/quiltmc/enigma/network/IntegratedEnigmaServer.java
rename to enigma-swing/src/main/java/org/quiltmc/enigma/gui/network/IntegratedEnigmaServer.java
index 417af509e..af6638356 100644
--- a/enigma-server/src/main/java/org/quiltmc/enigma/network/IntegratedEnigmaServer.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/network/IntegratedEnigmaServer.java
@@ -1,6 +1,7 @@
-package org.quiltmc.enigma.network;
+package org.quiltmc.enigma.gui.network;
 
 import org.quiltmc.enigma.api.translation.mapping.EntryRemapper;
+import org.quiltmc.enigma.network.EnigmaServer;
 
 import javax.swing.SwingUtilities;
 
diff --git a/enigma/src/main/java/org/quiltmc/enigma/api/translation/mapping/EntryMapping.java b/enigma/src/main/java/org/quiltmc/enigma/api/translation/mapping/EntryMapping.java
index 17333abac..3f55e4226 100644
--- a/enigma/src/main/java/org/quiltmc/enigma/api/translation/mapping/EntryMapping.java
+++ b/enigma/src/main/java/org/quiltmc/enigma/api/translation/mapping/EntryMapping.java
@@ -23,7 +23,9 @@ public EntryMapping(@Nullable String targetName, @Nullable String javadoc) {
 	public EntryMapping {
 		validateSourcePluginId(sourcePluginId);
 
-		if (tokenType == TokenType.OBFUSCATED && targetName != null) {
+		if (tokenType == null) {
+			throw new RuntimeException("cannot create a mapping without a token type!");
+		} else if (tokenType == TokenType.OBFUSCATED && targetName != null) {
 			throw new RuntimeException("cannot create a named mapping with an obfuscated token type!");
 		} else if (targetName == null && tokenType != TokenType.OBFUSCATED) {
 			throw new RuntimeException("cannot create a non-obfuscated mapping with no name!");
diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/EntryUtil.java b/enigma/src/main/java/org/quiltmc/enigma/util/EntryUtil.java
index 256f17600..13dbb62b4 100644
--- a/enigma/src/main/java/org/quiltmc/enigma/util/EntryUtil.java
+++ b/enigma/src/main/java/org/quiltmc/enigma/util/EntryUtil.java
@@ -60,4 +60,30 @@ public static EntryMapping applyChange(@Nonnull EntryMapping self, EntryChange<?
 
 		return self.withName(name, tokenType, sourcePluginId).withJavadoc(javadoc);
 	}
+
+	public static <E extends Entry<?>> EntryChange<E> changeFromMapping(E entry, EntryMapping mapping) {
+		EntryChange<E> change = EntryChange.modify(entry);
+
+		if (mapping.targetName() != null) {
+			change = change.withDeobfName(mapping.targetName());
+		} else {
+			change = change.clearDeobfName();
+		}
+
+		if (mapping.javadoc() != null) {
+			change = change.withDeobfName(mapping.javadoc());
+		} else {
+			change = change.clearJavadoc();
+		}
+
+		change = change.withTokenType(mapping.tokenType());
+
+		if (mapping.sourcePluginId() != null) {
+			change = change.withSourcePluginId(mapping.sourcePluginId());
+		} else {
+			change = change.clearSourcePluginId();
+		}
+
+		return change;
+	}
 }
diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/validation/Message.java b/enigma/src/main/java/org/quiltmc/enigma/util/validation/Message.java
index 72eaaa482..13221e818 100644
--- a/enigma/src/main/java/org/quiltmc/enigma/util/validation/Message.java
+++ b/enigma/src/main/java/org/quiltmc/enigma/util/validation/Message.java
@@ -5,6 +5,7 @@
 public class Message {
 	public static final Message EMPTY_FIELD = create(Type.ERROR, "empty_field");
 	public static final Message INVALID_IP = create(Type.ERROR, "invalid_ip");
+	public static final Message INVALID_USERNAME = create(Type.ERROR, "invalid_username");
 	public static final Message NOT_INT = create(Type.ERROR, "not_int");
 	public static final Message FIELD_OUT_OF_RANGE_INT = create(Type.ERROR, "field_out_of_range_int");
 	public static final Message FIELD_LENGTH_OUT_OF_RANGE = create(Type.ERROR, "field_length_out_of_range");
diff --git a/enigma/src/main/resources/lang/en_us.json b/enigma/src/main/resources/lang/en_us.json
index 10336ad61..76b961588 100644
--- a/enigma/src/main/resources/lang/en_us.json
+++ b/enigma/src/main/resources/lang/en_us.json
@@ -247,6 +247,7 @@
 	"disconnect.wrong_jar": "Jar checksums don't match (you have the wrong jar)!",
 	"disconnect.wrong_password": "Incorrect password",
 	"disconnect.username_taken": "Username is taken",
+	"disconnect.invalid_username": "Username contains invalid characters",
 
 	"message.chat.text": "%s: %s",
 	"message.connect.text": "[+] %s",
@@ -263,6 +264,7 @@
 
 	"validation.message.empty_field": "This field is required.",
 	"validation.message.invalid_ip": "Invalid IP/Port combination.",
+	"validation.message.invalid_username": "Invalid username. It must start with a letter or underscore, and be 3-32 characters long.",
 	"validation.message.not_int": "Value must be an integer.",
 	"validation.message.field_out_of_range_int": "Value must be an integer between %d and %d.",
 	"validation.message.field_length_out_of_range": "Value must be less than %d characters long.",
@@ -349,6 +351,7 @@
 
 	"dev.menu.show_mapping_source_plugin": "Show mapping source plugin",
 	"dev.menu.debug_token_highlights": "Debug token highlights",
+	"dev.menu.log_client_packets": "Log client packets",
 	"dev.menu.print_mapping_tree": "Print mapping tree",
 	"dev.mapping_tree": "Mapping tree",
 	"dev.source_plugin": "Source plugin"
diff --git a/enigma/src/testFixtures/java/org/quiltmc/enigma/test/plugin/TestEnigmaPlugin.java b/enigma/src/testFixtures/java/org/quiltmc/enigma/test/plugin/TestEnigmaPlugin.java
new file mode 100644
index 000000000..afac6ab12
--- /dev/null
+++ b/enigma/src/testFixtures/java/org/quiltmc/enigma/test/plugin/TestEnigmaPlugin.java
@@ -0,0 +1,58 @@
+package org.quiltmc.enigma.test.plugin;
+
+import org.quiltmc.enigma.api.EnigmaPlugin;
+import org.quiltmc.enigma.api.EnigmaPluginContext;
+import org.quiltmc.enigma.api.analysis.index.jar.EntryIndex;
+import org.quiltmc.enigma.api.analysis.index.jar.JarIndex;
+import org.quiltmc.enigma.api.service.NameProposalService;
+import org.quiltmc.enigma.api.source.TokenType;
+import org.quiltmc.enigma.api.translation.mapping.EntryMapping;
+import org.quiltmc.enigma.api.translation.mapping.EntryRemapper;
+import org.quiltmc.enigma.api.translation.representation.MethodDescriptor;
+import org.quiltmc.enigma.api.translation.representation.entry.Entry;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class TestEnigmaPlugin implements EnigmaPlugin {
+	@Override
+	public void init(EnigmaPluginContext ctx) {
+		this.registerParameterNamingService(ctx);
+	}
+
+	private void registerParameterNamingService(EnigmaPluginContext ctx) {
+		ctx.registerService(NameProposalService.TYPE, ctx1 -> new ParameterNameProposalService());
+	}
+
+	public static class ParameterNameProposalService implements NameProposalService {
+		private static final MethodDescriptor EQUALS_DESC = new MethodDescriptor("(Ljava/lang/Object;)Z");
+
+		@Override
+		public String getId() {
+			return "test:parameters";
+		}
+
+		@Override
+		public Map<Entry<?>, EntryMapping> getProposedNames(JarIndex index) {
+			EntryIndex entryIndex = index.getIndex(EntryIndex.class);
+			Map<Entry<?>, EntryMapping> names = new HashMap<>();
+			for (var method : entryIndex.getMethods()) {
+				if (method.getName().equals("equals") && method.getDesc().equals(EQUALS_DESC)) {
+					var param = method.getParameters(entryIndex).get(0);
+					names.put(param, this.createMapping("o", TokenType.JAR_PROPOSED));
+				} else {
+					for (var param : method.getParameters(entryIndex)) {
+						names.put(param, this.createMapping("param" + param.getIndex(), TokenType.JAR_PROPOSED));
+					}
+				}
+			}
+
+			return names;
+		}
+
+		@Override
+		public Map<Entry<?>, EntryMapping> getDynamicProposedNames(EntryRemapper remapper, Entry<?> obfEntry, EntryMapping oldMapping, EntryMapping newMapping) {
+			return null;
+		}
+	}
+}
diff --git a/enigma/src/testFixtures/resources/META-INF/services/org.quiltmc.enigma.api.EnigmaPlugin b/enigma/src/testFixtures/resources/META-INF/services/org.quiltmc.enigma.api.EnigmaPlugin
new file mode 100644
index 000000000..5a79b5842
--- /dev/null
+++ b/enigma/src/testFixtures/resources/META-INF/services/org.quiltmc.enigma.api.EnigmaPlugin
@@ -0,0 +1 @@
+org.quiltmc.enigma.test.plugin.TestEnigmaPlugin
diff --git a/enigma/src/testFixtures/resources/profile.json b/enigma/src/testFixtures/resources/profile.json
new file mode 100644
index 000000000..cb69ee3ba
--- /dev/null
+++ b/enigma/src/testFixtures/resources/profile.json
@@ -0,0 +1,23 @@
+{
+	"services": {
+		"jar_indexer": [
+			{
+				"id": "enigma:enum_initializer_indexer"
+			},
+			{
+				"id": "enigma:specialized_bridge_method_indexer"
+			}
+		],
+		"name_proposal": [
+			{
+				"id": "enigma:enum_name_proposer"
+			},
+			{
+				"id": "enigma:specialized_method_name_proposer"
+			},
+			{
+				"id": "test:parameters"
+			}
+		]
+	}
+}