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