diff --git a/rsx-protocol/scripts/build.ps1 b/rsx-protocol/scripts/build.ps1 new file mode 100644 index 0000000..c34f6d7 --- /dev/null +++ b/rsx-protocol/scripts/build.ps1 @@ -0,0 +1,63 @@ +# Builds rsx-protocol into dist\. +# +# Only a JDK is required, same as the loader: javac with --release 17, no Gradle +# and no network. The protocol is deliberately dependency free so both ends can +# carry it and so its tests run without a game, a server or a socket library. +# +# NOTE: ASCII on purpose. Windows PowerShell 5.1 reads a .ps1 with no byte order +# mark using the system ANSI code page, which mangles UTF-8 and breaks the +# parser. Keep script messages English. + +$ErrorActionPreference = 'Stop' + +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Dist = Join-Path $Repo 'dist' +$Out = Join-Path $Repo 'build' + +function Say($text) { Write-Host " $text" -ForegroundColor Cyan } + +if (-not (Get-Command javac -ErrorAction SilentlyContinue)) { + Write-Host ' A JDK (javac) is required.' -ForegroundColor Red + exit 1 +} + +New-Item -ItemType Directory -Force -Path $Dist, $Out | Out-Null + +$mainOut = Join-Path $Out 'classes' +Remove-Item $mainOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $mainOut | Out-Null + +$mainFiles = @(Get-ChildItem (Join-Path $Repo 'src\main\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +if ($mainFiles.Count -eq 0) { + Write-Host ' No sources found.' -ForegroundColor Red + exit 1 +} + +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -d $mainOut $mainFiles +if ($LASTEXITCODE -ne 0) { + Write-Host ' Protocol failed to compile.' -ForegroundColor Red + exit 1 +} +Say "protocol compiled ($($mainFiles.Count) files)" + +$manifest = Join-Path $Out 'manifest.txt' +@" +Manifest-Version: 1.0 +Implementation-Title: RustNotMC protocol +Implementation-Version: 0.1.0 +"@ | Set-Content -LiteralPath $manifest -Encoding ASCII + +$jar = Join-Path $Dist 'rsx-protocol.jar' +if (Test-Path -LiteralPath $jar) { Remove-Item -LiteralPath $jar -Force } +& jar cfm $jar $manifest -C $mainOut . +if ($LASTEXITCODE -ne 0) { + Write-Host ' Could not package the jar.' -ForegroundColor Red + exit 1 +} +Say "rsx-protocol.jar ($((Get-Item $jar).Length) bytes)" + +Write-Host '' +Write-Host ' Done:' -ForegroundColor Green +Write-Host " $jar" \ No newline at end of file diff --git a/rsx-protocol/scripts/test.ps1 b/rsx-protocol/scripts/test.ps1 new file mode 100644 index 0000000..eb01e50 --- /dev/null +++ b/rsx-protocol/scripts/test.ps1 @@ -0,0 +1,42 @@ +# Compiles rsx-protocol plus its tests and runs them. +# +# ASCII only, for the same reason as build.ps1. + +$ErrorActionPreference = 'Stop' + +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Out = Join-Path $Repo 'build' + +function Say($text) { Write-Host " $text" -ForegroundColor Cyan } + +New-Item -ItemType Directory -Force -Path $Out | Out-Null + +$mainOut = Join-Path $Out 'classes' +Remove-Item $mainOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $mainOut | Out-Null + +$mainFiles = @(Get-ChildItem (Join-Path $Repo 'src\main\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -d $mainOut $mainFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' Main failed.' -ForegroundColor Red; exit 1 } + +$testOut = Join-Path $Out 'test-classes' +Remove-Item $testOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $testOut | Out-Null + +$testFiles = @(Get-ChildItem (Join-Path $Repo 'src\test\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -cp $mainOut -d $testOut $testFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' Test failed to compile.' -ForegroundColor Red; exit 1 } +Say "compiled $($mainFiles.Count) main + $($testFiles.Count) test files" + +& java -cp "$mainOut;$testOut" net.rustnotmc.protocol.ProtocolTest +if ($LASTEXITCODE -ne 0) { + Write-Host '' + Write-Host ' PROTOCOL TESTS FAILED' -ForegroundColor Red + exit 1 +} + +Write-Host '' +Write-Host ' Protocol tests passed.' -ForegroundColor Green \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Buf.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Buf.java new file mode 100644 index 0000000..0fac27d --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Buf.java @@ -0,0 +1,343 @@ +package net.rustnotmc.protocol; + +/** + * A growable byte buffer with the primitives the wire format is built from. + * + * Every read is bounded. A server spends its life taking numbers off sockets + * that belong to strangers, and the difference between a cheap check and an + * allocation is the difference between a hostile peer costing a few cycles and + * costing the heap. Nothing here trusts a length before the bytes behind it + * have actually arrived. + * + * Integers travel as zig-zag varints, so small negatives stay small. The whole + * point of that is worth one sentence: negative coordinates are ordinary in a + * world, and without zig-zag they would take five bytes each instead of one. + */ +public final class Buf { + + /** A varint of a 32 bit value never needs more than five bytes. */ + public static final int MAX_VAR_INT_BYTES = 5; + + /** A varint of a 64 bit value never needs more than ten bytes. */ + public static final int MAX_VAR_LONG_BYTES = 10; + + /** Default ceiling for a length-prefixed byte array or string. */ + public static final int DEFAULT_MAX_PAYLOAD = 2 * 1024 * 1024; + + private byte[] data; + private int writerIndex; + private int readerIndex; + + public Buf() { + this(64); + } + + public Buf(int capacity) { + if (capacity < 0) { + throw new IllegalArgumentException("negative capacity: " + capacity); + } + this.data = new byte[Math.max(8, capacity)]; + } + + public Buf(byte[] initial) { + this(initial.length); + this.writerIndex = initial.length; + System.arraycopy(initial, 0, this.data, 0, initial.length); + } + + // ------------------------------------------------------------- capacity + + public int readableBytes() { + return this.writerIndex - this.readerIndex; + } + + public int writerIndex() { + return this.writerIndex; + } + + /** + * Moves the write cursor, used to rewind a buffer that has nothing left in + * it. Refuses to move past what has actually been written, because a cursor + * past the end would hand out zeroes that were never sent. + */ + public Buf writerIndex(int index) { + if (index < 0 || index > this.data.length) { + throw new IndexOutOfBoundsException("writer index " + index + + " outside 0.." + this.data.length); + } + this.writerIndex = index; + if (this.readerIndex > index) { + this.readerIndex = index; + } + return this; + } + + public int readerIndex() { + return this.readerIndex; + } + + public Buf readerIndex(int index) { + if (index < 0 || index > this.writerIndex) { + throw new IndexOutOfBoundsException("reader index " + index + + " outside 0.." + this.writerIndex); + } + this.readerIndex = index; + return this; + } + + private void ensure(int extra) { + if (this.writerIndex + extra <= this.data.length) { + return; + } + int needed = this.writerIndex + extra; + int grown = Math.max(this.data.length * 2, needed); + byte[] bigger = new byte[grown]; + System.arraycopy(this.data, 0, bigger, 0, this.writerIndex); + this.data = bigger; + } + + // ---------------------------------------------------------------- write + + public Buf writeByte(int value) { + this.ensure(1); + this.data[this.writerIndex++] = (byte) value; + return this; + } + + public Buf writeBoolean(boolean value) { + return writeByte(value ? 1 : 0); + } + + public Buf writeShort(int value) { + this.ensure(2); + this.data[this.writerIndex++] = (byte) (value >>> 8); + this.data[this.writerIndex++] = (byte) value; + return this; + } + + public Buf writeInt(int value) { + this.ensure(4); + this.data[this.writerIndex++] = (byte) (value >>> 24); + this.data[this.writerIndex++] = (byte) (value >>> 16); + this.data[this.writerIndex++] = (byte) (value >>> 8); + this.data[this.writerIndex++] = (byte) value; + return this; + } + + public Buf writeLong(long value) { + this.ensure(8); + for (int shift = 56; shift >= 0; shift -= 8) { + this.data[this.writerIndex++] = (byte) (value >>> shift); + } + return this; + } + + public Buf writeFloat(float value) { + return writeInt(Float.floatToIntBits(value)); + } + + public Buf writeDouble(double value) { + return writeLong(Double.doubleToLongBits(value)); + } + + /** Writes a zig-zag encoded varint. */ + public Buf writeVarInt(int value) { + int zig = (value << 1) ^ (value >> 31); + while (true) { + if ((zig & 0xFFFFFF80) == 0) { + return writeByte(zig); + } + writeByte((zig & 0x7F) | 0x80); + zig >>>= 7; + } + } + + /** Writes a zig-zag encoded varint over a long. */ + public Buf writeVarLong(long value) { + long zig = (value << 1) ^ (value >> 63); + while (true) { + if ((zig & 0xFFFFFFFFFFFFFF80L) == 0L) { + return writeByte((int) zig); + } + writeByte((int) ((zig & 0x7F) | 0x80)); + zig >>>= 7; + } + } + + public Buf writeBytes(byte[] value) { + return writeBytes(value, 0, value.length); + } + + public Buf writeBytes(byte[] value, int offset, int length) { + this.ensure(length); + System.arraycopy(value, offset, this.data, this.writerIndex, length); + this.writerIndex += length; + return this; + } + + public Buf writeString(String value) { + byte[] encoded = value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + writeVarInt(encoded.length); + return writeBytes(encoded); + } + + /** + * Writes a block position as one varint. + * + * The axes do not get equal shares of the long. Three 26 bit fields would be 78 + * bits and simply do not fit, so the split follows what a world actually needs: + * X and Z take 26 bits each, +/-33 million blocks at 1/32 block resolution, and + * Y takes the remaining 12 bits as whole blocks in two's complement, covering + * -2048 to 2047, which is the whole vertical range the format supports. + * + * Y is stored signed and not offset by 2048 on purpose: an offset would need 13 + * bits to hold 0..4095, and the twelfth bit is exactly the one that decides + * whether a value reads back negative. + * + * Every mask is parenthesised on purpose: in Java, << binds tighter than + * &, so "a & 0x3FFFFFF << 12" silently masks the wrong value and + * throws away the low bits of the axis. + */ +public Buf writePosition(double x, double y, double z) { + long packed = ((long) (x * 32.0D) & 0x3FFFFFFL) << 38; + packed |= ((long) (int) y & 0xFFFL) << 26; + packed |= (long) (z * 32.0D) & 0x3FFFFFFL; + return writeVarLong(packed); + } + + /** Reads a position written by {@link #writePosition}. */ + public double[] readPosition() { + long packed = readVarLong(); + // a field of n bits at offset k is sign extended by shifting left far + // enough to land its top bit at 63, then arithmetically shifting right + double x = (double) (packed << 0 >> 38) / 32.0D; + double y = (double) (packed << 26 >> 52); + double z = (double) (packed << 38 >> 38) / 32.0D; + return new double[]{x, y, z}; + } + + // ----------------------------------------------------------------- read + + public int readUnsignedByte() { + require(1); + return this.data[this.readerIndex++] & 0xFF; + } + + public boolean readBoolean() { + return readUnsignedByte() != 0; + } + + public int readShort() { + require(2); + int high = this.data[this.readerIndex++] & 0xFF; + int low = this.data[this.readerIndex++] & 0xFF; + return (high << 8) | low; + } + + public int readInt() { + require(4); + int value = ((this.data[this.readerIndex++] & 0xFF) << 24) + | ((this.data[this.readerIndex++] & 0xFF) << 16) + | ((this.data[this.readerIndex++] & 0xFF) << 8) + | (this.data[this.readerIndex++] & 0xFF); + return value; + } + + public long readLong() { + require(8); + long value = 0L; + for (int i = 0; i < 8; i++) { + value = (value << 8) | (this.data[this.readerIndex++] & 0xFF); + } + return value; + } + + public float readFloat() { + return Float.intBitsToFloat(readInt()); + } + + public double readDouble() { + return Double.longBitsToDouble(readLong()); + } + + /** + * Reads a varint, refusing anything longer than it has to be. + * + * Without the byte cap a peer can send ten bytes of 0xFF and turn a five + * byte integer into an endless loop. + */ + public int readVarInt() { + int result = 0; + for (int shift = 0; shift < MAX_VAR_INT_BYTES * 7; shift += 7) { + byte b = (byte) readUnsignedByte(); + result |= (b & 0x7F) << shift; + if ((b & 0x80) == 0) { + return (result >>> 1) ^ -(result & 1); + } + } + throw new ProtocolException("varint is longer than " + MAX_VAR_INT_BYTES + " bytes"); + } + + public long readVarLong() { + long result = 0L; + for (int shift = 0; shift < MAX_VAR_LONG_BYTES * 7; shift += 7) { + byte b = (byte) readUnsignedByte(); + result |= (long) (b & 0x7F) << shift; + if ((b & 0x80) == 0) { + return (result >>> 1) ^ -(result & 1); + } + } + throw new ProtocolException("varlong is longer than " + MAX_VAR_LONG_BYTES + " bytes"); + } + + public byte[] readBytes(int length) { + if (length < 0) { + throw new ProtocolException("negative length: " + length); + } + require(length); + byte[] value = new byte[length]; + System.arraycopy(this.data, this.readerIndex, value, 0, length); + this.readerIndex += length; + return value; + } + + public String readString(int maxLength) { + int length = readVarInt(); + if (length < 0 || length > maxLength) { + throw new ProtocolException("string length " + length + " is out of range"); + } + require(length); + String value = new String(this.data, this.readerIndex, length, + java.nio.charset.StandardCharsets.UTF_8); + this.readerIndex += length; + return value; + } + + /** Bytes written but not yet read, as a copy. */ + public byte[] toByteArray() { + byte[] value = new byte[this.writerIndex]; + System.arraycopy(this.data, 0, value, 0, this.writerIndex); + return value; + } + + /** A reader over a slice of what has been written. */ + public Buf reader() { + Buf view = new Buf(Math.max(8, this.writerIndex - this.readerIndex)); + view.writeBytes(this.data, this.readerIndex, this.writerIndex - this.readerIndex); + view.readerIndex = 0; + return view; + } + + private void require(int count) { + if (this.readerIndex + count > this.writerIndex) { + throw new IncompleteException("wanted " + count + " byte(s) but only " + + readableBytes() + " remain"); + } + } + + @Override + public String toString() { + return "Buf[read=" + this.readerIndex + ", written=" + this.writerIndex + + ", capacity=" + this.data.length + "]"; + } +} \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Frame.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Frame.java new file mode 100644 index 0000000..70768ec --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Frame.java @@ -0,0 +1,144 @@ +package net.rustnotmc.protocol; + +/** + * One message: an envelope plus the packet body. + * + * The wire layout is deliberately dull and fixed on both ends: + * + *
+ * magic 4 bytes 0x52535831, "RSX1" + * version varint protocol version, refused if it is not ours + * packetId varint what the body means + * bodyLength varint bytes of body that follow + * body bytes + *+ * + * The magic sits in front so a port can answer immediately instead of waiting + * to be told it is speaking nonsense. The declared body length is checked + * against the maximum before a single byte is reserved, because the only thing + * a malicious length is good for is making the server allocate. + */ +public final class Frame { + + /** "RSX1" in ASCII, as a big endian int. */ + public static final int MAGIC = 0x52535831; + + /** The version this build speaks. */ + public static final int VERSION = 1; + + private final int version; + private final int packetId; + private final byte[] body; + + public Frame(int packetId, byte[] body) { + this(VERSION, packetId, body); + } + + public Frame(int version, int packetId, byte[] body) { + if (body == null) { + throw new IllegalArgumentException("body is null"); + } + this.version = version; + this.packetId = packetId; + this.body = body; + } + + public int version() { + return this.version; + } + + public int packetId() { + return this.packetId; + } + + public byte[] body() { + return this.body; + } + + public int size() { + return 4 + MAX_ENVELOPE + this.body.length; + } + + /** Worst case envelope: magic plus four varints at their longest. */ + public static final int MAX_ENVELOPE = 4 + + Buf.MAX_VAR_INT_BYTES // version + + Buf.MAX_VAR_INT_BYTES // packet id + + Buf.MAX_VAR_INT_BYTES; // body length + + public Buf encode() { + Buf buf = new Buf(size()); + buf.writeInt(MAGIC); + buf.writeVarInt(this.version); + buf.writeVarInt(this.packetId); + buf.writeVarInt(this.body.length); + buf.writeBytes(this.body); + return buf; + } + + /** + * Reads one frame from the front of a buffer. + * + * Returns null when the buffer does not hold a whole frame yet, so a + * caller feeding it a socket can keep the remainder and come back when more + * arrives. Throws as soon as it can tell the stream is impossible, rather + * than waiting forever for bytes that will make it valid. + */ + public static Frame decode(Buf in, int maxBody) { + if (in.readableBytes() < 4) { + return null; + } + int start = in.readerIndex(); + int magic = peekInt(in); + if (magic != MAGIC) { + throw new ProtocolException(String.format( + "not a RustNotMC stream: magic is 0x%08X", Integer.valueOf(magic))); + } + in.readerIndex(start + 4); + + int version; + int packetId; + int length; + try { + version = in.readVarInt(); + packetId = in.readVarInt(); + length = in.readVarInt(); + } catch (IncompleteException more) { + // TCP cut the envelope; the rest is on its way + in.readerIndex(start); + return null; + } catch (ProtocolException malformed) { + throw new ProtocolException("malformed envelope: " + malformed.getMessage(), malformed); + } + + if (version != VERSION) { + throw new ProtocolException("protocol version " + version + + ", this build speaks " + VERSION); + } + if (length < 0 || length > maxBody) { + throw new ProtocolException("declared body of " + length + + " bytes is outside the limit of " + maxBody); + } + if (in.readableBytes() < length) { + in.readerIndex(start); + return null; + } + byte[] body = in.readBytes(length); + return new Frame(version, packetId, body); + } + + private static int peekInt(Buf in) { + int r = in.readerIndex(); + int value = ((in.readUnsignedByte() & 0xFF) << 24) + | ((in.readUnsignedByte() & 0xFF) << 16) + | ((in.readUnsignedByte() & 0xFF) << 8) + | (in.readUnsignedByte() & 0xFF); + in.readerIndex(r); + return value; + } + + @Override + public String toString() { + return "Frame[id=" + this.packetId + ", version=" + this.version + + ", body=" + this.body.length + "B]"; + } +} \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/FrameDecoder.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/FrameDecoder.java new file mode 100644 index 0000000..11886e3 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/FrameDecoder.java @@ -0,0 +1,124 @@ +package net.rustnotmc.protocol; + +/** + * Turns a byte stream into frames, one call per whatever the socket had. + * + * TCP hands over bytes, not messages: one read may be half a frame, or nine + * and a half. The decoder keeps whatever it could not use yet and returns only + * whole frames, so the layer above sees a stream of packets and never thinks + * about segmentation again. + * + * The one thing it will not do is buffer without limit. A peer that announces + * a body of a gigabyte and then sends nothing must not be able to hold a + * gigabyte of this process's heap, so a body larger than the configured + * ceiling is refused the moment its length is read. + */ +public final class FrameDecoder { + + private final Buf pending; + private final int maxBody; + + private boolean poisoned; + private int framesRead; + private long bytesRead; + + public FrameDecoder() { + this(Buf.DEFAULT_MAX_PAYLOAD); + } + + public FrameDecoder(int maxBody) { + if (maxBody <= 0) { + throw new IllegalArgumentException("max body must be positive: " + maxBody); + } + this.pending = new Buf(); + this.maxBody = maxBody; + } + + public int maxBody() { + return this.maxBody; + } + + public int bufferedBytes() { + return this.pending.readableBytes(); + } + + public int framesRead() { + return this.framesRead; + } + + public long bytesRead() { + return this.bytesRead; + } + + /** + * Feeds freshly read bytes in. + * + * Returns every frame that is now complete. Throws on a stream that can no + * longer become valid; a caller should treat that as the end of the + * connection rather than something to retry. + */ + public java.util.List offer(byte[] chunk, int offset, int length) { + if (this.poisoned) { + throw new ProtocolException("decoder was poisoned by an earlier failure"); + } + if (chunk == null) { + throw new IllegalArgumentException("chunk is null"); + } + if (length < 0 || offset < 0 || offset + length > chunk.length) { + throw new IllegalArgumentException("chunk range is out of bounds"); + } + if (length == 0) { + return java.util.Collections.emptyList(); + } + this.pending.writeBytes(chunk, offset, length); + this.bytesRead += length; + + // refuse to sit on more than one maximum frame plus a partial header + int ceiling = this.maxBody + Frame.MAX_ENVELOPE; + if (this.pending.readableBytes() > ceiling) { + this.poisoned = true; + throw new ProtocolException("buffered " + this.pending.readableBytes() + + " bytes without a complete frame, limit is " + ceiling); + } + + java.util.List out = new java.util.ArrayList(); + while (true) { + Frame frame; + try { + frame = Frame.decode(this.pending, this.maxBody); + } catch (ProtocolException bad) { + this.poisoned = true; + throw bad; + } + if (frame == null) { + break; + } + this.framesRead++; + out.add(frame); + } + // hand the tail back to the next read instead of growing the buffer + this.compact(); + return out; + } + + private void compact() { + // keep the original object: callers may be holding it, and replacing the + // field under them would be a nasty surprise + int left = this.pending.readableBytes(); + int from = this.pending.readerIndex(); + if (left == 0) { + this.pending.readerIndex(0); + this.pending.writerIndex(0); + return; + } + if (from == 0) { + return; + } + byte[] all = this.pending.toByteArray(); + byte[] tail = new byte[left]; + System.arraycopy(all, from, tail, 0, left); + this.pending.readerIndex(0); + this.pending.writerIndex(0); + this.pending.writeBytes(tail); + } +} \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/IncompleteException.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/IncompleteException.java new file mode 100644 index 0000000..e14a5f5 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/IncompleteException.java @@ -0,0 +1,19 @@ +package net.rustnotmc.protocol; + +/** + * Raised when a buffer does not yet hold the bytes a read asked for. + * + * Separate from {@link ProtocolException} because the two mean opposite things. + * Running out of bytes is normal: TCP cut a frame in half and the rest is on + * its way. A malformed varint is not normal and the stream is finished. If the + * decoder treated both as fatal it would drop every connection that happened to + * span two packets, which on a real network is most of them. + */ +public final class IncompleteException extends ProtocolException { + + private static final long serialVersionUID = 1L; + + public IncompleteException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java new file mode 100644 index 0000000..b5a8947 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java @@ -0,0 +1,266 @@ +package net.rustnotmc.protocol; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The packets that start a connection, and the table that maps them to ids. + * + * The ids are written down here rather than generated, because they end up in + * every capture and every log line forever. Renumbering one is a protocol + * change and should look like a deliberate act, not a side effect of moving a + * class around. + * + * The order of a connection is fixed and short: + * + *
+ * client -> Hello protocol version, what the client is + * server -> HelloAck accepted version, server name, motd, online count + * client -> Auth account and proof + * server -> AuthAck session token, or a reason it was refused + * client -> Login the session token + * server -> LoginAck player id and entity id, or a reason + * either -> Disconnect a human readable reason + *+ */ +public final class Packets { + + private Packets() { + } + + /** Longest a name, motd or reason may be, in bytes. */ + public static final int MAX_TEXT = 512; + + /** Longest a handshake proof may be, in bytes. */ + public static final int MAX_PROOF = 256; + + /** Longest the handshake body may be, in bytes. */ + public static final int MAX_HANDSHAKE_BODY = 8 * 1024; + + public static final int HELLO = 0x01; + public static final int HELLO_ACK = 0x02; + public static final int AUTH = 0x03; + public static final int AUTH_ACK = 0x04; + public static final int LOGIN = 0x05; + public static final int LOGIN_ACK = 0x06; + public static final int DISCONNECT = 0x7F; + + /** The client introducing itself. */ + public static final class Hello { + public final int protocolVersion; + public final String clientName; + public final int clientVersion; + + public Hello(int protocolVersion, String clientName, int clientVersion) { + this.protocolVersion = protocolVersion; + this.clientName = clientName; + this.clientVersion = clientVersion; + } + + public Buf write() { + Buf buf = new Buf(); + buf.writeVarInt(this.protocolVersion); + buf.writeString(this.clientName); + buf.writeVarInt(this.clientVersion); + return buf; + } + + public static Hello read(Buf in) { + return new Hello(in.readVarInt(), in.readString(MAX_TEXT), in.readVarInt()); + } + } + + /** The server accepting, and describing itself. */ + public static final class HelloAck { + public final int protocolVersion; + public final String serverName; + public final String motd; + public final int onlinePlayers; + public final int maxPlayers; + + public HelloAck(int protocolVersion, String serverName, String motd, + int onlinePlayers, int maxPlayers) { + this.protocolVersion = protocolVersion; + this.serverName = serverName; + this.motd = motd; + this.onlinePlayers = onlinePlayers; + this.maxPlayers = maxPlayers; + } + + public Buf write() { + Buf buf = new Buf(); + buf.writeVarInt(this.protocolVersion); + buf.writeString(this.serverName); + buf.writeString(this.motd); + buf.writeVarInt(this.onlinePlayers); + buf.writeVarInt(this.maxPlayers); + return buf; + } + + public static HelloAck read(Buf in) { + return new HelloAck(in.readVarInt(), in.readString(MAX_TEXT), + in.readString(MAX_TEXT), in.readVarInt(), in.readVarInt()); + } + } + + /** The client claiming an account. */ + public static final class Auth { + public final String account; + public final String proof; + + public Auth(String account, String proof) { + this.account = account; + this.proof = proof; + } + + public Buf write() { + Buf buf = new Buf(); + buf.writeString(this.account); + buf.writeString(this.proof); + return buf; + } + + public static Auth read(Buf in) { + return new Auth(in.readString(MAX_TEXT), in.readString(MAX_PROOF)); + } + } + + /** Whether the account was accepted, and on what terms. */ + public static final class AuthAck { + public final boolean accepted; + public final String reason; + public final String token; + public final long expiresAt; + + private AuthAck(boolean accepted, String reason, String token, long expiresAt) { + this.accepted = accepted; + this.reason = reason; + this.token = token; + this.expiresAt = expiresAt; + } + + public static AuthAck ok(String token, long expiresAt) { + return new AuthAck(true, "", token, expiresAt); + } + + public static AuthAck refused(String reason) { + return new AuthAck(false, reason, "", 0L); + } + + public Buf write() { + Buf buf = new Buf(); + buf.writeBoolean(this.accepted); + buf.writeString(this.reason); + if (this.accepted) { + buf.writeString(this.token); + buf.writeLong(this.expiresAt); + } + return buf; + } + + public static AuthAck read(Buf in) { + if (!in.readBoolean()) { + return refused(in.readString(MAX_TEXT)); + } + return ok(in.readString(MAX_TEXT), in.readLong()); + } + } + + /** The client redeeming the token it was just handed. */ + public static final class Login { + public final String account; + public final String token; + + public Login(String account, String token) { + this.account = account; + this.token = token; + } + + public Buf write() { + Buf buf = new Buf(); + buf.writeString(this.account); + buf.writeString(this.token); + return buf; + } + + public static Login read(Buf in) { + return new Login(in.readString(MAX_TEXT), in.readString(MAX_TEXT)); + } + } + + /** Whether the player is in. */ + public static final class LoginAck { + public final boolean accepted; + public final String reason; + public final int playerId; + public final int entityId; + + private LoginAck(boolean accepted, String reason, int playerId, int entityId) { + this.accepted = accepted; + this.reason = reason; + this.playerId = playerId; + this.entityId = entityId; + } + + public static LoginAck ok(int playerId, int entityId) { + return new LoginAck(true, "", playerId, entityId); + } + + public static LoginAck refused(String reason) { + return new LoginAck(false, reason, -1, -1); + } + + public Buf write() { + Buf buf = new Buf(); + buf.writeBoolean(this.accepted); + buf.writeString(this.reason); + if (this.accepted) { + buf.writeVarInt(this.playerId); + buf.writeVarInt(this.entityId); + } + return buf; + } + + public static LoginAck read(Buf in) { + if (!in.readBoolean()) { + return refused(in.readString(MAX_TEXT)); + } + return ok(in.readVarInt(), in.readVarInt()); + } + } + + /** A reason, said out loud, before the connection goes away. */ + public static final class Disconnect { + public final String reason; + + public Disconnect(String reason) { + this.reason = reason; + } + + public Buf write() { + return new Buf().writeString(this.reason); + } + + public static Disconnect read(Buf in) { + return new Disconnect(in.readString(MAX_TEXT)); + } + } + + /** + * Every packet id this build knows, and what it decodes into. + * + * Written as plain data so that adding a packet is one line and an unknown + * id is a loud error instead of a silent skip. + */ + public static Map