diff --git a/mapsync-mod/build.gradle.kts b/mapsync-mod/build.gradle.kts index eb2adffb..e0e9fe62 100644 --- a/mapsync-mod/build.gradle.kts +++ b/mapsync-mod/build.gradle.kts @@ -35,7 +35,7 @@ dependencies { libs.voxelmap.also { modCompileOnly(it) - //modLocalDep(it) // Uncomment to test VoxelMap + modLocalDep(it) // Uncomment to test VoxelMap } libs.journeymap.also { modCompileOnly(it) diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/Cartography.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/Cartography.java index c21e655d..7138fbd1 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/Cartography.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/Cartography.java @@ -3,6 +3,7 @@ import gjum.minecraft.mapsync.mod.data.BlockColumn; import gjum.minecraft.mapsync.mod.data.BlockInfo; import gjum.minecraft.mapsync.mod.data.ChunkTile; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import gjum.minecraft.mapsync.mod.utils.Shortcuts; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; @@ -14,6 +15,7 @@ import net.minecraft.world.level.LightLayer; import net.minecraft.world.level.chunk.LevelChunk; import net.minecraft.world.level.levelgen.Heightmap; +import org.apache.commons.lang3.function.Failable; public class Cartography { public static ChunkTile chunkTileFromLevel(Level level, int cx, int cz) { @@ -35,7 +37,7 @@ public static ChunkTile chunkTileFromLevel(Level level, int cx, int cz) { // TODO speedup: don't serialize twice (once here, once later when writing to network) final byte[] dataHash; { final ByteBuf columnsBuf = Unpooled.buffer(); - ChunkTile.writeColumns(columns, columnsBuf); + Failable.run(() -> ChunkTile.writeColumns(columns, new BufferWriter(columnsBuf))); final MessageDigest md = Shortcuts.shaHash(); md.update(columnsBuf.nioBuffer()); dataHash = md.digest(); diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/CatchupLogic.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/CatchupLogic.java index d0e26cfd..c2fcb703 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/CatchupLogic.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/CatchupLogic.java @@ -71,7 +71,7 @@ synchronized void maybeRequestMoreCatchup() { // if none get received within a second (all outdated etc.) then request more anyway tsRequestMore = now + 1000; var chunksToRequest = pollCatchupChunks(WATERMARK_REQUEST_MORE); - getMod().requestCatchupData(chunksToRequest); + getMod().requestCatchupData(dimensionState, chunksToRequest); } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java index 49b6e328..83aa6b00 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java @@ -13,13 +13,17 @@ import gjum.minecraft.mapsync.mod.net.packet.ClientboundRegionTimestampsPacket; import gjum.minecraft.mapsync.mod.net.packet.ServerboundCatchupRequestPacket; import gjum.minecraft.mapsync.mod.net.packet.ServerboundChunkTimestampsRequestPacket; +import it.unimi.dsi.fastutil.objects.Object2LongArrayMap; +import it.unimi.dsi.fastutil.objects.Object2LongMap; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; @@ -31,6 +35,7 @@ import net.minecraft.network.protocol.game.ClientboundLoginPacket; import net.minecraft.network.protocol.game.ClientboundRespawnPacket; import net.minecraft.resources.Identifier; +import net.minecraft.world.level.ChunkPos; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; @@ -272,23 +277,21 @@ public void handleRegionTimestamps(ClientboundRegionTimestampsPacket packet, Syn if (!dimension.dimension.identifier().toString().equals(packet.getDimension())) { return; } - var outdatedRegions = new ArrayList(); - for (var regionTs : packet.getTimestamps()) { - var regionPos = new RegionPos(regionTs.x(), regionTs.z()); - long oldestChunkTs = dimension.getOldestChunkTsInRegion(regionPos); - boolean requiresUpdate = regionTs.timestamp() > oldestChunkTs; - - debugLog("region " + regionPos - + (requiresUpdate ? " requires update." : " is up to date.") - + " oldest client chunk ts: " + oldestChunkTs - + ", newest server chunk ts: " + regionTs.timestamp()); - - if (requiresUpdate) { - outdatedRegions.add(regionPos); - } - } - client.send(new ServerboundChunkTimestampsRequestPacket(packet.getDimension(), outdatedRegions)); + var regionTs = packet.getTimestamp(); + + var regionPos = new RegionPos(regionTs.x(), regionTs.z()); + long oldestChunkTs = dimension.getOldestChunkTsInRegion(regionPos); + boolean requiresUpdate = regionTs.timestamp() > oldestChunkTs; + + debugLog("region " + regionPos + + (requiresUpdate ? " requires update." : " is up to date.") + + " oldest client chunk ts: " + oldestChunkTs + + ", newest server chunk ts: " + regionTs.timestamp()); + + if (requiresUpdate) { + client.send(new ServerboundChunkTimestampsRequestPacket(packet.getDimension(), regionPos)); + } } public void handleSharedChunk(ChunkTile chunkTile) { @@ -305,25 +308,44 @@ public void handleSharedChunk(ChunkTile chunkTile) { public void handleCatchupData(ClientboundChunkTimestampsResponsePacket packet) { var dimensionState = getDimensionState(); if (dimensionState == null) return; - debugLog("received catchup: " + packet.chunks.size() + " " + packet.chunks.get(0).syncClient.address); - dimensionState.addCatchupChunks(packet.chunks); + debugLog("received catchup: " + packet.chunks().size() + " " + packet.chunks().get(0).syncClient.address); + dimensionState.addCatchupChunks(packet.chunks()); } - public void requestCatchupData(List chunks) { + public void requestCatchupData( + final @NotNull DimensionState dimensionState, + final List<@NotNull CatchupChunk> chunks + ) { if (chunks == null || chunks.isEmpty()) { debugLog("not requesting more catchup: null/empty"); return; } - - debugLog("requesting more catchup: " + chunks.size()); - var byServer = new HashMap>(); - for (CatchupChunk chunk : chunks) { - var list = byServer.computeIfAbsent(chunk.syncClient.address, (a) -> new ArrayList<>()); - list.add(chunk); + debugLog("requesting %d more catchup chunks".formatted( + chunks.size() + )); + final var catchupChunksBySyncServer = new IdentityHashMap>(); + for (final CatchupChunk chunk : chunks) { + catchupChunksBySyncServer + .computeIfAbsent(chunk.syncClient, (key) -> new ArrayList<>()) + .add(chunk); } - for (List chunksForServer : byServer.values()) { - SyncClient client = chunksForServer.get(0).syncClient; - client.send(new ServerboundCatchupRequestPacket(chunksForServer)); + for (final var byServerEntry : catchupChunksBySyncServer.entrySet()) { + final SyncClient syncConnection = byServerEntry.getKey(); + final Map> regionChunkRequests = new HashMap<>(); + for (final CatchupChunk catchupChunk : byServerEntry.getValue()) { + regionChunkRequests + .computeIfAbsent(RegionPos.forChunkPos(catchupChunk.chunkPos()), (regionPos) -> new Object2LongArrayMap<>()) + .mergeLong(catchupChunk.chunkPos(), catchupChunk.timestamp(), Math::max); + } + for (final var byRegionEntry : regionChunkRequests.entrySet()) { + final RegionPos regionPos = byRegionEntry.getKey(); + syncConnection.send(new ServerboundCatchupRequestPacket( + dimensionState.dimension.identifier(), + (short) regionPos.x(), + (short) regionPos.z(), + byRegionEntry.getValue() + )); + } } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/data/BlockColumn.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/data/BlockColumn.java index ad4d15ce..afbf4402 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/data/BlockColumn.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/data/BlockColumn.java @@ -2,7 +2,8 @@ import static gjum.minecraft.mapsync.mod.Utils.getBiomeRegistry; -import io.netty.buffer.ByteBuf; +import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import java.util.ArrayList; import java.util.List; import net.minecraft.world.level.biome.Biome; @@ -12,27 +13,27 @@ public record BlockColumn( int light, List layers ) { - public void write(ByteBuf buf) { - buf.writeShort(getBiomeRegistry().getId(biome)); - buf.writeByte(light); + public void write(BufferWriter writer) throws Exception { + writer.writeUnt16(getBiomeRegistry().getId(biome)); + writer.writeUnt8(light); // write at most 127 layers, and always include the bottom layer - buf.writeByte(Math.min(127, layers.size())); + writer.writeUnt8(Math.min(127, layers.size())); int i = 0; for (BlockInfo layer : layers) { if (++i == 127) break; - layer.write(buf); + layer.write(writer); } - if (i == 127) layers.get(layers.size() - 1).write(buf); + if (i == 127) layers.getLast().write(writer); } - public static BlockColumn fromBuf(ByteBuf buf) { - int biomeId = buf.readUnsignedShort(); + public static BlockColumn read(BufferReader reader) throws Exception { + int biomeId = reader.readUnt16(); Biome biome = getBiomeRegistry().byId(biomeId); - int light = buf.readUnsignedByte(); - int numLayers = buf.readUnsignedByte(); + int light = reader.readUnt8(); + int numLayers = reader.readUnt8(); var layers = new ArrayList(numLayers); for (int i = 0; i < numLayers; i++) { - layers.add(BlockInfo.fromBuf(buf)); + layers.add(BlockInfo.read(reader)); } return new BlockColumn(biome, light, layers); } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/data/BlockInfo.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/data/BlockInfo.java index e854d1d2..e498257b 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/data/BlockInfo.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/data/BlockInfo.java @@ -1,18 +1,19 @@ package gjum.minecraft.mapsync.mod.data; -import io.netty.buffer.ByteBuf; +import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockState; public record BlockInfo(int y, BlockState state) { - public void write(ByteBuf buf) { - buf.writeShort(y); - buf.writeShort(Block.BLOCK_STATE_REGISTRY.getId(state)); // we can assume this never becomes large enough to overflow + public void write(BufferWriter writer) throws Exception { + writer.writeInt16((short) y); + writer.writeUnt16(Block.BLOCK_STATE_REGISTRY.getId(state)); // we can assume this never becomes large enough to overflow } - public static BlockInfo fromBuf(ByteBuf in) { - int y = in.readShort(); - int stateId = in.readUnsignedShort(); + public static BlockInfo read(BufferReader reader) throws Exception { + int y = reader.readInt16(); + int stateId = reader.readUnt16(); return new BlockInfo(y, Block.BLOCK_STATE_REGISTRY.byId(stateId)); } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/data/ChunkTile.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/data/ChunkTile.java index 7fb682e5..79f64b82 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/data/ChunkTile.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/data/ChunkTile.java @@ -1,9 +1,9 @@ package gjum.minecraft.mapsync.mod.data; -import gjum.minecraft.mapsync.mod.net.Packet; +import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import gjum.minecraft.mapsync.mod.utils.Assertions; import gjum.minecraft.mapsync.mod.utils.MagicValues; -import io.netty.buffer.ByteBuf; import net.minecraft.core.registries.Registries; import net.minecraft.resources.ResourceKey; import net.minecraft.world.level.ChunkPos; @@ -18,48 +18,48 @@ public record ChunkTile( BlockColumn[] columns ) { public ChunkTile { - Assertions.assertNotNull("dataHash", dataHash); - Assertions.assertLength("dataHash", dataHash.length, MagicValues.SHA1_HASH_LENGTH); + Assertions.assertNotNull(dataHash); + Assertions.assertLength(dataHash.length, MagicValues.SHA1_HASH_LENGTH); } public ChunkPos chunkPos() { return new ChunkPos(x, z); } - public void write(ByteBuf buf) { - writeMetadata(buf); - writeColumns(columns, buf); + public void write(BufferWriter writer) throws Exception { + writeMetadata(writer); + writeColumns(columns, writer); } /** * without columns */ - public void writeMetadata(ByteBuf buf) { - Packet.writeResourceKey(buf, dimension); - buf.writeInt(x); - buf.writeInt(z); - buf.writeLong(timestamp); - buf.writeShort(dataVersion); - buf.writeBytes(dataHash); + public void writeMetadata(BufferWriter writer) throws Exception { + writer.writeString(dimension.identifier().toString()); + writer.writeInt32(x); + writer.writeInt32(z); + writer.writeInt64(timestamp); + writer.writeUnt16(dataVersion); + writer.writeBytes(dataHash); } - public static void writeColumns(BlockColumn[] columns, ByteBuf buf) { + public static void writeColumns(BlockColumn[] columns, BufferWriter writer) throws Exception { // TODO compress for (BlockColumn column : columns) { - column.write(buf); + column.write(writer); } } - public static ChunkTile fromBuf(ByteBuf buf) { - final ResourceKey dimension = Packet.readResourceKey(buf, Registries.DIMENSION); - int x = buf.readInt(); - int z = buf.readInt(); - long timestamp = buf.readLong(); - int dataVersion = buf.readUnsignedShort(); - byte[] hash = Packet.readByteArrayOfSize(buf, MagicValues.SHA1_HASH_LENGTH); + public static ChunkTile read(BufferReader reader) throws Exception { + final ResourceKey dimension = reader.readResourceKey(Registries.DIMENSION); + int x = reader.readInt32(); + int z = reader.readInt32(); + long timestamp = reader.readInt64(); + int dataVersion = reader.readUnt16(); + byte[] hash = reader.readBytesOfLength(MagicValues.SHA1_HASH_LENGTH); var columns = new BlockColumn[256]; for (int i = 0; i < 256; i++) { - columns[i] = BlockColumn.fromBuf(buf); + columns[i] = BlockColumn.read(reader); } return new ChunkTile(dimension, x, z, timestamp, dataVersion, hash, columns); } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientHandler.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientHandler.java index 8fd1a3ce..91e8a7a8 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientHandler.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientHandler.java @@ -34,7 +34,7 @@ public void channelRead(ChannelHandlerContext ctx, Object packet) { } else if (packet instanceof ClientboundRegionTimestampsPacket pktRegionTimestamps) { getMod().handleRegionTimestamps(pktRegionTimestamps, client); } else if (packet instanceof ClientboundChunkTimestampsResponsePacket pktCatchup) { - for (CatchupChunk chunk : pktCatchup.chunks) { + for (CatchupChunk chunk : pktCatchup.chunks()) { chunk.syncClient = this.client; } getMod().handleCatchupData((ClientboundChunkTimestampsResponsePacket) packet); diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientboundPacketDecoder.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientboundPacketDecoder.java index f512afb8..bc33097b 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientboundPacketDecoder.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ClientboundPacketDecoder.java @@ -1,5 +1,6 @@ package gjum.minecraft.mapsync.mod.net; +import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; import gjum.minecraft.mapsync.mod.net.packet.ChunkTilePacket; import gjum.minecraft.mapsync.mod.net.packet.ClientboundChunkTimestampsResponsePacket; import gjum.minecraft.mapsync.mod.net.packet.ClientboundEncryptionRequestPacket; @@ -11,11 +12,11 @@ import org.jetbrains.annotations.Nullable; public class ClientboundPacketDecoder extends ReplayingDecoder { - public static @Nullable Packet constructServerPacket(int id, ByteBuf buf) { - if (id == ChunkTilePacket.PACKET_ID) return ChunkTilePacket.read(buf); - if (id == ClientboundEncryptionRequestPacket.PACKET_ID) return ClientboundEncryptionRequestPacket.read(buf); - if (id == ClientboundChunkTimestampsResponsePacket.PACKET_ID) return ClientboundChunkTimestampsResponsePacket.read(buf); - if (id == ClientboundRegionTimestampsPacket.PACKET_ID) return ClientboundRegionTimestampsPacket.read(buf); + public static @Nullable Packet constructServerPacket(int id, BufferReader reader) throws Exception { + if (id == ChunkTilePacket.PACKET_ID) return ChunkTilePacket.read(reader); + if (id == ClientboundEncryptionRequestPacket.PACKET_ID) return ClientboundEncryptionRequestPacket.read(reader); + if (id == ClientboundChunkTimestampsResponsePacket.PACKET_ID) return ClientboundChunkTimestampsResponsePacket.read(reader); + if (id == ClientboundRegionTimestampsPacket.PACKET_ID) return ClientboundRegionTimestampsPacket.read(reader); return null; } @@ -23,7 +24,7 @@ public class ClientboundPacketDecoder extends ReplayingDecoder { protected void decode(ChannelHandlerContext ctx, ByteBuf buf, List out) { try { byte id = buf.readByte(); - final Packet packet = constructServerPacket(id, buf); + final Packet packet = constructServerPacket(id, new BufferReader(buf)); if (packet == null) { SyncClient.logger.error("[ServerPacketDecoder] " + "Unknown server packet id " + id + " 0x" + Integer.toHexString(id)); diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/Packet.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/Packet.java index 83c482ce..38129ee5 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/Packet.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/Packet.java @@ -1,84 +1,13 @@ package gjum.minecraft.mapsync.mod.net; -import io.netty.buffer.ByteBuf; -import java.nio.charset.StandardCharsets; -import net.minecraft.core.Registry; -import net.minecraft.resources.Identifier; -import net.minecraft.resources.ResourceKey; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import org.apache.commons.lang3.NotImplementedException; import org.jetbrains.annotations.NotNull; public interface Packet { - default void write(@NotNull ByteBuf out) { + public default void write( + final @NotNull BufferWriter writer + ) throws Exception { throw new NotImplementedException(); } - - static byte @NotNull [] readByteArrayOfSize( - final @NotNull ByteBuf in, - final int size - ) { - final var bytes = new byte[size]; - if (size > 0) { - in.readBytes(bytes); - } - return bytes; - } - - static byte @NotNull [] readIntLengthByteArray( - final @NotNull ByteBuf in - ) { - return readByteArrayOfSize(in, in.readInt()); - } - - static void writeIntLengthByteArray( - final @NotNull ByteBuf out, - final byte @NotNull [] array - ) { - if (array.length > 0) { - out.writeInt(array.length); - out.writeBytes(array); - } - else { - out.writeInt(0); - } - } - - static @NotNull String readUtf8String( - final @NotNull ByteBuf in - ) { - return new String( - readIntLengthByteArray(in), - StandardCharsets.UTF_8 - ); - } - - static void writeUtf8String( - final @NotNull ByteBuf out, - final @NotNull String string - ) { - writeIntLengthByteArray( - out, - string.getBytes(StandardCharsets.UTF_8) - ); - } - - static >> @NotNull ResourceKey readResourceKey( - final @NotNull ByteBuf in, - final @NotNull R registry - ) { - return ResourceKey.create( - registry, - Identifier.tryParse(readUtf8String(in)) - ); - } - - static void writeResourceKey( - final @NotNull ByteBuf out, - final @NotNull ResourceKey resourceKey - ) { - writeUtf8String( - out, - resourceKey.identifier().toString() - ); - } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ServerboundPacketEncoder.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ServerboundPacketEncoder.java index e026aac5..7023c229 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ServerboundPacketEncoder.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/ServerboundPacketEncoder.java @@ -1,5 +1,6 @@ package gjum.minecraft.mapsync.mod.net; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import gjum.minecraft.mapsync.mod.net.packet.ChunkTilePacket; import gjum.minecraft.mapsync.mod.net.packet.ServerboundCatchupRequestPacket; import gjum.minecraft.mapsync.mod.net.packet.ServerboundChunkTimestampsRequestPacket; @@ -23,7 +24,7 @@ public static int getClientPacketId(Packet packet) { protected void encode(ChannelHandlerContext ctx, Packet packet, ByteBuf out) { try { out.writeByte(getClientPacketId(packet)); - packet.write(out); + packet.write(new BufferWriter(out)); } catch (Throwable err) { err.printStackTrace(); ctx.close(); diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferReader.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferReader.java new file mode 100644 index 00000000..e1fa68e4 --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferReader.java @@ -0,0 +1,79 @@ +package gjum.minecraft.mapsync.mod.net.buffers; + +import gjum.minecraft.mapsync.mod.utils.Assertions; +import gjum.minecraft.mapsync.mod.utils.MagicValues; +import io.netty.buffer.ByteBuf; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import net.minecraft.core.Registry; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import org.jetbrains.annotations.NotNull; + +public final class BufferReader { + private final ByteBuf internal; + + public BufferReader( + final @NotNull ByteBuf internal + ) { + this.internal = Objects.requireNonNull(internal); + } + + public int readUnt5() throws Exception { + return (int) Assertions.assertMasked(MagicValues.UNT5_MASK, this.readUnt8()); + } + + public int readUnt8() throws Exception { + return this.internal.readUnsignedByte(); + } + + public int readUnt10() throws Exception { + return (int) Assertions.assertMasked(MagicValues.UNT10_MASK, this.readUnt16()); + } + + public int readUnt16() throws Exception { + return this.internal.readUnsignedShort(); + } + + public int readInt16() throws Exception { + return this.internal.readShort(); + } + + public int readInt32() throws Exception { + return this.internal.readInt(); + } + + public long readInt64() throws Exception { + return this.internal.readLong(); + } + + public byte @NotNull [] readBytesOfLength( + final int size + ) throws Exception { + final var bytes = new byte[size]; + if (size > 0) { + this.internal.readBytes(bytes); + } + return bytes; + } + + /// Reads a u8 length-prefixed UTF-8 string. + public @NotNull String readString() throws Exception { + return new String( + this.readBytesOfLength(this.readUnt8()), + StandardCharsets.UTF_8 + ); + } + + /// Convenience function + public @NotNull Identifier readIdentifier() throws Exception { + return Identifier.parse(this.readString()); + } + + /// Convenience function + public >> @NotNull ResourceKey readResourceKey( + final @NotNull R registry + ) throws Exception { + return ResourceKey.create(registry, this.readIdentifier()); + } +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferWriter.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferWriter.java new file mode 100644 index 00000000..9249b812 --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/buffers/BufferWriter.java @@ -0,0 +1,94 @@ +package gjum.minecraft.mapsync.mod.net.buffers; + +import gjum.minecraft.mapsync.mod.utils.Assertions; +import gjum.minecraft.mapsync.mod.utils.MagicValues; +import io.netty.buffer.ByteBuf; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +public final class BufferWriter { + private final ByteBuf internal; + + public BufferWriter( + final @NotNull ByteBuf internal + ) { + this.internal = Objects.requireNonNull(internal); + } + + public void writeUnt5( + final int value + ) throws Exception { + this.internal.writeByte((int) Assertions.assertMasked(MagicValues.UNT5_MASK, value)); + } + + public void writeUnt8( + final int value + ) throws Exception { + this.internal.writeByte((int) Assertions.assertMasked(MagicValues.UNT8_MASK, value)); + } + + public void writeUnt10( + final int value + ) throws Exception { + this.internal.writeShort((int) Assertions.assertMasked(MagicValues.UNT10_MASK, value)); + } + + public void writeUnt16( + final int value + ) throws Exception { + this.internal.writeShort((int) Assertions.assertMasked(MagicValues.UNT16_MASK, value)); + } + + public void writeInt16( + final short value + ) throws Exception { + this.internal.writeShort(value); + } + + public void writeInt32( + final int value + ) throws Exception { + this.internal.writeInt(value); + } + + public void writeInt64( + final long value + ) throws Exception { + this.internal.writeLong(value); + } + + public void writeBytes( + final byte @NotNull [] array + ) throws Exception { + if (array.length > 0) { + this.internal.writeBytes(array); + } + } + + public void writeLengthPrefixedBytes( + final @NotNull LengthPrefixSetter lengthSetter, + final byte @NotNull [] array + ) throws Exception { + lengthSetter.writeLength(this, array.length); + this.writeBytes(array); + } + + /// Converts a string to UTF-8 bytes and writes it with a u8 length-prefix. + public void writeString( + final @NotNull String string + ) throws Exception { + this.writeLengthPrefixedBytes( + BufferWriter::writeUnt8, + string.getBytes(StandardCharsets.UTF_8) + ); + } + + @FunctionalInterface + public interface LengthPrefixSetter { + void writeLength( + @NotNull BufferWriter writer, + int length + ) throws Exception; + } +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ChunkTilePacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ChunkTilePacket.java index cef45aba..92c3d2c9 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ChunkTilePacket.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ChunkTilePacket.java @@ -2,7 +2,8 @@ import gjum.minecraft.mapsync.mod.data.ChunkTile; import gjum.minecraft.mapsync.mod.net.Packet; -import io.netty.buffer.ByteBuf; +import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import org.jetbrains.annotations.NotNull; /** @@ -21,13 +22,13 @@ public ChunkTilePacket(@NotNull ChunkTile chunkTile) { this.chunkTile = chunkTile; } - public static Packet read(ByteBuf buf) { + public static Packet read(BufferReader reader) throws Exception { return new ChunkTilePacket( - ChunkTile.fromBuf(buf)); + ChunkTile.read(reader)); } @Override - public void write(@NotNull ByteBuf buf) { - chunkTile.write(buf); + public void write(@NotNull BufferWriter writer) throws Exception { + chunkTile.write(writer); } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundChunkTimestampsResponsePacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundChunkTimestampsResponsePacket.java index 7c2cfb73..6862e4cf 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundChunkTimestampsResponsePacket.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundChunkTimestampsResponsePacket.java @@ -2,44 +2,46 @@ import gjum.minecraft.mapsync.mod.data.CatchupChunk; import gjum.minecraft.mapsync.mod.net.Packet; -import io.netty.buffer.ByteBuf; -import java.util.ArrayList; +import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; +import gjum.minecraft.mapsync.mod.utils.Assertions; import java.util.List; import net.minecraft.core.registries.Registries; import net.minecraft.resources.ResourceKey; import net.minecraft.world.level.Level; import org.jetbrains.annotations.NotNull; -/** - * You'll receive this in response to a sent {@link ServerboundChunkTimestampsRequestPacket}, - * containing an elaboration of chunk timestamps of all the regions you listed. - * You should respond with a {@link ServerboundCatchupRequestPacket}. - */ -public class ClientboundChunkTimestampsResponsePacket implements Packet { +/// The server will send this packet, containing an elaboration of chunk timestamps of a particular region as requested +/// via [ServerboundChunkTimestampsRequestPacket]. The client should respond with a [ServerboundCatchupRequestPacket] +/// if it finds any chunks with a timestamp newer than its own. +/// +/// - Prev: [ServerboundChunkTimestampsRequestPacket] +/// - Next: [ServerboundCatchupRequestPacket] +public record ClientboundChunkTimestampsResponsePacket( + @NotNull List<@NotNull CatchupChunk> chunks +) implements Packet { public static final int PACKET_ID = 5; - /** - * sorted by newest to oldest - */ - public final @NotNull List chunks; - - public ClientboundChunkTimestampsResponsePacket(@NotNull List chunks) { - this.chunks = chunks; + public ClientboundChunkTimestampsResponsePacket { + chunks = Assertions.assertNonNullList(chunks); } - public static Packet read(ByteBuf buf) { - final ResourceKey dimension = Packet.readResourceKey(buf, Registries.DIMENSION); - - int length = buf.readInt(); - List chunks = new ArrayList<>(length); - for (int i = 0; i < length; i++) { - int chunk_x = buf.readInt(); - int chunk_z = buf.readInt(); - long timestamp = buf.readLong(); - CatchupChunk chunk = new CatchupChunk( - dimension, chunk_x, chunk_z, timestamp); - chunks.add(chunk); + public static @NotNull ClientboundChunkTimestampsResponsePacket read( + final @NotNull BufferReader reader + ) throws Exception { + final ResourceKey dimension = reader.readResourceKey(Registries.DIMENSION); + final int anchorChunkX = reader.readInt16() << 5; + final int anchorChunkZ = reader.readInt16() << 5; + final var chunks = new CatchupChunk[reader.readUnt10()]; + for (int i = 0; i < chunks.length; i++) { + chunks[i] = new CatchupChunk( + dimension, + anchorChunkX + reader.readUnt5(), + anchorChunkZ + reader.readUnt5(), + reader.readInt64() + ); } - return new ClientboundChunkTimestampsResponsePacket(chunks); + return new ClientboundChunkTimestampsResponsePacket( + List.of(chunks) + ); } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundEncryptionRequestPacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundEncryptionRequestPacket.java index dda79720..7d4988cd 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundEncryptionRequestPacket.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundEncryptionRequestPacket.java @@ -1,7 +1,7 @@ package gjum.minecraft.mapsync.mod.net.packet; import gjum.minecraft.mapsync.mod.net.Packet; -import io.netty.buffer.ByteBuf; +import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; @@ -24,15 +24,15 @@ public ClientboundEncryptionRequestPacket(@NotNull PublicKey publicKey, byte @No this.verifyToken = verifyToken; } - public static Packet read(ByteBuf buf) { + public static Packet read(BufferReader reader) throws Exception { return new ClientboundEncryptionRequestPacket( - readKey(buf), - Packet.readIntLengthByteArray(buf)); + readKey(reader), + reader.readBytesOfLength(reader.readUnt8())); } - protected static PublicKey readKey(ByteBuf in) { + protected static PublicKey readKey(BufferReader reader) throws Exception { try { - byte[] encodedKey = Packet.readIntLengthByteArray(in); + byte[] encodedKey = reader.readBytesOfLength(reader.readUnt16()); X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encodedKey); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); return keyFactory.generatePublic(keySpec); diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundRegionTimestampsPacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundRegionTimestampsPacket.java index 93f7b1a2..53b05d98 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundRegionTimestampsPacket.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ClientboundRegionTimestampsPacket.java @@ -2,7 +2,7 @@ import gjum.minecraft.mapsync.mod.data.RegionTimestamp; import gjum.minecraft.mapsync.mod.net.Packet; -import io.netty.buffer.ByteBuf; +import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; /** * This is the packet for the first-stage of the synchronisation process. It's @@ -14,35 +14,29 @@ public class ClientboundRegionTimestampsPacket implements Packet { private final String dimension; - private final RegionTimestamp[] timestamps; + private final RegionTimestamp timestamp; - public ClientboundRegionTimestampsPacket(String dimension, RegionTimestamp[] timestamps) { + public ClientboundRegionTimestampsPacket(String dimension, RegionTimestamp timestamp) { this.dimension = dimension; - this.timestamps = timestamps; + this.timestamp = timestamp; } public String getDimension() { return dimension; } - public RegionTimestamp[] getTimestamps() { - return timestamps; + public RegionTimestamp getTimestamp() { + return timestamp; } - public static Packet read(ByteBuf buf) { - String dimension = Packet.readUtf8String(buf); - - short totalRegions = buf.readShort(); - RegionTimestamp[] timestamps = new RegionTimestamp[totalRegions]; - // row = x - for (short i = 0; i < totalRegions; i++) { - short regionX = buf.readShort(); - short regionZ = buf.readShort(); - - long timestamp = buf.readLong(); - timestamps[i] = new RegionTimestamp(regionX, regionZ, timestamp); - } - - return new ClientboundRegionTimestampsPacket(dimension, timestamps); + public static Packet read(BufferReader reader) throws Exception { + return new ClientboundRegionTimestampsPacket( + reader.readString(), + new RegionTimestamp( + (short) reader.readInt16(), + (short) reader.readInt16(), + reader.readInt64() + ) + ); } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundCatchupRequestPacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundCatchupRequestPacket.java index 5857a0b9..1159aaeb 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundCatchupRequestPacket.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundCatchupRequestPacket.java @@ -1,50 +1,48 @@ package gjum.minecraft.mapsync.mod.net.packet; -import gjum.minecraft.mapsync.mod.data.CatchupChunk; import gjum.minecraft.mapsync.mod.net.Packet; -import io.netty.buffer.ByteBuf; -import java.util.List; -import net.minecraft.resources.ResourceKey; -import net.minecraft.world.level.Level; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; +import gjum.minecraft.mapsync.mod.utils.Assertions; +import gjum.minecraft.mapsync.mod.utils.MagicValues; +import java.util.Map; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.ChunkPos; +import org.apache.commons.lang3.LongRange; import org.jetbrains.annotations.NotNull; -/** - * This is the final stage in the synchronisation process, sent in response to - * a received {@link ClientboundChunkTimestampsResponsePacket}. Here you list - * what chunks you'd like to receive from the server, who'll then respond with - * a bunch of {@link ChunkTilePacket}. - */ -public class ServerboundCatchupRequestPacket implements Packet { +/// The client sends this in response to a [ClientboundChunkTimestampsResponsePacket], requesting the server to send +/// chunk-tile data for each of the specified chunks within a specified region. The server may respond by sending a +/// [ChunkTilePacket] for each chunk. +/// +/// - Prev: [ClientboundChunkTimestampsResponsePacket] +/// - Next: [ChunkTilePacket] +public record ServerboundCatchupRequestPacket( + @NotNull Identifier dimension, + short regionX, + short regionZ, + @NotNull Map<@NotNull ChunkPos, @NotNull Long> chunks +) implements Packet { public static final int PACKET_ID = 6; - /** - * Chunks must all be in the same dimension - */ - public final List chunks; - - /** - * Chunks must all be in the same dimension - */ - public ServerboundCatchupRequestPacket(@NotNull List chunks) { - if (chunks.isEmpty()) throw new Error("Chunks list must not be empty"); - ResourceKey dim = null; - for (CatchupChunk chunk : chunks) { - if (dim == null) dim = chunk.dimension(); - else if (!dim.equals(chunk.dimension())) { - throw new Error("Chunks must all be in the same dimension " + dim + " but this one was " + chunk.dimension()); - } - } - this.chunks = chunks; + public ServerboundCatchupRequestPacket { + Assertions.assertNotNull(dimension); + chunks = Assertions.assertNonNullMap(chunks); + Assertions.assertRange(LongRange.of(1, MagicValues.REGION_GRID), chunks.size()); } @Override - public void write(@NotNull ByteBuf buf) { - Packet.writeResourceKey(buf, chunks.get(0).dimension()); - buf.writeInt(chunks.size()); - for (CatchupChunk chunk : chunks) { - buf.writeInt(chunk.chunk_x()); - buf.writeInt(chunk.chunk_z()); - buf.writeLong(chunk.timestamp()); + public void write( + final @NotNull BufferWriter writer + ) throws Exception { + writer.writeString(this.dimension().toString()); + writer.writeInt16(this.regionX()); + writer.writeInt16(this.regionZ()); + writer.writeUnt10(this.chunks().size()); + for (final var entry : this.chunks().entrySet()) { + final ChunkPos chunkPos = entry.getKey(); + writer.writeUnt5(chunkPos.getRegionLocalX()); + writer.writeUnt5(chunkPos.getRegionLocalZ()); + writer.writeInt64(entry.getValue()); } } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundChunkTimestampsRequestPacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundChunkTimestampsRequestPacket.java index 51e455da..bf376bc3 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundChunkTimestampsRequestPacket.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundChunkTimestampsRequestPacket.java @@ -2,8 +2,7 @@ import gjum.minecraft.mapsync.mod.data.RegionPos; import gjum.minecraft.mapsync.mod.net.Packet; -import io.netty.buffer.ByteBuf; -import java.util.List; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import org.jetbrains.annotations.NotNull; /** @@ -15,20 +14,17 @@ public class ServerboundChunkTimestampsRequestPacket implements Packet { public static final int PACKET_ID = 8; private final String dimension; - private final List regions; + private final RegionPos region; - public ServerboundChunkTimestampsRequestPacket(String dimension, List regions) { + public ServerboundChunkTimestampsRequestPacket(String dimension, RegionPos region) { this.dimension = dimension; - this.regions = regions; + this.region = region; } @Override - public void write(@NotNull ByteBuf buf) { - Packet.writeUtf8String(buf, dimension); - buf.writeShort(regions.size()); - for (var region : regions) { - buf.writeShort(region.x()); - buf.writeShort(region.z()); - } + public void write(@NotNull BufferWriter writer) throws Exception { + writer.writeString(dimension); + writer.writeInt16((short) region.x()); + writer.writeInt16((short) region.z()); } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundEncryptionResponsePacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundEncryptionResponsePacket.java index 231360ba..bcb9e25b 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundEncryptionResponsePacket.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundEncryptionResponsePacket.java @@ -1,7 +1,7 @@ package gjum.minecraft.mapsync.mod.net.packet; import gjum.minecraft.mapsync.mod.net.Packet; -import io.netty.buffer.ByteBuf; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import org.jetbrains.annotations.NotNull; /** @@ -27,8 +27,8 @@ public ServerboundEncryptionResponsePacket(byte[] sharedSecret, byte[] verifyTok } @Override - public void write(@NotNull ByteBuf out) { - Packet.writeIntLengthByteArray(out, sharedSecret); - Packet.writeIntLengthByteArray(out, verifyToken); + public void write(@NotNull BufferWriter writer) throws Exception { + writer.writeLengthPrefixedBytes(BufferWriter::writeUnt8, sharedSecret); + writer.writeLengthPrefixedBytes(BufferWriter::writeUnt8, verifyToken); } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundHandshakePacket.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundHandshakePacket.java index a4b970da..844f26c3 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundHandshakePacket.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/packet/ServerboundHandshakePacket.java @@ -1,7 +1,7 @@ package gjum.minecraft.mapsync.mod.net.packet; import gjum.minecraft.mapsync.mod.net.Packet; -import io.netty.buffer.ByteBuf; +import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import org.jetbrains.annotations.NotNull; /** @@ -14,21 +14,21 @@ public class ServerboundHandshakePacket implements Packet { public final @NotNull String modVersion; public final @NotNull String username; public final @NotNull String gameAddress; - public final @NotNull String world; + public final @NotNull String dimension; - public ServerboundHandshakePacket(@NotNull String modVersion, @NotNull String username, @NotNull String gameAddress, @NotNull String world) { + public ServerboundHandshakePacket(@NotNull String modVersion, @NotNull String username, @NotNull String gameAddress, @NotNull String dimension) { this.modVersion = modVersion; this.username = username; this.gameAddress = gameAddress; - this.world = world; + this.dimension = dimension; } @Override - public void write(@NotNull ByteBuf out) { - Packet.writeUtf8String(out, modVersion); - Packet.writeUtf8String(out, username); - Packet.writeUtf8String(out, gameAddress); - Packet.writeUtf8String(out, world); + public void write(@NotNull BufferWriter writer) throws Exception { + writer.writeString(modVersion); + writer.writeString(username); + writer.writeString(gameAddress); + writer.writeString(dimension); } @Override diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/Assertions.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/Assertions.java index bbf53b96..ed348d80 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/Assertions.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/Assertions.java @@ -1,38 +1,87 @@ package gjum.minecraft.mapsync.mod.utils; +import java.util.List; +import java.util.Map; import java.util.Objects; +import org.apache.commons.lang3.LongRange; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Unmodifiable; /// This is intended as a remedial gap-filler class to cover for the inadequacies of Java's type system. It includes its /// own exception class to ensure the exception is generic enough to apply: [IllegalArgumentException] is not relevant /// for assertions during packet \[de\]serialisation. public final class Assertions { public static void assertNotNull( - final @NotNull String name, final Object value ) { - Objects.requireNonNull(name); if (value == null) { - throw new AssertionException("'%s' is null!".formatted( - name - )); + throw new AssertionException(" is null!"); } } public static void assertLength( - final @NotNull String name, final int currentLength, final int requiredLength ) { - Objects.requireNonNull(name); if (currentLength != requiredLength) { - throw new AssertionException("'%s' has length %d when it must be %d".formatted( - name, + throw new AssertionException("length is %d when it must be %d".formatted( currentLength, requiredLength )); } } + + /// Asserts whether a given value fits within the given mask. For example: `256` would not fit within a mask of + /// `0xFF`, making this a good way to test the validity of unsigned integers in larger integer types. + public static long assertMasked( + final long mask, + final long value + ) { + if ((value & ~mask) != 0L) { + throw new AssertionException("%d does not fit within mask %d".formatted( + value, + mask + )); + } + return value; + } + + /// @return Returns an unmodifiable shallow-copy of the given map. + public static @NotNull @Unmodifiable List<@NotNull T> assertNonNullList( + final List map + ) { + try { + return List.copyOf(map); + } + catch (final NullPointerException thrown) { + throw new AssertionException("list is null or contains nulls!"); + } + } + + /// @return Returns an unmodifiable shallow-copy of the given map. + public static @NotNull @Unmodifiable Map<@NotNull K, @NotNull V> assertNonNullMap( + final Map map + ) { + try { + return Map.copyOf(map); + } + catch (final NullPointerException thrown) { + throw new AssertionException("map is null or contains nulls!"); + } + } + + public static void assertRange( + final @NotNull LongRange range, + final long value + ) { + if (!range.contains(value)) { + throw new AssertionException("%d is not within range %d..%d".formatted( + value, + range.getMinimum(), + range.getMaximum() + )); + } + } } final class AssertionException extends RuntimeException { diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/MagicValues.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/MagicValues.java index 981cce65..4a5e2078 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/MagicValues.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/utils/MagicValues.java @@ -1,7 +1,15 @@ package gjum.minecraft.mapsync.mod.utils; public final class MagicValues { + public static final int REGION_AXIS = 32; + public static final int REGION_GRID = REGION_AXIS * REGION_AXIS; + // SHA1 produces 160-bit (20-byte) hashes // https://en.wikipedia.org/wiki/SHA-1 public static final int SHA1_HASH_LENGTH = 20; + + public static final int UNT5_MASK = 0x1F; + public static final int UNT8_MASK = 0xFF; + public static final int UNT10_MASK = 0x03_FF; + public static final int UNT16_MASK = 0xFF_FF; } diff --git a/mapsync-server/src/cli.ts b/mapsync-server/src/cli.ts index e9ca26ea..085bd2f3 100644 --- a/mapsync-server/src/cli.ts +++ b/mapsync-server/src/cli.ts @@ -5,6 +5,7 @@ import * as metadata from "./metadata"; import { TcpServer } from "./server"; import * as database from "./database"; +import { ClientboundRegionTimestampsPacket } from "./protocol"; //idk where these come from lol interface TerminalExtras { @@ -143,18 +144,25 @@ async function handle_input(input: string): Promise { return; } - const world = client.world; - if (!world) { + const dimension = client.dimension; + if (!dimension) { console.log("Client has no world yet"); return; } - const regions = await database.getRegionTimestamps(world); - await client.send({ - type: "RegionTimestamps", - world, - regions, - }); + const regions = await database.getRegionTimestamps(client.dimension!); + await Promise.allSettled( + regions.map((region) => + client.send( + new ClientboundRegionTimestampsPacket( + client.dimension!, + region.regionX, + region.regionZ, + region.timestamp, + ), + ), + ), + ); } else if (command === "kick") { const target = extras.trim(); // IGN or UUID diff --git a/mapsync-server/src/database.ts b/mapsync-server/src/database.ts index 0b073c05..dc74d3bb 100644 --- a/mapsync-server/src/database.ts +++ b/mapsync-server/src/database.ts @@ -1,22 +1,32 @@ import * as kysely from "kysely"; import sqlite from "better-sqlite3"; import { DATA_FOLDER } from "./metadata"; -import { type Pos2D } from "./model"; +import { + asInt16, + asInt32, + asInt64, + asUnt16, + int16, + int32, + int64, + unt16, +} from "./deps/ints"; +import { CatchupChunk, CatchupRegion, StoredChunk } from "./model"; let database: kysely.Kysely | null = null; export interface Database { chunk_data: { hash: Buffer; - version: number; + version: bigint; data: Buffer; }; player_chunk: { world: string; - chunk_x: number; - chunk_z: number; + chunk_x: bigint; + chunk_z: bigint; uuid: string; - ts: number; + ts: bigint; hash: Buffer; }; } @@ -25,12 +35,15 @@ export function get() { if (!database) { database = new kysely.Kysely({ dialect: new kysely.SqliteDialect({ - database: async () => - sqlite( + database: async () => { + const conn = sqlite( process.env["SQLITE_PATH"] ?? `${DATA_FOLDER}/db.sqlite`, {}, - ), + ); + conn.defaultSafeIntegers(true); + return conn; + }, }), }); } @@ -74,17 +87,19 @@ export async function setup() { * Converts the entire database of player chunks into regions, with each region * having the highest (aka newest) timestamp. */ -export function getRegionTimestamps(dimension: string) { +export async function getRegionTimestamps( + dimension: string, +): Promise { // computing region coordinates in SQL requires truncating, not rounding return get() .selectFrom("player_chunk") .select([ (eb) => - kysely.sql`floor(${eb.ref("chunk_x")} / 32.0)`.as( + kysely.sql`floor(${eb.ref("chunk_x")} / 32.0)`.as( "regionX", ), (eb) => - kysely.sql`floor(${eb.ref("chunk_z")} / 32.0)`.as( + kysely.sql`floor(${eb.ref("chunk_z")} / 32.0)`.as( "regionZ", ), (eb) => eb.fn.max("ts").as("timestamp"), @@ -92,40 +107,50 @@ export function getRegionTimestamps(dimension: string) { .where("world", "=", dimension) .groupBy(["regionX", "regionZ"]) .orderBy("regionX", "desc") - .execute(); + .execute() + .then(async (regions) => + regions.map((region) => ({ + regionX: asInt16(region.regionX), + regionZ: asInt16(region.regionZ), + timestamp: asInt64(region.timestamp), + })), + ); } /** * Converts an array of region coords into an array of timestamped chunk coords. */ -export async function getChunkTimestamps(dimension: string, regions: Pos2D[]) { +export async function getChunkTimestamps( + dimension: string, + regionX: int16, + regionZ: int16, +): Promise { + const minChunkX = regionX << 5n, + maxChunkX = minChunkX + 32n; + const minChunkZ = regionZ << 5n, + maxChunkZ = minChunkZ + 32n; return get() - .with("regions", (db) => - db - .selectFrom("player_chunk") - .select([ - (eb) => - kysely.sql`(cast(floor(${eb.ref( - "chunk_x", - )} / 32.0) as int) || '_' || cast(floor(${eb.ref( - "chunk_z", - )} / 32.0) as int))`.as("region"), - "chunk_x as x", - "chunk_z as z", - (eb) => eb.fn.max("ts").as("timestamp"), - ]) - .where("world", "=", dimension) - .groupBy(["x", "z"]), - ) - .selectFrom("regions") - .select(["x as chunkX", "z as chunkZ", "timestamp"]) - .where( - "region", - "in", - regions.map((region) => region.x + "_" + region.z), - ) - .orderBy("timestamp", "desc") - .execute(); + .selectFrom("player_chunk") + .select([ + "chunk_x as chunkX", + "chunk_z as chunkZ", + (eb) => eb.fn.max("ts").as("timestamp"), + ]) + .where("world", "=", dimension) + .where("chunk_x", ">=", minChunkX) + .where("chunk_x", "<", maxChunkX) + .where("chunk_z", ">=", minChunkZ) + .where("chunk_z", "<", maxChunkZ) + .groupBy(["chunk_x", "chunk_z"]) + .orderBy("ts", "desc") + .execute() + .then(async (chunks) => + chunks.map((chunk) => ({ + chunkX: asInt32(chunk.chunkX), + chunkZ: asInt32(chunk.chunkZ), + timestamp: asInt64(chunk.timestamp), + })), + ); } /** @@ -136,9 +161,9 @@ export async function getChunkTimestamps(dimension: string, regions: Pos2D[]) { */ export async function getChunkData( dimension: string, - chunkX: number, - chunkZ: number, -) { + chunkX: int32, + chunkZ: int32, +): Promise { return get() .selectFrom("player_chunk") .innerJoin("chunk_data", "chunk_data.hash", "player_chunk.hash") @@ -153,7 +178,17 @@ export async function getChunkData( .where("player_chunk.chunk_z", "=", chunkZ) .orderBy("player_chunk.ts", "desc") .limit(1) - .executeTakeFirst(); + .executeTakeFirst() + .then(async (chunk) => + chunk + ? { + hash: chunk.hash, + version: asUnt16(chunk.version), + timestamp: asInt64(chunk.ts), + data: chunk.data, + } + : null, + ); } /** @@ -161,11 +196,11 @@ export async function getChunkData( */ export async function storeChunkData( dimension: string, - chunkX: number, - chunkZ: number, + chunkX: int32, + chunkZ: int32, uuid: string, - timestamp: number, - version: number, + timestamp: int64, + version: unt16, hash: Buffer, data: Buffer, ) { @@ -186,34 +221,3 @@ export async function storeChunkData( }) .execute(); } - -/** - * Gets all the [latest] chunks within a region. - */ -export async function getRegionChunks( - dimension: string, - regionX: number, - regionZ: number, -) { - const minChunkX = regionX << 4, - maxChunkX = minChunkX + 16; - const minChunkZ = regionZ << 4, - maxChunkZ = minChunkZ + 16; - return get() - .selectFrom("player_chunk") - .innerJoin("chunk_data", "chunk_data.hash", "player_chunk.hash") - .select([ - "player_chunk.chunk_x as chunk_x", - "player_chunk.chunk_z as chunk_z", - (eb) => eb.fn.max("player_chunk.ts").as("timestamp"), - "chunk_data.version as version", - "chunk_data.data as data", - ]) - .where("player_chunk.world", "=", dimension) - .where("player_chunk.chunk_x", ">=", minChunkX) - .where("player_chunk.chunk_x", "<", maxChunkX) - .where("player_chunk.chunk_z", ">=", minChunkZ) - .where("player_chunk.chunk_z", "<", maxChunkZ) - .orderBy("player_chunk.ts", "desc") - .execute(); -} diff --git a/mapsync-server/src/deps/ints.ts b/mapsync-server/src/deps/ints.ts new file mode 100644 index 00000000..a50e9869 --- /dev/null +++ b/mapsync-server/src/deps/ints.ts @@ -0,0 +1,82 @@ +export type numeric = number | bigint; +function ensureBigInt(value: numeric): bigint { + if (typeof value === "number") { + if (!Number.isInteger(value)) { + throw new Error(`invalid integer value: ${value}`); + } + value = BigInt(value); + } + return value; +} + +export type unt5 = bigint & { readonly __brand: "u5" }; +export function asUnt5(value: numeric): unt5 { + value = ensureBigInt(value); + if (value < 0n || value > 31n) { + throw new Error(`invalid unt5 value: ${value}`); + } + return value as unt5; +} + +export type unt8 = bigint & { readonly __brand: "u8" }; +export function asUnt8(value: numeric): unt8 { + value = ensureBigInt(value); + if (value < 0n || value > 255n) { + throw new Error(`invalid unt8 value: ${value}`); + } + return value as unt8; +} + +export type unt10 = bigint & { readonly __brand: "u10" }; +export function asUnt10(value: numeric): unt10 { + value = ensureBigInt(value); + if (value < 0n || value > 1023n) { + throw new Error(`invalid unt10 value: ${value}`); + } + return value as unt10; +} + +export type unt16 = bigint & { readonly __brand: "u16" }; +export function asUnt16(value: numeric): unt16 { + value = ensureBigInt(value); + if (value < 0n || value > 65535n) { + throw new Error(`invalid unt16 value: ${value}`); + } + return value as unt16; +} + +export type int16 = bigint & { readonly __brand: "i16" }; +export function asInt16(value: numeric): int16 { + value = ensureBigInt(value); + if (value < -32768n || value > 32767n) { + throw new Error(`invalid int16 value: ${value}`); + } + return value as int16; +} + +export type unt31 = bigint & { readonly __brand: "u31" }; +export function asUnt31(value: numeric): unt31 { + value = ensureBigInt(value); + if (value < 0 || value > 2147483647n) { + throw new Error(`invalid unt31 value: ${value}`); + } + return value as unt31; +} + +export type int32 = bigint & { readonly __brand: "i32" }; +export function asInt32(value: numeric): int32 { + value = ensureBigInt(value); + if (value < -2147483648n || value > 2147483647n) { + throw new Error(`invalid int32 value: ${value}`); + } + return value as int32; +} + +export type int64 = bigint & { readonly __brand: "i64" }; +export function asInt64(value: numeric): int64 { + value = ensureBigInt(value); + if (value < -9223372036854775808n || value > 9223372036854775807n) { + throw new Error(`invalid int64 value: ${value}`); + } + return value as int64; +} diff --git a/mapsync-server/src/main.ts b/mapsync-server/src/main.ts index f7fdb953..051b96b2 100644 --- a/mapsync-server/src/main.ts +++ b/mapsync-server/src/main.ts @@ -1,12 +1,17 @@ +import node_utils from "node:util"; import "./cli"; import { setServer } from "./cli"; import * as database from "./database"; import * as metadata from "./metadata"; -import { ClientPacket } from "./protocol"; -import { CatchupRequestPacket } from "./protocol/CatchupRequestPacket"; -import { ChunkTilePacket } from "./protocol/ChunkTilePacket"; import { TcpClient, TcpServer } from "./server"; -import { RegionCatchupPacket } from "./protocol/RegionCatchupPacket"; +import { + ChunkTilePacket, + ClientboundChunkTimestampsResponsePacket, + ClientboundRegionTimestampsPacket, + ServerboundCatchupRequestPacket, + ServerboundChunkTimestampsRequestPacket, + ServerboundPacket, +} from "./protocol"; let config: metadata.Config = null!; let main: Main = null!; @@ -50,30 +55,37 @@ export class Main { // TODO check version, mc server, user access - const timestamps = await database.getRegionTimestamps(client.world!); - client.send({ - type: "RegionTimestamps", - world: client.world!, - regions: timestamps, - }); + const regions = await database.getRegionTimestamps(client.dimension!); + await Promise.allSettled( + regions.map((region) => + client.send( + new ClientboundRegionTimestampsPacket( + client.dimension!, + region.regionX, + region.regionZ, + region.timestamp, + ), + ), + ), + ); } handleClientDisconnected(client: ProtocolClient) {} - handleClientPacketReceived(client: ProtocolClient, pkt: ClientPacket) { - client.debug(client.mcName + " <- " + pkt.type); - switch (pkt.type) { - case "ChunkTile": - return this.handleChunkTilePacket(client, pkt); - case "CatchupRequest": + async handleClientPacketReceived( + client: ProtocolClient, + pkt: ServerboundPacket, + ) { + switch (true) { + case pkt instanceof ServerboundChunkTimestampsRequestPacket: + return this.handleChunkTimestampsRequest(client, pkt); + case pkt instanceof ServerboundCatchupRequestPacket: return this.handleCatchupRequest(client, pkt); - case "RegionCatchup": - return this.handleRegionCatchupPacket(client, pkt); + case pkt instanceof ChunkTilePacket: + return this.handleChunkTilePacket(client, pkt); default: throw new Error( - `Unknown packet '${(pkt as any).type}' from client ${ - client.id - }`, + `Unknown packet [${node_utils.inspect(pkt)}] from client ${client.id}`, ); } } @@ -86,14 +98,14 @@ export class Main { await database .storeChunkData( - pkt.world, - pkt.chunk_x, - pkt.chunk_z, + pkt.dimension, + pkt.chunkX, + pkt.chunkZ, client.uuid, - pkt.ts, - pkt.data.version, - pkt.data.hash, - pkt.data.data, + pkt.timestamp, + pkt.dataVersion, + pkt.dataHash, + pkt.data, ) .catch(console.error); @@ -106,55 +118,63 @@ export class Main { async handleCatchupRequest( client: ProtocolClient, - pkt: CatchupRequestPacket, + pkt: ServerboundCatchupRequestPacket, ) { if (!client.uuid) throw new Error(`${client.name} is not authenticated`); for (const req of pkt.chunks) { let chunk = await database.getChunkData( - pkt.world, + pkt.dimension, req.chunkX, req.chunkZ, ); if (!chunk) { console.error(`${client.name} requested unavailable chunk`, { - world: pkt.world, + dimension: pkt.dimension, ...req, }); continue; } - if (chunk.ts > req.timestamp) continue; // someone sent a new chunk, which presumably got relayed to the client - if (chunk.ts < req.timestamp) continue; // the client already has a chunk newer than this - - client.send({ - type: "ChunkTile", - world: pkt.world, - chunk_x: req.chunkX, - chunk_z: req.chunkZ, - ts: req.timestamp, - data: { - hash: chunk.hash, - data: chunk.data, - version: chunk.version, - }, - }); + if (chunk.timestamp > req.timestamp) continue; // someone sent a new chunk, which presumably got relayed to the client + if (chunk.timestamp < req.timestamp) continue; // the client already has a chunk newer than this + + client.send( + new ChunkTilePacket( + pkt.dimension, + req.chunkX, + req.chunkZ, + req.timestamp, + chunk.version, + chunk.hash, + chunk.data, + ), + ); } } - async handleRegionCatchupPacket( + async handleChunkTimestampsRequest( client: ProtocolClient, - pkt: RegionCatchupPacket, + pkt: ServerboundChunkTimestampsRequestPacket, ) { if (!client.uuid) throw new Error(`${client.name} is not authenticated`); const chunks = await database.getChunkTimestamps( - pkt.world, - pkt.regions, + pkt.dimension, + pkt.regionX, + pkt.regionZ, ); - if (chunks.length) - client.send({ type: "Catchup", world: pkt.world, chunks }); + if (chunks.length) { + client.send( + new ClientboundChunkTimestampsResponsePacket( + pkt.dimension, + pkt.regionX, + pkt.regionZ, + chunks, + ), + ); + } } } diff --git a/mapsync-server/src/model.ts b/mapsync-server/src/model.ts index dc990c2f..38390c03 100644 --- a/mapsync-server/src/model.ts +++ b/mapsync-server/src/model.ts @@ -1,16 +1,20 @@ +import { int16, int32, int64, unt16 } from "./deps/ints"; + export interface CatchupRegion { - readonly regionX: number; - readonly regionZ: number; - readonly timestamp: number; + readonly regionX: int16; + readonly regionZ: int16; + readonly timestamp: int64; } export interface CatchupChunk { - readonly chunkX: number; - readonly chunkZ: number; - readonly timestamp: number; + readonly chunkX: int32; + readonly chunkZ: int32; + readonly timestamp: int64; } -export interface Pos2D { - readonly x: number; - readonly z: number; +export interface StoredChunk { + version: unt16; + timestamp: int64; + hash: Buffer; + data: Buffer; } diff --git a/mapsync-server/src/protocol/BufReader.ts b/mapsync-server/src/protocol/BufReader.ts deleted file mode 100644 index e4d39ef2..00000000 --- a/mapsync-server/src/protocol/BufReader.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** Each read advances the internal offset into the buffer. */ -export class BufReader { - private off = 0; - private offStack: number[] = []; - - constructor(private buf: Buffer) {} - - saveOffset() { - this.offStack.push(this.off); - } - - restoreOffset() { - const off = this.offStack.pop(); - if (off === undefined) throw new Error("Offset stack is empty"); - this.off = off; - } - - readUInt8() { - const val = this.buf.readUInt8(this.off); - this.off += 1; - return val; - } - - readInt8() { - const val = this.buf.readInt8(this.off); - this.off += 1; - return val; - } - - readUInt16() { - const val = this.buf.readUInt16BE(this.off); - this.off += 2; - return val; - } - - readInt16() { - const val = this.buf.readInt16BE(this.off); - this.off += 2; - return val; - } - - readUInt32() { - const val = this.buf.readUInt32BE(this.off); - this.off += 4; - return val; - } - - readInt32() { - const val = this.buf.readInt32BE(this.off); - this.off += 4; - return val; - } - - readUInt64() { - const valBig = this.buf.readBigUInt64BE(this.off); - if (valBig > Number.MAX_SAFE_INTEGER) { - throw new Error(`64-bit number too big: ${valBig}`); - } - this.off += 8; - return Number(valBig); - } - - readInt64() { - const valBig = this.buf.readBigInt64BE(this.off); - if (valBig > Number.MAX_SAFE_INTEGER) { - throw new Error(`64-bit number too big: ${valBig}`); - } - if (valBig < Number.MIN_SAFE_INTEGER) { - throw new Error(`64-bit number too small: ${valBig}`); - } - this.off += 8; - return Number(valBig); - } - - /** length-prefixed (32 bits), UTF-8 encoded */ - readString() { - const len = this.readUInt32(); - const str = this.buf.toString("utf8", this.off, this.off + len); - this.off += len; - return str; - } - - readBufWithLen() { - const len = this.readUInt32(); - return this.readBufLen(len); - } - - readBufLen(length: number) { - // simply returning a slice() would retain the entire buf in memory - const buf = Buffer.allocUnsafe(length); - this.buf.copy(buf, 0, this.off, this.off + length); - this.off += length; - return buf; - } - - /** any reads after this will fail */ - readRemainder() { - return this.readBufLen(this.buf.length - this.off); - } -} diff --git a/mapsync-server/src/protocol/BufWriter.ts b/mapsync-server/src/protocol/BufWriter.ts deleted file mode 100644 index 0dba9ea9..00000000 --- a/mapsync-server/src/protocol/BufWriter.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** Each write advances the internal offset into the buffer. - * Grows the buffer to twice the current size if a write would exceed the buffer. */ -export class BufWriter { - private off = 0; - private buf: Buffer; - - constructor(initialSize?: number) { - this.buf = Buffer.alloc(initialSize || 1024); - } - - /** Returns a slice reference to the written bytes so far. */ - getBuffer() { - return this.buf.slice(0, this.off); - } - - writeUInt8(val: number) { - this.ensureSpace(1); - this.buf.writeUInt8(val, this.off); - this.off += 1; - } - - writeInt8(val: number) { - this.ensureSpace(1); - this.buf.writeInt8(val, this.off); - this.off += 1; - } - - writeUInt16(val: number) { - this.ensureSpace(2); - this.buf.writeUInt16BE(val, this.off); - this.off += 2; - } - - writeInt16(val: number) { - this.ensureSpace(2); - this.buf.writeInt16BE(val, this.off); - this.off += 2; - } - - writeUInt32(val: number) { - this.ensureSpace(4); - this.buf.writeUInt32BE(val, this.off); - this.off += 4; - } - - writeInt32(val: number) { - this.ensureSpace(4); - this.buf.writeInt32BE(val, this.off); - this.off += 4; - } - - writeUInt64(val: number) { - this.ensureSpace(8); - this.buf.writeBigUInt64BE(BigInt(val), this.off); - this.off += 8; - } - - writeInt64(val: number) { - this.ensureSpace(8); - this.buf.writeBigInt64BE(BigInt(val), this.off); - this.off += 8; - } - - /** length-prefixed (32 bits), UTF-8 encoded */ - writeString(str: string) { - const strBuf = Buffer.from(str, "utf8"); - this.ensureSpace(4 + strBuf.length); - this.buf.writeUInt32BE(strBuf.length, this.off); - this.off += 4; - this.buf.set(strBuf, this.off); - this.off += strBuf.length; - } - - /** length-prefixed (32 bits), UTF-8 encoded */ - writeBufWithLen(buf: Buffer) { - this.ensureSpace(4 + buf.length); - this.buf.writeUInt32BE(buf.length, this.off); - this.off += 4; - this.buf.set(buf, this.off); - this.off += buf.length; - } - - writeBufRaw(buf: Buffer) { - this.ensureSpace(buf.length); - this.buf.set(buf, this.off); - this.off += buf.length; - } - - private ensureSpace(bytes: number) { - let len = this.buf.length; - while (len <= this.off + bytes) { - len = len * 2; - } - if (len !== this.buf.length) { - const newBuf = Buffer.alloc(len); - this.buf.copy(newBuf, 0, 0, this.off); - this.buf = newBuf; - } - } -} diff --git a/mapsync-server/src/protocol/CatchupPacket.ts b/mapsync-server/src/protocol/CatchupPacket.ts deleted file mode 100644 index d05f839b..00000000 --- a/mapsync-server/src/protocol/CatchupPacket.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { type CatchupChunk } from "../model"; -import { BufWriter } from "./BufWriter"; - -export interface CatchupPacket { - type: "Catchup"; - world: string; - chunks: CatchupChunk[]; -} - -export namespace CatchupPacket { - export function encode(pkt: CatchupPacket, writer: BufWriter) { - if (pkt.chunks.length < 1) - throw new Error(`Catchup chunks must not be empty`); - writer.writeString(pkt.world); - writer.writeUInt32(pkt.chunks.length); - for (const row of pkt.chunks) { - writer.writeInt32(row.chunkX); - writer.writeInt32(row.chunkZ); - writer.writeUInt64(row.timestamp); - } - } -} diff --git a/mapsync-server/src/protocol/CatchupRequestPacket.ts b/mapsync-server/src/protocol/CatchupRequestPacket.ts deleted file mode 100644 index a14ddc86..00000000 --- a/mapsync-server/src/protocol/CatchupRequestPacket.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { type CatchupChunk } from "../model"; -import { BufReader } from "./BufReader"; - -export interface CatchupRequestPacket { - type: "CatchupRequest"; - world: string; - chunks: CatchupChunk[]; -} - -export namespace CatchupRequestPacket { - export function decode(reader: BufReader): CatchupRequestPacket { - const world = reader.readString(); - const chunks: CatchupChunk[] = new Array(reader.readUInt32()); - for (let i = 0; i < chunks.length; i++) { - chunks[i] = { - chunkX: reader.readInt32(), - chunkZ: reader.readInt32(), - timestamp: reader.readUInt64(), - }; - } - return { type: "CatchupRequest", world, chunks }; - } -} diff --git a/mapsync-server/src/protocol/ChunkTilePacket.ts b/mapsync-server/src/protocol/ChunkTilePacket.ts deleted file mode 100644 index eee9f326..00000000 --- a/mapsync-server/src/protocol/ChunkTilePacket.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { BufReader } from "./BufReader"; -import { BufWriter } from "./BufWriter"; -import { SHA1_HASH_LENGTH } from "../constants"; - -export interface ChunkTilePacket { - type: "ChunkTile"; - world: string; - chunk_x: number; - chunk_z: number; - ts: number; - data: { version: number; hash: Buffer; data: Buffer }; -} - -export namespace ChunkTilePacket { - export function decode(reader: BufReader): ChunkTilePacket { - return { - type: "ChunkTile", - world: reader.readString(), - chunk_x: reader.readInt32(), - chunk_z: reader.readInt32(), - ts: reader.readUInt64(), - data: { - version: reader.readUInt16(), - hash: reader.readBufLen(SHA1_HASH_LENGTH), - data: reader.readRemainder(), - }, - }; - } - - export function encode(pkt: ChunkTilePacket, writer: BufWriter) { - writer.writeString(pkt.world); - writer.writeInt32(pkt.chunk_x); - writer.writeInt32(pkt.chunk_z); - writer.writeUInt64(pkt.ts); - writer.writeUInt16(pkt.data.version); - writer.writeBufRaw(pkt.data.hash); - writer.writeBufRaw(pkt.data.data); // XXX do we need to prefix with length? - } -} diff --git a/mapsync-server/src/protocol/EncryptionRequestPacket.ts b/mapsync-server/src/protocol/EncryptionRequestPacket.ts deleted file mode 100644 index 148e4212..00000000 --- a/mapsync-server/src/protocol/EncryptionRequestPacket.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BufReader } from "./BufReader"; -import { BufWriter } from "./BufWriter"; - -export interface EncryptionRequestPacket { - type: "EncryptionRequest"; - publicKey: Buffer; - verifyToken: Buffer; -} - -export namespace EncryptionRequestPacket { - export function decode(reader: BufReader): EncryptionRequestPacket { - return { - type: "EncryptionRequest", - publicKey: reader.readBufWithLen(), - verifyToken: reader.readBufWithLen(), - }; - } - - export function encode(pkt: EncryptionRequestPacket, writer: BufWriter) { - writer.writeBufWithLen(pkt.publicKey); - writer.writeBufWithLen(pkt.verifyToken); - } -} diff --git a/mapsync-server/src/protocol/EncryptionResponsePacket.ts b/mapsync-server/src/protocol/EncryptionResponsePacket.ts deleted file mode 100644 index e17adc5f..00000000 --- a/mapsync-server/src/protocol/EncryptionResponsePacket.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BufReader } from "./BufReader"; -import { BufWriter } from "./BufWriter"; - -export interface EncryptionResponsePacket { - type: "EncryptionResponse"; - /** encrypted with server's public key */ - sharedSecret: Buffer; - /** encrypted with server's public key */ - verifyToken: Buffer; -} - -export namespace EncryptionResponsePacket { - export function decode(reader: BufReader): EncryptionResponsePacket { - return { - type: "EncryptionResponse", - sharedSecret: reader.readBufWithLen(), - verifyToken: reader.readBufWithLen(), - }; - } - - export function encode(pkt: EncryptionResponsePacket, writer: BufWriter) { - writer.writeBufWithLen(pkt.sharedSecret); - writer.writeBufWithLen(pkt.verifyToken); - } -} diff --git a/mapsync-server/src/protocol/HandshakePacket.ts b/mapsync-server/src/protocol/HandshakePacket.ts deleted file mode 100644 index 32bd4b82..00000000 --- a/mapsync-server/src/protocol/HandshakePacket.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { BufReader } from "./BufReader"; -import { BufWriter } from "./BufWriter"; - -export interface HandshakePacket { - type: "Handshake"; - modVersion: string; - mojangName: string; - gameAddress: string; - world: string; -} - -export namespace HandshakePacket { - export function decode(reader: BufReader): HandshakePacket { - return { - type: "Handshake", - modVersion: reader.readString(), - mojangName: reader.readString(), - gameAddress: reader.readString(), - world: reader.readString(), - }; - } -} diff --git a/mapsync-server/src/protocol/RegionCatchupPacket.ts b/mapsync-server/src/protocol/RegionCatchupPacket.ts deleted file mode 100644 index 13890d9b..00000000 --- a/mapsync-server/src/protocol/RegionCatchupPacket.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BufReader } from "./BufReader"; -import { type Pos2D } from "../model"; - -export interface RegionCatchupPacket { - type: "RegionCatchup"; - world: string; - regions: Pos2D[]; -} - -export namespace RegionCatchupPacket { - export function decode(reader: BufReader): RegionCatchupPacket { - let world = reader.readString(); - const len = reader.readInt16(); - const regions: Pos2D[] = []; - for (let i = 0; i < len; i++) { - regions.push({ - x: reader.readInt16(), - z: reader.readInt16(), - }); - } - return { type: "RegionCatchup", world, regions }; - } -} diff --git a/mapsync-server/src/protocol/RegionTimestampsPacket.ts b/mapsync-server/src/protocol/RegionTimestampsPacket.ts deleted file mode 100644 index e99a151c..00000000 --- a/mapsync-server/src/protocol/RegionTimestampsPacket.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { BufWriter } from "./BufWriter"; -import { CatchupRegion } from "../model"; - -export interface RegionTimestampsPacket { - type: "RegionTimestamps"; - world: string; - regions: Array; -} - -export namespace RegionTimestampsPacket { - export function encode(pkt: RegionTimestampsPacket, writer: BufWriter) { - writer.writeString(pkt.world); - writer.writeInt16(pkt.regions.length); - console.log("Sending regions " + JSON.stringify(pkt.regions)); - for (let i = 0; i < pkt.regions.length; i++) { - let region = pkt.regions[i]; - writer.writeInt16(region.regionX); - writer.writeInt16(region.regionZ); - writer.writeInt64(region.timestamp); - } - } -} diff --git a/mapsync-server/src/protocol/buffers.ts b/mapsync-server/src/protocol/buffers.ts new file mode 100644 index 00000000..531b3405 --- /dev/null +++ b/mapsync-server/src/protocol/buffers.ts @@ -0,0 +1,197 @@ +import { + asInt16, + asInt32, + asInt64, + asUnt10, + asUnt16, + asUnt31, + asUnt5, + asUnt8, + int16, + int32, + int64, + numeric, + unt10, + unt16, + unt31, + unt5, + unt8, +} from "../deps/ints"; + +/** Each read advances the internal offset into the buffer. */ +export class BufferReader { + private offset = 0; + + public constructor(private readonly buffer: Buffer) {} + + public remainder(): unt31 { + return asUnt31(this.buffer.length - this.offset); + } + + public readUnt5(): unt5 { + const value = this.buffer.readUInt8(this.offset); + this.offset += 1; + return asUnt5(value); + } + + public readUnt8(): unt8 { + const value = this.buffer.readUInt8(this.offset); + this.offset += 1; + return asUnt8(value); + } + + public readUnt10(): unt10 { + const value = this.buffer.readUInt16BE(this.offset); + this.offset += 2; + return asUnt10(value); + } + + public readUnt16(): unt16 { + const value = this.buffer.readUInt16BE(this.offset); + this.offset += 2; + return asUnt16(value); + } + + public readInt16(): int16 { + const value = this.buffer.readInt16BE(this.offset); + this.offset += 2; + return asInt16(value); + } + + public readUnt31(): unt31 { + const value = this.buffer.readInt32BE(this.offset); + this.offset += 4; + return asUnt31(value); + } + + public readInt32(): int32 { + const value = this.buffer.readInt32BE(this.offset); + this.offset += 4; + return asInt32(value); + } + + public readInt64(): int64 { + const value = this.buffer.readBigInt64BE(this.offset); + this.offset += 8; + return asInt64(value); + } + + public readBytesOfLength(length: numeric): Buffer { + length = asUnt31(length); + length = Number(length); + const bytes = Buffer.allocUnsafe(length); + this.buffer.copy(bytes, 0, this.offset, this.offset + length); + this.offset += length; + return bytes; + } + + public readString(): string { + return this.readBytesOfLength(this.readUnt8()).toString("utf8"); + } + + /** any reads after this will fail */ + public readRemainder(): Buffer { + return this.readBytesOfLength(this.remainder()); + } +} + +type LengthPrefixSetter = (this: BufferWriter, length: numeric) => void; + +/** Each write advances the internal offset into the buffer. + * Grows the buffer to twice the current size if a write would exceed the buffer. */ +export class BufferWriter { + private offset = 0; + private buffer: Buffer; + + public constructor(initialSize: unt31 = asUnt31(1024)) { + this.buffer = Buffer.alloc(Number(initialSize)); + } + + /** Returns a slice reference to the written bytes so far. */ + public getBuffer(): Buffer { + return this.buffer.subarray(0, this.offset); + } + + public writeUnt5(value: numeric) { + value = asUnt5(value); + this.writeUnt8(value as unknown as unt8); + } + + public writeUnt8(value: numeric) { + value = asUnt8(value); + this.ensureSpace(1); + this.buffer.writeUInt8(Number(value), this.offset); + this.offset += 1; + } + + public writeUnt10(value: numeric) { + value = asUnt10(value); + this.writeUnt16(value as unknown as unt16); + } + + public writeUnt16(value: numeric) { + value = asUnt16(value); + this.ensureSpace(2); + this.buffer.writeUInt16BE(Number(value), this.offset); + this.offset += 2; + } + + public writeInt16(value: numeric) { + value = asInt16(value); + this.ensureSpace(2); + this.buffer.writeInt16BE(Number(value), this.offset); + this.offset += 2; + } + + public writeUnt31(value: numeric) { + value = asUnt31(value); + this.writeInt32(value as unknown as int32); + } + + public writeInt32(value: numeric) { + value = asInt32(value); + this.ensureSpace(4); + this.buffer.writeInt32BE(Number(value), this.offset); + this.offset += 4; + } + + public writeInt64(value: numeric) { + value = asInt64(value); + this.ensureSpace(8); + this.buffer.writeBigInt64BE(value, this.offset); + this.offset += 8; + } + + public writeBytes(buf: Buffer) { + this.ensureSpace(buf.length); + this.buffer.set(buf, this.offset); + this.offset += buf.length; + } + + public writeLengthPrefixedBytes( + lengthSetter: LengthPrefixSetter, + data: Buffer, + ) { + lengthSetter.bind(this)(data.length); + this.buffer.set(data, this.offset); + this.offset += data.length; + } + + public writeString(value: string) { + const bytes: Buffer = Buffer.from(value, "utf8"); + this.writeUnt8(bytes.length); + this.writeBytes(bytes); + } + + private ensureSpace(bytes: number) { + let length = this.buffer.length; + while (length <= this.offset + bytes) { + length *= 2; + } + if (length > this.buffer.length) { + const replacement = Buffer.allocUnsafe(length); + this.buffer.copy(replacement, 0, 0, this.offset); + this.buffer = replacement; + } + } +} diff --git a/mapsync-server/src/protocol/index.ts b/mapsync-server/src/protocol/index.ts index da615fb8..577d242a 100644 --- a/mapsync-server/src/protocol/index.ts +++ b/mapsync-server/src/protocol/index.ts @@ -1,75 +1,264 @@ -import { BufReader } from "./BufReader"; -import { BufWriter } from "./BufWriter"; -import { ChunkTilePacket } from "./ChunkTilePacket"; -import { EncryptionRequestPacket } from "./EncryptionRequestPacket"; -import { EncryptionResponsePacket } from "./EncryptionResponsePacket"; -import { HandshakePacket } from "./HandshakePacket"; -import { CatchupPacket } from "./CatchupPacket"; -import { CatchupRequestPacket } from "./CatchupRequestPacket"; -import { RegionTimestampsPacket } from "./RegionTimestampsPacket"; -import { RegionCatchupPacket } from "./RegionCatchupPacket"; - -export type ClientPacket = - | ChunkTilePacket - | EncryptionResponsePacket - | HandshakePacket - | CatchupRequestPacket - | RegionCatchupPacket; - -export type ServerPacket = - | ChunkTilePacket - | EncryptionRequestPacket - | CatchupPacket - | RegionTimestampsPacket; - -export const packetIds = [ - "ERROR:pkt0", - "Handshake", - "EncryptionRequest", - "EncryptionResponse", - "ChunkTile", - "Catchup", - "CatchupRequest", - "RegionTimestamps", - "RegionCatchup", -]; - -export function getPacketId(type: ServerPacket["type"]) { - const id = packetIds.indexOf(type); - if (id === -1) throw new Error(`Unknown packet type ${type}`); - return id; +import { BufferReader, BufferWriter } from "./buffers"; +import type { CatchupChunk } from "../model"; +import { SHA1_HASH_LENGTH } from "../constants"; +import { + asInt32, + asUnt8, + int16, + int32, + int64, + unt16, + unt8, +} from "../deps/ints"; + +export type ServerboundPacket = + | ServerboundHandshakePacket + | ServerboundEncryptionResponsePacket + | ServerboundChunkTimestampsRequestPacket + | ServerboundCatchupRequestPacket + | ChunkTilePacket; + +export type ClientboundPacket = + | ClientboundEncryptionRequestPacket + | ClientboundRegionTimestampsPacket + | ClientboundChunkTimestampsResponsePacket + | ChunkTilePacket; + +abstract class Packet { + protected constructor(public readonly packetId: unt8) {} + + public get name(): string { + return this.constructor.name ?? `Packet[${this.packetId}]`; + } } -export function decodePacket(reader: BufReader): ClientPacket { - const packetType = reader.readUInt8(); - switch (packetIds[packetType]) { - case "ChunkTile": - return ChunkTilePacket.decode(reader); - case "Handshake": - return HandshakePacket.decode(reader); - case "EncryptionResponse": - return EncryptionResponsePacket.decode(reader); - case "CatchupRequest": - return CatchupRequestPacket.decode(reader); - case "RegionCatchup": - return RegionCatchupPacket.decode(reader); - default: - throw new Error(`Unknown packet type ${packetType}`); +export class ServerboundHandshakePacket extends Packet { + public static readonly PACKET_ID = asUnt8(1); + + public constructor( + public readonly modVersion: string, + public readonly mojangName: string, + public readonly gameAddress: string, + public readonly dimension: string, + ) { + super(ServerboundHandshakePacket.PACKET_ID); + } + + public static decode(reader: BufferReader): ServerboundHandshakePacket { + return new ServerboundHandshakePacket( + reader.readString(), + reader.readString(), + reader.readString(), + reader.readString(), + ); } } -export function encodePacket(pkt: ServerPacket, writer: BufWriter): void { - writer.writeUInt8(getPacketId(pkt.type)); - switch (pkt.type) { - case "ChunkTile": - return ChunkTilePacket.encode(pkt, writer); - case "Catchup": - return CatchupPacket.encode(pkt, writer); - case "EncryptionRequest": - return EncryptionRequestPacket.encode(pkt, writer); - case "RegionTimestamps": - return RegionTimestampsPacket.encode(pkt, writer); +export class ClientboundEncryptionRequestPacket extends Packet { + public static readonly PACKET_ID = asUnt8(2); + + public constructor( + public readonly publicKey: Buffer, + public readonly verifyToken: Buffer, + ) { + super(ClientboundEncryptionRequestPacket.PACKET_ID); + } + + public encode(writer: BufferWriter) { + writer.writeLengthPrefixedBytes( + BufferWriter.prototype.writeUnt16, + this.publicKey, + ); + writer.writeLengthPrefixedBytes( + BufferWriter.prototype.writeUnt8, + this.verifyToken, + ); + } +} + +export class ServerboundEncryptionResponsePacket extends Packet { + public static readonly PACKET_ID = asUnt8(3); + + public constructor( + /** encrypted with server's public key */ + public readonly sharedSecret: Buffer, + /** encrypted with server's public key */ + public readonly verifyToken: Buffer, + ) { + super(ServerboundEncryptionResponsePacket.PACKET_ID); + } + + public static decode( + reader: BufferReader, + ): ServerboundEncryptionResponsePacket { + return new ServerboundEncryptionResponsePacket( + reader.readBytesOfLength(reader.readUnt8()), + reader.readBytesOfLength(reader.readUnt8()), + ); + } +} + +export class ClientboundRegionTimestampsPacket extends Packet { + public static readonly PACKET_ID = asUnt8(7); + + public constructor( + public readonly dimension: string, + public readonly regionX: int16, + public readonly regionZ: int16, + public readonly timestamp: int64, + ) { + super(ClientboundRegionTimestampsPacket.PACKET_ID); + } + + public encode(writer: BufferWriter) { + writer.writeString(this.dimension); + writer.writeInt16(this.regionX); + writer.writeInt16(this.regionZ); + writer.writeInt64(this.timestamp); + } +} + +export class ServerboundChunkTimestampsRequestPacket extends Packet { + public static readonly PACKET_ID = asUnt8(8); + + public constructor( + public readonly dimension: string, + public readonly regionX: int16, + public readonly regionZ: int16, + ) { + super(ServerboundChunkTimestampsRequestPacket.PACKET_ID); + } + + public static decode( + reader: BufferReader, + ): ServerboundChunkTimestampsRequestPacket { + return new ServerboundChunkTimestampsRequestPacket( + reader.readString(), + reader.readInt16(), + reader.readInt16(), + ); + } +} + +export class ClientboundChunkTimestampsResponsePacket extends Packet { + public static readonly PACKET_ID = asUnt8(5); + + public constructor( + public readonly dimension: string, + public readonly regionX: int16, + public readonly regionZ: int16, + public readonly chunks: CatchupChunk[], + ) { + super(ClientboundChunkTimestampsResponsePacket.PACKET_ID); + switch (true) { + case this.chunks.length < 1: + throw new Error(`Catchup chunks must not be empty`); + case this.chunks.length > 1024: + throw new Error(`Catchup chunks contains too many chunks!`); + } + } + + public encode(writer: BufferWriter) { + writer.writeString(this.dimension); + writer.writeInt16(this.regionX); + writer.writeInt16(this.regionZ); + writer.writeUnt10(this.chunks.length); + for (const row of this.chunks) { + writer.writeUnt5(row.chunkX & 31n); + writer.writeUnt5(row.chunkZ & 31n); + writer.writeInt64(row.timestamp); + } + } +} + +export class ServerboundCatchupRequestPacket extends Packet { + public static readonly PACKET_ID = asUnt8(6); + + public constructor( + public readonly dimension: string, + public readonly chunks: CatchupChunk[], + ) { + super(ServerboundCatchupRequestPacket.PACKET_ID); + } + + public static decode( + reader: BufferReader, + ): ServerboundCatchupRequestPacket { + const dimension = reader.readString(); + const anchorChunkX = reader.readInt16() << 5n; + const anchorChunkZ = reader.readInt16() << 5n; + const chunks: CatchupChunk[] = new Array(Number(reader.readUnt10())); + for (let i = 0; i < chunks.length; i++) { + chunks[i] = { + chunkX: asInt32(anchorChunkX + reader.readUnt5()), + chunkZ: asInt32(anchorChunkZ + reader.readUnt5()), + timestamp: reader.readInt64(), + }; + } + return new ServerboundCatchupRequestPacket(dimension, chunks); + } +} + +export class ChunkTilePacket extends Packet { + public static readonly PACKET_ID = asUnt8(4); + + public constructor( + public readonly dimension: string, + public readonly chunkX: int32, + public readonly chunkZ: int32, + public readonly timestamp: int64, + public readonly dataVersion: unt16, + public readonly dataHash: Buffer, + public readonly data: Buffer, + ) { + super(ChunkTilePacket.PACKET_ID); + } + + public static decode(reader: BufferReader): ChunkTilePacket { + return new ChunkTilePacket( + reader.readString(), + reader.readInt32(), + reader.readInt32(), + reader.readInt64(), + reader.readUnt16(), + reader.readBytesOfLength(SHA1_HASH_LENGTH), + reader.readRemainder(), + ); + } + + public encode(writer: BufferWriter) { + writer.writeString(this.dimension); + writer.writeInt32(this.chunkX); + writer.writeInt32(this.chunkZ); + writer.writeInt64(this.timestamp); + writer.writeUnt16(this.dataVersion); + writer.writeBytes(this.dataHash); + writer.writeBytes(this.data); // XXX do we need to prefix with length? + } +} + +export function decodePacket(reader: BufferReader): ServerboundPacket { + const packetId: unt8 = reader.readUnt8(); + switch (packetId) { + case ServerboundHandshakePacket.PACKET_ID: + return ServerboundHandshakePacket.decode(reader); + case ServerboundEncryptionResponsePacket.PACKET_ID: + return ServerboundEncryptionResponsePacket.decode(reader); + case ServerboundChunkTimestampsRequestPacket.PACKET_ID: + return ServerboundChunkTimestampsRequestPacket.decode(reader); + case ServerboundCatchupRequestPacket.PACKET_ID: + return ServerboundCatchupRequestPacket.decode(reader); + case ChunkTilePacket.PACKET_ID: + return ChunkTilePacket.decode(reader); default: - throw new Error(`Unknown packet type ${(pkt as any).type}`); + throw new Error(`Unknown packet type ${packetId}`); } } + +export function encodePacket( + pkt: ClientboundPacket, + writer: BufferWriter, +): void { + writer.writeUnt8(pkt.packetId); + pkt.encode(writer); +} diff --git a/mapsync-server/src/server.ts b/mapsync-server/src/server.ts index 4f4c90c6..979927b6 100644 --- a/mapsync-server/src/server.ts +++ b/mapsync-server/src/server.ts @@ -1,14 +1,19 @@ import crypto from "crypto"; import net from "net"; import { Main } from "./main"; -import type { ClientPacket, ServerPacket } from "./protocol"; -import { decodePacket, encodePacket } from "./protocol"; -import { BufReader } from "./protocol/BufReader"; -import { BufWriter } from "./protocol/BufWriter"; -import { EncryptionResponsePacket } from "./protocol/EncryptionResponsePacket"; -import { HandshakePacket } from "./protocol/HandshakePacket"; +import { + decodePacket, + encodePacket, + type ServerboundPacket, + type ClientboundPacket, + ServerboundHandshakePacket, + ServerboundEncryptionResponsePacket, + ClientboundEncryptionRequestPacket, +} from "./protocol"; +import { BufferReader, BufferWriter } from "./protocol/buffers"; import { SUPPORTED_VERSIONS } from "./constants"; import * as metadata from "./metadata"; +import { asUnt31 } from "./deps/ints"; const { PORT = "12312", HOST = "127.0.0.1" } = process.env; @@ -66,7 +71,7 @@ export class TcpClient { gameAddress: string | undefined; uuid: string | undefined; mcName: string | undefined; - world: string | undefined; + dimension: string | undefined; /** prevent Out of Memory when client sends a large packet */ maxFrameSize = 2 ** 21; @@ -109,22 +114,20 @@ export class TcpClient { // prevent Out of Memory if (frameSize > this.maxFrameSize) { - return this.kick( - "Frame too large: " + - frameSize + - " have " + - accBuf.length, + this.kick( + `Frame's length [${frameSize}] is too large, max is [${this.maxFrameSize}]!`, ); + return; } if (accBuf.length < 4 + frameSize) return; // wait for more data - const frameReader = new BufReader(accBuf); - frameReader.readUInt32(); // skip frame size - let pktBuf = frameReader.readBufLen(frameSize); + const frameReader = new BufferReader(accBuf); + frameReader.readUnt31(); // skip frame size + let pktBuf = frameReader.readBytesOfLength(frameSize); accBuf = frameReader.readRemainder(); - const reader = new BufReader(pktBuf); + const reader = new BufferReader(pktBuf); try { const packet = decodePacket(reader); @@ -163,18 +166,19 @@ export class TcpClient { }); } - private async handlePacketReceived(pkt: ClientPacket) { + private async handlePacketReceived(pkt: ServerboundPacket) { + this.debug(`Received ${pkt.name}:`, pkt); if (!this.uuid) { - // not authenticated yet - switch (pkt.type) { - case "Handshake": + switch (true) { + case pkt instanceof ServerboundHandshakePacket: return await this.handleHandshakePacket(pkt); - case "EncryptionResponse": + case pkt instanceof ServerboundEncryptionResponsePacket: return await this.handleEncryptionResponsePacket(pkt); + default: + throw new Error( + `Packet ${pkt.name} from unauth'd client ${this.id}`, + ); } - throw new Error( - `Packet ${pkt.type} from unauth'd client ${this.id}`, - ); } else { return await this.handler.handleClientPacketReceived(this, pkt); } @@ -185,16 +189,15 @@ export class TcpClient { this.socket.destroy(); } - async send(pkt: ServerPacket) { + async send(pkt: ClientboundPacket) { if (!this.cryptoPromise) { - this.debug("Not encrypted, dropping packet", pkt.type); + this.debug("Not encrypted, dropping packet", pkt); return; } if (!this.uuid) { - this.debug("Not authenticated, dropping packet", pkt.type); + this.debug("Not authenticated, dropping packet", pkt); return; } - this.debug(this.mcName + " -> " + pkt.type); await this.sendInternal(pkt, true); } @@ -210,27 +213,32 @@ export class TcpClient { * - If encryption is enabled, waits for the handshake to complete and encrypts the buffer. * - Drops the packet if the socket is not writable. */ - private async sendInternal(pkt: ServerPacket, doCrypto = false) { + private async sendInternal(pkt: ClientboundPacket, doCrypto = false) { if (!this.socket.writable) - return this.debug("Socket closed, dropping", pkt.type); + return this.debug("Socket closed, dropping", pkt); if (doCrypto && !this.cryptoPromise) throw new Error(`Can't encrypt: handshake not finished`); - - const writer = new BufWriter(); // TODO size hint - writer.writeUInt32(0); // set later, but reserve space in buffer - encodePacket(pkt, writer); - let buf: Buffer = writer.getBuffer(); - buf.writeUInt32BE(buf.length - 4, 0); // write into space reserved above - + this.debug(`Sending ${pkt.name}:`, pkt); + let buf: Buffer; + { + const packetWriter = new BufferWriter(); + encodePacket(pkt, packetWriter); + buf = packetWriter.getBuffer(); + } + { + const frameWriter = new BufferWriter(asUnt31(4 + buf.length)); + frameWriter.writeUnt31(buf.length); + frameWriter.writeBytes(buf); + buf = frameWriter.getBuffer(); + } if (doCrypto) { const { cipher } = await this.cryptoPromise!; buf = cipher!.update(buf); } - this.socket.write(buf); } - private async handleHandshakePacket(packet: HandshakePacket) { + private async handleHandshakePacket(packet: ServerboundHandshakePacket) { if (this.cryptoPromise) throw new Error(`Already authenticated`); if (this.verifyToken) throw new Error(`Encryption already started`); @@ -245,18 +253,19 @@ export class TcpClient { this.gameAddress = packet.gameAddress; this.claimedMojangName = packet.mojangName; - this.world = packet.world; + this.dimension = packet.dimension; this.verifyToken = crypto.randomBytes(4); - await this.sendInternal({ - type: "EncryptionRequest", - publicKey: this.server.publicKeyBuffer, - verifyToken: this.verifyToken, - }); + await this.sendInternal( + new ClientboundEncryptionRequestPacket( + this.server.publicKeyBuffer, + this.verifyToken, + ), + ); } private async handleEncryptionResponsePacket( - pkt: EncryptionResponsePacket, + pkt: ServerboundEncryptionResponsePacket, ) { if (this.cryptoPromise) throw new Error(`Already authenticated`); if (!this.claimedMojangName)