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:
+ *
+ *
+ * - SSAO. Sixteen hemisphere samples around each pixel's view-space
+ * position, the surface normal reconstructed from depth neighbours, softened
+ * by a five by five blur that stops at depth edges, then applied as a
+ * multiplier on the scene color.
+ * - Bloom. A threshold pass picks what shines, a chain of downsamples
+ * spreads it, and the chain walks back up adding what it blurred into what
+ * lies beneath, landing as one additive layer over the screen.
+ *
+ *
+ * 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
}
}