Клиент говорит с сервером по нашему протоколу

Раньше клиент умел только показывать меню. Теперь он заходит на сервер,
получает наш мир и рисует его.

Как это устроено:

- Наш сетевой слой (net/rustnotmc/client/net) читает наш протокол и
  переводит его пакеты в пакеты игры. Игра остаётся игрой: у неё есть
  мир, игрок, сущности и рендер, и все они работают. Не переписывать их
  ради того, чтобы нарисовать тот же мир иначе, незачем.
- Пакеты игры, которые не имеют соответствия у нас, не отправляются, а
  наоборот: перемещение игрока и ломание блока переводятся в наши.
- Экран входа игры (GuiConnecting) заменён наш: он спрашивает у лаунчера,
  кем пришёл игрок, и подставляет это в рукопожатие.
- Соединение качается из тика игры, а не из экрана. Рукопожатие, которое
  зависит от того, что экран ещё на экране, ломается alt-tab'ом.

Вход теперь через лаунчер, а не внутри игры:

- Лаунчер уже умеет логинить игрока и отдаёт игре имя, uuid и токен
  (--username, --uuid, --accessToken). Это та же роль, что у Yggdrasil в
  ванилле, только лаунчер наш.
- Сервер спрашивает лаунчер, один из ли наш этот игрок, и заодно получает
  бан-лист. Токен используется один раз и не хранится.
- Свои аккаунты сервера, когда настроен лаунчер, закрыты: сервер с двумя
  дверями не имеет дверей. Открыть их можно флагом, но намеренно.
- Если игру запустили без лаунчера, экран всё равно спрашивает имя и
  пароль: это путь для своего сервера и для разработки.

Проверено живьём: клиент вошёл по токену лаунчера, сервер спросил
лаунчер, лаунчер сказал "свой", игрок получил мир, 210 чанков пришли и
лежат на диске, рельеф нарисован.

Что чинилось по дороге:

- Minecraft.setIngameFocus() возвращал на главное меню, когда окно
  получало фокус. Игрок как раз кликает в окно, чтобы напечатать пароль, и
  экран входа исчезал в этот самый момент.
- Ответ лаунчера разбирается, а не ищется подстрокой: {"valid": true} и
  {"valid":true} это один и тот же ответ, и сервер, который понимает
  только второй, ломает работающий лаунчер.
This commit is contained in:
sasheg dev
2026-10-19 20:10:20 +03:00
parent 46784aa88f
commit 450bdecc34
10 changed files with 1085 additions and 3 deletions
+16
View File
@@ -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;
+8 -1
View File
@@ -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
+7 -2
View File
@@ -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 + '"')
@@ -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;
}
}
}
@@ -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;
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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<Frame> inbox = new LinkedBlockingQueue<Frame>();
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<Packet<INetHandlerPlayClient>> 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<INetHandlerPlayClient> 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
}
}
@@ -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<Packet<INetHandlerPlayClient>> join(int entityId, int maxPlayers,
double spawnX, double spawnY, double spawnZ) {
List<Packet<INetHandlerPlayClient>> packets =
new ArrayList<Packet<INetHandlerPlayClient>>();
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<INetHandlerPlayClient> 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;
}
}
}
+11
View File
@@ -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))