diff --git a/game-fork/patches/decompiler-fixes.txt b/game-fork/patches/decompiler-fixes.txt
index 998beee..276fc3f 100644
--- a/game-fork/patches/decompiler-fixes.txt
+++ b/game-fork/patches/decompiler-fixes.txt
@@ -1117,3 +1117,19 @@ this.framebuffer.framebufferRender(l * k, i1 * k);
net.rustnotmc.client.RsxBootstrap.renderLoadingScreen(this.mc);
this.mc.updateDisplay();
+
+=== FILE net/minecraft/client/Minecraft.java
+--- BEFORE
+ this.mouseHelper.grabMouseCursor();
+ this.displayGuiScreen((GuiScreen) null);
+ this.leftClickCounter = 10000;
+--- AFTER
+ this.mouseHelper.grabMouseCursor();
+ // the login screen has to survive the window being focused.
+ // Focusing the window is exactly what a player does before
+ // typing their password, and the game would otherwise put the
+ // main menu up in the moment the cursor lands in the box.
+ if (!(this.currentScreen instanceof GuiConnecting)) {
+ this.displayGuiScreen((GuiScreen) null);
+ }
+ this.leftClickCounter = 10000;
diff --git a/game-fork/scripts/compile-game.ps1 b/game-fork/scripts/compile-game.ps1
index f26977a..e80d8ae 100644
--- a/game-fork/scripts/compile-game.ps1
+++ b/game-fork/scripts/compile-game.ps1
@@ -32,7 +32,14 @@ foreach ($j in (Get-ChildItem (Join-Path $Astral 'libraries') -Recurse -Filter '
# from drifting apart.
$Loader = Join-Path $Client 'dist\rsx-loader.jar'
-$cp = @($good + $(if (Test-Path $Loader) { $Loader -replace '\\', '/' } else { $null })) -join ';'
+# The client speaks the server's own protocol, so the protocol and the account
+# code are compiled against as well. They are the same jars the game runs with.
+$Protocol = Join-Path $Root 'rsx-protocol\dist\rsx-protocol.jar'
+$Auth = Join-Path $Root 'rsx-auth\dist\rsx-auth.jar'
+
+$cp = @($good + $(if (Test-Path $Loader) { $Loader -replace '\\', '/' } else { $null }) `
+ + $(if (Test-Path $Protocol) { $Protocol -replace '\\', '/' } else { $null }) `
+ + $(if (Test-Path $Auth) { $Auth -replace '\\', '/' } else { $null })) -join ';'
$argf = Join-Path $Temp 'fork-build.txt'
$errf = Join-Path $Temp 'fork-build-errors.txt'
$sb = New-Object System.Text.StringBuilder
diff --git a/game-fork/scripts/prepare-game.ps1 b/game-fork/scripts/prepare-game.ps1
index 95aad90..81cfb16 100644
--- a/game-fork/scripts/prepare-game.ps1
+++ b/game-fork/scripts/prepare-game.ps1
@@ -128,9 +128,14 @@ foreach ($j in (Get-ChildItem (Join-Path $Astral 'libraries') -Recurse -Filter '
if ($ok) { $good.Add(($j.FullName -replace '\\', '/')) }
}
# The fork calls into the mod loader, so the loader has to be visible to javac
-# exactly as it is at runtime.
+# exactly as it is at runtime. The protocol and the account code are compiled
+# against too: the client speaks the server's protocol, not the vanilla one.
$Loader = Join-Path $Client 'dist\rsx-loader.jar'
-$cp = @($good + $(if (Test-Path $Loader) { $Loader -replace '\\', '/' } else { $null })) -join ';'
+$Protocol = Join-Path $Root 'rsx-protocol\dist\rsx-protocol.jar'
+$Auth = Join-Path $Root 'rsx-auth\dist\rsx-auth.jar'
+$cp = @($good + $(if (Test-Path $Loader) { $Loader -replace '\\', '/' } else { $null }) `
+ + $(if (Test-Path $Protocol) { $Protocol -replace '\\', '/' } else { $null }) `
+ + $(if (Test-Path $Auth) { $Auth -replace '\\', '/' } else { $null })) -join ';'
$sb = New-Object System.Text.StringBuilder
foreach ($a in @('-nowarn', '-proc:none', '-encoding', 'UTF-8', '--release', '17', '-d', ($Out -replace '\\', '/'))) { [void]$sb.AppendLine($a) }
[void]$sb.AppendLine('-cp'); [void]$sb.AppendLine('"' + $cp + '"')
diff --git a/game-fork/src-overlay/net/minecraft/client/multiplayer/GuiConnecting.java b/game-fork/src-overlay/net/minecraft/client/multiplayer/GuiConnecting.java
new file mode 100644
index 0000000..bcf15f7
--- /dev/null
+++ b/game-fork/src-overlay/net/minecraft/client/multiplayer/GuiConnecting.java
@@ -0,0 +1,252 @@
+package net.minecraft.client.multiplayer;
+
+import com.mojang.authlib.GameProfile;
+import java.io.IOException;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiButton;
+import net.minecraft.client.gui.GuiDisconnected;
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.client.gui.GuiTextField;
+import net.minecraft.util.text.TextComponentTranslation;
+import net.rustnotmc.client.gui.RsxPasswordField;
+import net.rustnotmc.client.net.RsxNetworkManager;
+import net.rustnotmc.client.net.RsxWorldBridge;
+
+/**
+ * The screen that gets the player into a server.
+ *
+ * The launcher is where accounts live, so the launcher signs the player in and
+ * hands the game a token. This screen then has nothing to ask: it takes the
+ * token out of the session the launcher built and offers it to the server.
+ *
+ * When the game was started without a launcher there is no session and
+ * somebody has to say who they are, so the screen asks for a name and a
+ * password. That is the way in for a server of our own and the way in for
+ * development; a server with a launcher in front of it does not accept it.
+ *
+ * This replaces the game's own connecting screen, which is where the player
+ * already is when they press "join server", so the flow stays the one they
+ * know.
+ */
+public class GuiConnecting extends GuiScreen {
+
+ /** What the launcher leaves in the session when nobody signed in. */
+ private static final String NO_TOKEN = "0";
+
+ private final GuiScreen previousGuiScreen;
+ private final String host;
+ private final int port;
+
+ private GuiTextField accountField;
+ private RsxPasswordField passwordField;
+ private RsxNetworkManager networkManager;
+ private volatile String status = "";
+
+ /** The launcher's session, if the game was started by the launcher. */
+ private String launcherAccount;
+ private String launcherUuid;
+ private String launcherToken;
+
+ public GuiConnecting(GuiScreen guiscreen, Minecraft minecraft, ServerData serverdata) {
+ this.mc = minecraft;
+ this.previousGuiScreen = guiscreen;
+ ServerAddress serveraddress = ServerAddress.fromString(serverdata.serverIP);
+ minecraft.loadWorld((WorldClient) null);
+ minecraft.setServerData(serverdata);
+ this.host = serveraddress.getIP();
+ this.port = serveraddress.getPort();
+ }
+
+ public GuiConnecting(GuiScreen guiscreen, Minecraft minecraft, String s, int i) {
+ this.mc = minecraft;
+ this.previousGuiScreen = guiscreen;
+ minecraft.loadWorld((WorldClient) null);
+ this.host = s;
+ this.port = i;
+ }
+
+ public void initGui() {
+ this.buttonList.clear();
+
+ this.launcherAccount = sessionValue(this.mc, 0);
+ this.launcherUuid = sessionValue(this.mc, 1);
+ this.launcherToken = sessionValue(this.mc, 2);
+
+ this.accountField = new GuiTextField(0, this.fontRenderer, this.width / 2 - 100,
+ this.height / 4 + 20, 200, 20);
+ this.accountField.setMaxStringLength(32);
+ this.accountField.setText(this.launcherAccount == null
+ ? this.mc.getSession().getProfile().getName() : this.launcherAccount);
+
+ this.passwordField = new RsxPasswordField(1, this.fontRenderer, this.width / 2 - 100,
+ this.height / 4 + 50, 200, 20);
+ this.passwordField.setMaxStringLength(128);
+
+ // two buttons side by side, and they must not lie on top of each other:
+ // the plain constructor makes a button two hundred wide
+ this.buttonList.add(new GuiButton(0, this.width / 2 - 100, this.height / 4 + 90,
+ 98, 20, "Connect"));
+ this.buttonList.add(new GuiButton(1, this.width / 2 + 2, this.height / 4 + 90,
+ 98, 20, "Cancel"));
+
+ if (this.hasLauncherSession()) {
+ // the launcher already signed the player in, so there is nothing to
+ // ask for and nothing to wait for
+ this.passwordField.setFocused(true);
+ this.start();
+ } else {
+ this.passwordField.setFocused(true);
+ }
+ }
+
+ public void updateScreen() {
+ if (this.networkManager == null) {
+ return;
+ }
+
+ if (this.networkManager.isChannelOpen()) {
+ this.networkManager.processReceivedPackets();
+ return;
+ }
+
+ String reason = this.networkManager.failure();
+ this.mc.displayGuiScreen(new GuiDisconnected(this.previousGuiScreen, "connect.failed",
+ new TextComponentTranslation("disconnect.genericReason", new Object[]{
+ reason == null ? "the connection was closed" : reason})));
+ }
+
+ protected void keyTyped(char c0, int i) {
+ if (c0 == '\r' || c0 == '\n') {
+ this.start();
+ return;
+ }
+
+ if (this.accountField.isFocused()) {
+ this.accountField.textboxKeyTyped(c0, i);
+ return;
+ }
+
+ if (this.passwordField.isFocused()) {
+ this.passwordField.textboxKeyTyped(c0, i);
+ }
+ }
+
+ public void mouseClicked(int mouseX, int mouseY, int mouseButton) {
+ super.mouseClicked(mouseX, mouseY, mouseButton);
+ this.accountField.mouseClicked(mouseX, mouseY, mouseButton);
+ this.passwordField.mouseClicked(mouseX, mouseY, mouseButton);
+ }
+
+ protected void actionPerformed(GuiButton guibutton) {
+ if (guibutton.id == 0) {
+ this.start();
+ } else if (guibutton.id == 1) {
+ this.mc.displayGuiScreen(this.previousGuiScreen);
+ }
+ }
+
+ public void drawScreen(int i, int j, float f) {
+ this.drawDefaultBackground();
+ this.drawCenteredString(this.fontRenderer, "RustNotMC", this.width / 2,
+ this.height / 4 - 20, 16777215);
+
+ if (this.hasLauncherSession()) {
+ this.drawCenteredString(this.fontRenderer, "signed in as " + this.launcherAccount,
+ this.width / 2, this.height / 4 + 10, 8421504);
+ } else {
+ this.drawString(this.fontRenderer, "Account", this.width / 2 - 100,
+ this.height / 4 + 10, 8421504);
+ this.accountField.drawTextBox();
+ this.drawString(this.fontRenderer, "Password", this.width / 2 - 100,
+ this.height / 4 + 40, 8421504);
+ this.passwordField.drawTextBox();
+ }
+
+ if (!this.status.isEmpty()) {
+ this.drawCenteredString(this.fontRenderer, this.status, this.width / 2,
+ this.height / 4 + 120, 16777215);
+ }
+
+ super.drawScreen(i, j, f);
+ }
+
+ /**
+ * Starts the connection.
+ *
+ * The socket is opened on a thread of its own, because a server that does
+ * not answer must not freeze the game.
+ */
+ private void start() {
+ if (this.networkManager != null) {
+ return;
+ }
+
+ String account;
+ String token = null;
+ String uuid = null;
+ char[] password = new char[0];
+
+ if (this.hasLauncherSession()) {
+ account = this.launcherAccount;
+ token = this.launcherToken;
+ uuid = this.launcherUuid;
+ } else {
+ account = this.accountField.getText().trim();
+ password = this.passwordField.getText().toCharArray();
+ if (account.isEmpty()) {
+ this.status = "an account name is needed";
+ return;
+ }
+ if (password.length == 0) {
+ this.status = "a password is needed";
+ return;
+ }
+ this.passwordField.setText("");
+ }
+
+ this.status = "connecting to " + this.host + ":" + this.port;
+ System.out.println("[rsx] connecting to " + this.host + ":" + this.port + " as " + account);
+
+ final RsxNetworkManager manager = new RsxNetworkManager(this.mc, this.previousGuiScreen,
+ new GameProfile(RsxWorldBridge.uuidFor(account), account),
+ account, password, token, uuid);
+ this.networkManager = manager;
+
+ (new Thread("Rsx Connect") {
+ public void run() {
+ try {
+ manager.connect(GuiConnecting.this.host, GuiConnecting.this.port);
+ } catch (IOException e) {
+ manager.fail("could not connect: " + e);
+ }
+ }
+ }).start();
+ }
+
+ /** True when the launcher gave the game a session to use. */
+ private boolean hasLauncherSession() {
+ return this.launcherToken != null && !this.launcherToken.isEmpty()
+ && !NO_TOKEN.equals(this.launcherToken);
+ }
+
+ /**
+ * A piece of the session the launcher built.
+ *
+ * Zero is the name, one is the identity, two is the token.
+ */
+ private static String sessionValue(Minecraft minecraft, int which) {
+ if (minecraft == null || minecraft.getSession() == null) {
+ return null;
+ }
+ switch (which) {
+ case 0:
+ return minecraft.getSession().getUsername();
+ case 1:
+ return minecraft.getSession().getPlayerID();
+ case 2:
+ return minecraft.getSession().getToken();
+ default:
+ return null;
+ }
+ }
+}
diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java b/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java
index 0324778..67546dd 100644
--- a/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java
+++ b/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java
@@ -145,6 +145,11 @@ public final class RsxBootstrap {
* mod that counts ticks would disagree with the world it is drawn over.
*/
public static synchronized void clientTick() {
+ // the connection is pumped from here, not from the screen that started
+ // it. A handshake that only runs while a screen is up is a handshake
+ // that the player can break by moving the window.
+ net.rustnotmc.client.net.RsxNetworkManager.pump();
+
if (loader == null || broken || tickFailed) {
return;
}
diff --git a/game-fork/src-overlay/net/rustnotmc/client/gui/RsxPasswordField.java b/game-fork/src-overlay/net/rustnotmc/client/gui/RsxPasswordField.java
new file mode 100644
index 0000000..4e7560f
--- /dev/null
+++ b/game-fork/src-overlay/net/rustnotmc/client/gui/RsxPasswordField.java
@@ -0,0 +1,38 @@
+package net.rustnotmc.client.gui;
+
+import net.minecraft.client.gui.FontRenderer;
+import net.minecraft.client.gui.GuiTextField;
+
+/**
+ * A text field that shows dots instead of what is in it.
+ *
+ * The game has no password field, and a password that appears on the screen
+ * while it is typed is a password that ends up in a screenshot.
+ *
+ * The game draws the text field from its own private text, so the only way to
+ * mask it without copying the whole field is to put the dots in for the moment
+ * it takes to draw and put the real text back afterwards. The cursor ends up
+ * at the end of the text, which for a password is where it is wanted anyway.
+ */
+public class RsxPasswordField extends GuiTextField {
+
+ public RsxPasswordField(int id, FontRenderer font, int x, int y, int width, int height) {
+ super(id, font, x, y, width, height);
+ }
+
+ @Override
+ public void drawTextBox() {
+ String real = getText();
+ if (real.isEmpty()) {
+ super.drawTextBox();
+ return;
+ }
+ StringBuilder dots = new StringBuilder(real.length());
+ for (int i = 0; i < real.length(); i++) {
+ dots.append('*');
+ }
+ setText(dots.toString());
+ super.drawTextBox();
+ setText(real);
+ }
+}
diff --git a/game-fork/src-overlay/net/rustnotmc/client/net/RsxBlockMap.java b/game-fork/src-overlay/net/rustnotmc/client/net/RsxBlockMap.java
new file mode 100644
index 0000000..408b539
--- /dev/null
+++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxBlockMap.java
@@ -0,0 +1,76 @@
+package net.rustnotmc.client.net;
+
+import net.minecraft.block.Block;
+import net.minecraft.block.state.IBlockState;
+import net.minecraft.init.Blocks;
+
+/**
+ * What a block id from the server looks like in the game.
+ *
+ * The server has a small vocabulary of blocks and the game has a large one.
+ * The server's ids are what travels on the wire, so they are the ones that
+ * have to be stable; the game's blocks are what gets drawn.
+ *
+ * A block the game has and the server does not is not sent, and a block the
+ * server has and the game does not is drawn as air. Both directions lose
+ * something and both are honest about it: the server's world is a small one
+ * for now, and pretending otherwise would mean sending ids the client cannot
+ * draw.
+ */
+public final class RsxBlockMap {
+
+ private RsxBlockMap() {
+ }
+
+ /** The game state for a block id from the server. */
+ public static IBlockState stateFor(byte id) {
+ switch (id) {
+ case net.rustnotmc.protocol.Blocks.STONE:
+ return Blocks.STONE.getDefaultState();
+ case net.rustnotmc.protocol.Blocks.DIRT:
+ return Blocks.DIRT.getDefaultState();
+ case net.rustnotmc.protocol.Blocks.GRASS:
+ return Blocks.GRASS.getDefaultState();
+ case net.rustnotmc.protocol.Blocks.SAND:
+ return Blocks.SAND.getDefaultState();
+ case net.rustnotmc.protocol.Blocks.WATER:
+ return Blocks.WATER.getDefaultState();
+ case net.rustnotmc.protocol.Blocks.WOOD:
+ return Blocks.LOG.getDefaultState();
+ case net.rustnotmc.protocol.Blocks.LEAVES:
+ return Blocks.LEAVES.getDefaultState();
+ default:
+ return Blocks.AIR.getDefaultState();
+ }
+ }
+
+ /** The server id for a game block, or -1 when the server has no such block. */
+ public static byte idFor(IBlockState state) {
+ if (state == null) {
+ return -1;
+ }
+ Block block = state.getBlock();
+ if (block == Blocks.STONE) {
+ return net.rustnotmc.protocol.Blocks.STONE;
+ }
+ if (block == Blocks.DIRT) {
+ return net.rustnotmc.protocol.Blocks.DIRT;
+ }
+ if (block == Blocks.GRASS) {
+ return net.rustnotmc.protocol.Blocks.GRASS;
+ }
+ if (block == Blocks.SAND) {
+ return net.rustnotmc.protocol.Blocks.SAND;
+ }
+ if (block == Blocks.WATER) {
+ return net.rustnotmc.protocol.Blocks.WATER;
+ }
+ if (block == Blocks.LOG) {
+ return net.rustnotmc.protocol.Blocks.WOOD;
+ }
+ if (block == Blocks.LEAVES) {
+ return net.rustnotmc.protocol.Blocks.LEAVES;
+ }
+ return -1;
+ }
+}
diff --git a/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java b/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java
new file mode 100644
index 0000000..3f4d3ec
--- /dev/null
+++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java
@@ -0,0 +1,444 @@
+package net.rustnotmc.client.net;
+
+import com.mojang.authlib.GameProfile;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.SocketAddress;
+import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiScreen;
+import net.minecraft.client.network.NetHandlerPlayClient;
+import net.minecraft.network.EnumConnectionState;
+import net.minecraft.network.EnumPacketDirection;
+import net.minecraft.network.INetHandler;
+import net.minecraft.network.NetworkManager;
+import net.minecraft.network.Packet;
+import net.minecraft.network.play.INetHandlerPlayClient;import net.minecraft.network.play.client.CPacketPlayer;
+import net.minecraft.network.play.client.CPacketPlayerDigging;
+import net.minecraft.util.text.ITextComponent;
+import net.minecraft.util.text.TextComponentTranslation;
+import net.rustnotmc.protocol.Buf;
+import net.rustnotmc.protocol.Frame;
+import net.rustnotmc.protocol.FrameDecoder;
+import net.rustnotmc.protocol.Packets;
+import net.rustnotmc.protocol.ProtocolException;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+/**
+ * The client's side of the wire.
+ *
+ * The game is a Minecraft client and it will stay one: the world, the player,
+ * the entities and the renderer all work, and there is no sense in writing a
+ * second set of them. What the game does not have is the server's packets, so
+ * this class speaks the server's protocol and hands the game the packets it
+ * already understands.
+ *
+ * The transport is a plain socket with a thread reading it. The game's own
+ * transport is Netty, and Netty is left alone: the packets are read here and
+ * delivered on the thread the game runs on, which is what the game expects.
+ */
+public final class RsxNetworkManager extends NetworkManager {
+
+ /** How long to wait for the server to answer the connection. */
+ public static final int CONNECT_TIMEOUT_MILLIS = 15000;
+
+ private static final Logger LOGGER = LogManager.getLogger();
+
+ /** Set with -Drsx.trace=true to see every packet that arrives. */
+ private static final boolean TRACE = System.getProperty("rsx.trace") != null;
+
+ /**
+ * The connection that is open, if any.
+ *
+ * The game pumps the connection from its own tick, so that a packet is
+ * dealt with even when the screen that started the connection is gone. A
+ * handshake that depends on a screen surviving is a handshake that can be
+ * interrupted by the player alt-tabbing away.
+ */
+ private static volatile RsxNetworkManager active;
+
+ private final Minecraft mc;
+ private final GuiScreen previousScreen;
+ private final GameProfile profile;
+ private final String account;
+ private final char[] password;
+ private final String token;
+ private final String uuid;
+
+ private final BlockingQueue inbox = new LinkedBlockingQueue();
+ private final FrameDecoder decoder = new FrameDecoder();
+
+ private volatile Socket socket;
+ private volatile OutputStream out;
+ private volatile boolean open;
+ private volatile boolean disconnected;
+ private volatile ITextComponent exitMessage;
+ private volatile String failure;
+ private volatile NetHandlerPlayClient play;
+
+ private byte[] nonce;
+ private byte[] salt;
+
+ public RsxNetworkManager(Minecraft mc, GuiScreen previousScreen, GameProfile profile,
+ String account, char[] password,
+ String token, String uuid) {
+ super(EnumPacketDirection.CLIENTBOUND);
+ this.mc = mc;
+ this.previousScreen = previousScreen;
+ this.profile = profile;
+ this.account = account;
+ this.password = password;
+ this.token = token;
+ this.uuid = uuid;
+ }
+
+ /** The connection that is open, if any. */
+ public static RsxNetworkManager active() {
+ return active;
+ }
+
+ /**
+ * Deals with whatever arrived, if there is a connection.
+ *
+ * Called from the game's tick. During the play phase the game pumps the
+ * connection itself, and doing it twice costs nothing: the queue is
+ * emptied by whoever gets there first.
+ */
+ public static void pump() {
+ RsxNetworkManager manager = active;
+ if (manager != null) {
+ manager.processReceivedPackets();
+ }
+ }
+
+ // ------------------------------------------------------------- connecting
+
+ /**
+ * Opens the socket and says hello.
+ *
+ * The rest of the handshake happens on the thread that reads the socket,
+ * so that a slow server does not freeze the game while the password is
+ * being checked.
+ */
+ public void connect(String host, int port) throws IOException {
+ Socket socket = new Socket();
+ socket.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MILLIS);
+ socket.setTcpNoDelay(true);
+ this.socket = socket;
+ this.out = socket.getOutputStream();
+ this.open = true;
+ active = this;
+
+ Thread reader = new Thread(new Runnable() {
+ public void run() {
+ readLoop();
+ }
+ }, "rsx-client-reader");
+ reader.setDaemon(true);
+ reader.start();
+
+ send(Packets.HELLO, new Packets.Hello(Frame.VERSION, RsxNetworkManager.this.account,
+ "RustNotMC", 1));
+ }
+
+ private void readLoop() {
+ try {
+ InputStream in = this.socket.getInputStream();
+ byte[] buffer = new byte[16384];
+ while (this.open) {
+ int read = in.read(buffer);
+ if (read < 0) {
+ lost("the server closed the connection");
+ return;
+ }
+ for (Frame frame : this.decoder.offer(buffer, 0, read)) {
+ this.inbox.add(frame);
+ }
+ }
+ } catch (ProtocolException broken) {
+ lost(broken.getMessage());
+ } catch (IOException e) {
+ if (this.open) {
+ lost(String.valueOf(e.getMessage()));
+ }
+ }
+ }
+
+ /** The reason the connection ended, or null while it is alive. */
+ public String failure() {
+ return this.failure;
+ }
+
+ /**
+ * Gives up on the connection, with a reason.
+ *
+ * Called from the thread that was opening the socket, when the socket
+ * could not be opened at all.
+ */
+ public void fail(String reason) {
+ lost(reason);
+ }
+
+ // ---------------------------------------------------------------- packets
+
+ /**
+ * Takes what arrived off the socket and hands it to the game.
+ *
+ * Called by the game on its own thread, which is the thread everything
+ * else in the game happens on. A packet that arrives on the reader thread
+ * is only put in a queue; it is dealt with here.
+ */
+ @Override
+ public void processReceivedPackets() {
+ Frame frame;
+ while ((frame = this.inbox.poll()) != null) {
+ try {
+ handle(frame);
+ } catch (Exception e) {
+ LOGGER.error("RustNotMC: could not handle a packet", e);
+ }
+ }
+ }
+
+ private void handle(Frame frame) throws java.io.IOException {
+ if (TRACE) {
+ System.out.println("[rsx] packet " + frame.packetId() + " ("
+ + frame.body().length + " bytes)");
+ }
+ switch (frame.packetId()) {
+ case Packets.HELLO_ACK:
+ onHelloAck(frame);
+ return;
+ case Packets.AUTH_ACK:
+ onAuthAck(frame);
+ return;
+ case Packets.LOGIN_ACK:
+ onLoginAck(frame);
+ return;
+ case Packets.DISCONNECT: {
+ Packets.Disconnect goodbye =
+ Packets.Disconnect.read(new Buf(frame.body()));
+ lost(goodbye.reason);
+ return;
+ }
+ case Packets.WORLD_JOIN:
+ onWorldJoin(frame);
+ return;
+ default:
+ onPlay(frame);
+ }
+ }
+
+ private void onHelloAck(Frame frame) {
+ Packets.HelloAck hello = Packets.HelloAck.read(new Buf(frame.body()));
+ this.nonce = hello.nonce;
+ this.salt = hello.salt;
+
+ if (this.token != null && !this.token.isEmpty()) {
+ // the launcher already signed the player in, and the token it
+ // handed the game is all the game server needs
+ send(Packets.LAUNCHER_AUTH,
+ new Packets.LauncherAuth(this.account, this.uuid, this.token));
+ return;
+ }
+
+ byte[] proof = Packets.Auth.proofFor(this.account, this.password, this.salt, this.nonce);
+ java.util.Arrays.fill(this.password, '\0');
+ send(Packets.AUTH, new Packets.Auth(this.account, proof));
+ }
+
+ private void onAuthAck(Frame frame) {
+ Packets.AuthAck auth = Packets.AuthAck.read(new Buf(frame.body()));
+ if (!auth.accepted) {
+ lost(auth.reason);
+ return;
+ }
+ send(Packets.LOGIN, new Packets.Login(this.account, auth.token));
+ }
+
+ private void onLoginAck(Frame frame) {
+ Packets.LoginAck login = Packets.LoginAck.read(new Buf(frame.body()));
+ if (!login.accepted) {
+ lost(login.reason);
+ return;
+ }
+ // the game takes over from here: from this moment the packets are the
+ // game's own, and only the transport is ours
+ this.play = new NetHandlerPlayClient(this.mc, this.previousScreen, this, this.profile);
+ setNetHandler(this.play);
+ send(Packets.JOIN_WORLD, Packets.JoinWorld.fresh());
+ }
+
+ private void onWorldJoin(Frame frame) {
+ Packets.WorldJoin world = Packets.WorldJoin.read(new Buf(frame.body()));
+ if (this.play == null) {
+ return;
+ }
+ List> packets = RsxWorldBridge.join(world.entityId, 200,
+ world.spawnX, world.spawnY, world.spawnZ);
+ for (int i = 0; i < packets.size(); i++) {
+ packets.get(i).processPacket(this.play);
+ }
+ }
+
+ private void onPlay(Frame frame) throws java.io.IOException {
+ NetHandlerPlayClient handler = this.play;
+ if (handler == null) {
+ return;
+ }
+ if (frame.packetId() == Packets.CHUNK_DATA) {
+ Packets.ChunkData chunk = Packets.ChunkData.read(new Buf(frame.body()));
+ if (this.mc.world == null) {
+ return;
+ }
+ RsxWorldBridge.chunk(this.mc.world, chunk.chunkX, chunk.chunkZ, chunk.blocks)
+ .processPacket(handler);
+ return;
+ }
+ if (frame.packetId() == Packets.ENTITY_SPAWN) {
+ Packets.EntitySpawn spawn = Packets.EntitySpawn.read(new Buf(frame.body()));
+ // the game will not draw a player it has never heard of, so the
+ // name has to arrive before the player does
+ RsxWorldBridge.playerList(spawn.name).processPacket(handler);
+ RsxWorldBridge.spawnPlayer(spawn.entityId, spawn.name,
+ spawn.x, spawn.y, spawn.z, spawn.yaw, spawn.pitch).processPacket(handler);
+ return;
+ }
+ Packet packet = RsxWorldBridge.translate(frame, this.mc.world);
+ if (packet != null) {
+ packet.processPacket(handler);
+ }
+ }
+
+ /**
+ * Sends one of ours.
+ *
+ * The socket is written from one place at a time, because a half written
+ * frame is a stream the server cannot read.
+ */
+ public void send(int packetId, net.rustnotmc.protocol.Packet packet) {
+ OutputStream stream = this.out;
+ if (stream == null || !this.open) {
+ return;
+ }
+ try {
+ Frame frame = new Frame(packetId, packet.write().toByteArray());
+ byte[] bytes = frame.encode().toByteArray();
+ synchronized (this) {
+ stream.write(bytes);
+ stream.flush();
+ }
+ } catch (IOException gone) {
+ lost("the connection was lost");
+ }
+ }
+
+ /**
+ * The game is sending something; the server does not speak the game's
+ * language, so it is translated.
+ *
+ * Most of what the game sends has no counterpart on the server and is
+ * dropped. The two that matter are where the player is and what the
+ * player is breaking.
+ */
+ @Override
+ public void sendPacket(Packet> packet) {
+ if (packet instanceof CPacketPlayer) {
+ CPacketPlayer move = (CPacketPlayer) packet;
+ send(Packets.PLAYER_MOVE, new Packets.PlayerMove(
+ move.getX(0.0D), move.getY(0.0D), move.getZ(0.0D),
+ move.getYaw(0.0F), move.getPitch(0.0F), move.isOnGround()));
+ return;
+ }
+ if (packet instanceof CPacketPlayerDigging) {
+ CPacketPlayerDigging dig = (CPacketPlayerDigging) packet;
+ if (dig.getAction() == CPacketPlayerDigging.Action.START_DESTROY_BLOCK) {
+ net.minecraft.util.math.BlockPos pos = dig.getPosition();
+ send(Packets.BLOCK_CHANGE, new Packets.BlockChange(pos.getX(), pos.getY(),
+ pos.getZ(), net.rustnotmc.protocol.Blocks.AIR));
+ }
+ return;
+ }
+ // everything else the game sends has no place in our protocol yet
+ }
+
+ // ------------------------------------------------------------- teardown
+
+ private void lost(String reason) {
+ this.failure = reason == null ? "the connection was lost" : reason;
+ this.open = false;
+ Socket socket = this.socket;
+ if (socket != null) {
+ try {
+ socket.close();
+ } catch (IOException ignored) {
+ // it is going away anyway
+ }
+ }
+ }
+
+ @Override
+ public void closeChannel(ITextComponent reason) {
+ if (this.disconnected) {
+ return;
+ }
+ this.disconnected = true;
+ this.exitMessage = reason;
+ this.open = false;
+ Socket socket = this.socket;
+ if (socket != null) {
+ try {
+ socket.close();
+ } catch (IOException ignored) {
+ // it is going away anyway
+ }
+ }
+ }
+
+ @Override
+ public void handleDisconnection() {
+ if (this.disconnected) {
+ return;
+ }
+ this.disconnected = true;
+ ITextComponent reason = this.exitMessage;
+ if (reason == null) {
+ reason = new TextComponentTranslation("multiplayer.disconnect.generic");
+ }
+ INetHandler handler = getNetHandler();
+ if (handler instanceof INetHandlerPlayClient) {
+ ((INetHandlerPlayClient) handler).onDisconnect(reason);
+ }
+ }
+
+ @Override
+ public boolean isChannelOpen() {
+ return this.open;
+ }
+
+ @Override
+ public boolean hasNoChannel() {
+ return !this.open;
+ }
+
+ @Override
+ public SocketAddress getRemoteAddress() {
+ return this.socket == null ? null : this.socket.getRemoteSocketAddress();
+ }
+
+ @Override
+ public ITextComponent getExitMessage() {
+ return this.exitMessage;
+ }
+
+ @Override
+ public void setConnectionState(EnumConnectionState state) {
+ // the state machine belongs to the protocol, not to the game's
+ }
+}
diff --git a/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java b/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java
new file mode 100644
index 0000000..1883e3d
--- /dev/null
+++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java
@@ -0,0 +1,228 @@
+package net.rustnotmc.client.net;
+
+import io.netty.buffer.Unpooled;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.UUID;
+import net.minecraft.block.Block;
+import net.minecraft.network.Packet;
+import net.minecraft.network.PacketBuffer;
+import net.minecraft.network.play.INetHandlerPlayClient;
+import net.minecraft.network.play.server.SPacketBlockChange;
+import net.minecraft.network.play.server.SPacketChunkData;
+import net.minecraft.network.play.server.SPacketDestroyEntities;
+import net.minecraft.network.play.server.SPacketEntityTeleport;
+import net.minecraft.network.play.server.SPacketJoinGame;
+import net.minecraft.network.play.server.SPacketPlayerListItem;
+import net.minecraft.network.play.server.SPacketPlayerPosLook;
+import net.minecraft.network.play.server.SPacketSpawnPlayer;
+import net.minecraft.network.play.server.SPacketSpawnPosition;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.world.EnumDifficulty;
+import net.minecraft.world.GameType;
+import net.minecraft.world.World;
+import net.minecraft.world.WorldType;
+import net.minecraft.world.chunk.Chunk;
+import net.minecraft.world.chunk.storage.ExtendedBlockStorage;
+import net.rustnotmc.protocol.ChunkFormat;
+import net.rustnotmc.protocol.Frame;
+import net.rustnotmc.protocol.Packets;
+
+/**
+ * Turns the server's packets into the game's own.
+ *
+ * The client already knows how to be a Minecraft client: it has a world, a
+ * player, entities and a renderer, and all of that works. The only thing it
+ * does not have is the server's packets. So instead of writing a second
+ * renderer and a second world, the server's packets are translated into the
+ * game's own and handed to the game, which does what it already knows how to
+ * do.
+ *
+ * That is the whole reason the wire format is ours: it lets the client keep
+ * the parts that are worth keeping.
+ */
+public final class RsxWorldBridge {
+
+ private RsxWorldBridge() {
+ }
+
+ /**
+ * The packets that put a client into a world.
+ *
+ * Three of them, in this order: the world is created, the spawn point is
+ * set, and the player is put on it. The player cannot be moved before the
+ * world exists, because the player belongs to the world.
+ */
+ public static List> join(int entityId, int maxPlayers,
+ double spawnX, double spawnY, double spawnZ) {
+ List> packets =
+ new ArrayList>();
+ packets.add(new SPacketJoinGame(entityId, GameType.SURVIVAL, false, 0,
+ EnumDifficulty.NORMAL, maxPlayers, WorldType.DEFAULT, false));
+ packets.add(new SPacketSpawnPosition(
+ new BlockPos((int) Math.floor(spawnX), (int) Math.floor(spawnY),
+ (int) Math.floor(spawnZ))));
+ packets.add(new SPacketPlayerPosLook(spawnX, spawnY, spawnZ, 0.0F, 0.0F,
+ EnumSet.noneOf(SPacketPlayerPosLook.EnumFlags.class), 0));
+ return packets;
+ }
+
+ /**
+ * One chunk, as the game wants it.
+ *
+ * The server sends a flat array of block ids; the game wants sections with
+ * palettes, light and biome data. The chunk is built by hand and then
+ * given to the game's own serialiser, which is the only way to be sure the
+ * two agree about the format.
+ *
+ * The light is set to full. The server does not compute light yet, and a
+ * world with no light in it is a world nobody can see.
+ */
+ public static SPacketChunkData chunk(World world, int chunkX, int chunkZ, byte[] blocks) {
+ Chunk chunk = new Chunk(world, chunkX, chunkZ);
+ for (int y = 0; y < ChunkFormat.HEIGHT; y++) {
+ for (int z = 0; z < ChunkFormat.WIDTH; z++) {
+ for (int x = 0; x < ChunkFormat.WIDTH; x++) {
+ byte id = blocks[ChunkFormat.index(x, y, z)];
+ if (id == net.rustnotmc.protocol.Blocks.AIR) {
+ continue;
+ }
+ chunk.setBlockState(new BlockPos(x, y, z), RsxBlockMap.stateFor(id));
+ }
+ }
+ }
+
+ ExtendedBlockStorage[] storages = chunk.getBlockStorageArray();
+ for (int i = 0; i < storages.length; i++) {
+ ExtendedBlockStorage storage = storages[i];
+ if (storage == Chunk.NULL_BLOCK_STORAGE) {
+ continue;
+ }
+ java.util.Arrays.fill(storage.getBlockLight().getData(), (byte) 0xFF);
+ if (storage.getSkyLight() != null) {
+ java.util.Arrays.fill(storage.getSkyLight().getData(), (byte) 0xFF);
+ }
+ }
+ return new SPacketChunkData(chunk, 65535);
+ }
+
+ /** One block, changed. */
+ public static SPacketBlockChange blockChange(int x, int y, int z, byte id) throws java.io.IOException {
+ // the game's packet has no constructor that takes the values, so the
+ // bytes are written the way the game writes them and read back by the
+ // game's own reader. One format, one implementation of it.
+ PacketBuffer buffer = new PacketBuffer(Unpooled.buffer());
+ buffer.writeBlockPos(new BlockPos(x, y, z));
+ buffer.writeVarInt(Block.BLOCK_STATE_IDS.get(RsxBlockMap.stateFor(id)));
+ SPacketBlockChange packet = new SPacketBlockChange();
+ packet.readPacketData(buffer);
+ return packet;
+ }
+
+ /** Somebody else is here. */
+ public static SPacketSpawnPlayer spawnPlayer(int entityId, String name,
+ double x, double y, double z,
+ float yaw, float pitch) throws java.io.IOException {
+ // the game's packet has no constructor that takes the values, so the
+ // bytes are written the way the game writes them and read back by the
+ // game's own reader. One format, one implementation of it.
+ PacketBuffer buffer = new PacketBuffer(Unpooled.buffer());
+ buffer.writeVarInt(entityId);
+ buffer.writeUniqueId(uuidFor(name));
+ buffer.writeDouble(x);
+ buffer.writeDouble(y);
+ buffer.writeDouble(z);
+ buffer.writeByte(angle(yaw));
+ buffer.writeByte(angle(pitch));
+ buffer.writeVarInt(0); // no entity data entries
+ SPacketSpawnPlayer packet = new SPacketSpawnPlayer();
+ packet.readPacketData(buffer);
+ return packet;
+ }
+
+ /** Somebody else moved. */
+ public static SPacketEntityTeleport entityMove(int entityId, double x, double y,
+ double z, float yaw, float pitch)
+ throws java.io.IOException {
+ PacketBuffer buffer = new PacketBuffer(Unpooled.buffer());
+ buffer.writeVarInt(entityId);
+ buffer.writeDouble(x);
+ buffer.writeDouble(y);
+ buffer.writeDouble(z);
+ buffer.writeByte(angle(yaw));
+ buffer.writeByte(angle(pitch));
+ buffer.writeBoolean(true);
+ SPacketEntityTeleport packet = new SPacketEntityTeleport();
+ packet.readPacketData(buffer);
+ return packet;
+ }
+
+ /** Somebody else has left. */
+ public static SPacketDestroyEntities entityRemove(int entityId) {
+ return new SPacketDestroyEntities(new int[]{entityId});
+ }
+
+ /**
+ * The entry that tells the game who a player is.
+ *
+ * The game looks a player up by their identity when one appears, and
+ * refuses to draw a player it has never heard of. The server sends a name
+ * and no identity, so the identity is derived the same way the game
+ * derives it for an offline account.
+ */
+ public static SPacketPlayerListItem playerList(String name) throws java.io.IOException {
+ PacketBuffer buffer = new PacketBuffer(Unpooled.buffer());
+ buffer.writeEnumValue(SPacketPlayerListItem.Action.ADD_PLAYER);
+ buffer.writeVarInt(1);
+ buffer.writeUniqueId(uuidFor(name));
+ buffer.writeString(name);
+ buffer.writeVarInt(0); // no profile properties
+ buffer.writeVarInt(GameType.SURVIVAL.getID());
+ buffer.writeVarInt(0); // latency
+ buffer.writeBoolean(false); // no display name
+ SPacketPlayerListItem packet = new SPacketPlayerListItem();
+ packet.readPacketData(buffer);
+ return packet;
+ }
+
+ /** The identity an offline account gets. */
+ public static UUID uuidFor(String name) {
+ return UUID.nameUUIDFromBytes(
+ ("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
+ }
+
+ /** An angle in the eighths of a turn the game uses. */
+ private static byte angle(float degrees) {
+ return (byte) (int) (degrees * 256.0F / 360.0F);
+ }
+
+ /**
+ * The game's packet for one of ours, or null when we have no counterpart.
+ *
+ * A packet we cannot translate is dropped rather than guessed at.
+ */
+ public static Packet translate(Frame frame, World world)
+ throws java.io.IOException {
+ switch (frame.packetId()) {
+ case Packets.BLOCK_CHANGE: {
+ Packets.BlockChange change =
+ Packets.BlockChange.read(new net.rustnotmc.protocol.Buf(frame.body()));
+ return blockChange(change.x, change.y, change.z, change.blockId);
+ }
+ case Packets.ENTITY_MOVE: {
+ Packets.EntityMove move =
+ Packets.EntityMove.read(new net.rustnotmc.protocol.Buf(frame.body()));
+ return entityMove(move.entityId, move.x, move.y, move.z, move.yaw, move.pitch);
+ }
+ case Packets.ENTITY_REMOVE: {
+ Packets.EntityRemove remove =
+ Packets.EntityRemove.read(new net.rustnotmc.protocol.Buf(frame.body()));
+ return entityRemove(remove.entityId);
+ }
+ default:
+ return null;
+ }
+ }
+}
diff --git a/rsx-client/tools/stage_run.py b/rsx-client/tools/stage_run.py
index 44f1cf4..ca865d8 100644
--- a/rsx-client/tools/stage_run.py
+++ b/rsx-client/tools/stage_run.py
@@ -132,6 +132,17 @@ def main():
else:
print('NOTE: %s missing, run scripts\\build.ps1' % loader_jar)
+ # the client speaks the server's protocol, not the vanilla one, so the
+ # protocol and the account code have to be in the same JVM as the game
+ workspace = os.path.dirname(os.path.dirname(os.path.dirname(
+ os.path.abspath(__file__))))
+ for name in ('rsx-protocol', 'rsx-auth'):
+ jar = os.path.join(workspace, name, 'dist', name + '.jar')
+ if os.path.isfile(jar):
+ cp.append(jar)
+ else:
+ print('NOTE: %s missing, build %s first' % (jar, name))
+
with open(os.path.join(run, 'classpath.txt'), 'w', encoding='utf-8') as handle:
handle.write(os.pathsep.join(cp))