From 07ed4ce02d2c7d2bd739628d06c675bf8d9f6caf Mon Sep 17 00:00:00 2001 From: sasheg dev <152582704+SashegDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:38:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=B8=D1=80:=20=D0=BE=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=B4=D0=BE=D1=80=D0=BE=D0=B3=D0=B8-=D0=B4?= =?UTF-8?q?=D0=B0=D0=BC=D0=B1=D1=8B,=20=D0=B1=D0=B8=D0=BE=D0=BC=D1=8B;=20L?= =?UTF-8?q?OD=20=D1=81=D1=82=D1=83=D0=BF=D0=B5=D0=BD=D1=8F=D0=BC=D0=B8;=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D1=8C=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B0;=20=D0=B1=D0=B0=D0=BD=D1=8B;=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game-fork/patches/decompiler-fixes.txt | 39 +- .../net/rustnotmc/client/RsxBootstrap.java | 3 + .../net/rustnotmc/client/RsxLod.java | 42 +- .../net/rustnotmc/client/RsxPostPipeline.java | 1037 +++++++++++++++++ .../net/rustnotmc/client/net/RsxBlockMap.java | 15 +- .../rustnotmc/client/net/RsxWorldBridge.java | 57 +- .../java/net/rustnotmc/protocol/Blocks.java | 6 +- .../net/rustnotmc/server/ClientSession.java | 56 +- .../net/rustnotmc/server/ConsoleCommands.java | 192 +++ .../java/net/rustnotmc/server/Server.java | 17 + .../java/net/rustnotmc/server/ServerMain.java | 8 + .../rustnotmc/server/admin/AdminRegistry.java | 44 + .../rustnotmc/server/admin/BanRegistry.java | 140 +++ .../rustnotmc/server/world/LodGenerator.java | 74 +- .../rustnotmc/server/world/RoadNetwork.java | 20 + .../server/world/WorldGenerator.java | 469 +++++++- .../net/rustnotmc/server/world/LodTest.java | 34 +- .../net/rustnotmc/server/world/WorldTest.java | 95 +- tools/watch-game.ps1 | 31 +- 19 files changed, 2257 insertions(+), 122 deletions(-) create mode 100644 game-fork/src-overlay/net/rustnotmc/client/RsxPostPipeline.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/ConsoleCommands.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/admin/BanRegistry.java diff --git a/game-fork/patches/decompiler-fixes.txt b/game-fork/patches/decompiler-fixes.txt index c7d849f..f044163 100644 --- a/game-fork/patches/decompiler-fixes.txt +++ b/game-fork/patches/decompiler-fixes.txt @@ -1337,4 +1337,41 @@ import org.lwjgl.opengl.ContextCapabilities; --- 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 + renderglobal.setupTerrain(entity, (double) f, frustum, this.frameCount++, this.mc.player.isSpectator()); + +# The post effects read the projection the world was drawn with. +=== FILE net/minecraft/client/renderer/EntityRenderer.java +--- BEFORE + if (this.mc.gameSettings.viewBobbing) { + this.applyBobbing(f); + } + + } + } + + public void disableLightmap() { +--- AFTER + if (this.mc.gameSettings.viewBobbing) { + this.applyBobbing(f); + } + net.rustnotmc.client.RsxPostPipeline.captureProjection(); + + } + } + + public void disableLightmap() { + +# The post effects run on what the world drew, before the hand and the +# interface come over it. +=== FILE net/minecraft/client/renderer/EntityRenderer.java +--- BEFORE + this.mc.profiler.endStartSection("hand"); + if (this.renderHand) { +--- AFTER + this.mc.profiler.endStartSection("rsxpost"); + if (i == 2 && !this.mc.gameSettings.anaglyph) { + net.rustnotmc.client.RsxPostPipeline.apply(this.mc); + } + + this.mc.profiler.endStartSection("hand"); + if (this.renderHand) { \ No newline at end of file diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java b/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java index 19e4831..288931a 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java @@ -127,6 +127,9 @@ public final class RsxBootstrap { } // our own commands come first: they are not mod commands, and a mod // should not be able to take a name that the game itself uses + if (net.rustnotmc.client.RsxPostPipeline.command(line)) { + return true; + } if (RsxHitboxDebug.command(line)) { return true; } diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxLod.java b/game-fork/src-overlay/net/rustnotmc/client/RsxLod.java index bcd3927..3762b18 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/RsxLod.java +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxLod.java @@ -139,9 +139,14 @@ public final class RsxLod { + (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); + // the colour the far view fades into: the colour of the sky. The far + // view reaches further than the chunks do, so its fog sits at the + // edge of the far view and not at the edge of the chunks; the two + // kinds of ground fade into the same sky where they meet. + float chunkEdge = (float) (mc.gameSettings.renderDistanceChunks * 16.0D); + float farEdge = 1600.0F; + float fogStart = Math.max(chunkEdge * 0.8F, 128.0F); + float fogEnd = chunkEdge + farEdge; float fogRed = 0.47F; float fogGreen = 0.65F; float fogBlue = 1.0F; @@ -193,6 +198,12 @@ public final class RsxLod { int vertices = cells * 6; ByteBuffer data = ByteBuffer.allocateDirect(vertices * 16).order(ByteOrder.nativeOrder()); + // the sun of the far view: the same direction from the north-west + // and above, so that the far ground is not flat colour + float sunX = -0.4629F; + float sunY = 0.8165F; + float sunZ = -0.3492F; + for (int z = 0; z < size - 1; z++) { for (int x = 0; x < size - 1; x++) { int at = z * size + x; @@ -211,14 +222,27 @@ public final class RsxLod { float g = (region.colors[c + 1] & 0xFF) / 255.0F; float b = (region.colors[c + 2] & 0xFF) / 255.0F; + // the slope of the cell: the client works out its own light + // here, because the far view has no blocks for the game to + // light, only the shape of the ground it has already got + float dx = (h10 + h11 - h00 - h01) / (2.0F * step); + float dz = (h01 + h11 - h00 - h10) / (2.0F * step); + float length = (float) Math.sqrt(dx * dx + dz * dz + 1.0F); + float facing = (-dx * sunX + sunY - dz * sunZ) / length; + float light = Math.max(0.42F, (facing - 0.35F) * 0.95F + 0.72F); + + float lr = Math.min(1.0F, r * light); + float lg = Math.min(1.0F, g * light); + float lb = Math.min(1.0F, b * light); + // 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); + vertex(data, x0, h00, z0, lr, lg, lb); + vertex(data, x1, h10, z0, lr, lg, lb); + vertex(data, x1, h11, z1, lr, lg, lb); + vertex(data, x0, h00, z0, lr, lg, lb); + vertex(data, x1, h11, z1, lr, lg, lb); + vertex(data, x0, h01, z1, lr, lg, lb); } } data.flip(); diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxPostPipeline.java b/game-fork/src-overlay/net/rustnotmc/client/RsxPostPipeline.java new file mode 100644 index 0000000..013bb35 --- /dev/null +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxPostPipeline.java @@ -0,0 +1,1037 @@ +package net.rustnotmc.client; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.FloatBuffer; +import java.util.Properties; +import java.util.Random; + +import org.lwjgl.BufferUtils; +import org.lwjgl.opengl.EXTFramebufferObject; +import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL13; +import org.lwjgl.opengl.GL14; +import org.lwjgl.opengl.GL20; + +import net.minecraft.client.Minecraft; +import net.rustnotmc.loader.Log; + +/** + * The post processing of the remake, redone on top of what the game has. + * + * The remake owns its whole renderer and runs its effects on a core profile + * against G-buffers it fills itself. This game runs a fixed pipeline over a + * 2.1 context, so the same ideas are rebuilt with the tools that are here: + * the scene color and the scene depth are copied off the back buffer the + * moment the world is drawn, and both effects run as full-screen passes in + * programs of their own. + * + * Two effects, the way the remake has them: + * + * + * + * The server sends none of this and the world bridge sends no light that it + * works from: every pass reads back what the client already drew, which is + * the whole point of doing them here. + * + * Everything is wrapped. A driver that refuses one of the programs must cost + * the player the effect, not the game. + */ +public final class RsxPostPipeline { + + private static boolean working = true; + private static boolean programsBuilt; + private static int outputWidth; + private static int outputHeight; + + private static int colorCopyTexture; + private static int depthCopyTexture; + private static int ssaoTexture; + private static int ssaoFramebuffer; + private static int blurTexture; + private static int blurFramebuffer; + private static int[] bloomTextures; + private static int[] bloomBuffers; + private static int mips; + + private static Program thresholdProgram; + private static Program downSampleProgram; + private static Program combineProgram; + private static Program bloomAddProgram; + private static Program ssaoProgram; + private static Program ssaoBlurProgram; + private static Program ssaoCompositeProgram; + + /** The world projection as the camera left it, and its inverse. */ + private static final float[] PROJECTION = new float[16]; + private static final float[] INVERSE = new float[16]; + private static final FloatBuffer PROJECTION_BUF = BufferUtils.createFloatBuffer(16); + private static final FloatBuffer COLUMN_BUF = BufferUtils.createFloatBuffer(16); + + /* -------- settings, kept the way the remake keeps them -------- */ + + private static boolean buffered; + private static boolean master = true; + private static boolean useSSAO = true; + private static boolean useBloom = true; + private static float ssaoStrength = 1.0F; + private static float ssaoRadius = 0.5F; + private static float ssaoBias = 0.02F; + private static float ssaoPower = 2.0F; + private static float bloomIntensity = 1.5F; + private static float bloomThreshold = 0.3F; + private static float bloomKnee = 0.0F; + + private RsxPostPipeline() { + } + + /** + * A chat line beginning with /rsxpost turns the effects around. + * + *
+     *   /rsxpost                 what is on right now
+     *   /rsxpost on|off          the whole pipeline
+     *   /rsxpost ssao on|off     the shading of corners
+     *   /rsxpost bloom on|off    the glow of the sky
+     *   /rsxpost strength 0.8    how strongly SSAO darkens
+     *   /rsxpost radius 1.2      how far SSAO reaches, in blocks
+     *   /rsxpost threshold 0.4   what bloom starts to pick up
+     *   /rsxpost intensity 2.0   how bright the bloom lands
+     * 
+ * + * A line that is not one of ours is left alone, which matters: the chat + * pre-fills commands the player typed before, and a half written + * /rsxpostday has to go on to the world like any other word. + */ + public static boolean command(String line) { + if (line == null || !line.startsWith("/rsxpost")) { + return false; + } + if (line.length() > 8) { + char next = line.charAt(8); + if (next != ' ' && next != '\t') { + return false; + } + } + String[] words = line.trim().split("\\s+"); + if (words.length < 2) { + status(); + return true; + } + String what = words[1].toLowerCase(); + String value = words.length > 2 ? words[2] : null; + boolean on = "on".equalsIgnoreCase(value); + boolean off = "off".equalsIgnoreCase(value); + if ("on".equals(what)) { + setMaster(true); + } else if ("off".equals(what)) { + setMaster(false); + } else if ("ssao".equals(what)) { + if (on || off) { + useSSAO = on; + save(); + say("SSAO " + (on ? "on" : "off")); + } else { + status(); + } + } else if ("bloom".equals(what)) { + if (on || off) { + useBloom = on; + save(); + say("bloom " + (on ? "on" : "off")); + } else { + status(); + } + } else if ("strength".equals(what) && value != null) { + ssaoStrength = clamp(parse(value), 0.0F, 1.0F); + useSSAO = true; + save(); + say("SSAO strength " + ssaoStrength); + } else if ("radius".equals(what) && value != null) { + ssaoRadius = clamp(parse(value), 0.1F, 2.0F); + save(); + say("SSAO radius " + ssaoRadius); + } else if ("threshold".equals(what) && value != null) { + bloomThreshold = clamp(parse(value), 0.0F, 1.0F); + save(); + say("bloom threshold " + bloomThreshold); + } else if ("intensity".equals(what) && value != null) { + bloomIntensity = clamp(parse(value), 0.0F, 8.0F); + save(); + say("bloom intensity " + bloomIntensity); + } else { + say("unknown: " + what + "; try on, off, ssao, bloom, strength," + + " radius, threshold, intensity"); + } + return true; + } + + private static float parse(String value) { + try { + return Float.parseFloat(value); + } catch (NumberFormatException notANumber) { + say("'" + value + "' is not a number"); + return 0.0F; + } + } + + private static float clamp(float value, float least, float most) { + if (value < least) { + return least; + } + if (value > most) { + return most; + } + return value; + } + + private static void status() { + say("post processing: master=" + master + " ssao=" + useSSAO + + " bloom=" + useBloom + " strength=" + ssaoStrength + + " radius=" + ssaoRadius + " threshold=" + bloomThreshold + + " intensity=" + bloomIntensity); + } + + private static void say(String text) { + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft != null && minecraft.player != null) { + minecraft.player.sendMessage( + new net.minecraft.util.text.TextComponentString(text)); + } + } + + /* -------- one frame -------- */ + + /** + * Runs the effects over what the world just drew. + * + * Called at the end of the world pass, after everything from the sky to + * the water and before the hand, so the copies carry the whole world and + * the hand stays sharp above them. Nothing runs when the pipeline is + * switched off, and once it has failed it stays off: the effects are + * worth less than the game they draw over. + */ + public static void apply(Minecraft minecraft) { + if (!master || !working || minecraft.world == null) { + return; + } + int w = minecraft.displayWidth; + int h = minecraft.displayHeight; + if (w <= 0 || h <= 0) { + return; + } + load(); + try { + if (!programsBuilt || w != outputWidth || h != outputHeight) { + build(w, h); + } + if (!working) { + return; + } + copyTheBackBuffer(w, h); + + boolean blendWas = GL11.glIsEnabled(GL11.GL_BLEND); + boolean depthWas = GL11.glIsEnabled(GL11.GL_DEPTH_TEST); + boolean alphaWas = GL11.glIsEnabled(GL11.GL_ALPHA_TEST); + int unitWas = GL11.glGetInteger(GL13.GL_ACTIVE_TEXTURE); + java.nio.IntBuffer bindingBuf = BufferUtils.createIntBuffer(1); + GL11.glGetInteger( + EXTFramebufferObject.GL_FRAMEBUFFER_BINDING_EXT, + bindingBuf); + int binding = bindingBuf.get(0); + + GL11.glDisable(GL11.GL_BLEND); + GL11.glDisable(GL11.GL_DEPTH_TEST); + GL11.glDisable(GL11.GL_ALPHA_TEST); + GL11.glDepthMask(false); + + if (useSSAO && ssaoProgram.complete()) { + runSSAO(w, h); + } + if (useBloom && thresholdProgram.complete()) { + runBloom(w, h); + } + + GL11.glDepthMask(true); + if (depthWas) { + GL11.glEnable(GL11.GL_DEPTH_TEST); + } + if (blendWas) { + GL11.glEnable(GL11.GL_BLEND); + } + if (alphaWas) { + GL11.glEnable(GL11.GL_ALPHA_TEST); + } + GL13.glActiveTexture(unitWas); + GL11.glBindTexture(GL11.GL_TEXTURE_2D, 0); + EXTFramebufferObject.glBindFramebufferEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, binding); + } catch (Throwable failure) { + Log.error("post processing stopped", failure); + working = false; + } + } + + /** + * The projection of the world, taken at camera setup. + * + * Everything the SSAO maths does stands on this: positions are read back + * out of the depth buffer with its inverse, samples are pushed back into + * it with the matrix itself. + */ + public static void captureProjection() { + PROJECTION_BUF.rewind(); + GL11.glGetFloat(GL11.GL_PROJECTION_MATRIX, PROJECTION_BUF); + PROJECTION_BUF.rewind(); + for (int i = 0; i < 16; i++) { + PROJECTION[i] = PROJECTION_BUF.get(); + } + // the matrices travel column major; a straight copy is enough + inverted(PROJECTION, INVERSE); + } + + /** One matrix, in the column major order the uniforms expect. */ + private static FloatBuffer columnOf(float[] matrix) { + COLUMN_BUF.rewind(); + COLUMN_BUF.put(matrix); + COLUMN_BUF.flip(); + return COLUMN_BUF; + } + + /* -------- the scene copies -------- */ + + /** + * Copies color and depth into textures. + * + * The storage is allocated once and refilled with a copy every frame; + * six repeated reallocations a second are how a machine that cannot + * afford the effects says so. + */ + private static void copyTheBackBuffer(int w, int h) { + GL11.glBindTexture(GL11.GL_TEXTURE_2D, colorCopyTexture); + GL11.glCopyTexSubImage2D(GL11.GL_TEXTURE_2D, 0, 0, 0, 0, 0, w, h); + + GL11.glBindTexture(GL11.GL_TEXTURE_2D, depthCopyTexture); + GL11.glCopyTexSubImage2D(GL11.GL_TEXTURE_2D, 0, 0, 0, 0, 0, w, h); + } + + /* -------- SSAO -------- */ + + private static void runSSAO(int w, int h) { + GL11.glViewport(0, 0, w, h); + + EXTFramebufferObject.glBindFramebufferEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, ssaoFramebuffer); + GL11.glClearColor(1.0F, 1.0F, 1.0F, 1.0F); + GL11.glClear(GL11.GL_COLOR_BUFFER_BIT); + ssaoProgram.use(); + bind(0, depthCopyTexture); + ssaoProgram.mat4("uProjection", columnOf(PROJECTION)); + ssaoProgram.mat4("uInvProjection", columnOf(INVERSE)); + for (int i = 0; i < 16; i++) { + ssaoProgram.float3("uSamples[" + i + "]", SAMPLES[i]); + } + ssaoProgram.float1("uRadius", ssaoRadius); + ssaoProgram.float1("uBias", ssaoBias); + ssaoProgram.float1("uPower", ssaoPower); + ssaoProgram.float2("uTexel", 1.0F / w, 1.0F / h); + drawQuad(); + ssaoProgram.close(); + + EXTFramebufferObject.glBindFramebufferEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, blurFramebuffer); + ssaoBlurProgram.use(); + bind(0, ssaoTexture); + bind(1, depthCopyTexture); + ssaoBlurProgram.float2("uTexelSize", 1.0F / w, 1.0F / h); + drawQuad(); + ssaoBlurProgram.close(); + + // the AO lands on the whole scene in one pass; the hand comes after + // and the interface after that, both are drawn on top of the result + GL11.glViewport(0, 0, outputWidth, outputHeight); + ssaoCompositeProgram.use(); + bind(0, colorCopyTexture); + bind(1, blurTexture); + ssaoCompositeProgram.float1("uStrength", ssaoStrength); + drawQuad(); + ssaoCompositeProgram.close(); + } + + /** + * Sixteen directions spread over a hemisphere, farther out the later + * they come, the same spread the remake's sampler was seeded with. + */ + private static final float[][] SAMPLES = buildSamples(); + + private static float[][] buildSamples() { + Random random = new Random(42L); + float[][] spread = new float[16][3]; + for (int i = 0; i < 16; i++) { + float x = random.nextFloat() * 2.0F - 1.0F; + float y = random.nextFloat() * 2.0F - 1.0F; + float z = 0.05F + random.nextFloat() * 0.9F; + float len = (float) Math.sqrt(x * x + y * y + z * z); + if (len < 1.0E-5F) { + len = 1.0E-5F; + } + float eased = (float) i / 15.0F; + eased = eased * eased; + float scale = (0.1F + 0.9F * eased) + * (0.1F + random.nextFloat() * 0.8F); + spread[i][0] = x / len * scale / 2.0F; + spread[i][1] = y / len * scale / 2.0F; + spread[i][2] = z / len * scale / 2.0F; + } + return spread; + } + + /* -------- bloom -------- */ + + private static void runBloom(int w, int h) { + // down the chain: the threshold picks what shines, then each level + // carries half of what the one above it held + for (int level = 0; level < mips; level++) { + GL11.glViewport(0, 0, mipWidth(level), mipHeight(level)); + EXTFramebufferObject.glBindFramebufferEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + bloomBuffers[level]); + GL11.glClearColor(0.0F, 0.0F, 0.0F, 0.0F); + GL11.glClear(GL11.GL_COLOR_BUFFER_BIT); + if (level == 0) { + thresholdProgram.use(); + bind(0, colorCopyTexture); + thresholdProgram.float2("uSrcTexelSize", 1.0F / w, 1.0F / h); + thresholdProgram.float1("uThreshold", bloomThreshold); + thresholdProgram.float1("uKnee", bloomKnee); + drawQuad(); + thresholdProgram.close(); + } else { + downSampleProgram.use(); + bind(0, bloomTextures[level - 1]); + downSampleProgram.float2("uSrcTexelSize", + 1.0F / mipWidth(level - 1), 1.0F / mipHeight(level - 1)); + drawQuad(); + downSampleProgram.close(); + } + } + + // back up the chain: what the level below blurred is added to what + // this level already holds, so halos keep the shape of their light + for (int level = mips - 2; level >= 0; level--) { + GL11.glViewport(0, 0, mipWidth(level), mipHeight(level)); + EXTFramebufferObject.glBindFramebufferEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + bloomBuffers[level]); + combineProgram.use(); + bind(0, bloomTextures[level + 1]); + bind(1, bloomTextures[level]); + combineProgram.float2("uBlurTexelSize", + 1.0F / mipWidth(level + 1), 1.0F / mipHeight(level + 1)); + drawQuad(); + combineProgram.close(); + } + + // and the sum lands on the screen one additive pass later + GL11.glViewport(0, 0, outputWidth, outputHeight); + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_ONE, GL11.GL_ONE); + bloomAddProgram.use(); + bind(0, bloomTextures[0]); + bloomAddProgram.float1("uIntensity", bloomIntensity); + drawQuad(); + bloomAddProgram.close(); + GL11.glDisable(GL11.GL_BLEND); + } + + private static int mipWidth(int level) { + return Math.max(4, outputWidth >> (level + 1)); + } + + private static int mipHeight(int level) { + return Math.max(4, outputHeight >> (level + 1)); + } + + /* -------- machinery -------- */ + + private static void bind(int unit, int texture) { + GL13.glActiveTexture(GL13.GL_TEXTURE0 + unit); + GL11.glBindTexture(GL11.GL_TEXTURE_2D, texture); + } + + private static void drawQuad() { + GL11.glBegin(GL11.GL_QUADS); + GL11.glVertex2f(-1.0F, -1.0F); + GL11.glVertex2f(1.0F, -1.0F); + GL11.glVertex2f(1.0F, 1.0F); + GL11.glVertex2f(-1.0F, 1.0F); + GL11.glEnd(); + } + + /** + * Builds everything once, and grows everything when the window grows. + * + * The programs survive a resize: their programs know nothing of pixels. + * The textures do not exist until the first frame and are re-proposed at + * every size, and the framebuffers keep pointing at the same names. + */ + private static void build(int w, int h) throws IOException { + outputWidth = w; + outputHeight = h; + + if (!programsBuilt) { + captureIfFresh(); + programsBuilt = true; + bloomTextures = new int[BLOOM_MIP_LIMIT]; + bloomBuffers = new int[BLOOM_MIP_LIMIT]; + + colorCopyTexture = GL11.glGenTextures(); + depthCopyTexture = GL11.glGenTextures(); + ssaoTexture = GL11.glGenTextures(); + ssaoFramebuffer = EXTFramebufferObject.glGenFramebuffersEXT(); + blurTexture = GL11.glGenTextures(); + blurFramebuffer = EXTFramebufferObject.glGenFramebuffersEXT(); + for (int level = 0; level < BLOOM_MIP_LIMIT; level++) { + bloomTextures[level] = GL11.glGenTextures(); + bloomBuffers[level] = EXTFramebufferObject + .glGenFramebuffersEXT(); + } + + thresholdProgram = program(COMMON_VERTEX, THRESHOLD_FRAGMENT); + downSampleProgram = program(COMMON_VERTEX, DOWNSAMPLE_FRAGMENT); + combineProgram = program(COMMON_VERTEX, COMBINE_FRAGMENT); + bloomAddProgram = program(COMMON_VERTEX, BLOOM_ADD_FRAGMENT); + ssaoProgram = program(COMMON_VERTEX, SSAO_FRAGMENT); + ssaoBlurProgram = program(COMMON_VERTEX, SSAO_BLUR_FRAGMENT); + ssaoCompositeProgram = program(COMMON_VERTEX, + SSAO_COMPOSITE_FRAGMENT); + } + + mips = 0; + for (int level = 0; level < BLOOM_MIP_LIMIT; level++) { + if (mipWidth(level) < 8 || mipHeight(level) < 8) { + break; + } + mips++; + } + if (mips < 2) { + mips = Math.min(2, BLOOM_MIP_LIMIT); + } + + sizeTexture(colorCopyTexture, w, h, GL11.GL_RGB8, GL11.GL_LINEAR); + GL11.glBindTexture(GL11.GL_TEXTURE_2D, depthCopyTexture); + GL11.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL14.GL_DEPTH_COMPONENT24, + w, h, 0, GL11.GL_DEPTH_COMPONENT, GL11.GL_UNSIGNED_INT, + (java.nio.ByteBuffer) null); + nearest(GL11.GL_CLAMP); + sizeTexture(ssaoTexture, w, h, GL11.GL_RGB8, GL11.GL_LINEAR); + sizeTexture(blurTexture, w, h, GL11.GL_RGB8, GL11.GL_LINEAR); + for (int level = 0; level < mips; level++) { + sizeTexture(bloomTextures[level], mipWidth(level), + mipHeight(level), GL11.GL_RGB8, GL11.GL_LINEAR); + } + + attach(ssaoFramebuffer, ssaoTexture); + attach(blurFramebuffer, blurTexture); + for (int level = 0; level < mips; level++) { + attach(bloomBuffers[level], bloomTextures[level]); + } + } + + /** + * The projection may not be captured yet the first time around; after + * that the camera has run long before the effects will. + */ + private static void captureIfFresh() { + boolean any = false; + for (float v : PROJECTION) { + if (v != 0.0F) { + any = true; + break; + } + } + if (!any) { + identity(PROJECTION); + identity(INVERSE); + } + } + + private static void sizeTexture(int texture, int w, int h, int format, + int filter) { + GL11.glBindTexture(GL11.GL_TEXTURE_2D, texture); + GL11.glTexImage2D(GL11.GL_TEXTURE_2D, 0, format, w, h, 0, + GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, + (java.nio.ByteBuffer) null); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, + filter); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, + filter); + wrap(GL11.GL_CLAMP); + } + + private static void wrap(int mode) { + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, mode); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, mode); + } + + private static void nearest(int mode) { + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, + GL11.GL_NEAREST); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, + GL11.GL_NEAREST); + wrap(mode); + } + + private static final int BLOOM_MIP_LIMIT = 5; + + /** + * Connects a texture to a framebuffer of the extension the game itself + * uses everywhere a framebuffer is mentioned, so compatibility is the + * same compatibility the world already proved. + */ + private static void attach(int framebuffer, int texture) { + EXTFramebufferObject.glBindFramebufferEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, framebuffer); + EXTFramebufferObject.glFramebufferTexture2DEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, + EXTFramebufferObject.GL_COLOR_ATTACHMENT0_EXT, + GL11.GL_TEXTURE_2D, texture, 0); + int status = EXTFramebufferObject.glCheckFramebufferStatusEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT); + if (status != EXTFramebufferObject.GL_FRAMEBUFFER_COMPLETE_EXT) { + Log.error("post processing framebuffer is incomplete: " + status); + working = false; + } + EXTFramebufferObject.glBindFramebufferEXT( + EXTFramebufferObject.GL_FRAMEBUFFER_EXT, 0); + } + + /** One program: the shaders go in, the uniforms come out by name. */ + private static final class Program { + int handle = 0; + + void use() { + GL20.glUseProgram(handle); + } + + void close() { + GL20.glUseProgram(0); + } + + boolean complete() { + return handle != 0; + } + + private int at(String name) { + if (handle == 0) { + return -1; + } + return GL20.glGetUniformLocation(handle, name); + } + + void float1(String name, float value) { + int position = at(name); + if (position != -1) { + GL20.glUniform1f(position, value); + } + } + + void float2(String name, float x, float y) { + int position = at(name); + if (position != -1) { + GL20.glUniform2f(position, x, y); + } + } + + void float3(String name, float[] value) { + int position = at(name); + if (position != -1) { + GL20.glUniform3f(position, value[0], value[1], value[2]); + } + } + + void mat4(String name, FloatBuffer value) { + int position = at(name); + if (position != -1) { + GL20.glUniformMatrix4(position, false, value); + } + } + } + + private static Program program(String vertex, String fragment) { + Program made = new Program(); + int vertexShader = GL20.glCreateShader(GL20.GL_VERTEX_SHADER); + GL20.glShaderSource(vertexShader, vertex); + GL20.glCompileShader(vertexShader); + if (!compiled(vertexShader)) { + GL20.glDeleteShader(vertexShader); + return made; + } + int fragmentShader = GL20.glCreateShader(GL20.GL_FRAGMENT_SHADER); + GL20.glShaderSource(fragmentShader, fragment); + GL20.glCompileShader(fragmentShader); + if (!compiled(fragmentShader)) { + GL20.glDeleteShader(fragmentShader); + GL20.glDeleteShader(vertexShader); + return made; + } + int handle = GL20.glCreateProgram(); + GL20.glAttachShader(handle, vertexShader); + GL20.glAttachShader(handle, fragmentShader); + GL20.glLinkProgram(handle); + GL20.glDeleteShader(vertexShader); + GL20.glDeleteShader(fragmentShader); + if (GL20.glGetProgrami(handle, GL20.GL_LINK_STATUS) == 0) { + Log.error("post processing program did not link: " + + GL20.glGetProgramInfoLog(handle, 4096)); + GL20.glDeleteProgram(handle); + return made; + } + made.handle = handle; + return made; + } + + private static boolean compiled(int shader) { + int state = GL20.glGetShaderi(shader, GL20.GL_COMPILE_STATUS); + if (state == 0) { + Log.error("post processing shader did not compile: " + + GL20.glGetShaderInfoLog(shader, 4096)); + return false; + } + return true; + } + + /** The projection turned the other way, for positions read from depth. */ + private static void inverted(float[] matrix, float[] into) { + float a00 = matrix[0], a01 = matrix[1], a02 = matrix[2], a03 = matrix[3]; + float a10 = matrix[4], a11 = matrix[5], a12 = matrix[6], a13 = matrix[7]; + float a20 = matrix[8], a21 = matrix[9], a22 = matrix[10], a23 = matrix[11]; + float a30 = matrix[12], a31 = matrix[13], a32 = matrix[14], a33 = matrix[15]; + + float b00 = a00 * a11 - a01 * a10; + float b01 = a00 * a12 - a02 * a10; + float b02 = a00 * a13 - a03 * a10; + float b03 = a01 * a12 - a02 * a11; + float b04 = a01 * a13 - a03 * a11; + float b05 = a02 * a13 - a03 * a12; + float b06 = a20 * a31 - a21 * a30; + float b07 = a20 * a32 - a22 * a30; + float b08 = a20 * a33 - a23 * a30; + float b09 = a21 * a32 - a22 * a31; + float b10 = a21 * a33 - a23 * a31; + float b11 = a22 * a33 - a23 * a32; + + float determinant = b00 * b11 - b01 * b10 + b02 * b09 + + b03 * b08 - b04 * b07 + b05 * b06; + if (Math.abs(determinant) < 1.0E-9F) { + identity(into); + return; + } + float over = 1.0F / determinant; + + into[0] = (a11 * b11 - a12 * b10 + a13 * b09) * over; + into[1] = (-a01 * b11 + a02 * b10 - a03 * b09) * over; + into[2] = (a31 * b05 - a32 * b04 + a33 * b03) * over; + into[3] = (-a21 * b05 + a22 * b04 - a23 * b03) * over; + into[4] = (-a10 * b11 + a12 * b08 - a13 * b07) * over; + into[5] = (a00 * b11 - a02 * b08 + a03 * b07) * over; + into[6] = (-a30 * b05 + a32 * b02 - a33 * b01) * over; + into[7] = (a20 * b05 - a22 * b02 + a23 * b01) * over; + into[8] = (a10 * b10 - a11 * b08 + a13 * b06) * over; + into[9] = (-a00 * b10 + a01 * b08 - a03 * b06) * over; + into[10] = (a30 * b04 - a31 * b02 + a33 * b00) * over; + into[11] = (-a20 * b04 + a21 * b02 - a23 * b00) * over; + into[12] = (-a10 * b09 + a11 * b07 - a12 * b06) * over; + into[13] = (a00 * b09 - a01 * b07 + a02 * b06) * over; + into[14] = (-a30 * b03 + a31 * b01 - a32 * b00) * over; + into[15] = (a20 * b03 - a21 * b01 + a22 * b00) * over; + } + + private static void identity(float[] into) { + java.util.Arrays.fill(into, 0.0F); + into[0] = 1.0F; + into[5] = 1.0F; + into[10] = 1.0F; + into[15] = 1.0F; + } + + /* -------- the shaders -------- */ + + private static final String COMMON_VERTEX = + "#version 120\n" + + "varying vec2 vUV;\n" + + "void main() {\n" + + " vUV = gl_Vertex.xy * 0.5 + 0.5;\n" + + " gl_Position = vec4(gl_Vertex.xy, 0.0, 1.0);\n" + + "}\n"; + + private static final String THRESHOLD_FRAGMENT = + "#version 120\n" + + "varying vec2 vUV;\n" + + "uniform sampler2D uTex;\n" + + "uniform vec2 uSrcTexelSize;\n" + + "uniform float uThreshold;\n" + + "uniform float uKnee;\n" + + "vec3 gated(vec3 c) {\n" + + " float br = max(c.r, max(c.g, c.b));\n" + + " float soft = clamp(br - uThreshold + uKnee, 0.0, 2.0 * uKnee);\n" + + " soft = (soft * soft) / (4.0 * uKnee + 0.00001);\n" + + " float w = max(soft, br - uThreshold) / max(br, 0.00001);\n" + + " return c * max(w, 0.0);\n" + + "}\n" + + "void main() {\n" + + " vec2 ht = uSrcTexelSize * 0.5;\n" + + " vec3 a = gated(texture2D(uTex, vUV + vec2(-ht.x, -ht.y)).rgb);\n" + + " vec3 b = gated(texture2D(uTex, vUV + vec2( ht.x, -ht.y)).rgb);\n" + + " vec3 c = gated(texture2D(uTex, vUV + vec2(-ht.x, ht.y)).rgb);\n" + + " vec3 d = gated(texture2D(uTex, vUV + vec2( ht.x, ht.y)).rgb);\n" + + " gl_FragColor = vec4((a + b + c + d) * 0.25, 1.0);\n" + + "}\n"; + + private static final String DOWNSAMPLE_FRAGMENT = + "#version 120\n" + + "varying vec2 vUV;\n" + + "uniform sampler2D uTex;\n" + + "uniform vec2 uSrcTexelSize;\n" + + "void main() {\n" + + " vec2 ht = uSrcTexelSize * 0.5;\n" + + " vec3 a = texture2D(uTex, vUV + vec2(-ht.x, -ht.y)).rgb;\n" + + " vec3 b = texture2D(uTex, vUV + vec2( ht.x, -ht.y)).rgb;\n" + + " vec3 c = texture2D(uTex, vUV + vec2(-ht.x, ht.y)).rgb;\n" + + " vec3 d = texture2D(uTex, vUV + vec2( ht.x, ht.y)).rgb;\n" + + " gl_FragColor = vec4((a + b + c + d) * 0.25, 1.0);\n" + + "}\n"; + + private static final String COMBINE_FRAGMENT = + "#version 120\n" + + "varying vec2 vUV;\n" + + "uniform sampler2D uBlurTex;\n" + + "uniform sampler2D uPrevTex;\n" + + "uniform vec2 uBlurTexelSize;\n" + + "void main() {\n" + + " vec2 t = uBlurTexelSize;\n" + + " vec3 s = vec3(0.0);\n" + + " s += texture2D(uBlurTex, vUV + vec2(-t.x, -t.y)).rgb;\n" + + " s += texture2D(uBlurTex, vUV + vec2( 0.0, -t.y)).rgb * 2.0;\n" + + " s += texture2D(uBlurTex, vUV + vec2( t.x, -t.y)).rgb;\n" + + " s += texture2D(uBlurTex, vUV + vec2(-t.x, 0.0)).rgb * 2.0;\n" + + " s += texture2D(uBlurTex, vUV ).rgb * 4.0;\n" + + " s += texture2D(uBlurTex, vUV + vec2( t.x, 0.0)).rgb * 2.0;\n" + + " s += texture2D(uBlurTex, vUV + vec2(-t.x, t.y)).rgb;\n" + + " s += texture2D(uBlurTex, vUV + vec2( 0.0, t.y)).rgb * 2.0;\n" + + " s += texture2D(uBlurTex, vUV + vec2( t.x, t.y)).rgb;\n" + + " s /= 16.0;\n" + + " vec3 prev = texture2D(uPrevTex, vUV).rgb;\n" + + " gl_FragColor = vec4(prev + s, 1.0);\n" + + "}\n"; + + private static final String BLOOM_ADD_FRAGMENT = + "#version 120\n" + + "varying vec2 vUV;\n" + + "uniform sampler2D uBloom;\n" + + "uniform float uIntensity;\n" + + "void main() {\n" + + " gl_FragColor = vec4(texture2D(uBloom, vUV).rgb * uIntensity, 0.0);\n" + + "}\n"; + + private static final String SSAO_FRAGMENT = + "#version 120\n" + + "varying vec2 vUV;\n" + + "uniform sampler2D uDepth;\n" + + "uniform mat4 uProjection;\n" + + "uniform mat4 uInvProjection;\n" + + "uniform vec3 uSamples[16];\n" + + "uniform vec2 uTexel;\n" + + "uniform float uRadius;\n" + + "uniform float uBias;\n" + + "uniform float uPower;\n" + + "vec3 viewPosFromDepth(float depth, vec2 uv) {\n" + + " vec4 clipPos = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);\n" + + " vec4 viewPos = uInvProjection * clipPos;\n" + + " return viewPos.xyz / viewPos.w;\n" + + "}\n" + + "vec3 hash3(vec2 seed) {\n" + + " vec3 q = vec3(dot(seed, vec2(127.1, 311.7)),\n" + + " dot(seed, vec2(269.5, 183.3)),\n" + + " dot(seed, vec2(419.2, 371.9)));\n" + + " return fract(sin(q) * 43758.5453) * 2.0 - 1.0;\n" + + "}\n" + + "void main() {\n" + + " float centerDepth = texture2D(uDepth, vUV).r;\n" + + " if (centerDepth >= 0.9999) { gl_FragColor = vec4(1.0); return; }\n" + + " float dX = texture2D(uDepth, vUV + vec2(uTexel.x * 2.0, 0.0)).r;\n" + + " float dY = texture2D(uDepth, vUV + vec2(0.0, uTexel.y * 2.0)).r;\n" + + " if (dX >= 0.9999 || dY >= 0.9999) {\n" + + " gl_FragColor = vec4(1.0); return;\n" + + " }\n" + + " vec3 center = viewPosFromDepth(centerDepth, vUV);\n" + + " vec3 pX = viewPosFromDepth(dX, vUV + vec2(uTexel.x * 2.0, 0.0));\n" + + " vec3 pY = viewPosFromDepth(dY, vUV + vec2(0.0, uTexel.y * 2.0));\n" + + " vec3 n = normalize(cross(pX - center, pY - center));\n" + + " if (dot(n, -center) < 0.0) { n = -n; }\n" + + " vec3 randVec = normalize(hash3(gl_FragCoord.xy));\n" + + " vec3 tangent = normalize(randVec - n * dot(randVec, n));\n" + + " vec3 bitangent = cross(n, tangent);\n" + + " mat3 tbn = mat3(tangent, bitangent, n);\n" + + " float occlusion = 0.0;\n" + + " for (int i = 0; i < 16; i++) {\n" + + " vec3 samplePos = center + tbn * uSamples[i] * uRadius;\n" + + " vec4 offset = uProjection * vec4(samplePos, 1.0);\n" + + " offset.xyz /= offset.w;\n" + + " vec2 ndc = offset.xy * 0.5 + 0.5;\n" + + " if (offset.z >= 1.0 || ndc.x < 0.0 || ndc.x > 1.0\n" + + " || ndc.y < 0.0 || ndc.y > 1.0) { continue; }\n" + + " float depth = texture2D(uDepth, ndc).r;\n" + + " if (depth >= 0.9999) { continue; }\n" + + " vec3 sampleView = viewPosFromDepth(depth, ndc);\n" + + " if (sampleView.z >= center.z + uBias) {\n" + + " occlusion += smoothstep(0.0, 1.0, uRadius\n" + + " / max(length(sampleView - center), 0.0001));\n" + + " }\n" + + " }\n" + + " occlusion /= 16.0;\n" + + " float ao = 1.0 - pow(occlusion, uPower);\n" + + " gl_FragColor = vec4(ao, ao, ao, 1.0);\n" + + "}\n"; + + private static final String SSAO_BLUR_FRAGMENT = + "#version 120\n" + + "varying vec2 vUV;\n" + + "uniform sampler2D uSSAO;\n" + + "uniform sampler2D uDepth;\n" + + "uniform vec2 uTexelSize;\n" + + "void main() {\n" + + " float centerDepth = texture2D(uDepth, vUV).r;\n" + + " if (centerDepth >= 0.9999) { gl_FragColor = vec4(1.0); return; }\n" + + " float result = 0.0;\n" + + " float weight = 0.0;\n" + + " for (int x = -2; x <= 2; x++) {\n" + + " for (int y = -2; y <= 2; y++) {\n" + + " vec2 sampleUV = vUV + vec2(float(x), float(y)) * uTexelSize;\n" + + " float sampleDepth = texture2D(uDepth, sampleUV).r;\n" + + " float w = abs(centerDepth - sampleDepth) < 0.0002 ? 1.0 : 0.0;\n" + + " result += texture2D(uSSAO, sampleUV).r * w;\n" + + " weight += w;\n" + + " }\n" + + " }\n" + + " float val = (weight > 0.0) ? result / weight : 1.0;\n" + + " gl_FragColor = vec4(val, val, val, 1.0);\n" + + "}\n"; + + private static final String SSAO_COMPOSITE_FRAGMENT = + "#version 120\n" + + "varying vec2 vUV;\n" + + "uniform sampler2D uColor;\n" + + "uniform sampler2D uSSAO;\n" + + "uniform float uStrength;\n" + + "void main() {\n" + + " vec4 c = texture2D(uColor, vUV);\n" + + " float ao = texture2D(uSSAO, vUV).r;\n" + + " ao = mix(1.0, ao, clamp(uStrength, 0.0, 1.0));\n" + + " gl_FragColor = vec4(c.rgb * ao, 1.0);\n" + + "}\n"; + + /* -------- the file -------- */ + + /** Where the numbers sit when the game is closed. */ + private static final String FILE = "rsxpost.properties"; + + private static void load() { + if (buffered) { + return; + } + buffered = true; + File file = fileFor(); + if (!file.isFile()) { + save(); + return; + } + Properties held = new Properties(); + FileInputStream open = null; + try { + open = new FileInputStream(file); + held.load(open); + } catch (IOException couldNotRead) { + Log.error("rsxpost.properties unreadable", couldNotRead); + return; + } finally { + close(open); + } + master = Boolean.parseBoolean(held.getProperty("master", "true")); + useSSAO = Boolean.parseBoolean(held.getProperty("ssao", "true")); + useBloom = Boolean.parseBoolean(held.getProperty("bloom", "true")); + ssaoStrength = floatValue(held, "strength", 1.0F); + ssaoRadius = floatValue(held, "radius", 0.5F); + ssaoBias = floatValue(held, "bias", 0.02F); + ssaoPower = floatValue(held, "power", 2.0F); + bloomIntensity = floatValue(held, "intensity", 1.5F); + bloomThreshold = floatValue(held, "threshold", 0.3F); + bloomKnee = floatValue(held, "knee", 0.0F); + } + + private static float floatValue(Properties held, String key, float back) { + try { + return Float.parseFloat(held.getProperty(key, String.valueOf(back))); + } catch (NumberFormatException notANumber) { + return back; + } + } + + private static void save() { + Properties held = new Properties(); + held.setProperty("master", String.valueOf(master)); + held.setProperty("ssao", String.valueOf(useSSAO)); + held.setProperty("bloom", String.valueOf(useBloom)); + held.setProperty("strength", String.valueOf(ssaoStrength)); + held.setProperty("radius", String.valueOf(ssaoRadius)); + held.setProperty("bias", String.valueOf(ssaoBias)); + held.setProperty("power", String.valueOf(ssaoPower)); + held.setProperty("intensity", String.valueOf(bloomIntensity)); + held.setProperty("threshold", String.valueOf(bloomThreshold)); + held.setProperty("knee", String.valueOf(bloomKnee)); + FileOutputStream out = null; + try { + out = new FileOutputStream(fileFor()); + held.store(out, "the remake's post processing, on the fork"); + } catch (IOException couldNotWrite) { + Log.error("rsxpost.properties unwritable", couldNotWrite); + } finally { + close(out); + } + } + + private static void setMaster(boolean on) { + master = on; + save(); + if (on) { + working = true; + } + say("post processing " + (on ? "on" : "off")); + } + + private static void close(java.io.Closeable stream) { + if (stream == null) { + return; + } + try { + stream.close(); + } catch (IOException closedAnyway) { + } + } + + private static File fileFor() { + Minecraft minecraft = Minecraft.getMinecraft(); + File base = minecraft == null ? new File(".") : minecraft.gameDir; + return new File(base, FILE); + } +} 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 6fe2132..8c2b64a 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/net/RsxBlockMap.java +++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxBlockMap.java @@ -24,9 +24,13 @@ public final class RsxBlockMap { * * 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. + * chunk instead of a million. The room is (the highest legal id + 1) and + * a slot left null draws as air, so a block the client joined late on no + * longer sneaks past the end of the array as it did when the roads came + * off the wire as nothing at all. */ - private static final IBlockState[] STATES = new IBlockState[8]; + private static final IBlockState[] STATES = + new IBlockState[net.rustnotmc.protocol.Blocks.MAX + 1]; static { STATES[net.rustnotmc.protocol.Blocks.AIR] = Blocks.AIR.getDefaultState(); @@ -37,6 +41,13 @@ public final class RsxBlockMap { 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(); + // the game has no asphalt of its own; grey is the road's color + STATES[net.rustnotmc.protocol.Blocks.ASPHALT] = + Blocks.CONCRETE.getDefaultState().withProperty( + net.minecraft.block.BlockColored.COLOR, + net.minecraft.item.EnumDyeColor.GRAY); + STATES[net.rustnotmc.protocol.Blocks.SNOW] = + Blocks.SNOW.getDefaultState(); } private RsxBlockMap() { 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 7fb2f63..18f2810 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java +++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java @@ -96,6 +96,20 @@ public final class RsxWorldBridge { throws java.io.IOException { boolean skyLight = world.provider.hasSkyLight(); + int[] heights = new int[256]; + for (int column = 0; column < 256; column++) { + int x = column & 15; + int z = column >> 4; + int top = 0; + for (int y = 255; y >= 0; y--) { + if (blocks[ChunkFormat.index(x, y, z)] != net.rustnotmc.protocol.Blocks.AIR) { + top = y; + break; + } + } + heights[column] = top; + } + PacketBuffer sections = new PacketBuffer(Unpooled.buffer(128 * 1024)); int mask = 0; @@ -124,9 +138,9 @@ public final class RsxWorldBridge { } mask |= 1 << section; container.write(sections); - sections.writeBytes(LIGHT); + sections.writeBytes(UNLIT); if (skyLight) { - sections.writeBytes(LIGHT); + sections.writeBytes(skyLight(section * 16, heights)); } } @@ -150,14 +164,47 @@ public final class RsxWorldBridge { return packet; } - /** A section's light, all of it on. */ - private static final byte[] LIGHT = new byte[2048]; + /** The light of a block that is its own source. */ + private static final byte[] UNLIT = new byte[2048]; + + /** + * The light of one section, worked out from the chunk's heightmap. + * + * The server does not compute light yet, and it also has no business + * doing so: a client that owns the blocks is the one that knows where the + * sky is. Columns above the surface read as full sky, and everything below + * the top of its column sinks two light steps for every block of ground + * it is buried in. Buildings get their shade, tunnels their darkness, and + * the gradient runs in one block increments so the vertex interpolation + * the game already does turns it into a smooth ramp instead of a ledge. + */ + private static byte[] skyLight(int baseY, int[] heights) { + byte[] data = new byte[2048]; + for (int column = 0; column < 256; column++) { + int x = column & 15; + int z = column >> 4; + int top = heights[column]; + for (int y = 0; y < 16; y++) { + int global = baseY + y; + int light = global > top ? 15 : 15 - 2 * (top - global); + if (light <= 0) { + continue; + } + int coordinate = (y << 8) | (z << 4) | x; + if ((coordinate & 1) == 0) { + data[coordinate >> 1] |= (byte) light; + } else { + data[coordinate >> 1] |= (byte) (light << 4); + } + } + } + return data; + } /** 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 } diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Blocks.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Blocks.java index 6ed17f4..380b1fc 100644 --- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Blocks.java +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Blocks.java @@ -30,9 +30,12 @@ public final class Blocks { /** The surface of a road. */ public static final byte ASPHALT = 8; + /** The surface of the high and cold places. */ + public static final byte SNOW = 9; + /** Lowest and highest legal block id. */ public static final byte MIN = AIR; - public static final byte MAX = ASPHALT; + public static final byte MAX = SNOW; private Blocks() { } @@ -48,6 +51,7 @@ public final class Blocks { case WOOD: return "wood"; case LEAVES: return "leaves"; case ASPHALT: return "asphalt"; + case SNOW: return "snow"; default: return "unknown(" + id + ")"; } } 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 1941bc4..1ae1193 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,9 +66,9 @@ 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(); + /** The regions of the far view that were sent already, with their step. */ + private final java.util.Set sentLodRegions = + new java.util.LinkedHashSet(); /** When the session runs out. */ private volatile long sessionExpiresAt; @@ -386,6 +386,13 @@ public final class ClientSession implements Runnable { if (canonical == null) { canonical = claims.nick; } + if (this.server.bans().isBanned(canonical)) { + this.server.log("token of " + canonical + " refused: the account is banned"); + send(Packets.AUTH_ACK, Packets.AuthAck.refused( + "the session was refused: the account is banned")); + close(); + return; + } this.server.throttle().succeeded(given.account); this.account = canonical; this.uuid = claims.uuid == null ? "" : claims.uuid; @@ -503,8 +510,12 @@ public final class ClientSession implements Runnable { /** * 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. + * The far view is a grid of heights and colours, one region at a time, and + * the regions near the player are finer than the ones far away: a region + * comes with every fourth block up to two regions away, then every + * eighth, sixteenth, thirty-second and, at the horizon, every + * sixty-fourth. The whole thing reaches further than any player can see, a + * ring of regions at a time, and the client keeps the last of each. */ void sendLod() { if (this.player == null) { @@ -514,23 +525,41 @@ public final class ClientSession implements Runnable { 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++) { + for (int dx = -LOD_RINGS; dx <= LOD_RINGS; dx++) { + for (int dz = -LOD_RINGS; dz <= LOD_RINGS; 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); + int ring = Math.max(Math.abs(dx), Math.abs(dz)); + int step = lodStep(ring); + Long key = Long.valueOf(((rx & 0xFFFFL) << 48) | ((rz & 0xFFFFL) << 32) + | (step & 0xFFFFL)); if (this.sentLodRegions.contains(key)) { continue; } this.sentLodRegions.add(key); - if (this.sentLodRegions.size() > 128) { + if (this.sentLodRegions.size() > 1024) { this.sentLodRegions.clear(); } - send(Packets.LOD_REGION, this.server.lod().region(rx, rz)); + send(Packets.LOD_REGION, this.server.lod().region(rx, rz, step)); } } } + /** How many regions there are from the player out to the horizon. */ + private static final int LOD_RINGS = 6; + + /** How far apart the samples of a ring are. */ + static int lodStep(int ring) { + int step = net.rustnotmc.server.world.LodGenerator.STEP; + for (int r = ring; r > 1; r--) { + if (step >= 64) { + break; + } + step *= 2; + } + return step; + } + private void onAuth(Frame frame) { if (this.state != SessionState.HANDSHAKING) { return; @@ -578,6 +607,13 @@ public final class ClientSession implements Runnable { } this.server.throttle().succeeded(auth.account); + if (this.server.bans().isBanned(name)) { + this.server.log("auth of " + name + " refused: the account is banned"); + send(Packets.AUTH_ACK, Packets.AuthAck.refused( + "the session was refused: the account is banned")); + close(); + return; + } net.rustnotmc.auth.TokenService.Token issued = this.server.tokens().issue(name); this.account = name; this.token = issued.value; diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ConsoleCommands.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ConsoleCommands.java new file mode 100644 index 0000000..07e7216 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ConsoleCommands.java @@ -0,0 +1,192 @@ +package net.rustnotmc.server; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; + +/** + * The console of the server. + * + * A server is not just a socket that answers; it is a machine somebody is + * sitting at, and the person sitting at it needs to say things: who may do + * what, who may not come in, and when to stop. Typing a line is the plainest + * way to say it. + * + * The lines are the same ones the rest of the server uses, so a console + * command and a change to a file agree with each other: the rights live in + * files, the console only writes to the files, and what the file says is what + * the world obeys. + */ +public final class ConsoleCommands implements Runnable { + + private final Server server; + + public ConsoleCommands(Server server) { + this.server = server; + } + + @Override + public void run() { + BufferedReader reader = new BufferedReader( + new InputStreamReader(System.in)); + while (this.server.running()) { + String line; + try { + line = reader.readLine(); + } catch (IOException closed) { + // the console is gone; there is nobody left to talk to + return; + } + if (line == null) { + return; + } + try { + command(line.trim()); + } catch (RuntimeException e) { + System.out.println("[rsx] console: " + e); + } + } + } + + /** Carries out one line the console was given. */ + void command(String line) { + if (line.isEmpty() || line.startsWith("#")) { + return; + } + String[] words = line.split("\\s+", 2); + String command = words[0]; + String rest = words.length > 1 ? words[1].trim() : ""; + // a slash is what a player types; the console takes either + if (command.startsWith("/")) { + command = command.substring(1); + } + + switch (command.toLowerCase()) { + case "help": + System.out.println("[rsx] console commands:"); + System.out.println(" list who is here"); + System.out.println(" say say something to everybody"); + System.out.println(" op make somebody an administrator"); + System.out.println(" deop take the right back"); + System.out.println(" kick send somebody home"); + System.out.println(" ban keep an account out for good"); + System.out.println(" unban let an account come back"); + System.out.println(" save save accounts and players"); + System.out.println(" stop save everything and stop"); + break; + case "list": + list(); + break; + case "say": + if (rest.isEmpty()) { + System.out.println("[rsx] say what?"); + break; + } + this.server.broadcastChat("Server", rest); + System.out.println("[rsx] said to everybody: " + rest); + break; + case "op": + if (rest.isEmpty()) { + System.out.println("[rsx] op who?"); + break; + } + this.server.admins().give(rest); + System.out.println("[rsx] " + rest + " is an administrator now"); + break; + case "deop": + if (rest.isEmpty()) { + System.out.println("[rsx] deop who?"); + break; + } + this.server.admins().take(rest); + System.out.println("[rsx] " + rest + " is not an administrator anymore"); + break; + case "kick": { + if (rest.isEmpty()) { + System.out.println("[rsx] kick who?"); + break; + } + Player player = findOnline(rest); + if (player == null) { + System.out.println("[rsx] " + rest + " is not here"); + break; + } + player.session().close(); + System.out.println("[rsx] " + rest + " was sent home"); + break; + } + case "ban": + if (rest.isEmpty()) { + System.out.println("[rsx] ban who?"); + break; + } + this.server.bans().ban(rest); + // an account that is here now does not stay + for (Player player : this.server.players().values()) { + if (player.account().equalsIgnoreCase(rest)) { + player.session().close(); + } + } + System.out.println("[rsx] " + rest + " is banned"); + break; + case "unban": + if (rest.isEmpty()) { + System.out.println("[rsx] unban who?"); + break; + } + this.server.bans().unban(rest); + System.out.println("[rsx] " + rest + " may come back"); + break; + case "save": + saveEverything(); + System.out.println("[rsx] saved"); + break; + case "stop": + System.out.println("[rsx] stopping"); + saveEverything(); + this.server.stop(); + System.exit(0); + break; + default: + System.out.println("[rsx] unknown console command: " + command); + } + } + + /** Says who is in the world. */ + private void list() { + java.util.Map players = this.server.players(); + if (players.isEmpty()) { + System.out.println("[rsx] nobody is in the world"); + return; + } + for (Player player : players.values()) { + System.out.println("[rsx] " + player.account() + + " at " + ((int) player.x()) + "," + ((int) player.y()) + + "," + ((int) player.z())); + } + System.out.println("[rsx] " + players.size() + " online, " + + "sessions " + this.server.sessions().size()); + } + + /** The player of an account, online right now, or null. */ + private Player findOnline(String account) { + for (Player player : this.server.players().values()) { + if (player.account().equalsIgnoreCase(account)) { + return player; + } + } + return null; + } + + /** Writes down what would be a shame to lose. */ + private void saveEverything() { + for (Player player : this.server.players().values()) { + this.server.savePlayer(player); + } + try { + this.server.world().close(); + } catch (RuntimeException e) { + System.out.println("[rsx] the world would not all be written: " + e); + } + } +} 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 f437376..884fa4b 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 @@ -59,6 +59,8 @@ public final class Server implements net.rustnotmc.server.status.StatusSession.I private volatile TokenAuth tokenAuth; private volatile boolean localAccounts = true; private volatile AdminRegistry admins = new AdminRegistry(null); + private volatile net.rustnotmc.server.admin.BanRegistry bans = + new net.rustnotmc.server.admin.BanRegistry(null); private volatile net.rustnotmc.server.player.PlayerData playerData = new net.rustnotmc.server.player.PlayerData(null); private volatile net.rustnotmc.server.world.LodGenerator lod; @@ -196,6 +198,21 @@ public final class Server implements net.rustnotmc.server.status.StatusSession.I this.admins = admins == null ? new AdminRegistry(null) : admins; } + /** + * Who may not come in at all. + * + * The names live in a file beside the world, and every entrance is + * checked against the file before the player is believed. + */ + public net.rustnotmc.server.admin.BanRegistry bans() { + return this.bans; + } + + public void bans(net.rustnotmc.server.admin.BanRegistry bans) { + this.bans = bans == null ? new net.rustnotmc.server.admin.BanRegistry(null) + : bans; + } + /** * Writes down what a player leaves behind. * 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 708cb42..a2e0765 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 @@ -135,6 +135,8 @@ 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)); + // who may not come in at all, in a file beside the world + server.bans(new net.rustnotmc.server.admin.BanRegistry(worldDir.resolve("bans.txt"))); // the far view of the world, kept beside it server.lod(new net.rustnotmc.server.world.LodGenerator(world.generator(), worldDir.resolve("lod"))); @@ -213,6 +215,12 @@ public final class ServerMain { } }, "rsx-shutdown")); + // the console: whoever is sitting at the machine talks to the server + // here, and the queue of words is answered as it comes + Thread console = new Thread(new ConsoleCommands(server), "rsx-console"); + console.setDaemon(true); + console.start(); + long reportedAt = 0L; while (server.running()) { Thread.sleep(5_000L); diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/admin/AdminRegistry.java b/rsx-server-core/src/main/java/net/rustnotmc/server/admin/AdminRegistry.java index 0542ab9..c6fa735 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/admin/AdminRegistry.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/admin/AdminRegistry.java @@ -81,6 +81,50 @@ public final class AdminRegistry { } } + /** Names one of the administrators, in the file and at once. */ + public void give(String account) { + if (account == null || account.isEmpty()) { + return; + } + reread(); + String name = account.toLowerCase(Locale.ROOT); + if (!this.admins.add(name)) { + return; + } + save(); + this.lastRead = -1L; // the file was just written; do not read it back + } + + /** Takes a right away, in the file and at once. */ + public void take(String account) { + if (account == null || account.isEmpty()) { + return; + } + reread(); + String name = account.toLowerCase(Locale.ROOT); + if (!this.admins.remove(name)) { + return; + } + save(); + this.lastRead = -1L; + } + + /** Writes the administrators one per line, newest order forgotten. */ + private void save() { + if (this.file == null) { + return; + } + try { + java.util.List lines = new java.util.ArrayList( + new java.util.TreeSet(this.admins)); + lines.add(0, "# one name of an administrator per line"); + Files.createDirectories(this.file.toAbsolutePath().getParent()); + Files.write(this.file, lines, StandardCharsets.UTF_8); + } catch (IOException ignored) { + // the list still holds for this run; the log says what happened + } + } + /** Reads the file. */ private void read() { this.admins.clear(); diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/admin/BanRegistry.java b/rsx-server-core/src/main/java/net/rustnotmc/server/admin/BanRegistry.java new file mode 100644 index 0000000..7730e51 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/admin/BanRegistry.java @@ -0,0 +1,140 @@ +package net.rustnotmc.server.admin; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +/** + * Who may not come in at all. + * + * A ban is a promise the server keeps without anybody looking: the names live + * in a file beside the world, and every entrance is checked against it. A + * crashed or a stopped server forgets nothing, because the file is the thing + * that knows. + * + * The same file the registry of the administrators grows up from: one name per + * line, read again whenever anything else writes to it, so that a console + * command is law at once and not at the next restart. + */ +public final class BanRegistry { + + /** The file the names are written in. */ + private final Path file; + + private final Set names = new HashSet(); + + private long lastRead; + + public BanRegistry(Path file) { + this.file = file; + read(); + } + + /** The file the names are written in. */ + public Path file() { + return this.file; + } + + /** Whether the account may not come in. */ + public boolean isBanned(String account) { + if (account == null || account.isEmpty()) { + return false; + } + reread(); + return this.names.contains(account.toLowerCase(Locale.ROOT)); + } + + /** The names, as the file spells them. */ + public Set bans() { + reread(); + return Collections.unmodifiableSet(this.names); + } + + /** Bans an account, in the file and at once. */ + public void ban(String account) { + if (account == null || account.isEmpty()) { + return; + } + String name = account.toLowerCase(Locale.ROOT); + if (!this.names.add(name)) { + return; + } + save(); + // the file was just written; the clock must not read it again as new + this.lastRead = -1L; + } + + /** Takes a ban away, in the file and at once. */ + public void unban(String account) { + if (account == null || account.isEmpty()) { + return; + } + String name = account.toLowerCase(Locale.ROOT); + if (!this.names.remove(name)) { + return; + } + save(); + this.lastRead = -1L; + } + + /** Reads the file again when it changed. */ + private void reread() { + if (this.file == null) { + return; + } + try { + long changed = Files.getLastModifiedTime(this.file).toMillis(); + if (changed != this.lastRead) { + read(); + } + } catch (IOException ignored) { + // the list stays as it was until the disk answers again + } + } + + /** Reads the file. A missing one is an empty list. */ + private void read() { + this.names.clear(); + this.lastRead = 0L; + if (this.file == null) { + return; + } + try { + if (!Files.isRegularFile(this.file)) { + return; + } + this.lastRead = Files.getLastModifiedTime(this.file).toMillis(); + for (String line : Files.readAllLines(this.file, StandardCharsets.UTF_8)) { + String name = line.trim(); + if (name.isEmpty() || name.startsWith("#")) { + continue; + } + this.names.add(name.toLowerCase(Locale.ROOT)); + } + } catch (IOException ignored) { + // A list that cannot be read lets players in rather than locking + // the door behind a hiccup of the disk. + } + } + + /** Writes the names one per line. */ + private void save() { + if (this.file == null) { + return; + } + try { + java.util.List lines = new java.util.ArrayList( + new java.util.TreeSet(this.names)); + lines.add(0, "# one banned account per line"); + Files.createDirectories(this.file.toAbsolutePath().getParent()); + Files.write(this.file, lines, StandardCharsets.UTF_8); + } catch (IOException ignored) { + // the ban holds for this run; the log says what happened + } + } +} 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 index aa289e9..9ec5ae3 100644 --- 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 @@ -51,28 +51,42 @@ public final class LodGenerator { * The far view of one region. * * The region is read from the disk when it is there, and worked out when - * it is not. + * it is not. The step decides how far apart the samples are: near the + * player the ground is known finely, far away the same region can come + * with every second, fourth or eighth sample, and the client keeps the + * last one it was given. */ - public Packets.LodRegion region(int chunkX, int chunkZ) { + public Packets.LodRegion region(int chunkX, int chunkZ, int step) { int startChunk = regionChunk(chunkX); int startZ = regionChunk(chunkZ); int originX = startChunk * 16; int originZ = startZ * 16; + int count = samples(step); - byte[] heights = new byte[SAMPLES * SAMPLES]; - byte[] colors = new byte[SAMPLES * SAMPLES * 3]; - if (!read(startChunk, startZ, heights, colors)) { - write(startChunk, startZ, heights, colors); + byte[] heights = new byte[count * count]; + byte[] colors = new byte[count * count * 3]; + if (!read(startChunk, startZ, step, heights, colors)) { + write(startChunk, startZ, step, heights, colors); } - return new Packets.LodRegion(originX, originZ, STEP, SAMPLES, heights, colors); + return new Packets.LodRegion(originX, originZ, step, count, heights, colors); } - /** Reads a region from the disk. */ - private boolean read(int chunkX, int chunkZ, byte[] heights, byte[] colors) { + /** The far view of one region at the finest step. */ + public Packets.LodRegion region(int chunkX, int chunkZ) { + return region(chunkX, chunkZ, STEP); + } + + /** How many samples there are on the side of a region, at a step. */ + public static int samples(int step) { + return REGION_BLOCKS / Math.max(1, step); + } + + /** Reads a region from the disk, at a step. */ + private boolean read(int chunkX, int chunkZ, int step, byte[] heights, byte[] colors) { if (this.cache == null) { return false; } - Path file = this.cache.resolve(chunkX + "_" + chunkZ + ".bin"); + Path file = fileOf(chunkX, chunkZ, step); if (!Files.isRegularFile(file)) { return false; } @@ -90,14 +104,20 @@ public final class LodGenerator { } } - /** 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; + /** The file a region of a step lives in. */ + private Path fileOf(int chunkX, int chunkZ, int step) { + return this.cache.resolve(chunkX + "_" + chunkZ + "_s" + step + ".bin"); + } + + /** Works a region out and writes it down, at a step. */ + private void write(int chunkX, int chunkZ, int step, byte[] heights, byte[] colors) { + int count = samples(step); + for (int z = 0; z < count; z++) { + for (int x = 0; x < count; 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; + int at = z * count + x; heights[at] = (byte) height; int colour = colourOf(worldX, worldZ, height); colors[at * 3] = (byte) ((colour >> 16) & 0xFF); @@ -113,7 +133,7 @@ public final class LodGenerator { 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 file = fileOf(chunkX, chunkZ, step); Path temp = file.resolveSibling(file.getFileName() + ".tmp"); Files.write(temp, data); Files.move(temp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING); @@ -128,7 +148,7 @@ public final class LodGenerator { if (height < WorldGenerator.SEA_LEVEL) { return 0x2E5C8A; } - byte top = topBlock(worldX, worldZ, height); + byte top = this.generator.surfaceAt(worldX, worldZ); switch (top) { case Blocks.SAND: return 0xC8B47C; @@ -136,24 +156,12 @@ public final class LodGenerator { return 0x6E6E6E; case Blocks.ASPHALT: return 0x3A3A3A; + case Blocks.SNOW: + return 0xE9EDEF; 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/main/java/net/rustnotmc/server/world/RoadNetwork.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/RoadNetwork.java index 8ac5425..81c833b 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/world/RoadNetwork.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/RoadNetwork.java @@ -97,6 +97,26 @@ public final class RoadNetwork { return onStraightRoad(x, z) || onRing(x, z) != null; } + /** + * True when the column is on a road or within {@code extra} blocks of one. + * + * Things that need a clear view of the sky or the road, like the trees, + * keep their distance with this: a road with a crown hanging over it + * would be a road at the bottom of a green hole. + */ + public boolean nearRoad(int x, int z, int extra) { + int reach = HALF_WIDTH + extra; + boolean straights = Math.abs(x - centerLine(x)) <= reach + || Math.abs(z - centerLine(z)) <= reach; + if (straights) { + return true; + } + double dx = x - center(); + double dz = z - center(); + double distance = Math.sqrt(dx * dx + dz * dz); + return Math.abs(distance - this.ringRadius) <= reach; + } + /** * The points along the roads where a junk pile may stand. * diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java b/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java index e165995..60f092f 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/world/WorldGenerator.java @@ -17,8 +17,20 @@ import net.rustnotmc.protocol.ChunkFormat; * function of the seed and the coordinates. * * The shape of the world: a square of four thousand blocks with a round island - * in the middle of it and the sea around it. The roads of the road network run - * over it. + * in the middle of it and the sea around it. The land inside the island is a + * broad plain with ridged mountains rising in the middle of it, and it does not + * turn into lakes on its own: the inland ground sits at or above the sea, and + * the open water stays outside the shore where it belongs. + * + * The roads of the road network run over it. A road is level across its width + * and follows the land it runs over along its length, carried by a smooth rank + * of the ground under its middle: where the ground falls to the sea the road + * rides on to it as a causeway, so a road is never under water. + * + * The land wears three kinds of coat: grass in the temperate places, sand in + * the dry ones and on the beaches, and snow on the cold tops of the mountains. + * Trees grow on the temperate ground, evenly at random, and never close enough + * to a road to hang over it. */ public final class WorldGenerator { @@ -34,6 +46,18 @@ public final class WorldGenerator { /** How deep the sea is at its deepest. */ public static final int SEA_DEPTH = 12; + /** Above this height the ground turns to bare rock. */ + public static final int ROCK_TOP = 86; + + /** Above this height the rock keeps a coat of snow. */ + public static final int SNOW_TOP = 96; + + /** How many blocks either side of a road's middle the rank looks at. */ + private static final int ROAD_RANK = 8; + + /** How many blocks of canopy a tree may reach. */ + private static final int TREE_REACH = 2; + private final long seed; private final int size; private final int islandRadius; @@ -84,6 +108,9 @@ public final class WorldGenerator { * This is the whole trick behind a generator that survives being spread * across threads and months: any value in the world can be computed from a * coordinate without having visited any other coordinate. + * + * The middle coordinate is also the salt: two fields that must not be the + * same field are drawn from two different salts. */ private long hash(int x, int y, int z) { long h = this.seed; @@ -98,6 +125,10 @@ public final class WorldGenerator { /** Value noise in the range -1..1, smooth between lattice points. */ public double noise(double x, double z) { + return this.noise(x, z, 0); + } + + private double noise(double x, double z, int salt) { int x0 = (int) Math.floor(x); int z0 = (int) Math.floor(z); double fx = x - x0; @@ -106,30 +137,34 @@ public final class WorldGenerator { double u = fx * fx * (3.0D - 2.0D * fx); double v = fz * fz * (3.0D - 2.0D * fz); - double n00 = lattice(x0, z0); - double n10 = lattice(x0 + 1, z0); - double n01 = lattice(x0, z0 + 1); - double n11 = lattice(x0 + 1, z0 + 1); + double n00 = lattice(x0, z0, salt); + double n10 = lattice(x0 + 1, z0, salt); + double n01 = lattice(x0, z0 + 1, salt); + double n11 = lattice(x0 + 1, z0 + 1, salt); double top = n00 + u * (n10 - n00); double bottom = n01 + u * (n11 - n01); return top + v * (bottom - top); } - private double lattice(int x, int z) { - long h = hash(x, 0, z); + private double lattice(int x, int z, int salt) { + long h = hash(x, salt, z); // take the top bits so the distribution is even across the range return ((h >>> 11) / (double) (1L << 53)) * 2.0D - 1.0D; } /** Several octaves of noise, which is what makes it look like ground. */ public double fBm(double x, double z, int octaves) { + return this.fBm(x, z, octaves, 0); + } + + private double fBm(double x, double z, int octaves, int salt) { double sum = 0.0D; double amplitude = 1.0D; double frequency = 1.0D; double norm = 0.0D; for (int i = 0; i < octaves; i++) { - sum += noise(x * frequency, z * frequency) * amplitude; + sum += noise(x * frequency, z * frequency, salt) * amplitude; norm += amplitude; amplitude *= 0.5D; frequency *= 2.0D; @@ -137,6 +172,51 @@ public final class WorldGenerator { return norm == 0.0D ? 0.0D : sum / norm; } + /** + * Ridge noise: the crest of a folded landscape. + * + * Where plain fBm builds hills that wander, rounded noise makes ranges: + * long lines where the field drifts over zero, steep where it turns. The + * salt keeps the mountain field from being the land field. + */ + private double ridged(double x, double z, int salt) { + double n = this.noise(x, z, salt); + n = 1.0D - Math.abs(n); + return n * n; + } + + private double ridgedFbm(double x, double z, int salt) { + double coarse = (1.0D - Math.abs(this.noise(x, z, salt))); + coarse = coarse * coarse; + double fine = (1.0D - Math.abs(this.noise(x * 3.0D, z * 3.0D, salt))); + fine = fine * fine; + return coarse * 0.75D + fine * 0.25D; + } + + /** + * How warm a column is, from -1 (the coldest) to 1 (the hottest), salted + * so it is not the same field as the land it lies on. + */ + private double temperature(int blockX, int blockZ) { + return this.fBm(blockX / 300.0D, blockZ / 300.0D, 2, 1111); + } + + /** How damp a column is, from -1 (the driest) to 1 (the wettest). */ + private double humidity(int blockX, int blockZ) { + return this.fBm(blockX / 240.0D, blockZ / 240.0D, 2, 2222); + } + + /** True when the column is in the dry belt that wears sand. */ + public boolean isDesert(int blockX, int blockZ) { + return this.temperature(blockX, blockZ) > 0.4D + && this.humidity(blockX, blockZ) < 0.0D; + } + + /** True when the column is in the cold belt that wears snow. */ + public boolean isSnowCountry(int blockX, int blockZ) { + return this.temperature(blockX, blockZ) < -0.45D; + } + /** * How much of the land there is at a column. * @@ -155,13 +235,36 @@ public final class WorldGenerator { return falloff * falloff * (3.0D - 2.0D * falloff); } - /** The height of solid ground at a block column. */ - public int heightAt(int blockX, int blockZ) { + /** + * The height of the ground with no road on it. + * + * The blend is the classic one: the land field slides from a plain under + * the open sky down to the sea floor at the shore, and the sea asks for + * nothing more. On top of that come the mountains, masked by the falloff + * twice so that ranges stay off the beach. + */ + private int rawGround(int blockX, int blockZ) { double base = fBm(blockX / 160.0D, blockZ / 160.0D, 4); double detail = fBm(blockX / 32.0D, blockZ / 32.0D, 2) * 0.35D; - double land = 62.0D + base * 26.0D + detail * 6.0D; + double land = SEA_LEVEL + base * 26.0D + detail * 6.0D; double falloff = islandFalloff(blockX, blockZ); - double height = (SEA_LEVEL - SEA_DEPTH) + falloff * (land - (SEA_LEVEL - SEA_DEPTH)); + double height = (SEA_LEVEL - SEA_DEPTH) + + falloff * (land - (SEA_LEVEL - SEA_DEPTH)); + + if (falloff > 0.18D) { + double ridge = ridgedFbm(blockX / 420.0D, blockZ / 420.0D, 3333); + double mountain = Math.pow(Math.max(0.0D, (ridge - 0.42D) / 0.58D), + 1.8D) * 54.0D * falloff * falloff; + height += mountain; + } + + // the inland ground does not fall under the sea: where the plain would + // grow a lake, it is a damp flat instead, and the open water keeps to + // the shore and the sea beyond it + if (falloff > 0.25D && height < SEA_LEVEL) { + height = SEA_LEVEL + (falloff - 0.25D) * 4.0D; + } + int h = (int) Math.round(height); if (h < 2) { h = 2; @@ -172,11 +275,232 @@ public final class WorldGenerator { return h; } + /** The height of solid ground at a block column. */ + public int heightAt(int blockX, int blockZ) { + if (this.isRoad(blockX, blockZ)) { + int rank = this.roadRank(blockX, blockZ); + if (rank < SEA_LEVEL + 1) { + rank = SEA_LEVEL + 1; + } + return this.clamp(rank); + } + return this.clamp(this.rawGround(blockX, blockZ)); + } + + private int clamp(int h) { + if (h < 2) { + h = 2; + } + if (h > Chunk.HEIGHT - 2) { + h = Chunk.HEIGHT - 2; + } + return h; + } + + /** + * The height a road rides at, taken from the land under its middle. + * + * A road is level across its width because every column of it reads the + * same rank, and it follows the land along its length because the rank is + * the average of the ground a few blocks up and down the road, which + * smooths the ground without losing it. Where that rank has sunk under the + * sea the road still rides on it, a causeway, and never drowns. + */ + private int roadRank(int blockX, int blockZ) { + int cx = roads.centerLine(blockX); + int cz = roads.centerLine(blockZ); + boolean acrossX = Math.abs(blockZ - cz) <= RoadNetwork.HALF_WIDTH; + boolean acrossZ = Math.abs(blockX - cx) <= RoadNetwork.HALF_WIDTH; + + int level = SEA_LEVEL; + if (acrossX) { + level = rankOfStraight(blockX, blockZ, cz, true); + } + if (acrossZ) { + int other = rankOfStraight(blockX, blockZ, cx, false); + if (other > level) { + level = other; + } + } + if (roads.onRing(blockX, blockZ) != null) { + int ring = rankOfRing(blockX, blockZ); + if (ring > level) { + level = ring; + } + } + return level; + } + + /** + * The rank of one straight road. For a road that runs along + * {@code x} the cross line is the {@code z} the road sits on, and the + * other way round. + */ + private int rankOfStraight(int blockX, int blockZ, int crossLine, boolean alongX) { + long sum = 0L; + int count = 0; + for (int k = -ROAD_RANK; k <= ROAD_RANK; k++) { + int sx = alongX ? blockX + k : crossLine; + int sz = alongX ? crossLine : blockZ + k; + sum += rawGround(sx, sz); + count++; + } + int rank = (int) (sum / count); + if (rank < SEA_LEVEL + 1) { + rank = SEA_LEVEL + 1; + } + return rank; + } + + /** + * The rank of the ring road, smoothed along the ring itself. + */ + private int rankOfRing(int blockX, int blockZ) { + double dx = blockX - roads.center(); + double dz = blockZ - roads.center(); + double angle = Math.atan2(dz, dx); + double tx = -Math.sin(angle); + double tz = Math.cos(angle); + long sum = 0L; + int count = 0; + for (int k = -ROAD_RANK; k <= ROAD_RANK; k++) { + int sx = blockX + (int) Math.round(tx * k); + int sz = blockZ + (int) Math.round(tz * k); + sum += rawGround(sx, sz); + count++; + } + int rank = (int) (sum / count); + if (rank < SEA_LEVEL + 1) { + rank = SEA_LEVEL + 1; + } + return rank; + } + /** True when the column is under the sea. */ public boolean underWater(int blockX, int blockZ) { return heightAt(blockX, blockZ) < SEA_LEVEL; } + /** + * The block on the top of the ground at a column, before anything grows + * over it. + * + * Roads wear asphalt, ground under the sea and near it wears sand, the dry + * belt wears sand, the tops of the mountains wear rock, the coldest tops + * wear snow, and the rest of the island is grass. + */ + public byte surfaceAt(int blockX, int blockZ) { + if (isRoad(blockX, blockZ)) { + return Blocks.ASPHALT; + } + int height = heightAt(blockX, blockZ); + if (height < SEA_LEVEL) { + return Blocks.SAND; + } + if (height <= SEA_LEVEL + 1) { + return Blocks.SAND; + } + if (height >= SNOW_TOP) { + return Blocks.SNOW; + } + if (height >= ROCK_TOP) { + return Blocks.STONE; + } + if (this.isDesert(blockX, blockZ)) { + return Blocks.SAND; + } + if (this.isSnowCountry(blockX, blockZ)) { + return Blocks.SNOW; + } + return Blocks.GRASS; + } + + /** + * Whether a tree stands at a column. + * + * A tree is picked by the hash of its own square, and a square is only a + * candidate where the ground is grass and no road runs near enough for a + * canopy to hang over it. Nothing spans the island edge: no tree grows in + * sand or on rock or in snow, and none stands in the water. + */ + public boolean treeAt(int blockX, int blockZ) { + if (this.isRoad(blockX, blockZ)) { + return false; + } + if (this.roads.nearRoad(blockX, blockZ, TREE_REACH + 1)) { + return false; + } + int height = this.heightAt(blockX, blockZ); + if (height <= SEA_LEVEL + 1) { + return false; + } + byte surface = this.surfaceAtRaw(blockX, blockZ, height); + if (surface != Blocks.GRASS) { + return false; + } + double humidity = this.humidity(blockX, blockZ); + double temperature = this.temperature(blockX, blockZ); + double density = pickDensity(humidity, temperature); + double pick = treePick(blockX, blockZ); + return pick < density; + } + + private double treePick(int blockX, int blockZ) { + long h = hash(blockX, 7, blockZ); + double pick = (h >>> 11) / (double) (1L << 53); + if (pick > 1.0D) { + pick = 1.0D; + } + return pick; + } + + private double pickDensity(double humidity, double temperature) { + // the woods grow where the land is wet, the plains carry a tree here + // and there, and the snow and the desert nearly none. In the wettest + // woods about every sixth block carries a tree. + if (temperature < -0.45D) { + return 1.0D / 220.0D; + } + if (temperature > 0.4D && humidity < 0.0D) { + return 0.0D; + } + if (humidity > 0.25D) { + return 1.0D / 6.0D; + } + if (humidity > 0.0D) { + return 1.0D / 26.0D; + } + return 1.0D / 150.0D; + } + + /** How tall the trunk of a tree is, from the ground up. */ + public int trunkHeight(int blockX, int blockZ) { + return 4 + ((int) (hash(blockX, 8, blockZ) & 3)); + } + + /** The coat of the ground under the road's own rules, for tests. */ + public byte surfaceAtRaw(int blockX, int blockZ, int height) { + if (height < SEA_LEVEL) { + return Blocks.SAND; + } + if (height <= SEA_LEVEL + 1) { + return Blocks.SAND; + } + if (height >= SNOW_TOP) { + return Blocks.SNOW; + } + if (height >= ROCK_TOP) { + return Blocks.STONE; + } + if (this.isDesert(blockX, blockZ)) { + return Blocks.SAND; + } + if (this.isSnowCountry(blockX, blockZ)) { + return Blocks.SNOW; + } + return Blocks.GRASS; + } + /** Fills one chunk. Pure: same seed and position, same bytes out. */ public void generate(Chunk chunk) { ChunkPos pos = chunk.pos(); @@ -187,44 +511,70 @@ public final class WorldGenerator { synchronized (chunk) { byte[] blocks = chunk.raw(); - for (int z = 0; z < ChunkFormat.WIDTH; z++) { - for (int x = 0; x < ChunkFormat.WIDTH; x++) { + // the trees reach two blocks of canopy, so a chunk is planned not + // only over its own squares but over the rank of squares around it + int pad = TREE_REACH; + int span = ChunkFormat.WIDTH + pad * 2; + int[] spanHeights = new int[span * span]; + byte[] spanSurface = new byte[span * span]; + boolean[] spanTree = new boolean[span * span]; + for (int z = -pad; z < ChunkFormat.WIDTH + pad; z++) { + for (int x = -pad; x < ChunkFormat.WIDTH + pad; x++) { + int at = (z + pad) * span + (x + pad); int worldX = baseX + x; int worldZ = baseZ + z; int height = heightAt(worldX, worldZ); - boolean road = isRoad(worldX, worldZ); - boolean beach = height <= SEA_LEVEL + 1; + spanHeights[at] = height; + spanSurface[at] = surfaceAt(worldX, worldZ); + spanTree[at] = treeAt(worldX, worldZ); + } + } - // a column: dirt under the surface, grass on top, stone below + for (int z = 0; z < ChunkFormat.WIDTH; z++) { + for (int x = 0; x < ChunkFormat.WIDTH; x++) { + int at = (z + pad) * span + (x + pad); + int worldX = baseX + x; + int worldZ = baseZ + z; + int height = spanHeights[at]; + byte surface = spanSurface[at]; + + // a column: dirt under the surface, its coat on top, stone + // below, and roads are asphalt over packed ground for (int y = 0; y <= height; y++) { byte id; if (y == 0) { id = Blocks.STONE; } else if (y == height) { - if (road) { - id = Blocks.ASPHALT; - } else if (beach) { - id = Blocks.SAND; - } else { - id = Blocks.GRASS; - } - } else if (y > height - 4) { - id = Blocks.DIRT; - } else { + id = surface; + } else if (surface == Blocks.SAND) { + id = y > height - 3 ? Blocks.SAND + : (y > height - 4 ? Blocks.DIRT : Blocks.STONE); + } else if (surface == Blocks.STONE || surface == Blocks.SNOW) { id = Blocks.STONE; + } else { + id = y > height - 4 ? Blocks.DIRT : Blocks.STONE; } blocks[ChunkFormat.index(x, y, z)] = id; } // and the sea on top of the ground that is below it - for (int y = height + 1; y <= SEA_LEVEL; y++) { - if (y > 0 && y < Chunk.HEIGHT) { - blocks[ChunkFormat.index(x, y, z)] = Blocks.WATER; + if (height < SEA_LEVEL) { + for (int y = height + 1; y <= SEA_LEVEL; y++) { + if (y > 0 && y < Chunk.HEIGHT) { + blocks[ChunkFormat.index(x, y, z)] = Blocks.WATER; + } } } - for (int y = height + 1; y < Chunk.HEIGHT; y++) { - if (blocks[ChunkFormat.index(x, y, z)] == Blocks.AIR) { - // the air above the water stays air - continue; + + // the trees, if any stand over this column + for (int dz = -pad; dz <= pad; dz++) { + for (int dx = -pad; dx <= pad; dx++) { + int source = (z + pad + dz) * span + (x + pad + dx); + if (!spanTree[source]) { + continue; + } + growTree(blocks, x, z, spanHeights[source], + trunkHeight(baseX + x + dx, baseZ + z + dz), + dx, dz); } } } @@ -233,15 +583,58 @@ public final class WorldGenerator { } } + /** + * Lays the wood of one tree into a column of a chunk. + * + * The trunk stands on the tree's own square, and the leaves spread around + * it as a rounded crown: two wide rings under the top and two narrow ones + * above it. Only air is covered, so a trunk is never buried and two crowns + * that meet interleave. + */ + private void growTree(byte[] blocks, int x, int z, int groundHeight, + int trunk, int dx, int dz) { + int top = groundHeight + trunk; + int limit = Chunk.HEIGHT - 1; + + if (dx == 0 && dz == 0) { + for (int y = groundHeight + 1; y <= top && y < limit; y++) { + blocks[ChunkFormat.index(x, y, z)] = Blocks.WOOD; + } + return; + } + + for (int layer = 0; layer < 4; layer++) { + int y = top - 2 + layer; + if (y < 0 || y >= limit) { + continue; + } + int reach = layer < 2 ? TREE_REACH : 1; + if (Math.abs(dx) > reach || Math.abs(dz) > reach) { + continue; + } + if (reach == TREE_REACH && Math.abs(dx) == TREE_REACH + && Math.abs(dz) == TREE_REACH) { + continue; // a rounded crown, no corners + } + int index = ChunkFormat.index(x, y, z); + if (blocks[index] == Blocks.AIR) { + blocks[index] = Blocks.LEAVES; + } + } + } + /** * Whether a column is on one of the roads. * * The roads are the grid of the road network and the ring around the - * island. + * island, and they only run over the island: past the shore the network + * has no road and the sea keeps it so. */ public boolean isRoad(int blockX, int blockZ) { - RoadNetwork roads = new RoadNetwork(this.size); - return roads.onRoad(blockX, blockZ); + if (islandFalloff(blockX, blockZ) <= 0.0D) { + return false; + } + return this.roads.onRoad(blockX, blockZ); } /** 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 index fde7875..28149df 100644 --- 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 @@ -33,6 +33,9 @@ public final class LodTest { run("the regions are numbered from the origin", new Throwing() { public void go() throws Exception { testNumbering(); } }); + run("a region far away comes coarser", new Throwing() { + public void go() throws Exception { testSteps(work); } + }); System.out.println(); for (int i = 0; i < failures.size(); i++) { @@ -115,15 +118,40 @@ public final class LodTest { 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"))); + Packets.LodRegion first = lod.region(0, 0, LodGenerator.STEP); + yes("the region is written down", + Files.isRegularFile(cache.resolve("0_0_s" + LodGenerator.STEP + ".bin"))); // the second time it is read back, and it is the same - Packets.LodRegion second = lod.region(0, 0); + Packets.LodRegion second = lod.region(0, 0, LodGenerator.STEP); 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 testSteps(Path work) throws Exception { + Path cache = work.resolve("lod-steps"); + WorldGenerator generator = new WorldGenerator(1234L); + LodGenerator lod = new LodGenerator(generator, cache); + Packets.LodRegion mid = lod.region(0, 0, 16); + eq("the step of the request is the step of the answer", Integer.valueOf(16), + Integer.valueOf(mid.step)); + eq("the samples on a side", Integer.valueOf(16), Integer.valueOf(mid.size)); + eq("the heights", Integer.valueOf(256), Integer.valueOf(mid.heights.length)); + eq("the colours", Integer.valueOf(768), Integer.valueOf(mid.colors.length)); + + // the corner sample is the real ground at the corner + int got = mid.heights[0] & 0xFF; + eq("the corner height", Integer.valueOf(generator.heightAt(0, 0)), + Integer.valueOf(got)); + + Packets.LodRegion coarse = lod.region(3, 7, 64); + eq("and the coarsest step", Integer.valueOf(64), Integer.valueOf(coarse.step)); + eq("is the coarsest grid whatever the corner", Integer.valueOf(4), + Integer.valueOf(coarse.size)); + yes("it is on disk too", + Files.isRegularFile(cache.resolve("0_0_s64.bin"))); + } + private static void testNumbering() { eq("the first region", Integer.valueOf(0), Integer.valueOf(LodGenerator.regionChunk(0))); 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 23805fc..573cdd2 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 @@ -55,6 +55,9 @@ public final class WorldTest { run("a chunk knows where its surface is", new Throwing() { public void go() throws Exception { testSurfaceHeight(); } }); + run("the island keeps its water at the shore and the roads dry", new Throwing() { + public void go() throws Exception { testIslandAndRoads(); } + }); run("blocks can be written and read", new Throwing() { public void go() throws Exception { testBlockWrite(); } }); @@ -336,16 +339,14 @@ public final class WorldTest { int height = generator.heightAt(worldX, worldZ); eq("the floor is stone at " + x + "," + z, Byte.valueOf(Blocks.STONE), Byte.valueOf(chunk.block(x, 0, z))); - byte expectedTop = generator.isRoad(worldX, worldZ) - ? Blocks.ASPHALT - : (generator.underWater(worldX, worldZ) - ? Blocks.SAND - : (height <= WorldGenerator.SEA_LEVEL + 1 - ? Blocks.SAND : Blocks.GRASS)); + byte expectedTop = generator.surfaceAt(worldX, worldZ); eq("the top is the ground at " + x + "," + z, Byte.valueOf(expectedTop), Byte.valueOf(chunk.block(x, height, z))); // under the sea the water stands on the ground, above the sea - // there is air + // there is air, except where a tree grows over the column + if (anyTreeOver(generator, worldX, worldZ)) { + continue; + } byte expectedSky = height < WorldGenerator.SEA_LEVEL ? Blocks.WATER : Blocks.AIR; eq("the sky is air or water at " + x + "," + z, Byte.valueOf(expectedSky), @@ -356,6 +357,18 @@ public final class WorldTest { } } + /** True when a tree stands in the canopy reach of the column. */ + private static boolean anyTreeOver(WorldGenerator generator, int worldX, int worldZ) { + for (int dz = -2; dz <= 2; dz++) { + for (int dx = -2; dx <= 2; dx++) { + if (generator.treeAt(worldX + dx, worldZ + dz)) { + return true; + } + } + } + return false; + } + private static void testSurfaceHeight() { WorldGenerator generator = new WorldGenerator(808L); Chunk chunk = new Chunk(new ChunkPos(2, 2)); @@ -364,6 +377,14 @@ public final class WorldTest { for (int x = 0; x < Chunk.WIDTH; x++) { int worldX = 2 * Chunk.WIDTH + x; int worldZ = 2 * Chunk.WIDTH + z; + if (anyTreeOver(generator, worldX, worldZ)) { + // a crown over the column lifts its surface past the + // ground, unless the crown is buried in a hillside + yes("a tree does not sink the surface at " + worldX + "," + worldZ, + chunk.surfaceHeight(x, z) >= generator + .heightAt(worldX, worldZ) + 1); + continue; + } eq("the surface is where the height says at " + x + "," + z, Integer.valueOf(generator.heightAt(worldX, worldZ) + 1), Integer.valueOf(chunk.surfaceHeight(x, z))); @@ -371,6 +392,66 @@ public final class WorldTest { } } + private static void testIslandAndRoads() { + WorldGenerator generator = new WorldGenerator(909L); + double tallest = 0.0D; + boolean mountain = false; + for (int v = -1100; v <= 1100; v += 37) { + for (int u = -1100; u <= 1100; u += 53) { + double falloff = generator.islandFalloff(u, v); + int height = generator.heightAt(u, v); + if (falloff > 0.26D) { + yes("no lake inland at " + u + "," + v, + height >= WorldGenerator.SEA_LEVEL); + } + if (generator.treeAt(u, v) && falloff < 0.26D) { + failed++; + failures.add("a tree grew in the water at " + u + "," + v); + } + if (height > tallest) { + tallest = height; + } + if (height >= 92) { + mountain = true; + } + } + } + yes("the island is dry where its plains are", tallest > 70.0D); + yes("the island carries mountains", mountain); + + // roads ride over the land they cross, level across their width + RoadNetwork roads = new RoadNetwork(4000); + for (int v = -1200; v <= 1200; v += 111) { + for (int u = -1200; u <= 1200; u += 111) { + if (!generator.isRoad(u, v)) { + continue; + } + if (roads.onRoad(u, v) && generator.islandFalloff(u, v) < 0.001D) { + failed++; + failures.add("a road runs in the open sea at " + u + "," + v); + } + int height = generator.heightAt(u, v); + yes("the road holds itself above the sea at " + u + "," + v, + height >= WorldGenerator.SEA_LEVEL); + // the same road a few blocks across: level with this column + int dx = u > 0 ? 3 : -3; + int dz = v > 0 ? 3 : -3; + boolean sameX = roads.centerLine(u) == roads.centerLine(u + dx); + boolean sameZ = roads.centerLine(v) == roads.centerLine(v + dz); + if (sameX && generator.isRoad(u + dx, v) && roads.onRoad(u + dx, v)) { + eq("the road is level across it at " + u + "," + v, + Integer.valueOf(height), + Integer.valueOf(generator.heightAt(u + dx, v))); + } + if (sameZ && generator.isRoad(u, v + dz) && roads.onRoad(u, v + dz)) { + eq("and level across it the other way at " + u + "," + v, + Integer.valueOf(height), + Integer.valueOf(generator.heightAt(u, v + dz))); + } + } + } + } + // -------------------------------------------------------------- storage private static void testSaveLoadRoundTrip() throws IOException { diff --git a/tools/watch-game.ps1 b/tools/watch-game.ps1 index dd9cee2..534f3a5 100644 --- a/tools/watch-game.ps1 +++ b/tools/watch-game.ps1 @@ -49,19 +49,23 @@ $tokenFile = Join-Path $Root 'rsx-server-core\world\dev-token.txt' if (Test-Path $tokenFile) { Remove-Item $tokenFile -Force } Write-Host 'starting the server' -$serverArgs = @( - '-cp', $serverCp, 'net.rustnotmc.server.ServerMain', - '--port', "$Port", - '--accounts', (Join-Path $Root 'rsx-server-core\accounts.txt'), - '--world', (Join-Path $Root 'rsx-server-core\world'), - '--dev-token', $User, - '--bind', '127.0.0.1' -) +# The server runs in its own visible console window, so the log lies in view +# and whoever is sitting at the machine can speak to it: type list, say, +# op, deop, kick, ban, unban, save or stop and press Enter there. $java = 'D:\jdk-21\bin\java.exe' if (-not (Test-Path $java)) { $java = (Get-Command java).Source } -$server = Start-Process -FilePath $java -ArgumentList $serverArgs ` - -RedirectStandardOutput $ServerLog -RedirectStandardError "$ServerLog.err" -PassThru -Write-Host "server pid $($server.Id), log $ServerLog" +$serverCommand = + "& '$java' -cp '$serverCp' net.rustnotmc.server.ServerMain" + + " --port $Port" + + " --accounts '$(Join-Path $Root 'rsx-server-core\accounts.txt')'" + + " --world '$(Join-Path $Root 'rsx-server-core\world')'" + + " --dev-token $User" + + " --bind 127.0.0.1" + + " *>&1 | Tee-Object -FilePath '$ServerLog'" +$server = Start-Process -FilePath 'powershell.exe' -ArgumentList @( + '-NoExit', '-ExecutionPolicy', 'Bypass', '-Command', $serverCommand +) -PassThru +Write-Host "server window pid $($server.Id), log $ServerLog" # The server writes its own token, and the client needs it. The file has to be # the one this run wrote, not the one of the run before. @@ -94,11 +98,12 @@ Start-Process -FilePath 'powershell.exe' -ArgumentList $clientArgs -PassThru | O Write-Host 'the client is starting in its own window' Write-Host "the client log is in $ClientLog" -# stay alive while the game runs +# stay alive while the server window does; the game has its own window and +# its own life while ($true) { Start-Sleep -Seconds 5 if ($server.HasExited) { - Write-Host 'the server has stopped' + Write-Host 'the server window has closed' break } }