diff --git a/game-fork/patches/decompiler-fixes.txt b/game-fork/patches/decompiler-fixes.txt index 8190193..c7d849f 100644 --- a/game-fork/patches/decompiler-fixes.txt +++ b/game-fork/patches/decompiler-fixes.txt @@ -1327,3 +1327,14 @@ import org.lwjgl.opengl.ContextCapabilities; this.debugFeedbackTranslated(flag ? "debug.show_hitboxes.on" : "debug.show_hitboxes.off"); return true; + +# The far view of the world is drawn before the chunks, so that the chunks, +# which are nearer and have all the detail, cover it where they overlap. +=== FILE net/minecraft/client/renderer/EntityRenderer.java +--- BEFORE + this.mc.profiler.endStartSection("terrain_setup"); + renderglobal.setupTerrain(entity, (double) f, frustum, this.frameCount++, this.mc.player.isSpectator()); +--- AFTER + this.mc.profiler.endStartSection("terrain_setup"); + net.rustnotmc.client.RsxLod.render(this.mc); + renderglobal.setupTerrain(entity, (double) f, frustum, this.frameCount++, this.mc.player.isSpectator()); \ No newline at end of file diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxLod.java b/game-fork/src-overlay/net/rustnotmc/client/RsxLod.java new file mode 100644 index 0000000..bcd3927 --- /dev/null +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxLod.java @@ -0,0 +1,415 @@ +package net.rustnotmc.client; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import net.minecraft.client.Minecraft; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import net.minecraft.client.renderer.GlStateManager; +import net.rustnotmc.protocol.Packets; +import org.lwjgl.BufferUtils; +import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL15; +import org.lwjgl.opengl.GL20; + +/** + * The far view of the world. + * + * The chunks around a player hold the whole detail, but a player sees much + * further than that. The far view is the ground of the world seen from far + * away: a grid of heights and colours, one sample every four blocks. The + * server sends it a region at a time, and the client turns each region into + * a mesh and draws it beyond the loaded chunks. + * + * The mesh is drawn with its own shader, because the far view has no blocks + * and no textures: only the shape of the ground and its colour. + */ +public final class RsxLod { + + /** The regions that came from the server, by the corner of the region. */ + private static final java.util.Map REGIONS = + new java.util.concurrent.ConcurrentHashMap(); + + /** The meshes that were built, by the corner of the region. */ + private static final java.util.Map MESHES = + new java.util.concurrent.ConcurrentHashMap(); + + /** Where the far view writes down what went wrong. */ + private static final Logger LOGGER = LogManager.getLogger(); + + /** Whether the far view is drawn at all. */ + private static boolean enabled = true; + + /** The shader of the far view, built when it is first needed. */ + private static Program program; + + /** How many meshes were drawn the last time. */ + private static int drawn; + + /** Whether the drawing of the far view was already said out loud. */ + private static boolean announced; + + private RsxLod() { + } + + /** Takes a region from the server and builds its mesh. */ + public static void accept(Packets.LodRegion region) { + Long key = Long.valueOf(key(region.originX, region.originZ)); + REGIONS.put(key, region); + Mesh old = MESHES.remove(key); + if (old != null) { + old.free(); + } + if (REGIONS.size() == 1) { + LOGGER.info("RustNotMC: the far view came: region at " + region.originX + "," + + region.originZ + " with " + region.size + " samples"); + } + } + + /** Throws the far view away, for when the player leaves the world. */ + public static void clear() { + REGIONS.clear(); + for (Mesh mesh : MESHES.values()) { + mesh.free(); + } + MESHES.clear(); + } + + /** Whether the far view is drawn. */ + public static boolean enabled() { + return enabled; + } + + public static void enabled(boolean on) { + enabled = on; + } + + /** How many meshes were drawn the last time. */ + public static int drawn() { + return drawn; + } + + /** How many regions came from the server. */ + public static int regions() { + return REGIONS.size(); + } + + /** The key of a region: its corner, folded into one number. */ + private static long key(int originX, int originZ) { + return ((long) originX << 32) | (originZ & 0xFFFFFFFFL); + } + + /** + * Draws the far view. + * + * The far view is drawn before the chunks, so that the chunks, which are + * nearer and have all the detail, cover it where they overlap. + */ + public static void render(Minecraft mc) { + drawn = 0; + if (!enabled || REGIONS.isEmpty() || mc.player == null) { + return; + } + if (mc.gameSettings.renderDistanceChunks <= 0) { + return; + } + + Program prog = program(); + if (prog == null) { + return; + } + + FloatBuffer projection = BufferUtils.createFloatBuffer(16); + FloatBuffer model = BufferUtils.createFloatBuffer(16); + GL11.glGetFloat(GL11.GL_PROJECTION_MATRIX, projection); + GL11.glGetFloat(GL11.GL_MODELVIEW_MATRIX, model); + float[] projectionArray = new float[16]; + float[] modelArray = new float[16]; + projection.get(projectionArray); + model.get(modelArray); + + double camX = mc.getRenderViewEntity().lastTickPosX + + (mc.getRenderViewEntity().posX - mc.getRenderViewEntity().lastTickPosX) + * (double) mc.getRenderPartialTicks(); + double camY = mc.getRenderViewEntity().lastTickPosY + + (mc.getRenderViewEntity().posY - mc.getRenderViewEntity().lastTickPosY) + * (double) mc.getRenderPartialTicks(); + double camZ = mc.getRenderViewEntity().lastTickPosZ + + (mc.getRenderViewEntity().posZ - mc.getRenderViewEntity().lastTickPosZ) + * (double) mc.getRenderPartialTicks(); + + // the colour the far view fades into: the colour of the sky + float fogStart = (float) (mc.gameSettings.renderDistanceChunks * 16.0D * 0.75D); + float fogEnd = (float) (mc.gameSettings.renderDistanceChunks * 16.0D); + float fogRed = 0.47F; + float fogGreen = 0.65F; + float fogBlue = 1.0F; + + GlStateManager.pushMatrix(); + GlStateManager.disableAlpha(); + GlStateManager.disableLighting(); + GlStateManager.depthMask(true); + GlStateManager.enableDepth(); + GlStateManager.disableCull(); + GlStateManager.shadeModel(7425); + + prog.use(); + GL20.glUniformMatrix4(prog.uniform("uProj"), false, projection); + GL20.glUniformMatrix4(prog.uniform("uModel"), false, model); + GL20.glUniform3f(prog.uniform("uCam"), (float) camX, (float) camY, (float) camZ); + GL20.glUniform4f(prog.uniform("uFogColour"), fogRed, fogGreen, fogBlue, 1.0F); + GL20.glUniform1f(prog.uniform("uFogStart"), fogStart); + GL20.glUniform1f(prog.uniform("uFogEnd"), fogEnd); + + for (java.util.Map.Entry entry : REGIONS.entrySet()) { + Long key = entry.getKey(); + Mesh mesh = MESHES.get(key); + if (mesh == null) { + mesh = build(entry.getValue()); + MESHES.put(key, mesh); + } + mesh.draw(); + drawn++; + } + if (drawn > 0 && !announced) { + announced = true; + LOGGER.info("RustNotMC: the far view is drawn: " + drawn + " regions"); + } + + prog.unuse(); + GlStateManager.shadeModel(7424); + GlStateManager.enableCull(); + GlStateManager.enableLighting(); + GlStateManager.enableAlpha(); + GlStateManager.popMatrix(); + } + + /** Builds the mesh of one region. */ + private static Mesh build(Packets.LodRegion region) { + int size = region.size; + int step = region.step; + int cells = (size - 1) * (size - 1); + int vertices = cells * 6; + ByteBuffer data = ByteBuffer.allocateDirect(vertices * 16).order(ByteOrder.nativeOrder()); + + for (int z = 0; z < size - 1; z++) { + for (int x = 0; x < size - 1; x++) { + int at = z * size + x; + int h00 = region.heights[at] & 0xFF; + int h10 = region.heights[at + 1] & 0xFF; + int h01 = region.heights[at + size] & 0xFF; + int h11 = region.heights[at + size + 1] & 0xFF; + + float x0 = region.originX + x * step; + float x1 = x0 + step; + float z0 = region.originZ + z * step; + float z1 = z0 + step; + + int c = at * 3; + float r = (region.colors[c] & 0xFF) / 255.0F; + float g = (region.colors[c + 1] & 0xFF) / 255.0F; + float b = (region.colors[c + 2] & 0xFF) / 255.0F; + + // two triangles: the first one along the diagonal, the second + // one the other way, so that the ground is never torn + vertex(data, x0, h00, z0, r, g, b); + vertex(data, x1, h10, z0, r, g, b); + vertex(data, x1, h11, z1, r, g, b); + vertex(data, x0, h00, z0, r, g, b); + vertex(data, x1, h11, z1, r, g, b); + vertex(data, x0, h01, z1, r, g, b); + } + } + data.flip(); + + int id = GL15.glGenBuffers(); + GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, id); + GL15.glBufferData(GL15.GL_ARRAY_BUFFER, data, GL15.GL_STATIC_DRAW); + GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, 0); + return new Mesh(id, vertices); + } + + /** One vertex of the far view: a place and a colour. */ + private static void vertex(ByteBuffer data, float x, float y, float z, float r, float g, + float b) { + data.putFloat(x); + data.putFloat(y); + data.putFloat(z); + data.put((byte) (r * 255.0F)); + data.put((byte) (g * 255.0F)); + data.put((byte) (b * 255.0F)); + data.put((byte) 255); + } + + /** What the driver says about a shader that did not build. */ + private static String infoLog(int shader) { + java.nio.IntBuffer length = BufferUtils.createIntBuffer(1); + java.nio.ByteBuffer text = BufferUtils.createByteBuffer(8192); + GL20.glGetShaderInfoLog(shader, length, text); + byte[] bytes = new byte[Math.max(0, length.get(0))]; + text.get(bytes); + return new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + } + + /** What the driver says about a program that did not link. */ + private static String programLog(int program) { + java.nio.IntBuffer length = BufferUtils.createIntBuffer(1); + java.nio.ByteBuffer text = BufferUtils.createByteBuffer(8192); + GL20.glGetProgramInfoLog(program, length, text); + byte[] bytes = new byte[Math.max(0, length.get(0))]; + text.get(bytes); + return new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + } + + /** Builds the shader of the far view. */ + private static Program program() { + if (program != null) { + return program.ok ? program : null; + } + String vertex = + "#version 330\n" + + "in vec3 aPos;\n" + + "in vec4 aColour;\n" + + "uniform mat4 uProj;\n" + + "uniform mat4 uModel;\n" + + "uniform vec3 uCam;\n" + + "uniform vec4 uFogColour;\n" + + "uniform float uFogStart;\n" + + "uniform float uFogEnd;\n" + + "out vec4 vColour;\n" + + "out float vDistance;\n" + + "void main() {\n" + + " vColour = aColour;\n" + + " vDistance = length(aPos - uCam);\n" + + " gl_Position = uProj * uModel * vec4(aPos, 1.0);\n" + + "}\n"; + String fragment = + "#version 330\n" + + "in vec4 vColour;\n" + + "in float vDistance;\n" + + "uniform vec4 uFogColour;\n" + + "uniform float uFogStart;\n" + + "uniform float uFogEnd;\n" + + "out vec4 fragColour;\n" + + "void main() {\n" + + " float f = clamp((vDistance - uFogStart) / max(uFogEnd - uFogStart, 0.001)," + + " 0.0, 1.0);\n" + + " fragColour = vec4(mix(vColour.rgb, uFogColour.rgb, f), 1.0);\n" + + "}\n"; + program = Program.build(vertex, fragment); + if (!program.ok) { + LOGGER.error("RustNotMC: LOD: the shader would not build"); + } + return program.ok ? program : null; + } + + /** The mesh of one region. */ + private static final class Mesh { + + /** The buffer of the vertices. */ + final int buffer; + + /** How many vertices there are. */ + final int count; + + Mesh(int buffer, int count) { + this.buffer = buffer; + this.count = count; + } + + /** Draws the mesh. */ + void draw() { + GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, this.buffer); + GL20.glEnableVertexAttribArray(0); + GL20.glEnableVertexAttribArray(1); + GL20.glVertexAttribPointer(0, 3, GL11.GL_FLOAT, false, 16, 0L); + GL20.glVertexAttribPointer(1, 4, GL11.GL_UNSIGNED_BYTE, true, 16, 12L); + GL11.glDrawArrays(GL11.GL_TRIANGLES, 0, this.count); + GL20.glDisableVertexAttribArray(1); + GL20.glDisableVertexAttribArray(0); + GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, 0); + } + + /** Throws the mesh away. */ + void free() { + GL15.glDeleteBuffers(this.buffer); + } + } + + /** A shader program of the far view. */ + private static final class Program { + + /** The program itself. */ + final int id; + + /** Whether the program is good. */ + final boolean ok; + + /** The names of the uniforms. */ + final java.util.Map uniforms = + new java.util.HashMap(); + + private Program(int id, boolean ok) { + this.id = id; + this.ok = ok; + } + + /** Builds a program, or says why it did not build. */ + static Program build(String vertex, String fragment) { + int vs = GL20.glCreateShader(GL20.GL_VERTEX_SHADER); + GL20.glShaderSource(vs, vertex); + GL20.glCompileShader(vs); + if (GL20.glGetShaderi(vs, GL20.GL_COMPILE_STATUS) == GL11.GL_FALSE) { + String log = infoLog(vs); + GL20.glDeleteShader(vs); + LOGGER.error("RustNotMC: LOD: the vertex shader: " + log); + return new Program(0, false); + } + int fs = GL20.glCreateShader(GL20.GL_FRAGMENT_SHADER); + GL20.glShaderSource(fs, fragment); + GL20.glCompileShader(fs); + if (GL20.glGetShaderi(fs, GL20.GL_COMPILE_STATUS) == GL11.GL_FALSE) { + String log = infoLog(fs); + GL20.glDeleteShader(fs); + GL20.glDeleteShader(vs); + LOGGER.error("RustNotMC: LOD: the fragment shader: " + log); + return new Program(0, false); + } + int program = GL20.glCreateProgram(); + GL20.glAttachShader(program, vs); + GL20.glAttachShader(program, fs); + GL20.glBindAttribLocation(program, 0, "aPos"); + GL20.glBindAttribLocation(program, 1, "aColour"); + GL20.glLinkProgram(program); + if (GL20.glGetProgrami(program, GL20.GL_LINK_STATUS) == GL11.GL_FALSE) { + String log = programLog(program); + GL20.glDeleteProgram(program); + LOGGER.error("RustNotMC: LOD: the program: " + log); + return new Program(0, false); + } + return new Program(program, true); + } + + /** Uses the program. */ + void use() { + GL20.glUseProgram(this.id); + } + + /** Stops using the program. */ + void unuse() { + GL20.glUseProgram(0); + } + + /** The place of a uniform. */ + int uniform(String name) { + Integer known = this.uniforms.get(name); + if (known != null) { + return known.intValue(); + } + int at = GL20.glGetUniformLocation(this.id, name); + this.uniforms.put(name, Integer.valueOf(at)); + return at; + } + } +} 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 505fd54..04ce089 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java +++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java @@ -296,6 +296,11 @@ public final class RsxNetworkManager extends NetworkManager { } return; } + case Packets.LOD_REGION: { + Packets.LodRegion region = Packets.LodRegion.read(new Buf(frame.body())); + net.rustnotmc.client.RsxLod.accept(region); + return; + } default: onPlay(frame); } diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java index 02c73c7..7f54879 100644 --- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java @@ -158,6 +158,9 @@ public final class Packets { /** The client asks the server to read its box file again. */ public static final int HITBOXES_RELOAD = 0x2C; + /** A piece of the far view: the ground of one region, in a grid. */ + public static final int LOD_REGION = 0x34; + /** The client hits something. */ public static final int ATTACK = 0x2D; @@ -1834,6 +1837,66 @@ public final class Packets { } } + /** + * A piece of the far view. + * + * The far view is the ground of the world seen from far away: a grid of + * heights and colours, one sample every few blocks. The client turns it + * into a mesh and draws it beyond the chunks that are loaded. + */ + public static final class LodRegion implements Packet { + + /** The block the region starts at. */ + public final int originX; + public final int originZ; + + /** How many blocks there are between two samples. */ + public final int step; + + /** How many samples there are on a side. */ + public final int size; + + /** The height of every sample, in blocks. */ + public final byte[] heights; + + /** The colour of every sample: three bytes each, red green blue. */ + public final byte[] colors; + + public LodRegion(int originX, int originZ, int step, int size, byte[] heights, + byte[] colors) { + this.originX = originX; + this.originZ = originZ; + this.step = step; + this.size = size; + this.heights = heights; + this.colors = colors; + } + + public Buf write() { + Buf buf = new Buf(32 + this.heights.length + this.colors.length); + buf.writeVarInt(this.originX); + buf.writeVarInt(this.originZ); + buf.writeVarInt(this.step); + buf.writeVarInt(this.size); + buf.writeBytes(this.heights); + buf.writeBytes(this.colors); + return buf; + } + + public static LodRegion read(Buf in) { + int originX = in.readVarInt(); + int originZ = in.readVarInt(); + int step = in.readVarInt(); + int size = in.readVarInt(); + if (size < 0 || size > 1024) { + throw new ProtocolException("that many samples is not real"); + } + byte[] heights = in.readBytes(size * size); + byte[] colors = in.readBytes(size * size * 3); + return new LodRegion(originX, originZ, step, size, heights, colors); + } + } + /** * Every packet id this build knows, and what it decodes into. * @@ -1877,6 +1940,7 @@ public final class Packets { map.put(Integer.valueOf(HITBOXES), "Hitboxes"); map.put(Integer.valueOf(HITBOX_INSTANCES), "HitboxInstances"); map.put(Integer.valueOf(HITBOXES_RELOAD), "HitboxesReload"); + map.put(Integer.valueOf(LOD_REGION), "LodRegion"); map.put(Integer.valueOf(ATTACK), "Attack"); map.put(Integer.valueOf(CHAT), "Chat"); map.put(Integer.valueOf(CHAT_SEND), "ChatSend"); diff --git a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java index 3d97ef3..f7dc083 100644 --- a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java +++ b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java @@ -554,6 +554,7 @@ public final class ProtocolTest { Packets.HITBOXES, Packets.HITBOX_INSTANCES, Packets.HITBOXES_RELOAD, Packets.ATTACK, Packets.CHAT, Packets.CHAT_SEND, Packets.PLAYER_LIST, Packets.SESSION_RELOAD, Packets.SESSION_RENEWED, Packets.ADMIN_STATE, + Packets.LOD_REGION, Packets.DISCONNECT, }; for (int i = 0; i < ids.length; i++) { diff --git a/rsx-server-core/scripts/test.ps1 b/rsx-server-core/scripts/test.ps1 index feab2bf..150167f 100644 --- a/rsx-server-core/scripts/test.ps1 +++ b/rsx-server-core/scripts/test.ps1 @@ -49,6 +49,7 @@ $suites = @( 'net.rustnotmc.server.ServerTest', 'net.rustnotmc.server.world.WorldTest', 'net.rustnotmc.server.world.NodeTest', + 'net.rustnotmc.server.world.LodTest', 'net.rustnotmc.server.item.InventoryTest', 'net.rustnotmc.server.craft.CraftTest', 'net.rustnotmc.server.hitbox.HitboxTest', diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java index ea1832b..97ed74f 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java @@ -66,6 +66,10 @@ public final class ClientSession implements Runnable { /** The identity of the player, as the launcher gave it. */ private volatile String uuid = ""; + /** The regions of the far view that were sent already. */ + private final java.util.Set sentLodRegions = + new java.util.LinkedHashSet(); + /** When the session runs out. */ private volatile long sessionExpiresAt; @@ -459,6 +463,37 @@ public final class ClientSession implements Runnable { send(Packets.ADMIN_STATE, new Packets.AdminState(admin)); } + /** + * Sends the far view around the player. + * + * The far view is a grid of heights and colours, one region at a time. A + * region is only sent once: the client keeps what it got. + */ + void sendLod() { + if (this.player == null) { + return; + } + int chunkX = (int) Math.floor(this.player.x() / 16.0D); + int chunkZ = (int) Math.floor(this.player.z() / 16.0D); + int regionX = net.rustnotmc.server.world.LodGenerator.regionChunk(chunkX); + int regionZ = net.rustnotmc.server.world.LodGenerator.regionChunk(chunkZ); + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + int rx = regionX + dx * net.rustnotmc.server.world.LodGenerator.REGION_CHUNKS; + int rz = regionZ + dz * net.rustnotmc.server.world.LodGenerator.REGION_CHUNKS; + Integer key = Integer.valueOf(rx * 100003 + rz); + if (this.sentLodRegions.contains(key)) { + continue; + } + this.sentLodRegions.add(key); + if (this.sentLodRegions.size() > 128) { + this.sentLodRegions.clear(); + } + send(Packets.LOD_REGION, this.server.lod().region(rx, rz)); + } + } + } + private void onAuth(Frame frame) { if (this.state != SessionState.HANDSHAKING) { return; diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java b/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java index 9eebf80..94d3b16 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java @@ -61,6 +61,7 @@ public final class Server { private volatile AdminRegistry admins = new AdminRegistry(null); private volatile net.rustnotmc.server.player.PlayerData playerData = new net.rustnotmc.server.player.PlayerData(null); + private volatile net.rustnotmc.server.world.LodGenerator lod; private volatile HitboxRegistry hitboxes = new HitboxRegistry(null); private volatile ServerSocket listener; @@ -82,6 +83,9 @@ public final class Server { this.capacity = capacity; this.port = port; this.world = world; + // the far view of the world, kept in memory unless the caller says + // where it should be written down + this.lod = new net.rustnotmc.server.world.LodGenerator(world.generator(), null); } // -------------------------------------------------------------- getters @@ -251,6 +255,15 @@ public final class Server { } } + /** The far view of the world. */ + public net.rustnotmc.server.world.LodGenerator lod() { + return this.lod; + } + + public void lod(net.rustnotmc.server.world.LodGenerator lod) { + this.lod = lod; + } + /** Where the data of the players is kept. */ public net.rustnotmc.server.player.PlayerData playerData() { return this.playerData; @@ -502,6 +515,7 @@ public final class Server { this.world.evictIdle(); for (Player player : this.players.values()) { tickResearch(player); + player.session().sendLod(); // the rights of a player can be taken away while they play, and // the client has to hear about it at once boolean admin = player.isAdmin(); diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java index e475389..975fad0 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java @@ -129,6 +129,9 @@ public final class ServerMain { // The administrators are the server's own list. Path adminFile = adminsFile != null ? adminsFile : worldDir.resolve("admins.txt"); server.admins(new net.rustnotmc.server.admin.AdminRegistry(adminFile)); + // the far view of the world, kept beside it + server.lod(new net.rustnotmc.server.world.LodGenerator(world.generator(), + worldDir.resolve("lod"))); // where the players leave their things server.playerData(new net.rustnotmc.server.player.PlayerData( worldDir.resolve("players"))); diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/LodGenerator.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/LodGenerator.java new file mode 100644 index 0000000..aa289e9 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/LodGenerator.java @@ -0,0 +1,159 @@ +package net.rustnotmc.server.world; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import net.rustnotmc.protocol.Blocks; +import net.rustnotmc.protocol.Packets; + +/** + * The far view of the world. + * + * The chunks around a player hold the whole detail, but a player sees much + * further than that. The far view is the ground of the world seen from far + * away: a grid of heights and colours, one sample every four blocks. The + * client turns the grid into a mesh and draws it beyond the loaded chunks. + * + * The regions are counted from the origin of the world, so that every server + * and every client numbers them the same way, and they are written to disk, + * so that a region is only worked out once. + */ +public final class LodGenerator { + + /** How many chunks there are on the side of a region. */ + public static final int REGION_CHUNKS = 16; + + /** How many blocks there are on the side of a region. */ + public static final int REGION_BLOCKS = REGION_CHUNKS * 16; + + /** How many blocks there are between two samples. */ + public static final int STEP = 4; + + /** How many samples there are on the side of a region. */ + public static final int SAMPLES = REGION_BLOCKS / STEP; + + private final WorldGenerator generator; + + /** Where the regions are kept, or null when they are not kept. */ + private final Path cache; + + public LodGenerator(WorldGenerator generator, Path cache) { + this.generator = generator; + this.cache = cache; + } + + /** The chunk a region starts at. */ + public static int regionChunk(int chunk) { + return Math.floorDiv(chunk, REGION_CHUNKS) * REGION_CHUNKS; + } + + /** + * The far view of one region. + * + * The region is read from the disk when it is there, and worked out when + * it is not. + */ + public Packets.LodRegion region(int chunkX, int chunkZ) { + int startChunk = regionChunk(chunkX); + int startZ = regionChunk(chunkZ); + int originX = startChunk * 16; + int originZ = startZ * 16; + + byte[] heights = new byte[SAMPLES * SAMPLES]; + byte[] colors = new byte[SAMPLES * SAMPLES * 3]; + if (!read(startChunk, startZ, heights, colors)) { + write(startChunk, startZ, heights, colors); + } + return new Packets.LodRegion(originX, originZ, STEP, SAMPLES, heights, colors); + } + + /** Reads a region from the disk. */ + private boolean read(int chunkX, int chunkZ, byte[] heights, byte[] colors) { + if (this.cache == null) { + return false; + } + Path file = this.cache.resolve(chunkX + "_" + chunkZ + ".bin"); + if (!Files.isRegularFile(file)) { + return false; + } + try { + byte[] data = Files.readAllBytes(file); + int expected = heights.length + colors.length; + if (data.length != expected) { + return false; + } + System.arraycopy(data, 0, heights, 0, heights.length); + System.arraycopy(data, heights.length, colors, 0, colors.length); + return true; + } catch (IOException broken) { + return false; + } + } + + /** Works a region out and writes it down. */ + private void write(int chunkX, int chunkZ, byte[] heights, byte[] colors) { + for (int z = 0; z < SAMPLES; z++) { + for (int x = 0; x < SAMPLES; x++) { + int worldX = chunkX * 16 + x * STEP; + int worldZ = chunkZ * 16 + z * STEP; + int height = this.generator.heightAt(worldX, worldZ); + int at = z * SAMPLES + x; + heights[at] = (byte) height; + int colour = colourOf(worldX, worldZ, height); + colors[at * 3] = (byte) ((colour >> 16) & 0xFF); + colors[at * 3 + 1] = (byte) ((colour >> 8) & 0xFF); + colors[at * 3 + 2] = (byte) (colour & 0xFF); + } + } + if (this.cache == null) { + return; + } + try { + Files.createDirectories(this.cache); + byte[] data = new byte[heights.length + colors.length]; + System.arraycopy(heights, 0, data, 0, heights.length); + System.arraycopy(colors, 0, data, heights.length, colors.length); + Path file = this.cache.resolve(chunkX + "_" + chunkZ + ".bin"); + Path temp = file.resolveSibling(file.getFileName() + ".tmp"); + Files.write(temp, data); + Files.move(temp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (IOException broken) { + // The far view is a nicety: a region that cannot be written is + // worked out again next time. + } + } + + /** The colour of the ground at a place, as the far view shows it. */ + private int colourOf(int worldX, int worldZ, int height) { + if (height < WorldGenerator.SEA_LEVEL) { + return 0x2E5C8A; + } + byte top = topBlock(worldX, worldZ, height); + switch (top) { + case Blocks.SAND: + return 0xC8B47C; + case Blocks.STONE: + return 0x6E6E6E; + case Blocks.ASPHALT: + return 0x3A3A3A; + case Blocks.WATER: + return 0x2E5C8A; + default: + return 0x4E7A34; + } + } + + /** The block on the top of a column. */ + private byte topBlock(int worldX, int worldZ, int height) { + if (this.generator.isRoad(worldX, worldZ)) { + return Blocks.ASPHALT; + } + if (height < WorldGenerator.SEA_LEVEL) { + return Blocks.WATER; + } + if (height <= WorldGenerator.SEA_LEVEL + 1) { + return Blocks.SAND; + } + return Blocks.GRASS; + } +} diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/world/LodTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/world/LodTest.java new file mode 100644 index 0000000..fde7875 --- /dev/null +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/world/LodTest.java @@ -0,0 +1,137 @@ +package net.rustnotmc.server.world; + +import java.nio.file.Files; +import java.nio.file.Path; +import net.rustnotmc.protocol.Packets; + +/** + * Tests the far view of the world. + * + * The far view is what a player sees beyond the loaded chunks: a grid of + * heights and colours. It has to agree with the terrain the server has, or + * the world would look wrong at the edge. + */ +public final class LodTest { + + private static int checks; + private static int failed; + private static final java.util.List failures = new java.util.ArrayList(); + + public static void main(String[] args) throws Exception { + Path work = Path.of(args.length > 0 ? args[0] : "build/selftest"); + Files.createDirectories(work); + + run("a region holds a grid of samples", new Throwing() { + public void go() throws Exception { testGrid(work); } + }); + run("the heights are the heights of the terrain", new Throwing() { + public void go() throws Exception { testHeights(work); } + }); + run("the region is kept on disk", new Throwing() { + public void go() throws Exception { testCache(work); } + }); + run("the regions are numbered from the origin", new Throwing() { + public void go() throws Exception { testNumbering(); } + }); + + System.out.println(); + for (int i = 0; i < failures.size(); i++) { + System.out.println(" FAIL " + failures.get(i)); + } + System.out.println(); + System.out.println("checks: " + checks + ", failed: " + failed); + if (failed != 0) { + System.out.println("FAILED"); + System.exit(1); + } + System.out.println("OK"); + } + + private interface Throwing { + void go() throws Exception; + } + + private static void run(String name, Throwing body) { + checks++; + int before = failed; + try { + body.go(); + } catch (Throwable t) { + failed++; + failures.add(name + ": threw " + t); + t.printStackTrace(); + return; + } + if (failed == before) { + System.out.println(" ok " + name); + } else { + System.out.println(" FAIL " + name); + } + } + + private static void eq(Object what, Object expected, Object actual) { + if (expected == null ? actual != null : !expected.equals(actual)) { + failed++; + failures.add(what + ": expected " + expected + " but got " + actual); + } + } + + private static void yes(String what, boolean condition) { + if (!condition) { + failed++; + failures.add(what); + } + } + + private static void testGrid(Path work) throws Exception { + LodGenerator lod = new LodGenerator(new WorldGenerator(1234L), null); + Packets.LodRegion region = lod.region(0, 0); + eq("the samples on a side", Integer.valueOf(LodGenerator.SAMPLES), + Integer.valueOf(region.size)); + eq("the heights", Integer.valueOf(LodGenerator.SAMPLES * LodGenerator.SAMPLES), + Integer.valueOf(region.heights.length)); + eq("the colours", Integer.valueOf(LodGenerator.SAMPLES * LodGenerator.SAMPLES * 3), + Integer.valueOf(region.colors.length)); + eq("the step", Integer.valueOf(LodGenerator.STEP), Integer.valueOf(region.step)); + } + + private static void testHeights(Path work) throws Exception { + WorldGenerator generator = new WorldGenerator(1234L); + LodGenerator lod = new LodGenerator(generator, null); + Packets.LodRegion region = lod.region(0, 0); + for (int z = 0; z < 4; z++) { + for (int x = 0; x < 4; x++) { + int worldX = x * LodGenerator.STEP; + int worldZ = z * LodGenerator.STEP; + int at = z * region.size + x; + eq("the height at " + worldX + "," + worldZ, + Integer.valueOf(generator.heightAt(worldX, worldZ)), + Integer.valueOf(region.heights[at] & 0xFF)); + } + } + } + + private static void testCache(Path work) throws Exception { + Path cache = work.resolve("lod-test"); + WorldGenerator generator = new WorldGenerator(1234L); + LodGenerator lod = new LodGenerator(generator, cache); + Packets.LodRegion first = lod.region(0, 0); + yes("the region is written down", Files.isRegularFile(cache.resolve("0_0.bin"))); + + // the second time it is read back, and it is the same + Packets.LodRegion second = lod.region(0, 0); + yes("the same heights", java.util.Arrays.equals(first.heights, second.heights)); + yes("the same colours", java.util.Arrays.equals(first.colors, second.colors)); + } + + private static void testNumbering() { + eq("the first region", Integer.valueOf(0), + Integer.valueOf(LodGenerator.regionChunk(0))); + eq("the region of a chunk inside", Integer.valueOf(0), + Integer.valueOf(LodGenerator.regionChunk(15))); + eq("the next region", Integer.valueOf(16), + Integer.valueOf(LodGenerator.regionChunk(16))); + eq("and back for the negative ones", Integer.valueOf(-16), + Integer.valueOf(LodGenerator.regionChunk(-1))); + } +}