diff --git a/game-fork/src-overlay/net/rustnotmc/client/net/RsxBlockMap.java b/game-fork/src-overlay/net/rustnotmc/client/net/RsxBlockMap.java index 408b539..6fe2132 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/net/RsxBlockMap.java +++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxBlockMap.java @@ -19,29 +19,36 @@ import net.minecraft.init.Blocks; */ public final class RsxBlockMap { + /** + * The state of every block id, worked out once. + * + * A chunk has sixteen thousand blocks in it and a client may be given a + * hundred chunks at a time, so this is looked up sixty times a second per + * chunk instead of a million. + */ + private static final IBlockState[] STATES = new IBlockState[8]; + + static { + STATES[net.rustnotmc.protocol.Blocks.AIR] = Blocks.AIR.getDefaultState(); + STATES[net.rustnotmc.protocol.Blocks.STONE] = Blocks.STONE.getDefaultState(); + STATES[net.rustnotmc.protocol.Blocks.DIRT] = Blocks.DIRT.getDefaultState(); + STATES[net.rustnotmc.protocol.Blocks.GRASS] = Blocks.GRASS.getDefaultState(); + STATES[net.rustnotmc.protocol.Blocks.SAND] = Blocks.SAND.getDefaultState(); + STATES[net.rustnotmc.protocol.Blocks.WATER] = Blocks.WATER.getDefaultState(); + STATES[net.rustnotmc.protocol.Blocks.WOOD] = Blocks.LOG.getDefaultState(); + STATES[net.rustnotmc.protocol.Blocks.LEAVES] = Blocks.LEAVES.getDefaultState(); + } + private RsxBlockMap() { } /** The game state for a block id from the server. */ public static IBlockState stateFor(byte id) { - switch (id) { - case net.rustnotmc.protocol.Blocks.STONE: - return Blocks.STONE.getDefaultState(); - case net.rustnotmc.protocol.Blocks.DIRT: - return Blocks.DIRT.getDefaultState(); - case net.rustnotmc.protocol.Blocks.GRASS: - return Blocks.GRASS.getDefaultState(); - case net.rustnotmc.protocol.Blocks.SAND: - return Blocks.SAND.getDefaultState(); - case net.rustnotmc.protocol.Blocks.WATER: - return Blocks.WATER.getDefaultState(); - case net.rustnotmc.protocol.Blocks.WOOD: - return Blocks.LOG.getDefaultState(); - case net.rustnotmc.protocol.Blocks.LEAVES: - return Blocks.LEAVES.getDefaultState(); - default: - return Blocks.AIR.getDefaultState(); + if (id < 0 || id >= STATES.length) { + return Blocks.AIR.getDefaultState(); } + IBlockState state = STATES[id]; + return state == null ? Blocks.AIR.getDefaultState() : state; } /** The server id for a game block, or -1 when the server has no such block. */ diff --git a/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java b/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java index 3f4d3ec..385e19f 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java +++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java @@ -53,6 +53,9 @@ public final class RsxNetworkManager extends NetworkManager { /** Set with -Drsx.trace=true to see every packet that arrives. */ private static final boolean TRACE = System.getProperty("rsx.trace") != null; + /** How long one tick may spend on incoming chunks, in nanoseconds. */ + private static final long BUDGET_NANOS = 3000000L; + /** * The connection that is open, if any. * @@ -193,11 +196,23 @@ public final class RsxNetworkManager extends NetworkManager { * Called by the game on its own thread, which is the thread everything * else in the game happens on. A packet that arrives on the reader thread * is only put in a queue; it is dealt with here. + * + * The work is limited to a slice of a tick. Walking into new ground brings + * a column of chunks at once, and doing all of them in one tick is what + * makes the world stutter. What does not fit waits for the next tick, + * which is a fifth of a second later and nobody can see the difference. */ @Override public void processReceivedPackets() { + long deadline = System.nanoTime() + BUDGET_NANOS; Frame frame; - while ((frame = this.inbox.poll()) != null) { + while ((frame = this.inbox.peek()) != null) { + if (frame.packetId() == Packets.CHUNK_DATA && System.nanoTime() > deadline) { + // the chunks wait their turn; the rest of the queue waits with + // them, which is fine + return; + } + this.inbox.poll(); try { handle(frame); } catch (Exception e) { @@ -297,8 +312,14 @@ public final class RsxNetworkManager extends NetworkManager { if (this.mc.world == null) { return; } + long started = System.nanoTime(); RsxWorldBridge.chunk(this.mc.world, chunk.chunkX, chunk.chunkZ, chunk.blocks) .processPacket(handler); + long millis = (System.nanoTime() - started) / 1000000L; + if (millis > 4L) { + System.out.println("[rsx] chunk " + chunk.chunkX + "," + chunk.chunkZ + + " took " + millis + " ms"); + } return; } if (frame.packetId() == Packets.ENTITY_SPAWN) { diff --git a/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java b/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java index 1883e3d..e6ae7c8 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java +++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java @@ -24,8 +24,7 @@ import net.minecraft.world.EnumDifficulty; import net.minecraft.world.GameType; import net.minecraft.world.World; import net.minecraft.world.WorldType; -import net.minecraft.world.chunk.Chunk; -import net.minecraft.world.chunk.storage.ExtendedBlockStorage; +import net.minecraft.world.chunk.BlockStateContainer; import net.rustnotmc.protocol.ChunkFormat; import net.rustnotmc.protocol.Frame; import net.rustnotmc.protocol.Packets; @@ -72,40 +71,87 @@ public final class RsxWorldBridge { /** * One chunk, as the game wants it. * - * The server sends a flat array of block ids; the game wants sections with - * palettes, light and biome data. The chunk is built by hand and then - * given to the game's own serialiser, which is the only way to be sure the - * two agree about the format. + * The game reads a chunk as sixteen sections of sixteen by sixteen by + * sixteen, each with a palette of block states, a light array and a sky + * light array, and a biome for every column. + * + * The section data is written straight into the game's own format instead + * of being built by filling a game chunk block by block. Filling one takes + * sixteen thousand calls, and every one of them updates a height map and a + * light map: it cost the client a quarter of a second for every chunk it + * walked into, which is the difference between a world that streams and a + * world that stutters. * * The light is set to full. The server does not compute light yet, and a * world with no light in it is a world nobody can see. */ - public static SPacketChunkData chunk(World world, int chunkX, int chunkZ, byte[] blocks) { - Chunk chunk = new Chunk(world, chunkX, chunkZ); - for (int y = 0; y < ChunkFormat.HEIGHT; y++) { - for (int z = 0; z < ChunkFormat.WIDTH; z++) { - for (int x = 0; x < ChunkFormat.WIDTH; x++) { - byte id = blocks[ChunkFormat.index(x, y, z)]; - if (id == net.rustnotmc.protocol.Blocks.AIR) { - continue; + public static SPacketChunkData chunk(World world, int chunkX, int chunkZ, byte[] blocks) + throws java.io.IOException { + boolean skyLight = world.provider.hasSkyLight(); + + PacketBuffer sections = new PacketBuffer(Unpooled.buffer(128 * 1024)); + int mask = 0; + + for (int section = 0; section < 16; section++) { + BlockStateContainer container = new BlockStateContainer(); + boolean any = false; + int base = section * 16; + + for (int y = 0; y < 16; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + byte id = blocks[ChunkFormat.index(x, base + y, z)]; + if (id == net.rustnotmc.protocol.Blocks.AIR) { + // the palette starts with air and the storage + // starts at zero, so air is already there + continue; + } + container.set(x, y, z, RsxBlockMap.stateFor(id)); + any = true; } - chunk.setBlockState(new BlockPos(x, y, z), RsxBlockMap.stateFor(id)); } } + + if (!any) { + continue; + } + mask |= 1 << section; + container.write(sections); + sections.writeBytes(LIGHT); + if (skyLight) { + sections.writeBytes(LIGHT); + } } - ExtendedBlockStorage[] storages = chunk.getBlockStorageArray(); - for (int i = 0; i < storages.length; i++) { - ExtendedBlockStorage storage = storages[i]; - if (storage == Chunk.NULL_BLOCK_STORAGE) { - continue; - } - java.util.Arrays.fill(storage.getBlockLight().getData(), (byte) 0xFF); - if (storage.getSkyLight() != null) { - java.util.Arrays.fill(storage.getSkyLight().getData(), (byte) 0xFF); - } - } - return new SPacketChunkData(chunk, 65535); + // one biome per column, and the same one for now + sections.writeBytes(BIOMES); + + byte[] data = new byte[sections.writerIndex()]; + sections.readBytes(data); + + PacketBuffer body = new PacketBuffer(Unpooled.buffer(data.length + 64)); + body.writeInt(chunkX); + body.writeInt(chunkZ); + body.writeBoolean(true); + body.writeVarInt(mask); + body.writeVarInt(data.length); + body.writeBytes(data); + body.writeVarInt(0); // no tile entities + + SPacketChunkData packet = new SPacketChunkData(); + packet.readPacketData(body); + return packet; + } + + /** A section's light, all of it on. */ + private static final byte[] LIGHT = new byte[2048]; + + /** The biomes of a chunk: one byte per column. */ + private static final byte[] BIOMES = new byte[256]; + + static { + java.util.Arrays.fill(LIGHT, (byte) 0xFF); + java.util.Arrays.fill(BIOMES, (byte) 1); // plains } /** One block, changed. */ diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/Chunk.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/Chunk.java index 9d3fb00..1fc289a 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/world/Chunk.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/Chunk.java @@ -81,7 +81,11 @@ public final class Chunk { void markGenerated() { this.generated = true; - this.dirty = true; + // a generated chunk is not dirty. The terrain is a pure function of the + // seed and the position, so it can be generated again in a moment, and + // writing it out costs sixty four kilobytes a chunk for nothing. Only + // a chunk somebody built in is worth the disk. + this.dirty = false; } public boolean isDirty() { diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/world/WorldTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/world/WorldTest.java index 5d92755..c1880f7 100644 --- a/rsx-server-core/src/test/java/net/rustnotmc/server/world/WorldTest.java +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/world/WorldTest.java @@ -572,23 +572,35 @@ public final class WorldTest { ChunkStorage storage = new ChunkStorage(dir); World world = new World(11L, storage); ChunkPos pos = new ChunkPos(5, 5); + ChunkPos built = new ChunkPos(6, 6); world.chunkAt(pos); - eq("one chunk in memory", Integer.valueOf(1), Integer.valueOf(world.loadedChunks())); + world.chunkAt(built); + // one chunk is only walked over, the other is built in: the grass at + // the surface is replaced with stone + int surface = world.generator().heightAt(6 * Chunk.WIDTH, 6 * Chunk.WIDTH); + world.setBlock(6 * Chunk.WIDTH, surface, 6 * Chunk.WIDTH, Blocks.STONE); + eq("two chunks in memory", Integer.valueOf(2), Integer.valueOf(world.loadedChunks())); for (int i = 0; i < (int) World.EVICT_AFTER_TICKS + 10; i++) { world.tick(); } int dropped = world.evictIdle(); - eq("it was dropped", Integer.valueOf(1), Integer.valueOf(dropped)); + eq("they were dropped", Integer.valueOf(2), Integer.valueOf(dropped)); eq("nothing left in memory", Integer.valueOf(0), Integer.valueOf(world.loadedChunks())); - eq("one chunk evicted", Long.valueOf(1L), Long.valueOf(world.evictedCount())); - yes("and it is on disk", storage.exists(pos)); + eq("two chunks evicted", Long.valueOf(2L), Long.valueOf(world.evictedCount())); - // and it comes back with the same terrain - Chunk back = world.chunkAt(pos); + // a chunk somebody built in is worth the disk, and comes back + yes("the built one is on disk", storage.exists(built)); + // a chunk that was only walked over is not: the terrain is generated + // again in a moment, and writing it out would cost sixty four + // kilobytes a chunk for nothing + yes("the untouched one is not", !storage.exists(pos)); + + Chunk back = world.chunkAt(built); yes("it came back", back.isGenerated()); - eq("with the same surface", - Integer.valueOf(world.generator().heightAt(5 * Chunk.WIDTH, 5 * Chunk.WIDTH) + 1), + eq("with the build in it", Byte.valueOf(Blocks.STONE), + Byte.valueOf(back.block(0, surface, 0))); + eq("and the surface is where it was", Integer.valueOf(surface + 1), Integer.valueOf(back.surfaceHeight(0, 0))); }