Optimize world generation
This commit is contained in:
parent
a45c574595
commit
124e20662c
75 changed files with 3062 additions and 9 deletions
|
|
@ -32,10 +32,22 @@ sourceSets {
|
|||
}
|
||||
}
|
||||
|
||||
configurations { c2me }
|
||||
|
||||
def dfcJar = providers.provider {
|
||||
zipTree(configurations.c2me.singleFile).matching {
|
||||
include 'META-INF/jars/*-opts-dfc-*.jar'
|
||||
}.files
|
||||
}
|
||||
|
||||
dependencies {
|
||||
c2me('curse.maven:c2me-533097:9344813') { transitive = false }
|
||||
compileOnly files(dfcJar)
|
||||
testImplementation files(dfcJar)
|
||||
compileOnly('mekanism:Mekanism:1.21.1-10.7.19.85') { transitive = false }
|
||||
compileOnly('curse.maven:functional-storage-556861:8459097') { transitive = false }
|
||||
compileOnly('maven.modrinth:refined-storage:lHHiI26k') { transitive = false }
|
||||
compileOnly('maven.modrinth:distanthorizons:9w34y8ai') { transitive = false }
|
||||
compileOnly 'org.jetbrains:annotations:24.1.0'
|
||||
compileOnly 'org.apiguardian:apiguardian-api:1.1.2'
|
||||
compileOnly 'io.github.llamalad7:mixinextras-neoforge:0.5.3'
|
||||
|
|
|
|||
140
server/src/main/java/dev/quorate/tune/BiomeIndex.java
Normal file
140
server/src/main/java/dev/quorate/tune/BiomeIndex.java
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
public final class BiomeIndex {
|
||||
// Seven squared axis distances cannot overflow a long within this range.
|
||||
private static final long LIMIT = 1L << 28;
|
||||
|
||||
public record Result(Object first, long distance, Object second, long other) {}
|
||||
|
||||
private final Object[] nodes;
|
||||
private final Object[] keys;
|
||||
private final long[] bounds;
|
||||
private final int[] next;
|
||||
private final boolean[] branches;
|
||||
private final IdentityHashMap<Object, Integer> positions = new IdentityHashMap<>();
|
||||
private final Object nullKey;
|
||||
private final boolean narrow;
|
||||
|
||||
public BiomeIndex(
|
||||
Object root,
|
||||
Function<Object, Object[]> children,
|
||||
Function<Object, long[]> space,
|
||||
Function<Object, Object> key) {
|
||||
var order = new ArrayList<Object>();
|
||||
var ends = new ArrayList<Integer>();
|
||||
var inner = new ArrayList<Boolean>();
|
||||
flatten(root, children, order, ends, inner);
|
||||
nodes = order.toArray();
|
||||
keys = new Object[nodes.length];
|
||||
bounds = new long[nodes.length * 14];
|
||||
next = new int[nodes.length];
|
||||
branches = new boolean[nodes.length];
|
||||
nullKey = key.apply(null);
|
||||
boolean fits = true;
|
||||
for (int i = 0; i < nodes.length; i++) {
|
||||
positions.put(nodes[i], i);
|
||||
long[] extent = space.apply(nodes[i]);
|
||||
for (int axis = 0; axis < 7; axis++) {
|
||||
long low = extent[axis * 2];
|
||||
long high = extent[axis * 2 + 1];
|
||||
bounds[i * 14 + axis * 2] = low;
|
||||
bounds[i * 14 + axis * 2 + 1] = high;
|
||||
fits &= low >= -LIMIT && low <= LIMIT && high >= -LIMIT && high <= LIMIT;
|
||||
}
|
||||
next[i] = ends.get(i);
|
||||
branches[i] = inner.get(i);
|
||||
if (!branches[i]) keys[i] = key.apply(nodes[i]);
|
||||
}
|
||||
narrow = fits;
|
||||
}
|
||||
|
||||
private static void flatten(
|
||||
Object node,
|
||||
Function<Object, Object[]> children,
|
||||
ArrayList<Object> order,
|
||||
ArrayList<Integer> ends,
|
||||
ArrayList<Boolean> inner) {
|
||||
int index = order.size();
|
||||
var below = children.apply(node);
|
||||
order.add(node);
|
||||
ends.add(0);
|
||||
inner.add(below != null);
|
||||
if (below != null) for (var child : below) flatten(child, children, order, ends, inner);
|
||||
ends.set(index, order.size());
|
||||
}
|
||||
|
||||
public boolean single() {
|
||||
return !branches[0];
|
||||
}
|
||||
|
||||
private long distance(int index, long[] point, long limit, boolean capped) {
|
||||
if (index < 0) return Long.MAX_VALUE;
|
||||
long sum = 0;
|
||||
int offset = index * 14;
|
||||
for (int axis = 0; axis < 7; axis++) {
|
||||
long coordinate = point[axis];
|
||||
long above = coordinate - bounds[offset + axis * 2 + 1];
|
||||
long below = bounds[offset + axis * 2] - coordinate;
|
||||
long delta = above > 0 ? above : Math.max(below, 0L);
|
||||
sum += delta * delta;
|
||||
if (capped && sum >= limit) return sum;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
private int position(Object node) {
|
||||
return node == null ? -1 : positions.get(node);
|
||||
}
|
||||
|
||||
public Result search(long[] point, Object previous, Object penultimate) {
|
||||
boolean capped = narrow;
|
||||
for (long coordinate : point) capped &= coordinate >= -LIMIT && coordinate <= LIMIT;
|
||||
return lookup(point, previous, penultimate, capped);
|
||||
}
|
||||
|
||||
private Result lookup(long[] point, Object previous, Object penultimate, boolean capped) {
|
||||
if (single())
|
||||
return new Result(nodes[0], distance(0, point, 0, false), null, Long.MAX_VALUE);
|
||||
int first = position(previous);
|
||||
int second = position(penultimate);
|
||||
long best = distance(first, point, 0, false);
|
||||
long other = distance(second, point, 0, false);
|
||||
if (best > other) {
|
||||
int swap = first;
|
||||
first = second;
|
||||
second = swap;
|
||||
long distance = best;
|
||||
best = other;
|
||||
other = distance;
|
||||
}
|
||||
int index = 1;
|
||||
while (index < nodes.length) {
|
||||
long candidate = distance(index, point, other, capped);
|
||||
if (branches[index]) {
|
||||
index = other > candidate ? index + 1 : next[index];
|
||||
continue;
|
||||
}
|
||||
if (other > candidate) {
|
||||
boolean different = !keys[index].equals(first < 0 ? nullKey : keys[first]);
|
||||
if (best > candidate) {
|
||||
if (different) {
|
||||
other = best;
|
||||
second = first;
|
||||
}
|
||||
best = candidate;
|
||||
first = index;
|
||||
} else if (different) {
|
||||
other = candidate;
|
||||
second = index;
|
||||
}
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return new Result(
|
||||
first < 0 ? null : nodes[first], best, second < 0 ? null : nodes[second], other);
|
||||
}
|
||||
}
|
||||
152
server/src/main/java/dev/quorate/tune/BiomePatch.java
Normal file
152
server/src/main/java/dev/quorate/tune/BiomePatch.java
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import org.objectweb.asm.Handle;
|
||||
import org.objectweb.asm.Label;
|
||||
import org.objectweb.asm.Opcodes;
|
||||
import org.objectweb.asm.Type;
|
||||
import org.objectweb.asm.commons.InstructionAdapter;
|
||||
import org.objectweb.asm.tree.ClassNode;
|
||||
import org.objectweb.asm.tree.InvokeDynamicInsnNode;
|
||||
import org.objectweb.asm.tree.MethodInsnNode;
|
||||
import org.objectweb.asm.tree.MethodNode;
|
||||
import org.objectweb.asm.tree.TypeInsnNode;
|
||||
|
||||
public final class BiomePatch {
|
||||
private static final String CLIMATE = "net/minecraft/world/level/biome/Climate$";
|
||||
private static final String NODE = CLIMATE + "RTree$Node";
|
||||
private static final String LEAF = CLIMATE + "RTree$Leaf";
|
||||
private static final String BRANCH = CLIMATE + "RTree$SubTree";
|
||||
private static final String RESULT = "com/terraformersmc/biolith/api/biome/BiolithFittestNodes";
|
||||
private static final Type OBJECT = Type.getType(Object.class);
|
||||
|
||||
private BiomePatch() {}
|
||||
|
||||
public static int callers(ClassNode node) {
|
||||
int count = 0;
|
||||
for (var method : node.methods) {
|
||||
if (!method.name.equals("getBiome")) continue;
|
||||
for (var code : method.instructions.toArray()) {
|
||||
if (!(code instanceof InvokeDynamicInsnNode factory)
|
||||
|| factory.bsmArgs.length != 3
|
||||
|| !(factory.bsmArgs[1] instanceof Handle handle)
|
||||
|| !handle.getOwner().equals(NODE)
|
||||
|| !handle.getName().equals("distance")
|
||||
|| !handle.getDesc().equals("([J)J")) continue;
|
||||
var next = code.getNext();
|
||||
while (next != null && next.getOpcode() < 0) next = next.getNext();
|
||||
if (!(next instanceof MethodInsnNode call)
|
||||
|| !call.name.equals("biolith$searchTreeGet"))
|
||||
throw new IllegalStateException("Biome caller shape");
|
||||
method.instructions.set(
|
||||
code,
|
||||
new MethodInsnNode(
|
||||
Opcodes.INVOKEVIRTUAL,
|
||||
CLIMATE + "TargetPoint",
|
||||
"toParameterArray",
|
||||
"()[J",
|
||||
false));
|
||||
var search =
|
||||
new MethodInsnNode(
|
||||
Opcodes.INVOKEINTERFACE,
|
||||
"dev/quorate/tune/BiomeSearch",
|
||||
"saltatory$search",
|
||||
"([J)Ljava/lang/Object;",
|
||||
true);
|
||||
method.instructions.set(call, search);
|
||||
method.instructions.insert(search, new TypeInsnNode(Opcodes.CHECKCAST, RESULT));
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static InstructionAdapter edit(MethodNode method) {
|
||||
method.instructions.clear();
|
||||
method.localVariables = null;
|
||||
method.tryCatchBlocks.clear();
|
||||
return new InstructionAdapter(method);
|
||||
}
|
||||
|
||||
public static void tree(ClassNode node) {
|
||||
String key =
|
||||
node.methods.stream()
|
||||
.filter(method -> method.name.endsWith("biolith$keyOf"))
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.name;
|
||||
for (var method : node.methods) {
|
||||
String field =
|
||||
switch (method.name) {
|
||||
case "saltatory$root" -> "root";
|
||||
case "saltatory$first" -> "previousUltimateNode";
|
||||
case "saltatory$second" -> "previousPenultimateNode";
|
||||
default -> null;
|
||||
};
|
||||
if (field != null) {
|
||||
String descriptor =
|
||||
node.fields.stream()
|
||||
.filter(value -> value.name.equals(field))
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.desc;
|
||||
var emit = edit(method);
|
||||
emit.load(0, OBJECT);
|
||||
emit.getfield(node.name, field, descriptor);
|
||||
emit.areturn(OBJECT);
|
||||
emit.visitMaxs(1, 1);
|
||||
} else if (method.name.equals("saltatory$children")) {
|
||||
var emit = edit(method);
|
||||
var leaf = new Label();
|
||||
emit.load(1, OBJECT);
|
||||
emit.instanceOf(Type.getObjectType(BRANCH));
|
||||
emit.ifeq(leaf);
|
||||
emit.load(1, OBJECT);
|
||||
emit.checkcast(Type.getObjectType(BRANCH));
|
||||
emit.getfield(BRANCH, "children", "[L" + NODE + ";");
|
||||
emit.areturn(OBJECT);
|
||||
emit.mark(leaf);
|
||||
emit.visitFrame(Opcodes.F_SAME, 0, null, 0, null);
|
||||
emit.aconst(null);
|
||||
emit.areturn(OBJECT);
|
||||
emit.visitMaxs(1, 2);
|
||||
} else if (method.name.equals("saltatory$bounds")) {
|
||||
var emit = edit(method);
|
||||
emit.load(1, OBJECT);
|
||||
emit.checkcast(Type.getObjectType(NODE));
|
||||
emit.getfield(NODE, "parameterSpace", "[L" + CLIMATE + "Parameter;");
|
||||
emit.areturn(OBJECT);
|
||||
emit.visitMaxs(1, 2);
|
||||
} else if (method.name.equals("saltatory$key")) {
|
||||
var emit = edit(method);
|
||||
emit.load(0, OBJECT);
|
||||
emit.load(1, OBJECT);
|
||||
emit.checkcast(Type.getObjectType(LEAF));
|
||||
emit.invokespecial(
|
||||
node.name,
|
||||
key,
|
||||
"(L" + LEAF + ";)Lnet/minecraft/resources/ResourceKey;",
|
||||
false);
|
||||
emit.invokevirtual(
|
||||
"net/minecraft/resources/ResourceKey",
|
||||
"location",
|
||||
"()Lnet/minecraft/resources/ResourceLocation;",
|
||||
false);
|
||||
emit.areturn(OBJECT);
|
||||
emit.visitMaxs(2, 2);
|
||||
} else if (method.name.equals("saltatory$result")) {
|
||||
var emit = edit(method);
|
||||
emit.anew(Type.getObjectType(RESULT));
|
||||
emit.dup();
|
||||
emit.load(1, OBJECT);
|
||||
emit.checkcast(Type.getObjectType(LEAF));
|
||||
emit.load(2, Type.LONG_TYPE);
|
||||
emit.load(4, OBJECT);
|
||||
emit.checkcast(Type.getObjectType(LEAF));
|
||||
emit.load(5, Type.LONG_TYPE);
|
||||
emit.invokespecial(RESULT, "<init>", "(L" + LEAF + ";JL" + LEAF + ";J)V", false);
|
||||
emit.areturn(OBJECT);
|
||||
emit.visitMaxs(8, 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
server/src/main/java/dev/quorate/tune/BiomeSearch.java
Normal file
5
server/src/main/java/dev/quorate/tune/BiomeSearch.java
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
public interface BiomeSearch {
|
||||
Object saltatory$search(long[] point);
|
||||
}
|
||||
46
server/src/main/java/dev/quorate/tune/CaveFrame.java
Normal file
46
server/src/main/java/dev/quorate/tune/CaveFrame.java
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
public final class CaveFrame {
|
||||
private final double[][] a;
|
||||
private final double[][] b;
|
||||
private final double[][] c;
|
||||
private final double[][] d;
|
||||
private final double[][] values;
|
||||
private final float[] start;
|
||||
private final float[] end;
|
||||
private final int last;
|
||||
|
||||
public CaveFrame(
|
||||
double[][] a,
|
||||
double[][] b,
|
||||
double[][] c,
|
||||
double[][] d,
|
||||
float[] start,
|
||||
float[] end,
|
||||
int last) {
|
||||
this.a = a;
|
||||
this.b = b;
|
||||
this.c = c;
|
||||
this.d = d;
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
this.last = last;
|
||||
values = new double[a.length][a[0].length];
|
||||
}
|
||||
|
||||
public double[][] column(int x, int z) {
|
||||
double sx = start[x];
|
||||
double ex = end[x];
|
||||
double sz = start[z];
|
||||
double ez = end[z];
|
||||
for (int y = 0; y < values.length; y++) {
|
||||
double[] row = values[y];
|
||||
for (int g = 0; g < row.length; g++) {
|
||||
double left = x == 0 ? a[y][g] : x == last ? c[y][g] : a[y][g] * sx + c[y][g] * ex;
|
||||
double right = x == 0 ? b[y][g] : x == last ? d[y][g] : b[y][g] * sx + d[y][g] * ex;
|
||||
row[g] = z == 0 ? left : z == last ? right : left * sz + right * ez;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
7
server/src/main/java/dev/quorate/tune/CaveNoise.java
Normal file
7
server/src/main/java/dev/quorate/tune/CaveNoise.java
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
|
||||
public interface CaveNoise {
|
||||
CaveFrame caveFrame(BlockPos min, BlockPos max, int low, int high);
|
||||
}
|
||||
167
server/src/main/java/dev/quorate/tune/CavePatch.java
Normal file
167
server/src/main/java/dev/quorate/tune/CavePatch.java
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import org.objectweb.asm.Label;
|
||||
import org.objectweb.asm.Opcodes;
|
||||
import org.objectweb.asm.Type;
|
||||
import org.objectweb.asm.commons.InstructionAdapter;
|
||||
import org.objectweb.asm.tree.AbstractInsnNode;
|
||||
import org.objectweb.asm.tree.ClassNode;
|
||||
import org.objectweb.asm.tree.InsnList;
|
||||
import org.objectweb.asm.tree.InsnNode;
|
||||
import org.objectweb.asm.tree.MethodInsnNode;
|
||||
import org.objectweb.asm.tree.MethodNode;
|
||||
import org.objectweb.asm.tree.VarInsnNode;
|
||||
|
||||
public final class CavePatch {
|
||||
private static final String BC = "com/yungnickyoung/minecraft/bettercaves/";
|
||||
private static final String RANGE = BC + "worldgen/carver/CarverNoiseRange";
|
||||
private static final String CARVER = BC + "worldgen/carver/AbstractCarver";
|
||||
private static final String NOISE = BC + "noise/NoiseGen";
|
||||
private static final String POS = "Lnet/minecraft/core/BlockPos;";
|
||||
private static final String CUBE = "(" + POS + POS + "II)[[[[D";
|
||||
private static final String FRAME = Type.getInternalName(CaveFrame.class);
|
||||
private static final String SOURCE = Type.getInternalName(CaveNoise.class);
|
||||
private static final String FACTORY = "(" + POS + POS + "II)L" + FRAME + ";";
|
||||
private static final String DEMAND =
|
||||
"(Ljava/util/Map;Ljava/lang/Object;" + POS + POS + "III)[[D";
|
||||
private static final String METHOD = "saltatory$noise";
|
||||
|
||||
private CavePatch() {}
|
||||
|
||||
public static void apply(ClassNode node) {
|
||||
if (node.name.equals(NOISE)) {
|
||||
node.interfaces.add(SOURCE);
|
||||
node.methods.add(factory());
|
||||
return;
|
||||
}
|
||||
if (node.name.endsWith("/CaveCarver")) {
|
||||
CaveRows.apply(node);
|
||||
return;
|
||||
}
|
||||
var body =
|
||||
node.methods.stream()
|
||||
.filter(method -> method.name.equals("carveChunk"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
int cubes = 0;
|
||||
int demands = 0;
|
||||
for (var instruction : body.instructions.toArray()) {
|
||||
if (!(instruction instanceof MethodInsnNode call)) continue;
|
||||
if (call.owner.equals(NOISE)
|
||||
&& call.name.equals("interpolateNoiseCube")
|
||||
&& call.desc.equals(CUBE)) {
|
||||
var empty = new InsnList();
|
||||
for (int argument = 0; argument < 5; argument++)
|
||||
empty.add(new InsnNode(Opcodes.POP));
|
||||
empty.add(new InsnNode(Opcodes.ACONST_NULL));
|
||||
body.instructions.insertBefore(call, empty);
|
||||
body.instructions.remove(call);
|
||||
cubes++;
|
||||
} else if (call.owner.equals("java/util/Map") && call.name.equals("get")) {
|
||||
var extra = new InsnList();
|
||||
extra.add(new VarInsnNode(Opcodes.ALOAD, local(body, "startPos")));
|
||||
extra.add(new VarInsnNode(Opcodes.ALOAD, local(body, "endPos")));
|
||||
extra.add(new VarInsnNode(Opcodes.ILOAD, local(body, "maxHeight")));
|
||||
extra.add(new VarInsnNode(Opcodes.ILOAD, local(body, "offsetX")));
|
||||
extra.add(new VarInsnNode(Opcodes.ILOAD, local(body, "offsetZ")));
|
||||
body.instructions.insertBefore(call, extra);
|
||||
AbstractInsnNode cursor = call;
|
||||
for (int index = 0; index < 5; index++) {
|
||||
AbstractInsnNode next = cursor.getNext();
|
||||
while (next.getOpcode() < 0) next = next.getNext();
|
||||
body.instructions.remove(next);
|
||||
}
|
||||
body.instructions.set(
|
||||
call,
|
||||
new MethodInsnNode(Opcodes.INVOKESTATIC, node.name, METHOD, DEMAND, false));
|
||||
demands++;
|
||||
}
|
||||
}
|
||||
if (cubes != 1 || demands != 1) throw new IllegalStateException("Cave layer shape changed");
|
||||
body.maxStack += 5;
|
||||
node.methods.add(demand());
|
||||
}
|
||||
|
||||
private static int local(MethodNode method, String name) {
|
||||
return method.localVariables.stream()
|
||||
.filter(variable -> variable.name.equals(name))
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.index;
|
||||
}
|
||||
|
||||
private static MethodNode demand() {
|
||||
var method =
|
||||
new MethodNode(
|
||||
Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC, METHOD, DEMAND, null, null);
|
||||
var emit = new InstructionAdapter(method);
|
||||
var object = Type.getType(Object.class);
|
||||
var found = new Label();
|
||||
emit.load(0, object);
|
||||
emit.load(1, object);
|
||||
emit.invokeinterface("java/util/Map", "get", "(Ljava/lang/Object;)Ljava/lang/Object;");
|
||||
emit.store(7, object);
|
||||
emit.load(7, object);
|
||||
emit.ifnonnull(found);
|
||||
emit.load(1, object);
|
||||
emit.checkcast(Type.getObjectType(RANGE));
|
||||
emit.invokevirtual(RANGE, "getCarver", "()L" + CARVER + ";", false);
|
||||
emit.store(8, object);
|
||||
emit.load(8, object);
|
||||
emit.invokevirtual(CARVER, "getNoiseGen", "()L" + NOISE + ";", false);
|
||||
emit.checkcast(Type.getObjectType(SOURCE));
|
||||
emit.load(2, object);
|
||||
emit.load(3, object);
|
||||
emit.load(8, object);
|
||||
emit.invokevirtual(CARVER, "getBottomY", "()I", false);
|
||||
emit.load(4, Type.INT_TYPE);
|
||||
emit.invokeinterface(SOURCE, "caveFrame", FACTORY);
|
||||
emit.store(7, object);
|
||||
emit.load(0, object);
|
||||
emit.load(1, object);
|
||||
emit.load(7, object);
|
||||
emit.invokeinterface(
|
||||
"java/util/Map", "put", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;");
|
||||
emit.pop();
|
||||
emit.mark(found);
|
||||
emit.visitFrame(Opcodes.F_APPEND, 1, new Object[] {"java/lang/Object"}, 0, null);
|
||||
emit.load(7, object);
|
||||
emit.checkcast(Type.getObjectType(FRAME));
|
||||
emit.load(5, Type.INT_TYPE);
|
||||
emit.load(6, Type.INT_TYPE);
|
||||
emit.invokevirtual(FRAME, "column", "(II)[[D", false);
|
||||
emit.areturn(object);
|
||||
emit.visitMaxs(5, 9);
|
||||
return method;
|
||||
}
|
||||
|
||||
private static MethodNode factory() {
|
||||
var method = new MethodNode(Opcodes.ACC_PUBLIC, "caveFrame", FACTORY, null, null);
|
||||
var emit = new InstructionAdapter(method);
|
||||
var object = Type.getType(Object.class);
|
||||
var pos = "net/minecraft/core/BlockPos";
|
||||
emit.anew(Type.getObjectType(FRAME));
|
||||
emit.dup();
|
||||
for (int corner = 0; corner < 4; corner++) {
|
||||
emit.load(0, object);
|
||||
emit.load(corner / 2 + 1, object);
|
||||
emit.invokevirtual(pos, "getX", "()I", false);
|
||||
emit.load(corner % 2 + 1, object);
|
||||
emit.invokevirtual(pos, "getZ", "()I", false);
|
||||
emit.load(3, Type.INT_TYPE);
|
||||
emit.load(4, Type.INT_TYPE);
|
||||
emit.invokespecial(NOISE, "generateNoiseColumn", "(IIII)[[D", false);
|
||||
}
|
||||
emit.getstatic(BC + "BCConstants", "START_COEFFS", "[F");
|
||||
emit.getstatic(BC + "BCConstants", "END_COEFFS", "[F");
|
||||
emit.load(2, object);
|
||||
emit.invokevirtual(pos, "getX", "()I", false);
|
||||
emit.load(1, object);
|
||||
emit.invokevirtual(pos, "getX", "()I", false);
|
||||
emit.sub(Type.INT_TYPE);
|
||||
emit.invokespecial(FRAME, "<init>", "([[D[[D[[D[[D[F[FI)V", false);
|
||||
emit.areturn(object);
|
||||
emit.visitMaxs(12, 5);
|
||||
return method;
|
||||
}
|
||||
}
|
||||
106
server/src/main/java/dev/quorate/tune/CaveRows.java
Normal file
106
server/src/main/java/dev/quorate/tune/CaveRows.java
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import org.objectweb.asm.Opcodes;
|
||||
import org.objectweb.asm.tree.ClassNode;
|
||||
import org.objectweb.asm.tree.FieldInsnNode;
|
||||
import org.objectweb.asm.tree.FrameNode;
|
||||
import org.objectweb.asm.tree.InsnList;
|
||||
import org.objectweb.asm.tree.InsnNode;
|
||||
import org.objectweb.asm.tree.IntInsnNode;
|
||||
import org.objectweb.asm.tree.MethodInsnNode;
|
||||
import org.objectweb.asm.tree.MethodNode;
|
||||
import org.objectweb.asm.tree.TypeInsnNode;
|
||||
import org.objectweb.asm.tree.VarInsnNode;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public final class CaveRows {
|
||||
private static final Set<String> METHODS =
|
||||
Set.of("carveColumn", "generateThresholds", "preprocessCaveNoiseCol");
|
||||
|
||||
private CaveRows() {}
|
||||
|
||||
private static void frame(List<Object> values) {
|
||||
if (values == null) return;
|
||||
for (int i = 0; i < values.size(); i++)
|
||||
if ("java/util/Map".equals(values.get(i)) || "java/util/HashMap".equals(values.get(i)))
|
||||
values.set(i, "[F");
|
||||
}
|
||||
|
||||
private static InsnList length() {
|
||||
var code = new InsnList();
|
||||
code.add(new VarInsnNode(Opcodes.ILOAD, 1));
|
||||
code.add(new VarInsnNode(Opcodes.ILOAD, 2));
|
||||
code.add(new InsnNode(Opcodes.ISUB));
|
||||
code.add(new InsnNode(Opcodes.ICONST_1));
|
||||
code.add(new InsnNode(Opcodes.IADD));
|
||||
code.add(new InsnNode(Opcodes.ICONST_0));
|
||||
code.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Math", "max", "(II)I", false));
|
||||
code.add(new IntInsnNode(Opcodes.NEWARRAY, Opcodes.T_FLOAT));
|
||||
return code;
|
||||
}
|
||||
|
||||
private static InsnList offset(ClassNode owner, MethodNode method) {
|
||||
var code = new InsnList();
|
||||
switch (method.name) {
|
||||
case "generateThresholds" -> code.add(new VarInsnNode(Opcodes.ILOAD, 2));
|
||||
case "preprocessCaveNoiseCol" -> code.add(new VarInsnNode(Opcodes.ILOAD, 3));
|
||||
default -> {
|
||||
code.add(new VarInsnNode(Opcodes.ALOAD, 0));
|
||||
code.add(new FieldInsnNode(Opcodes.GETFIELD, owner.name, "bottomY", "I"));
|
||||
}
|
||||
}
|
||||
code.add(new InsnNode(Opcodes.ISUB));
|
||||
return code;
|
||||
}
|
||||
|
||||
public static void apply(ClassNode owner) {
|
||||
int reads = 0;
|
||||
int writes = 0;
|
||||
for (var method : owner.methods) {
|
||||
if (!METHODS.contains(method.name)) continue;
|
||||
method.desc = method.desc.replace("Ljava/util/Map;", "[F");
|
||||
method.signature = null;
|
||||
method.localVariables = null;
|
||||
method.maxStack += 2;
|
||||
for (var instruction : method.instructions.toArray()) {
|
||||
if (instruction instanceof FrameNode stack) {
|
||||
frame(stack.local);
|
||||
frame(stack.stack);
|
||||
} else if (instruction instanceof TypeInsnNode type
|
||||
&& type.desc.equals("java/util/HashMap")
|
||||
&& type.getOpcode() == Opcodes.NEW) {
|
||||
method.instructions.insertBefore(type, length());
|
||||
method.instructions.remove(type.getNext());
|
||||
method.instructions.remove(type);
|
||||
} else if (instruction instanceof TypeInsnNode type
|
||||
&& type.desc.equals("java/lang/Float")
|
||||
&& type.getOpcode() == Opcodes.CHECKCAST) {
|
||||
method.instructions.remove(type);
|
||||
} else if (instruction instanceof MethodInsnNode call) {
|
||||
if (call.owner.equals(owner.name) && METHODS.contains(call.name)) {
|
||||
call.desc = call.desc.replace("Ljava/util/Map;", "[F");
|
||||
} else if (call.owner.equals("java/lang/Integer")
|
||||
&& call.name.equals("valueOf")) {
|
||||
method.instructions.insertBefore(call, offset(owner, method));
|
||||
method.instructions.remove(call);
|
||||
} else if (call.owner.equals("java/util/Map") && call.name.equals("get")) {
|
||||
method.instructions.set(call, new InsnNode(Opcodes.FALOAD));
|
||||
reads++;
|
||||
} else if (call.owner.equals("java/util/Map") && call.name.equals("put")) {
|
||||
method.instructions.remove(call.getNext());
|
||||
method.instructions.set(call, new InsnNode(Opcodes.FASTORE));
|
||||
writes++;
|
||||
} else if (call.owner.equals("java/lang/Float")
|
||||
|| call.owner.equals("java/util/HashMap")
|
||||
&& call.name.equals("<init>")) {
|
||||
method.instructions.remove(call);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reads != 2 || writes != 1)
|
||||
throw new IllegalStateException("Cave threshold shape changed");
|
||||
}
|
||||
}
|
||||
152
server/src/main/java/dev/quorate/tune/ChunkQueue.java
Normal file
152
server/src/main/java/dev/quorate/tune/ChunkQueue.java
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import com.seibel.distanthorizons.core.api.internal.chunkUpdating.ChunkUpdateData;
|
||||
import com.seibel.distanthorizons.core.pos.DhChunkPos;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class ChunkQueue {
|
||||
private static final class Entry {
|
||||
final DhChunkPos pos;
|
||||
final ChunkUpdateData value;
|
||||
double distance;
|
||||
int near;
|
||||
int far;
|
||||
|
||||
Entry(DhChunkPos pos, ChunkUpdateData value, DhChunkPos center) {
|
||||
this.pos = pos;
|
||||
this.value = Objects.requireNonNull(value);
|
||||
distance = pos.squaredDistance(center);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Heap {
|
||||
private final boolean reverse;
|
||||
private Entry[] entries = new Entry[500];
|
||||
private int size;
|
||||
|
||||
Heap(boolean reverse) {
|
||||
this.reverse = reverse;
|
||||
}
|
||||
|
||||
int compare(Entry left, Entry right) {
|
||||
return reverse
|
||||
? Double.compare(right.distance, left.distance)
|
||||
: Double.compare(left.distance, right.distance);
|
||||
}
|
||||
|
||||
void put(int index, Entry entry) {
|
||||
entries[index] = entry;
|
||||
if (reverse) entry.far = index;
|
||||
else entry.near = index;
|
||||
}
|
||||
|
||||
void up(int index, Entry entry) {
|
||||
while (index != 0) {
|
||||
int parent = (index - 1) >>> 1;
|
||||
Entry previous = entries[parent];
|
||||
if (compare(entry, previous) >= 0) break;
|
||||
put(index, previous);
|
||||
index = parent;
|
||||
}
|
||||
put(index, entry);
|
||||
}
|
||||
|
||||
void down(int index, Entry entry) {
|
||||
int half = size >>> 1;
|
||||
while (index < half) {
|
||||
int child = (index << 1) + 1;
|
||||
int sibling = child + 1;
|
||||
if (sibling < size && compare(entries[child], entries[sibling]) > 0)
|
||||
child = sibling;
|
||||
if (compare(entry, entries[child]) <= 0) break;
|
||||
put(index, entries[child]);
|
||||
index = child;
|
||||
}
|
||||
put(index, entry);
|
||||
}
|
||||
|
||||
void add(Entry entry) {
|
||||
if (size == entries.length) entries = Arrays.copyOf(entries, size + (size >>> 1));
|
||||
up(size++, entry);
|
||||
}
|
||||
|
||||
Entry remove(int index) {
|
||||
Entry removed = entries[index];
|
||||
int last = --size;
|
||||
Entry replacement = entries[last];
|
||||
entries[last] = null;
|
||||
if (index != last) {
|
||||
down(index, replacement);
|
||||
if (entries[index] == replacement) up(index, replacement);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
Arrays.fill(entries, 0, size, null);
|
||||
size = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private final HashMap<DhChunkPos, Entry> entries = new HashMap<>();
|
||||
private final Heap near = new Heap(false);
|
||||
private final Heap far = new Heap(true);
|
||||
private DhChunkPos center = new DhChunkPos(0, 0);
|
||||
|
||||
public synchronized void addItem(DhChunkPos pos, ChunkUpdateData value) {
|
||||
if (entries.containsKey(pos)) return;
|
||||
var entry = new Entry(pos, value, center);
|
||||
entries.put(pos, entry);
|
||||
near.add(entry);
|
||||
far.add(entry);
|
||||
}
|
||||
|
||||
public synchronized boolean contains(DhChunkPos pos) {
|
||||
return entries.containsKey(pos);
|
||||
}
|
||||
|
||||
public synchronized int getQueuedCount() {
|
||||
return entries.size();
|
||||
}
|
||||
|
||||
public synchronized boolean isEmpty() {
|
||||
return entries.isEmpty();
|
||||
}
|
||||
|
||||
public synchronized void clear() {
|
||||
entries.clear();
|
||||
near.clear();
|
||||
far.clear();
|
||||
}
|
||||
|
||||
public synchronized void setCenter(DhChunkPos value) {
|
||||
if (value.equals(center)) return;
|
||||
center = value;
|
||||
near.clear();
|
||||
far.clear();
|
||||
for (var entry : entries.values()) {
|
||||
entry.distance = entry.pos.squaredDistance(center);
|
||||
near.add(entry);
|
||||
far.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized ChunkUpdateData popClosest() {
|
||||
if (near.size == 0) return null;
|
||||
Entry entry = near.remove(0);
|
||||
far.remove(entry.far);
|
||||
entries.remove(entry.pos);
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
public synchronized ChunkUpdateData popFurthest() {
|
||||
if (far.size == 0) return null;
|
||||
Entry entry = far.remove(0);
|
||||
near.remove(entry.near);
|
||||
entries.remove(entry.pos);
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
9
server/src/main/java/dev/quorate/tune/DensityLimit.java
Normal file
9
server/src/main/java/dev/quorate/tune/DensityLimit.java
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import net.minecraft.world.level.levelgen.DensityFunction;
|
||||
|
||||
public interface DensityLimit {
|
||||
int densityIndex();
|
||||
|
||||
double densityUpper(DensityFunction.FunctionContext point);
|
||||
}
|
||||
27
server/src/main/java/dev/quorate/tune/FeatureMath.java
Normal file
27
server/src/main/java/dev/quorate/tune/FeatureMath.java
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
|
||||
public final class FeatureMath {
|
||||
private FeatureMath() {}
|
||||
|
||||
public static int limit(int range, Object config) {
|
||||
return ((GrowthView) config)
|
||||
.placeBlock()
|
||||
.getClass()
|
||||
.getName()
|
||||
.equals(
|
||||
"net.mehvahdjukaar.supplementaries.common.block.blocks.BarnaclesBlock")
|
||||
? Math.min(range, 1)
|
||||
: range;
|
||||
}
|
||||
|
||||
public static boolean allow(Object context, Object value, BlockPos pos) {
|
||||
if (!(context instanceof StoneContext)) return true;
|
||||
var shape = (ShapeView) value;
|
||||
double x = (pos.getX() - shape.src().getX()) / shape.radius().x;
|
||||
double y = (pos.getY() - shape.src().getY()) / shape.radius().y;
|
||||
double z = (pos.getZ() - shape.src().getZ()) / shape.radius().z;
|
||||
return !(x * x + y * y + z * z > 1.);
|
||||
}
|
||||
}
|
||||
7
server/src/main/java/dev/quorate/tune/GrowthView.java
Normal file
7
server/src/main/java/dev/quorate/tune/GrowthView.java
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import net.minecraft.world.level.block.MultifaceBlock;
|
||||
|
||||
public interface GrowthView {
|
||||
MultifaceBlock placeBlock();
|
||||
}
|
||||
43
server/src/main/java/dev/quorate/tune/LightScan.java
Normal file
43
server/src/main/java/dev/quorate/tune/LightScan.java
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import it.unimi.dsi.fastutil.objects.Reference2LongOpenHashMap;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.EmptyBlockGetter;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
public final class LightScan {
|
||||
// Keeping metadata above the int payload preserves negative static emission values.
|
||||
private static final long DYNAMIC = 1L << 32;
|
||||
private static final long UNKNOWN = 2L << 32;
|
||||
|
||||
private LightScan() {}
|
||||
|
||||
public static void find(ChunkAccess chunk, BiConsumer<BlockPos, BlockState> consumer) {
|
||||
var emitters = new Reference2LongOpenHashMap<BlockState>();
|
||||
emitters.defaultReturnValue(UNKNOWN);
|
||||
chunk.findBlocks(
|
||||
state -> sample(state, emitters) != 0,
|
||||
(state, pos) -> {
|
||||
long light = sample(state, emitters);
|
||||
return (light == DYNAMIC ? state.getLightEmission(chunk, pos) : (int) light)
|
||||
!= 0;
|
||||
},
|
||||
consumer);
|
||||
}
|
||||
|
||||
private static long sample(BlockState state, Reference2LongOpenHashMap<BlockState> emitters) {
|
||||
long light = emitters.getLong(state);
|
||||
if (light != UNKNOWN) return light;
|
||||
light =
|
||||
state.hasDynamicLightEmission()
|
||||
? DYNAMIC
|
||||
: Integer.toUnsignedLong(
|
||||
state.getLightEmission(EmptyBlockGetter.INSTANCE, BlockPos.ZERO));
|
||||
emitters.put(state, light);
|
||||
return light;
|
||||
}
|
||||
}
|
||||
5
server/src/main/java/dev/quorate/tune/LimitSource.java
Normal file
5
server/src/main/java/dev/quorate/tune/LimitSource.java
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
public interface LimitSource {
|
||||
DensityLimit densityLimit();
|
||||
}
|
||||
43
server/src/main/java/dev/quorate/tune/NoiseCache.java
Normal file
43
server/src/main/java/dev/quorate/tune/NoiseCache.java
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
public final class NoiseCache {
|
||||
private static final int SIZE = 16384;
|
||||
private static final ThreadLocal<NoiseCache> LOCAL = ThreadLocal.withInitial(NoiseCache::new);
|
||||
private final Object[] owners = new Object[SIZE];
|
||||
private final long[] xs = new long[SIZE];
|
||||
private final long[] ys = new long[SIZE];
|
||||
private final long[] zs = new long[SIZE];
|
||||
private final double[] values = new double[SIZE];
|
||||
|
||||
public static NoiseCache local() {
|
||||
return LOCAL.get();
|
||||
}
|
||||
|
||||
// A miss returns the complemented slot, as in Arrays.binarySearch.
|
||||
public int find(Object owner, long x, long y, long z) {
|
||||
long key =
|
||||
x
|
||||
^ Long.rotateLeft(y, 21)
|
||||
^ Long.rotateLeft(z, 42)
|
||||
^ System.identityHashCode(owner);
|
||||
key ^= key >>> 33;
|
||||
key *= 0xff51afd7ed558ccdL;
|
||||
key ^= key >>> 33;
|
||||
int slot = (int) (key ^ (key >>> 32)) & (SIZE - 1);
|
||||
return owners[slot] == owner && xs[slot] == x && ys[slot] == y && zs[slot] == z
|
||||
? slot
|
||||
: ~slot;
|
||||
}
|
||||
|
||||
public double value(int slot) {
|
||||
return values[slot];
|
||||
}
|
||||
|
||||
public void put(int slot, Object owner, long x, long y, long z, double value) {
|
||||
owners[slot] = owner;
|
||||
xs[slot] = x;
|
||||
ys[slot] = y;
|
||||
zs[slot] = z;
|
||||
values[slot] = value;
|
||||
}
|
||||
}
|
||||
33
server/src/main/java/dev/quorate/tune/NoiseCall.java
Normal file
33
server/src/main/java/dev/quorate/tune/NoiseCall.java
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
|
||||
public final class NoiseCall {
|
||||
private static final MethodHandle SAMPLE = binding();
|
||||
|
||||
private NoiseCall() {}
|
||||
|
||||
private static MethodHandle binding() {
|
||||
try {
|
||||
var type = Class.forName("com.ishland.c2me.opts.natives_math.common.Bindings");
|
||||
return MethodHandles.publicLookup()
|
||||
.findStatic(
|
||||
type,
|
||||
"c2me_natives_noise_perlin_double",
|
||||
MethodType.methodType(
|
||||
double.class,
|
||||
long.class,
|
||||
double.class,
|
||||
double.class,
|
||||
double.class));
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new ExceptionInInitializerError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
public static double sample(long data, double x, double y, double z) throws Throwable {
|
||||
return (double) SAMPLE.invokeExact(data, x, y, z);
|
||||
}
|
||||
}
|
||||
21
server/src/main/java/dev/quorate/tune/PostOffsets.java
Normal file
21
server/src/main/java/dev/quorate/tune/PostOffsets.java
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import it.unimi.dsi.fastutil.shorts.ShortArrayList;
|
||||
import it.unimi.dsi.fastutil.shorts.ShortList;
|
||||
import it.unimi.dsi.fastutil.shorts.ShortListIterator;
|
||||
|
||||
public final class PostOffsets {
|
||||
private PostOffsets() {}
|
||||
|
||||
public static ShortListIterator snapshot(ShortList offsets) {
|
||||
synchronized (offsets) {
|
||||
return new ShortArrayList(offsets).iterator();
|
||||
}
|
||||
}
|
||||
|
||||
public static void remove(ShortList offsets, ShortListIterator iterator) {
|
||||
synchronized (offsets) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
10
server/src/main/java/dev/quorate/tune/ShapeView.java
Normal file
10
server/src/main/java/dev/quorate/tune/ShapeView.java
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
public interface ShapeView {
|
||||
BlockPos src();
|
||||
|
||||
Vec3 radius();
|
||||
}
|
||||
3
server/src/main/java/dev/quorate/tune/StoneContext.java
Normal file
3
server/src/main/java/dev/quorate/tune/StoneContext.java
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
public interface StoneContext {}
|
||||
30
server/src/main/java/dev/quorate/tune/SurfaceMath.java
Normal file
30
server/src/main/java/dev/quorate/tune/SurfaceMath.java
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package dev.quorate.tune;
|
||||
|
||||
import net.minecraft.util.Mth;
|
||||
|
||||
public final class SurfaceMath {
|
||||
private SurfaceMath() {}
|
||||
|
||||
public static double value(double offset, double depth, double factor, int y) {
|
||||
double gradient = Mth.clampedMap(y, -2048., 2048., 17., -15.);
|
||||
double sum = offset + (gradient + depth);
|
||||
double product = sum == 0. ? 0. : sum * factor;
|
||||
double shaped = product > 0. ? product : product * .25;
|
||||
return Math.max(-64., Math.min(64., -.7 + 4. * shaped));
|
||||
}
|
||||
|
||||
public static int height(
|
||||
double offset, double depth, double factor, int top, int bottom, int step) {
|
||||
if (factor <= 0.)
|
||||
return value(offset, depth, factor, top) > .390625 ? top : Integer.MAX_VALUE;
|
||||
int count = (top - bottom) / step + 1;
|
||||
int low = 0;
|
||||
int high = count;
|
||||
while (low < high) {
|
||||
int mid = (low + high) >>> 1;
|
||||
if (value(offset, depth, factor, top - mid * step) > .390625) high = mid;
|
||||
else low = mid + 1;
|
||||
}
|
||||
return low == count ? Integer.MAX_VALUE : top - low * step;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue