desc) {
+ DataExchange api = DataExchange.getInstance();
+ api.getDescriptors()
+ .addAll(desc);
+ }
+
+ /**
+ * Sends the Handler.
+ *
+ * Depending on what the result of {@link DataHandler#getOriginatesOnServer()}, the Data is sent from the server
+ * to the client (if {@code true}) or the other way around.
+ *
+ * The method {@link DataHandler#serializeData(FriendlyByteBuf, boolean)} is called just before the data is sent. You should
+ * use this method to add the Data you need to the communication.
+ *
+ * @param h The Data that you want to send
+ */
+ public static void send(BaseDataHandler h) {
+ if (h.getOriginatesOnServer()) {
+ DataExchangeAPI.getInstance().server.sendToClient(h);
+ } else {
+ DataExchangeAPI.getInstance().client.sendToServer(h);
+ }
+ }
+
+ /**
+ * Registers a File for automatic client syncing.
+ *
+ * @param modID The ID of the calling Mod
+ * @param fileName The name of the File
+ */
+ public static void addAutoSyncFile(String modID, File fileName) {
+ AutoSync.addAutoSyncFileData(modID, fileName, false, SyncFileHash.NEED_TRANSFER);
+ }
+
+ /**
+ * Registers a File for automatic client syncing.
+ *
+ * The file is synced of the {@link SyncFileHash} on client and server are not equal. This method will not copy the
+ * configs content from the client to the server.
+ *
+ * @param modID The ID of the calling Mod
+ * @param uniqueID A unique Identifier for the File. (see {@link SyncFileHash#uniqueID} for
+ * Details
+ * @param fileName The name of the File
+ */
+ public static void addAutoSyncFile(String modID, String uniqueID, File fileName) {
+ AutoSync.addAutoSyncFileData(modID, uniqueID, fileName, false, SyncFileHash.NEED_TRANSFER);
+ }
+
+ /**
+ * Registers a File for automatic client syncing.
+ *
+ * The content of the file is requested for comparison. This will copy the
+ * entire file from the client to the server.
+ *
+ * You should only use this option, if you need to compare parts of the file in order to decide
+ * if the File needs to be copied. Normally using the {@link SyncFileHash}
+ * for comparison is sufficient.
+ *
+ * @param modID The ID of the calling Mod
+ * @param fileName The name of the File
+ * @param needTransfer If the predicate returns true, the file needs to get copied to the server.
+ */
+ public static void addAutoSyncFile(String modID, File fileName, AutoSync.NeedTransferPredicate needTransfer) {
+ AutoSync.addAutoSyncFileData(modID, fileName, true, needTransfer);
+ }
+
+ /**
+ * Registers a File for automatic client syncing.
+ *
+ * The content of the file is requested for comparison. This will copy the
+ * entire file from the client to the server.
+ *
+ * You should only use this option, if you need to compare parts of the file in order to decide
+ * if the File needs to be copied. Normally using the {@link SyncFileHash}
+ * for comparison is sufficient.
+ *
+ * @param modID The ID of the calling Mod
+ * @param uniqueID A unique Identifier for the File. (see {@link SyncFileHash#uniqueID} for
+ * Details
+ * @param fileName The name of the File
+ * @param needTransfer If the predicate returns true, the file needs to get copied to the server.
+ */
+ public static void addAutoSyncFile(
+ String modID,
+ String uniqueID,
+ File fileName,
+ AutoSync.NeedTransferPredicate needTransfer
+ ) {
+ AutoSync.addAutoSyncFileData(modID, uniqueID, fileName, true, needTransfer);
+ }
+
+ /**
+ * Register a function that is called whenever the client receives a file from the server and replaced toe local
+ * file with the new content.
+ *
+ * This callback is usefull if you need to reload the new content before the game is quit.
+ *
+ * @param callback A Function that receives the AutoSyncID as well as the Filename.
+ */
+ public static void addOnWriteCallback(BiConsumer callback) {
+ AutoSync.addOnWriteCallback(callback);
+ }
+
+ /**
+ * Returns the sync-folder for a given Mod.
+ *
+ * BCLib will ensure that the contents of sync-folder on the client is the same as the one on the server.
+ *
+ * @param modID ID of the Mod
+ * @return The path to the sync-folder
+ */
+ public static File getModSyncFolder(String modID) {
+ File fl = AutoSync.SYNC_FOLDER.localFolder.resolve(modID.replace(".", "-")
+ .replace(":", "-")
+ .replace("\\", "-")
+ .replace("/", "-"))
+ .normalize()
+ .toFile();
+
+ if (!fl.exists()) {
+ fl.mkdirs();
+ }
+ return fl;
+ }
+
+ static {
+ addOnWriteCallback(Config::reloadSyncedConfig);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/DataHandler.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/DataHandler.java
new file mode 100644
index 00000000..649956af
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/DataHandler.java
@@ -0,0 +1,332 @@
+package org.betterx.bclib.api.v2.dataexchange;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.handler.autosync.Chunker;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.multiplayer.ClientPacketListener;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.network.ServerGamePacketListenerImpl;
+import net.minecraft.world.entity.player.Player;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
+import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
+import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
+
+import java.util.Collection;
+import java.util.List;
+
+public abstract class DataHandler extends BaseDataHandler {
+ public abstract static class WithoutPayload extends DataHandler {
+ protected WithoutPayload(ResourceLocation identifier, boolean originatesOnServer) {
+ super(identifier, originatesOnServer);
+ }
+
+ @Override
+ protected boolean prepareData(boolean isClient) {
+ return true;
+ }
+
+ @Override
+ protected void serializeData(FriendlyByteBuf buf, boolean isClient) {
+ }
+
+ @Override
+ protected void deserializeIncomingData(FriendlyByteBuf buf, PacketSender responseSender, boolean isClient) {
+ }
+ }
+
+ protected DataHandler(ResourceLocation identifier, boolean originatesOnServer) {
+ super(identifier, originatesOnServer);
+ }
+
+ protected boolean prepareData(boolean isClient) {
+ return true;
+ }
+
+ abstract protected void serializeData(FriendlyByteBuf buf, boolean isClient);
+
+ abstract protected void deserializeIncomingData(FriendlyByteBuf buf, PacketSender responseSender, boolean isClient);
+
+ abstract protected void runOnGameThread(Minecraft client, MinecraftServer server, boolean isClient);
+
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ void receiveFromServer(
+ Minecraft client,
+ ClientPacketListener handler,
+ FriendlyByteBuf buf,
+ PacketSender responseSender
+ ) {
+ deserializeIncomingData(buf, responseSender, true);
+ final Runnable runner = () -> runOnGameThread(client, null, true);
+
+ if (isBlocking()) client.executeBlocking(runner);
+ else client.execute(runner);
+ }
+
+ @Override
+ void receiveFromClient(
+ MinecraftServer server,
+ ServerPlayer player,
+ ServerGamePacketListenerImpl handler,
+ FriendlyByteBuf buf,
+ PacketSender responseSender
+ ) {
+ super.receiveFromClient(server, player, handler, buf, responseSender);
+
+ deserializeIncomingData(buf, responseSender, false);
+ final Runnable runner = () -> runOnGameThread(null, server, false);
+
+ if (isBlocking()) server.executeBlocking(runner);
+ else server.execute(runner);
+ }
+
+ @Override
+ void sendToClient(MinecraftServer server) {
+ if (prepareData(false)) {
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ serializeData(buf, false);
+
+ _sendToClient(getIdentifier(), server, PlayerLookup.all(server), buf);
+ }
+ }
+
+ @Override
+ void sendToClient(MinecraftServer server, ServerPlayer player) {
+ if (prepareData(false)) {
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ serializeData(buf, false);
+
+ _sendToClient(getIdentifier(), server, List.of(player), buf);
+ }
+ }
+
+
+ public static void _sendToClient(
+ ResourceLocation identifier,
+ MinecraftServer server,
+ Collection players,
+ FriendlyByteBuf buf
+ ) {
+ if (buf.readableBytes() > Chunker.MAX_PACKET_SIZE) {
+ final Chunker.PacketChunkSender sender = new Chunker.PacketChunkSender(buf, identifier);
+ sender.sendChunks(players);
+ } else {
+ for (ServerPlayer player : players) {
+ ServerPlayNetworking.send(player, identifier, buf);
+ }
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ void sendToServer(Minecraft client) {
+ if (prepareData(true)) {
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ serializeData(buf, true);
+ ClientPlayNetworking.send(getIdentifier(), buf);
+ }
+ }
+
+ /**
+ * A Message that always originates on the Client
+ */
+ public abstract static class FromClient extends BaseDataHandler {
+ public abstract static class WithoutPayload extends FromClient {
+ protected WithoutPayload(ResourceLocation identifier) {
+ super(identifier);
+ }
+
+ @Override
+ protected boolean prepareDataOnClient() {
+ return true;
+ }
+
+ @Override
+ protected void serializeDataOnClient(FriendlyByteBuf buf) {
+ }
+
+ @Override
+ protected void deserializeIncomingDataOnServer(
+ FriendlyByteBuf buf,
+ Player player,
+ PacketSender responseSender
+ ) {
+ }
+ }
+
+ protected FromClient(ResourceLocation identifier) {
+ super(identifier, false);
+ }
+
+ @Environment(EnvType.CLIENT)
+ protected boolean prepareDataOnClient() {
+ return true;
+ }
+
+ @Environment(EnvType.CLIENT)
+ abstract protected void serializeDataOnClient(FriendlyByteBuf buf);
+
+ protected abstract void deserializeIncomingDataOnServer(
+ FriendlyByteBuf buf,
+ Player player,
+ PacketSender responseSender
+ );
+ protected abstract void runOnServerGameThread(MinecraftServer server, Player player);
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ void receiveFromServer(
+ Minecraft client,
+ ClientPacketListener handler,
+ FriendlyByteBuf buf,
+ PacketSender responseSender
+ ) {
+ BCLib.LOGGER.error("[Internal Error] The message '" + getIdentifier() + "' must originate from the client!");
+ }
+
+ @Override
+ void receiveFromClient(
+ MinecraftServer server,
+ ServerPlayer player,
+ ServerGamePacketListenerImpl handler,
+ FriendlyByteBuf buf,
+ PacketSender responseSender
+ ) {
+ super.receiveFromClient(server, player, handler, buf, responseSender);
+
+ deserializeIncomingDataOnServer(buf, player, responseSender);
+ final Runnable runner = () -> runOnServerGameThread(server, player);
+
+ if (isBlocking()) server.executeBlocking(runner);
+ else server.execute(runner);
+ }
+
+ @Override
+ void sendToClient(MinecraftServer server) {
+ BCLib.LOGGER.error("[Internal Error] The message '" + getIdentifier() + "' must originate from the client!");
+ }
+
+ @Override
+ void sendToClient(MinecraftServer server, ServerPlayer player) {
+ BCLib.LOGGER.error("[Internal Error] The message '" + getIdentifier() + "' must originate from the client!");
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ void sendToServer(Minecraft client) {
+ if (prepareDataOnClient()) {
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ serializeDataOnClient(buf);
+ ClientPlayNetworking.send(getIdentifier(), buf);
+ }
+ }
+ }
+
+ /**
+ * A Message that always originates on the Server
+ */
+ public abstract static class FromServer extends BaseDataHandler {
+ public abstract static class WithoutPayload extends FromServer {
+ protected WithoutPayload(ResourceLocation identifier) {
+ super(identifier);
+ }
+
+ @Override
+ protected boolean prepareDataOnServer() {
+ return true;
+ }
+
+ @Override
+ protected void serializeDataOnServer(FriendlyByteBuf buf) {
+ }
+
+ @Override
+ protected void deserializeIncomingDataOnClient(FriendlyByteBuf buf, PacketSender responseSender) {
+ }
+ }
+
+ protected FromServer(ResourceLocation identifier) {
+ super(identifier, true);
+ }
+
+ protected boolean prepareDataOnServer() {
+ return true;
+ }
+
+ abstract protected void serializeDataOnServer(FriendlyByteBuf buf);
+
+ @Environment(EnvType.CLIENT)
+ abstract protected void deserializeIncomingDataOnClient(FriendlyByteBuf buf, PacketSender responseSender);
+
+ @Environment(EnvType.CLIENT)
+ abstract protected void runOnClientGameThread(Minecraft client);
+
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ final void receiveFromServer(
+ Minecraft client,
+ ClientPacketListener handler,
+ FriendlyByteBuf buf,
+ PacketSender responseSender
+ ) {
+ deserializeIncomingDataOnClient(buf, responseSender);
+ final Runnable runner = () -> runOnClientGameThread(client);
+
+ if (isBlocking()) client.executeBlocking(runner);
+ else client.execute(runner);
+ }
+
+ @Override
+ final void receiveFromClient(
+ MinecraftServer server,
+ ServerPlayer player,
+ ServerGamePacketListenerImpl handler,
+ FriendlyByteBuf buf,
+ PacketSender responseSender
+ ) {
+ super.receiveFromClient(server, player, handler, buf, responseSender);
+ BCLib.LOGGER.error("[Internal Error] The message '" + getIdentifier() + "' must originate from the server!");
+ }
+
+ public void receiveFromMemory(FriendlyByteBuf buf) {
+ receiveFromServer(Minecraft.getInstance(), null, buf, null);
+ }
+
+ @Override
+ final void sendToClient(MinecraftServer server) {
+ if (prepareDataOnServer()) {
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ serializeDataOnServer(buf);
+
+ _sendToClient(getIdentifier(), server, PlayerLookup.all(server), buf);
+ }
+ }
+
+ @Override
+ final void sendToClient(MinecraftServer server, ServerPlayer player) {
+ if (prepareDataOnServer()) {
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ serializeDataOnServer(buf);
+
+ _sendToClient(getIdentifier(), server, List.of(player), buf);
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ final void sendToServer(Minecraft client) {
+ BCLib.LOGGER.error("[Internal Error] The message '" + getIdentifier() + "' must originate from the server!");
+ }
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/DataHandlerDescriptor.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/DataHandlerDescriptor.java
new file mode 100644
index 00000000..b572bc31
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/DataHandlerDescriptor.java
@@ -0,0 +1,61 @@
+package org.betterx.bclib.api.v2.dataexchange;
+
+import net.minecraft.resources.ResourceLocation;
+
+import java.util.Objects;
+import java.util.function.Supplier;
+import org.jetbrains.annotations.NotNull;
+
+public class DataHandlerDescriptor {
+ public DataHandlerDescriptor(@NotNull ResourceLocation identifier, @NotNull Supplier instancer) {
+ this(identifier, instancer, instancer, false, false);
+ }
+
+ public DataHandlerDescriptor(
+ @NotNull ResourceLocation identifier,
+ @NotNull Supplier instancer,
+ boolean sendOnJoin,
+ boolean sendBeforeEnter
+ ) {
+ this(identifier, instancer, instancer, sendOnJoin, sendBeforeEnter);
+ }
+
+ public DataHandlerDescriptor(
+ @NotNull ResourceLocation identifier,
+ @NotNull Supplier receiv_instancer,
+ @NotNull Supplier join_instancer,
+ boolean sendOnJoin,
+ boolean sendBeforeEnter
+ ) {
+ this.INSTANCE = receiv_instancer;
+ this.JOIN_INSTANCE = join_instancer;
+ this.IDENTIFIER = identifier;
+
+ this.sendOnJoin = sendOnJoin;
+ this.sendBeforeEnter = sendBeforeEnter;
+ }
+
+ public final boolean sendOnJoin;
+ public final boolean sendBeforeEnter;
+ @NotNull
+ public final ResourceLocation IDENTIFIER;
+ @NotNull
+ public final Supplier INSTANCE;
+ @NotNull
+ public final Supplier JOIN_INSTANCE;
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o instanceof ResourceLocation) {
+ return o.equals(IDENTIFIER);
+ }
+ if (!(o instanceof DataHandlerDescriptor that)) return false;
+ return IDENTIFIER.equals(that.IDENTIFIER);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(IDENTIFIER);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/FileHash.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/FileHash.java
new file mode 100644
index 00000000..dcbef098
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/FileHash.java
@@ -0,0 +1,160 @@
+package org.betterx.bclib.api.v2.dataexchange;
+
+import org.betterx.bclib.BCLib;
+
+import net.minecraft.network.FriendlyByteBuf;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.Objects;
+import org.jetbrains.annotations.NotNull;
+
+public class FileHash {
+ private static final int ERR_DOES_NOT_EXIST = -10;
+ private static final int ERR_IO_ERROR = -20;
+
+ /**
+ * The md5-hash of the file
+ */
+ @NotNull
+ public final byte[] md5;
+
+ /**
+ * The size (in bytes) of the input.
+ */
+ public final int size;
+
+ /**
+ * a value that is directly calculated from defined byte positions.
+ */
+ public final int value;
+
+ FileHash(byte[] md5, int size, int value) {
+ Objects.nonNull(md5);
+
+ this.md5 = md5;
+ this.size = size;
+ this.value = value;
+ }
+
+ static FileHash createForEmpty(int errCode) {
+ return new FileHash(new byte[0], 0, errCode);
+ }
+
+ public boolean noFile() {
+ return md5.length == 0;
+ }
+
+ /**
+ * Serializes the Object to a buffer
+ *
+ * @param buf The buffer to write to
+ */
+ public void serialize(FriendlyByteBuf buf) {
+ buf.writeInt(size);
+ buf.writeInt(value);
+ buf.writeByteArray(md5);
+ }
+
+ /**
+ * Deserialize a Buffer to a new {@link SyncFileHash}-Object
+ *
+ * @param buf Thea buffer to read from
+ * @return The received String
+ */
+ public static FileHash deserialize(FriendlyByteBuf buf) {
+ final int size = buf.readInt();
+ final int value = buf.readInt();
+ final byte[] md5 = buf.readByteArray();
+
+ return new FileHash(md5, size, value);
+ }
+
+ /**
+ * Convert the md5-hash to a human readable string
+ *
+ * @return The converted String
+ */
+ public String getMd5String() {
+ return toHexString(md5);
+ }
+
+ /**
+ * Converts a byte-array to a hex-string representation
+ *
+ * @param bytes The source array
+ * @return The resulting string, or an empty String if the input was {@code null}
+ */
+ public static String toHexString(byte[] bytes) {
+ if (bytes == null) return "";
+
+ StringBuilder sb = new StringBuilder();
+ for (byte b : bytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Create a new {@link FileHash}.
+ *
+ * @param file The input file
+ * @return A new Instance. You can compare instances using {@link #equals(Object)} to determine if two files are
+ * identical. Will return {@code null} when an error occurs or the File does not exist
+ */
+ public static FileHash create(File file) {
+ if (!file.exists()) return createForEmpty(ERR_DOES_NOT_EXIST);
+ final Path path = file.toPath();
+
+ int size = 0;
+ byte[] md5 = new byte[0];
+ int value = 0;
+
+ try {
+ byte[] data = Files.readAllBytes(path);
+
+ size = data.length;
+
+ value = size > 0 ? (data[size / 3] | (data[size / 2] << 8) | (data[size / 5] << 16)) : -1;
+ if (size > 20) value |= data[20] << 24;
+
+ MessageDigest md = MessageDigest.getInstance("MD5");
+ md.update(data);
+ md5 = md.digest();
+
+ return new FileHash(md5, size, value);
+ } catch (IOException e) {
+ BCLib.LOGGER.error("Failed to read file: " + file);
+ return null;
+ } catch (NoSuchAlgorithmException e) {
+ BCLib.LOGGER.error("Unable to build hash for file: " + file);
+ }
+
+ return createForEmpty(ERR_IO_ERROR);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof FileHash)) return false;
+ FileHash fileHash = (FileHash) o;
+ return size == fileHash.size && value == fileHash.value && Arrays.equals(md5, fileHash.md5);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Objects.hash(size, value);
+ result = 31 * result + Arrays.hashCode(md5);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("%08x", size) + "-" + String.format("%08x", value) + "-" + getMd5String();
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/SyncFileHash.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/SyncFileHash.java
new file mode 100644
index 00000000..da416911
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/SyncFileHash.java
@@ -0,0 +1,113 @@
+package org.betterx.bclib.api.v2.dataexchange;
+
+import org.betterx.bclib.api.v2.dataexchange.handler.autosync.AutoSync;
+import org.betterx.bclib.api.v2.dataexchange.handler.autosync.AutoSyncID;
+
+import net.minecraft.network.FriendlyByteBuf;
+
+import java.io.File;
+import java.util.Objects;
+
+/**
+ * Calculates a hash based on the contents of a File.
+ *
+ * A File-Hash contains the md5-sum of the File, as well as its size and byte-values from defined positions
+ *
+ * You can compare instances using {@link #equals(Object)} to determine if two files are
+ * identical.
+ */
+public class SyncFileHash extends AutoSyncID {
+ public final FileHash hash;
+
+ SyncFileHash(String modID, File file, byte[] md5, int size, int value) {
+ this(modID, file.getName(), md5, size, value);
+ }
+
+ SyncFileHash(String modID, String uniqueID, byte[] md5, int size, int value) {
+ this(modID, uniqueID, new FileHash(md5, size, value));
+ }
+
+ SyncFileHash(String modID, File file, FileHash hash) {
+ this(modID, file.getName(), hash);
+ }
+
+ SyncFileHash(String modID, String uniqueID, FileHash hash) {
+ super(modID, uniqueID);
+ this.hash = hash;
+ }
+
+
+ final static AutoSync.NeedTransferPredicate NEED_TRANSFER = (clientHash, serverHash, content) -> !clientHash.equals(
+ serverHash);
+
+ @Override
+ public String toString() {
+ return super.toString() + ": " + hash.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof SyncFileHash)) return false;
+ if (!super.equals(o)) return false;
+ SyncFileHash that = (SyncFileHash) o;
+ return hash.equals(that.hash);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(super.hashCode(), hash);
+ }
+
+ /**
+ * Serializes the Object to a buffer
+ *
+ * @param buf The buffer to write to
+ */
+ public void serialize(FriendlyByteBuf buf) {
+ hash.serialize(buf);
+ DataHandler.writeString(buf, modID);
+ DataHandler.writeString(buf, uniqueID);
+ }
+
+ /**
+ * Deserialize a Buffer to a new {@link SyncFileHash}-Object
+ *
+ * @param buf Thea buffer to read from
+ * @return The received String
+ */
+ public static SyncFileHash deserialize(FriendlyByteBuf buf) {
+ final FileHash hash = FileHash.deserialize(buf);
+ final String modID = DataHandler.readString(buf);
+ final String uniqueID = DataHandler.readString(buf);
+
+ return new SyncFileHash(modID, uniqueID, hash);
+ }
+
+ /**
+ * Create a new {@link SyncFileHash}.
+ *
+ * Will call {@link #create(String, File, String)} using the name of the File as {@code uniqueID}.
+ *
+ * @param modID ID of the calling Mod
+ * @param file The input file
+ * @return A new Instance. You can compare instances using {@link #equals(Object)} to determine if two files are
+ * identical. Will return {@code null} when an error occurs or the File does not exist
+ */
+ public static SyncFileHash create(String modID, File file) {
+ return create(modID, file, file.getName());
+ }
+
+ /**
+ * Create a new {@link SyncFileHash}.
+ *
+ * @param modID ID of the calling Mod
+ * @param file The input file
+ * @param uniqueID The unique ID that is used for this File (see {@link SyncFileHash#uniqueID} for Details.
+ * @return A new Instance. You can compare instances using {@link #equals(Object)} to determine if two files are
+ * identical. Will return {@code null} when an error occurs or the File does not exist
+ */
+ public static SyncFileHash create(String modID, File file, String uniqueID) {
+ return new SyncFileHash(modID, uniqueID, FileHash.create(file));
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/DataExchange.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/DataExchange.java
new file mode 100644
index 00000000..255b994b
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/DataExchange.java
@@ -0,0 +1,111 @@
+package org.betterx.bclib.api.v2.dataexchange.handler;
+
+import org.betterx.bclib.api.v2.dataexchange.*;
+
+import net.minecraft.resources.ResourceLocation;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
+import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
+
+import java.util.HashSet;
+import java.util.Set;
+
+abstract public class DataExchange {
+
+
+ private static DataExchangeAPI instance;
+
+ protected static DataExchangeAPI getInstance() {
+ if (instance == null) {
+ instance = new DataExchangeAPI();
+ }
+ return instance;
+ }
+
+ protected ConnectorServerside server;
+ protected ConnectorClientside client;
+ protected final Set descriptors;
+
+
+ private final boolean didLoadSyncFolder = false;
+
+ abstract protected ConnectorClientside clientSupplier(DataExchange api);
+
+ abstract protected ConnectorServerside serverSupplier(DataExchange api);
+
+ protected DataExchange() {
+ descriptors = new HashSet<>();
+ }
+
+ public Set getDescriptors() {
+ return descriptors;
+ }
+
+ public static DataHandlerDescriptor getDescriptor(ResourceLocation identifier) {
+ return getInstance().descriptors.stream().filter(d -> d.equals(identifier)).findFirst().orElse(null);
+ }
+
+ @Environment(EnvType.CLIENT)
+ protected void initClientside() {
+ if (client != null) return;
+ client = clientSupplier(this);
+
+ ClientPlayConnectionEvents.INIT.register(client::onPlayInit);
+ ClientPlayConnectionEvents.JOIN.register(client::onPlayReady);
+ ClientPlayConnectionEvents.DISCONNECT.register(client::onPlayDisconnect);
+ }
+
+ protected void initServerSide() {
+ if (server != null) return;
+ server = serverSupplier(this);
+
+ ServerPlayConnectionEvents.INIT.register(server::onPlayInit);
+ ServerPlayConnectionEvents.JOIN.register(server::onPlayReady);
+ ServerPlayConnectionEvents.DISCONNECT.register(server::onPlayDisconnect);
+ }
+
+ /**
+ * Initializes all datastructures that need to exist in the client component.
+ *
+ * This is automatically called by BCLib. You can register {@link DataHandler}-Objects before this Method is called
+ */
+ @Environment(EnvType.CLIENT)
+ public static void prepareClientside() {
+ DataExchange api = DataExchange.getInstance();
+ api.initClientside();
+
+ }
+
+ /**
+ * Initializes all datastructures that need to exist in the server component.
+ *
+ * This is automatically called by BCLib. You can register {@link DataHandler}-Objects before this Method is called
+ */
+ public static void prepareServerside() {
+ DataExchange api = DataExchange.getInstance();
+ api.initServerSide();
+ }
+
+
+ /**
+ * Automatically called before the player enters the world.
+ *
+ * This is automatically called by BCLib. It will send all {@link DataHandler}-Objects that have {@link DataHandlerDescriptor#sendBeforeEnter} set to*
+ * {@code true},
+ */
+ @Environment(EnvType.CLIENT)
+ public static void sendOnEnter() {
+ getInstance().descriptors.forEach((desc) -> {
+ if (desc.sendBeforeEnter) {
+ BaseDataHandler h = desc.JOIN_INSTANCE.get();
+ if (!h.getOriginatesOnServer()) {
+ getInstance().client.sendToServer(h);
+ }
+ }
+ });
+ }
+
+
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoFileSyncEntry.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoFileSyncEntry.java
new file mode 100644
index 00000000..17a3920f
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoFileSyncEntry.java
@@ -0,0 +1,262 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.SyncFileHash;
+import org.betterx.bclib.util.Pair;
+import org.betterx.bclib.util.Triple;
+import org.betterx.worlds.together.util.ModUtil;
+import org.betterx.worlds.together.util.ModUtil.ModInfo;
+import org.betterx.worlds.together.util.PathUtil;
+
+import net.minecraft.network.FriendlyByteBuf;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+class AutoFileSyncEntry extends AutoSyncID {
+ static class ForDirectFileRequest extends AutoFileSyncEntry {
+ final File relFile;
+
+ ForDirectFileRequest(String syncID, File relFile, File absFile) {
+ super(AutoSyncID.ForDirectFileRequest.MOD_ID, syncID, absFile, false, (a, b, c) -> false);
+ this.relFile = relFile;
+ }
+
+ @Override
+ public int serializeContent(FriendlyByteBuf buf) {
+ int res = super.serializeContent(buf);
+ DataHandler.writeString(buf, relFile.toString());
+
+ return res;
+ }
+
+ static AutoFileSyncEntry.ForDirectFileRequest finishDeserializeContent(String syncID, FriendlyByteBuf buf) {
+ final String relFile = DataHandler.readString(buf);
+ SyncFolderDescriptor desc = AutoSync.getSyncFolderDescriptor(syncID);
+ if (desc != null) {
+ //ensures that the file is not above the base-folder
+ if (desc.acceptChildElements(desc.mapAbsolute(relFile))) {
+ return new AutoFileSyncEntry.ForDirectFileRequest(
+ syncID,
+ new File(relFile),
+ desc.localFolder.resolve(relFile)
+ .normalize()
+ .toFile()
+ );
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public String toString() {
+ return uniqueID + " - " + relFile;
+ }
+ }
+
+ static class ForModFileRequest extends AutoFileSyncEntry {
+ public static File getLocalPathForID(String modID, boolean matchLocalVersion) {
+ ModInfo mi = ModUtil.getModInfo(modID, matchLocalVersion);
+ if (mi != null) {
+ return mi.jarPath.toFile();
+ }
+ return null;
+ }
+
+ public final String version;
+
+ ForModFileRequest(String modID, boolean matchLocalVersion, String version) {
+ super(
+ modID,
+ AutoSyncID.ForModFileRequest.UNIQUE_ID,
+ getLocalPathForID(modID, matchLocalVersion),
+ false,
+ (a, b, c) -> false
+ );
+ if (this.fileName == null && matchLocalVersion) {
+ BCLib.LOGGER.error("Unknown mod '" + modID + "'.");
+ }
+ if (version == null)
+ this.version = ModUtil.getModVersion(modID);
+ else
+ this.version = version;
+ }
+
+ @Override
+ public int serializeContent(FriendlyByteBuf buf) {
+ final int res = super.serializeContent(buf);
+ buf.writeInt(ModUtil.convertModVersion(version));
+ return res;
+ }
+
+ static AutoFileSyncEntry.ForModFileRequest finishDeserializeContent(String modID, FriendlyByteBuf buf) {
+ final String version = ModUtil.convertModVersion(buf.readInt());
+ return new AutoFileSyncEntry.ForModFileRequest(modID, false, version);
+ }
+
+ @Override
+ public String toString() {
+ return "Mod " + modID + " (v" + version + ")";
+ }
+ }
+
+ public final AutoSync.NeedTransferPredicate needTransfer;
+ public final File fileName;
+ public final boolean requestContent;
+ private SyncFileHash hash;
+
+ AutoFileSyncEntry(
+ String modID,
+ File fileName,
+ boolean requestContent,
+ AutoSync.NeedTransferPredicate needTransfer
+ ) {
+ this(modID, fileName.getName(), fileName, requestContent, needTransfer);
+ }
+
+ AutoFileSyncEntry(
+ String modID,
+ String uniqueID,
+ File fileName,
+ boolean requestContent,
+ AutoSync.NeedTransferPredicate needTransfer
+ ) {
+ super(modID, uniqueID);
+ this.needTransfer = needTransfer;
+ this.fileName = fileName;
+ this.requestContent = requestContent;
+ }
+
+
+ public SyncFileHash getFileHash() {
+ if (hash == null) {
+ hash = SyncFileHash.create(modID, fileName, uniqueID);
+ }
+ return hash;
+ }
+
+ public byte[] getContent() {
+ if (!fileName.exists()) return new byte[0];
+ final Path path = fileName.toPath();
+
+ try {
+ return Files.readAllBytes(path);
+ } catch (IOException e) {
+
+ }
+ return new byte[0];
+ }
+
+ public int serializeContent(FriendlyByteBuf buf) {
+ DataHandler.writeString(buf, modID);
+ DataHandler.writeString(buf, uniqueID);
+ return serializeFileContent(buf);
+ }
+
+ public static Triple deserializeContent(FriendlyByteBuf buf) {
+ final String modID = DataHandler.readString(buf);
+ final String uniqueID = DataHandler.readString(buf);
+ byte[] data = deserializeFileContent(buf);
+
+ AutoFileSyncEntry entry;
+ if (AutoSyncID.ForDirectFileRequest.MOD_ID.equals(modID)) {
+ entry = AutoFileSyncEntry.ForDirectFileRequest.finishDeserializeContent(uniqueID, buf);
+ } else if (AutoSyncID.ForModFileRequest.UNIQUE_ID.equals(uniqueID)) {
+ entry = AutoFileSyncEntry.ForModFileRequest.finishDeserializeContent(modID, buf);
+ } else {
+ entry = AutoFileSyncEntry.findMatching(modID, uniqueID);
+ }
+ return new Triple<>(entry, data, new AutoSyncID(modID, uniqueID));
+ }
+
+
+ public void serialize(FriendlyByteBuf buf) {
+ getFileHash().serialize(buf);
+ buf.writeBoolean(requestContent);
+
+ if (requestContent) {
+ serializeFileContent(buf);
+ }
+ }
+
+ public static AutoSync.AutoSyncTriple deserializeAndMatch(FriendlyByteBuf buf) {
+ Pair e = deserialize(buf);
+ AutoFileSyncEntry match = findMatching(e.first);
+ return new AutoSync.AutoSyncTriple(e.first, e.second, match);
+ }
+
+ public static Pair deserialize(FriendlyByteBuf buf) {
+ SyncFileHash hash = SyncFileHash.deserialize(buf);
+ boolean withContent = buf.readBoolean();
+ byte[] data = null;
+ if (withContent) {
+ data = deserializeFileContent(buf);
+ }
+
+ return new Pair(hash, data);
+ }
+
+ private int serializeFileContent(FriendlyByteBuf buf) {
+ if (!org.betterx.worlds.together.util.PathUtil.isChildOf(
+ org.betterx.worlds.together.util.PathUtil.GAME_FOLDER,
+ fileName.toPath()
+ )) {
+ BCLib.LOGGER.error(fileName + " is not within game folder " + PathUtil.GAME_FOLDER + ". Pretending it does not exist.");
+ buf.writeInt(0);
+ return 0;
+ }
+
+ byte[] content = getContent();
+ buf.writeInt(content.length);
+ buf.writeByteArray(content);
+ return content.length;
+ }
+
+ private static byte[] deserializeFileContent(FriendlyByteBuf buf) {
+ byte[] data;
+ int size = buf.readInt();
+ data = buf.readByteArray(size);
+ return data;
+ }
+
+
+ public static AutoFileSyncEntry findMatching(SyncFileHash hash) {
+ return findMatching(hash.modID, hash.uniqueID);
+ }
+
+ public static AutoFileSyncEntry findMatching(AutoSyncID aid) {
+ if (aid instanceof AutoSyncID.ForDirectFileRequest) {
+ AutoSyncID.ForDirectFileRequest freq = (AutoSyncID.ForDirectFileRequest) aid;
+ SyncFolderDescriptor desc = AutoSync.getSyncFolderDescriptor(freq.uniqueID);
+ if (desc != null) {
+ SyncFolderDescriptor.SubFile subFile = desc.getLocalSubFile(freq.relFile.toString());
+ if (subFile != null) {
+ final File absPath = desc.localFolder.resolve(subFile.relPath)
+ .normalize()
+ .toFile();
+ return new AutoFileSyncEntry.ForDirectFileRequest(
+ freq.uniqueID,
+ new File(subFile.relPath),
+ absPath
+ );
+ }
+ }
+ return null;
+ } else if (aid instanceof AutoSyncID.ForModFileRequest) {
+ AutoSyncID.ForModFileRequest mreq = (AutoSyncID.ForModFileRequest) aid;
+ return new AutoFileSyncEntry.ForModFileRequest(mreq.modID, true, null);
+ }
+ return findMatching(aid.modID, aid.uniqueID);
+ }
+
+ public static AutoFileSyncEntry findMatching(String modID, String uniqueID) {
+ return AutoSync.getAutoSyncFiles()
+ .stream()
+ .filter(asf -> asf.modID.equals(modID) && asf.uniqueID.equals(uniqueID))
+ .findFirst()
+ .orElse(null);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoSync.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoSync.java
new file mode 100644
index 00000000..64a3811d
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoSync.java
@@ -0,0 +1,207 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataExchangeAPI;
+import org.betterx.bclib.api.v2.dataexchange.SyncFileHash;
+import org.betterx.bclib.config.Configs;
+import org.betterx.bclib.config.ServerConfig;
+import org.betterx.worlds.together.util.PathUtil;
+
+import net.fabricmc.loader.api.FabricLoader;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.BiConsumer;
+
+public class AutoSync {
+ public static final String SYNC_CATEGORY = "auto_sync";
+ public final static SyncFolderDescriptor SYNC_FOLDER = new SyncFolderDescriptor(
+ "BCLIB-SYNC",
+ FabricLoader.getInstance()
+ .getGameDir()
+ .resolve("bclib-sync")
+ .normalize()
+ .toAbsolutePath(),
+ true
+ );
+
+ @FunctionalInterface
+ public interface NeedTransferPredicate {
+ boolean test(SyncFileHash clientHash, SyncFileHash serverHash, FileContentWrapper content);
+ }
+
+ final static class AutoSyncTriple {
+ public final SyncFileHash serverHash;
+ public final byte[] serverContent;
+ public final AutoFileSyncEntry localMatch;
+
+ public AutoSyncTriple(SyncFileHash serverHash, byte[] serverContent, AutoFileSyncEntry localMatch) {
+ this.serverHash = serverHash;
+ this.serverContent = serverContent;
+ this.localMatch = localMatch;
+ }
+
+ @Override
+ public String toString() {
+ return serverHash.modID + "." + serverHash.uniqueID;
+ }
+ }
+
+
+ // ##### File Syncing
+ protected final static List> onWriteCallbacks = new ArrayList<>(2);
+
+ /**
+ * Register a function that is called whenever the client receives a file from the server and replaced toe local
+ * file with the new content.
+ *
+ * This callback is usefull if you need to reload the new content before the game is quit.
+ *
+ * @param callback A Function that receives the AutoSyncID as well as the Filename.
+ */
+ public static void addOnWriteCallback(BiConsumer callback) {
+ onWriteCallbacks.add(callback);
+ }
+
+ private static final List autoSyncFiles = new ArrayList<>(4);
+
+ public static List getAutoSyncFiles() {
+ return autoSyncFiles;
+ }
+
+ /**
+ * Registers a File for automatic client syncing.
+ *
+ * @param modID The ID of the calling Mod
+ * @param needTransfer If the predicate returns true, the file needs to get copied to the server.
+ * @param fileName The name of the File
+ * @param requestContent When {@code true} the content of the file is requested for comparison. This will copy the
+ * entire file from the client to the server.
+ *
+ * You should only use this option, if you need to compare parts of the file in order to decide
+ * If the File needs to be copied. Normally using the {@link SyncFileHash}
+ * for comparison is sufficient.
+ */
+ public static void addAutoSyncFileData(
+ String modID,
+ File fileName,
+ boolean requestContent,
+ NeedTransferPredicate needTransfer
+ ) {
+ if (!PathUtil.isChildOf(PathUtil.GAME_FOLDER, fileName.toPath())) {
+ BCLib.LOGGER.error(fileName + " is outside of Game Folder " + PathUtil.GAME_FOLDER);
+ } else {
+ autoSyncFiles.add(new AutoFileSyncEntry(modID, fileName, requestContent, needTransfer));
+ }
+ }
+
+ /**
+ * Registers a File for automatic client syncing.
+ *
+ * @param modID The ID of the calling Mod
+ * @param uniqueID A unique Identifier for the File. (see {@link SyncFileHash#uniqueID} for
+ * Details
+ * @param needTransfer If the predicate returns true, the file needs to get copied to the server.
+ * @param fileName The name of the File
+ * @param requestContent When {@code true} the content of the file is requested for comparison. This will copy the
+ * entire file from the client to the server.
+ *
+ * You should only use this option, if you need to compare parts of the file in order to decide
+ * If the File needs to be copied. Normally using the {@link SyncFileHash}
+ * for comparison is sufficient.
+ */
+ public static void addAutoSyncFileData(
+ String modID,
+ String uniqueID,
+ File fileName,
+ boolean requestContent,
+ NeedTransferPredicate needTransfer
+ ) {
+ if (!PathUtil.isChildOf(PathUtil.GAME_FOLDER, fileName.toPath())) {
+ BCLib.LOGGER.error(fileName + " is outside of Game Folder " + PathUtil.GAME_FOLDER);
+ } else {
+ autoSyncFiles.add(new AutoFileSyncEntry(modID, uniqueID, fileName, requestContent, needTransfer));
+ }
+ }
+
+ /**
+ * Called when {@code SendFiles} received a File on the Client and wrote it to the FileSystem.
+ *
+ * This is the place where reload Code should go.
+ *
+ * @param aid The ID of the received File
+ * @param file The location of the FIle on the client
+ */
+ static void didReceiveFile(AutoSyncID aid, File file) {
+ onWriteCallbacks.forEach(fkt -> fkt.accept(aid, file));
+ }
+
+
+ // ##### Folder Syncing
+ static final List syncFolderDescriptions = Arrays.asList(SYNC_FOLDER);
+
+ private List syncFolderContent;
+
+ protected List getSyncFolderContent() {
+ if (syncFolderContent == null) {
+ return new ArrayList<>(0);
+ }
+ return syncFolderContent;
+ }
+
+ private static boolean didRegisterAdditionalMods = false;
+
+ //we call this from HelloClient on the Server to prepare transfer
+ protected static void loadSyncFolder() {
+ if (Configs.SERVER_CONFIG.isOfferingFiles()) {
+ syncFolderDescriptions.forEach(desc -> desc.loadCache());
+ }
+
+ if (!didRegisterAdditionalMods && Configs.SERVER_CONFIG.isOfferingMods()) {
+ didRegisterAdditionalMods = true;
+ List modIDs = Configs.SERVER_CONFIG.get(ServerConfig.ADDITIONAL_MODS);
+ if (modIDs != null) {
+ modIDs.stream().forEach(modID -> DataExchangeAPI.registerModDependency(modID));
+ }
+ }
+
+ }
+
+ protected static SyncFolderDescriptor getSyncFolderDescriptor(String folderID) {
+ return syncFolderDescriptions.stream()
+ .filter(d -> d.equals(folderID))
+ .findFirst()
+ .orElse(null);
+ }
+
+ protected static Path localBasePathForFolderID(String folderID) {
+ final SyncFolderDescriptor desc = getSyncFolderDescriptor(folderID);
+ if (desc != null) {
+ return desc.localFolder;
+ } else {
+ BCLib.LOGGER.warning("Unknown Sync-Folder ID '" + folderID + "'");
+ return null;
+ }
+ }
+
+ public static void registerSyncFolder(String folderID, Path localBaseFolder, boolean removeAdditionalFiles) {
+ localBaseFolder = localBaseFolder.normalize();
+ if (PathUtil.isChildOf(PathUtil.GAME_FOLDER, localBaseFolder)) {
+ final SyncFolderDescriptor desc = new SyncFolderDescriptor(
+ folderID,
+ localBaseFolder,
+ removeAdditionalFiles
+ );
+ if (syncFolderDescriptions.contains(desc)) {
+ BCLib.LOGGER.warning("Tried to override Folder Sync '" + folderID + "' again.");
+ } else {
+ syncFolderDescriptions.add(desc);
+ }
+ } else {
+ BCLib.LOGGER.error(localBaseFolder + " (from " + folderID + ") is outside the game directory " + PathUtil.GAME_FOLDER + ". Sync is not allowed.");
+ }
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoSyncID.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoSyncID.java
new file mode 100644
index 00000000..b8ace9bc
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoSyncID.java
@@ -0,0 +1,144 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.config.Config;
+import org.betterx.worlds.together.util.ModUtil;
+
+import net.minecraft.network.FriendlyByteBuf;
+
+import java.io.File;
+import java.util.Objects;
+import org.jetbrains.annotations.NotNull;
+
+public class AutoSyncID {
+ static class WithContentOverride extends AutoSyncID {
+ final FileContentWrapper contentWrapper;
+ final File localFile;
+
+ WithContentOverride(String modID, String uniqueID, FileContentWrapper contentWrapper, File localFile) {
+ super(modID, uniqueID);
+ this.contentWrapper = contentWrapper;
+ this.localFile = localFile;
+ }
+
+ @Override
+ public String toString() {
+ return super.toString() + " (Content override)";
+ }
+ }
+
+ static class ForDirectFileRequest extends AutoSyncID {
+ public final static String MOD_ID = "bclib::FILE";
+ final File relFile;
+
+ ForDirectFileRequest(String syncID, File relFile) {
+ super(ForDirectFileRequest.MOD_ID, syncID);
+ this.relFile = relFile;
+ }
+
+ @Override
+ void serializeData(FriendlyByteBuf buf) {
+ super.serializeData(buf);
+ DataHandler.writeString(buf, relFile.toString());
+ }
+
+ static ForDirectFileRequest finishDeserialize(String modID, String uniqueID, FriendlyByteBuf buf) {
+ final File fl = new File(DataHandler.readString(buf));
+ return new ForDirectFileRequest(uniqueID, fl);
+ }
+
+ @Override
+ public String toString() {
+ return super.uniqueID + " (" + this.relFile + ")";
+ }
+ }
+
+ static class ForModFileRequest extends AutoSyncID {
+ public final static String UNIQUE_ID = "bclib::MOD";
+ private final String version;
+
+ ForModFileRequest(String modID, String version) {
+ super(modID, ForModFileRequest.UNIQUE_ID);
+ this.version = version;
+ }
+
+ @Override
+ void serializeData(FriendlyByteBuf buf) {
+ super.serializeData(buf);
+ buf.writeInt(ModUtil.convertModVersion(version));
+ }
+
+ static ForModFileRequest finishDeserialize(String modID, String uniqueID, FriendlyByteBuf buf) {
+ final String version = ModUtil.convertModVersion(buf.readInt());
+ return new ForModFileRequest(modID, version);
+ }
+
+ @Override
+ public String toString() {
+ return super.modID + " (v" + this.version + ")";
+ }
+ }
+
+ /**
+ * A Unique ID for the referenced File.
+ *
+ * Files with the same {@link #modID} need to have a unique IDs. Normally the filename from FileHash(String, File, byte[], int, int)
+ * is used to generated that ID, but you can directly specify one using FileHash(String, String, byte[], int, int).
+ */
+ @NotNull
+ public final String uniqueID;
+
+ /**
+ * The ID of the Mod that is registering the File
+ */
+ @NotNull
+ public final String modID;
+
+ public AutoSyncID(String modID, String uniqueID) {
+ Objects.nonNull(modID);
+ Objects.nonNull(uniqueID);
+
+ this.modID = modID;
+ this.uniqueID = uniqueID;
+ }
+
+ @Override
+ public String toString() {
+ return modID + "." + uniqueID;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof AutoSyncID)) return false;
+ AutoSyncID that = (AutoSyncID) o;
+ return uniqueID.equals(that.uniqueID) && modID.equals(that.modID);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uniqueID, modID);
+ }
+
+ void serializeData(FriendlyByteBuf buf) {
+ DataHandler.writeString(buf, modID);
+ DataHandler.writeString(buf, uniqueID);
+ }
+
+ static AutoSyncID deserializeData(FriendlyByteBuf buf) {
+ String modID = DataHandler.readString(buf);
+ String uID = DataHandler.readString(buf);
+
+ if (ForDirectFileRequest.MOD_ID.equals(modID)) {
+ return ForDirectFileRequest.finishDeserialize(modID, uID, buf);
+ } else if (ForModFileRequest.UNIQUE_ID.equals(uID)) {
+ return ForModFileRequest.finishDeserialize(modID, uID, buf);
+ } else {
+ return new AutoSyncID(modID, uID);
+ }
+ }
+
+ public boolean isConfigFile() {
+ return this.uniqueID.startsWith(Config.CONFIG_SYNC_PREFIX);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/Chunker.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/Chunker.java
new file mode 100644
index 00000000..d9eab98e
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/Chunker.java
@@ -0,0 +1,280 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.BaseDataHandler;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.DataHandlerDescriptor;
+import org.betterx.bclib.api.v2.dataexchange.handler.DataExchange;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.util.ProgressListener;
+
+import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
+
+import java.util.*;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Used to seperate large data transfers into multiple smaller messages.
+ *
+ * {@link DataHandler} will automatically convert larger messages into Chunks on the Server
+ * and assemble the original message from those chunks on the client.
+ */
+public class Chunker extends DataHandler.FromServer {
+
+ /**
+ * Responsible for assembling the original ByteBuffer created by {@link PacketChunkSender} on the
+ * receiving end. Automatically created from the header {@link Chunker}-Message (where the serialNo==-1)
+ */
+ static class PacketChunkReceiver {
+ @NotNull
+ public final UUID uuid;
+ public final int chunkCount;
+ @NotNull
+ private final FriendlyByteBuf networkedBuf;
+ @Nullable
+ private final DataHandlerDescriptor descriptor;
+
+ private static final List active = new ArrayList<>(1);
+
+ private static PacketChunkReceiver newReceiver(@NotNull UUID uuid, int chunkCount, ResourceLocation origin) {
+ DataHandlerDescriptor desc = DataExchange.getDescriptor(origin);
+ final PacketChunkReceiver r = new PacketChunkReceiver(uuid, chunkCount, desc);
+ active.add(r);
+ return r;
+ }
+
+ private static PacketChunkReceiver getOrCreate(@NotNull UUID uuid, int chunkCount, ResourceLocation origin) {
+ return active.stream()
+ .filter(r -> r.uuid.equals(uuid))
+ .findFirst()
+ .orElse(newReceiver(uuid, chunkCount, origin));
+ }
+
+ public static PacketChunkReceiver get(@NotNull UUID uuid) {
+ return active.stream().filter(r -> r.uuid.equals(uuid)).findFirst().orElse(null);
+ }
+
+ private PacketChunkReceiver(@NotNull UUID uuid, int chunkCount, @Nullable DataHandlerDescriptor descriptor) {
+ this.uuid = uuid;
+ this.chunkCount = chunkCount;
+ networkedBuf = PacketByteBufs.create();
+ this.descriptor = descriptor;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof PacketChunkReceiver)) return false;
+ PacketChunkReceiver that = (PacketChunkReceiver) o;
+ return uuid.equals(that.uuid);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uuid);
+ }
+
+ public boolean testFinished() {
+ ProgressListener listener = ChunkerProgress.getProgressListener();
+ if (listener != null) {
+ listener.progressStagePercentage((100 * receivedCount) / chunkCount);
+ }
+ if (incomingBuffer == null) {
+ return true;
+ }
+ if (lastReadSerial >= chunkCount - 1) {
+ onFinish();
+ return true;
+ }
+ return false;
+ }
+
+ private void addBuffer(FriendlyByteBuf input) {
+ final int size = input.readableBytes();
+ final int cap = networkedBuf.capacity() - networkedBuf.writerIndex();
+
+ if (cap < size) {
+ networkedBuf.capacity(networkedBuf.writerIndex() + size);
+ }
+ input.readBytes(networkedBuf, size);
+ input.clear();
+ }
+
+ protected void onFinish() {
+ incomingBuffer.clear();
+ incomingBuffer = null;
+
+ final BaseDataHandler baseHandler = descriptor.INSTANCE.get();
+ if (baseHandler instanceof DataHandler.FromServer handler) {
+ handler.receiveFromMemory(networkedBuf);
+ }
+ }
+
+ Map incomingBuffer = new HashMap<>();
+ int lastReadSerial = -1;
+ int receivedCount = 0;
+
+ public void processReceived(FriendlyByteBuf buf, int serialNo, int size) {
+ receivedCount++;
+
+ if (lastReadSerial == serialNo - 1) {
+ addBuffer(buf);
+ lastReadSerial = serialNo;
+ } else {
+ //not sure if order is guaranteed by the underlying system!
+ boolean haveAll = true;
+ for (int nr = lastReadSerial + 1; nr < serialNo - 1; nr++) {
+ if (incomingBuffer.get(nr) == null) {
+ haveAll = false;
+ break;
+ }
+ }
+
+ if (haveAll) {
+ for (int nr = lastReadSerial + 1; nr < serialNo - 1; nr++) {
+ addBuffer(incomingBuffer.get(nr));
+ incomingBuffer.put(nr, null);
+ }
+ addBuffer(buf);
+ lastReadSerial = serialNo;
+ } else {
+ incomingBuffer.put(serialNo, buf);
+ }
+ }
+ }
+ }
+
+ /**
+ * Responsible for splitting an outgoing ByteBuffer into several smaller Chunks and
+ * send them as seperate messages to the {@link Chunker}-Channel
+ */
+ public static class PacketChunkSender {
+ private final FriendlyByteBuf networkedBuf;
+ public final UUID uuid;
+ public final int chunkCount;
+ public final int size;
+ public final ResourceLocation origin;
+
+ public PacketChunkSender(FriendlyByteBuf buf, ResourceLocation origin) {
+ networkedBuf = buf;
+
+ size = buf.readableBytes();
+ chunkCount = (int) Math.ceil((double) size / MAX_PAYLOAD_SIZE);
+ uuid = UUID.randomUUID();
+ this.origin = origin;
+ }
+
+ public void sendChunks(Collection players) {
+ BCLib.LOGGER.info("Sending Request in " + chunkCount + " Packet-Chunks");
+ for (int i = -1; i < chunkCount; i++) {
+ Chunker c = new Chunker(i, uuid, networkedBuf, chunkCount, origin);
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ c.serializeDataOnServer(buf);
+
+ for (ServerPlayer player : players) {
+ ServerPlayNetworking.send(player, DESCRIPTOR.IDENTIFIER, buf);
+ }
+ }
+ }
+ }
+
+ //header = version + UUID + serialNo + size, see serializeDataOnServer
+ private static final int HEADER_SIZE = 1 + 16 + 4 + 4;
+
+ public static final int MAX_PACKET_SIZE = 1024 * 1024;
+ private static final int MAX_PAYLOAD_SIZE = MAX_PACKET_SIZE - HEADER_SIZE;
+
+ public static final DataHandlerDescriptor DESCRIPTOR = new DataHandlerDescriptor(
+ new ResourceLocation(
+ BCLib.MOD_ID,
+ "chunker"
+ ),
+ Chunker::new,
+ false,
+ false
+ );
+
+ private int serialNo;
+ private UUID uuid;
+ private int chunkCount;
+ private FriendlyByteBuf networkedBuf;
+ private ResourceLocation origin;
+
+ protected Chunker(int serialNo, UUID uuid, FriendlyByteBuf networkedBuf, int chunkCount, ResourceLocation origin) {
+ super(DESCRIPTOR.IDENTIFIER);
+ this.serialNo = serialNo;
+ this.uuid = uuid;
+ this.networkedBuf = networkedBuf;
+ this.chunkCount = chunkCount;
+ this.origin = origin;
+ }
+
+ protected Chunker() {
+ super(DESCRIPTOR.IDENTIFIER);
+ }
+
+
+ @Override
+ protected void serializeDataOnServer(FriendlyByteBuf buf) {
+ //Sending Header. Make sure to change HEADER_SIZE if you change this!
+ buf.writeByte(0);
+ buf.writeLong(uuid.getMostSignificantBits());
+ buf.writeLong(uuid.getLeastSignificantBits());
+ buf.writeInt(serialNo);
+
+ //sending Payload
+ if (serialNo == -1) {
+ //this is our header-Chunk that transports status information
+ buf.writeInt(chunkCount);
+ writeString(buf, origin.getNamespace());
+ writeString(buf, origin.getPath());
+ } else {
+ //this is an actual payload chunk
+ buf.capacity(MAX_PACKET_SIZE);
+ final int size = Math.min(MAX_PAYLOAD_SIZE, networkedBuf.readableBytes());
+ buf.writeInt(size);
+ networkedBuf.readBytes(buf, size);
+ }
+ }
+
+ private PacketChunkReceiver receiver;
+
+ @Override
+ protected void deserializeIncomingDataOnClient(FriendlyByteBuf buf, PacketSender responseSender) {
+ final int version = buf.readByte();
+ uuid = new UUID(buf.readLong(), buf.readLong());
+ serialNo = buf.readInt();
+
+ if (serialNo == -1) {
+ chunkCount = buf.readInt();
+ final String namespace = readString(buf);
+ final String path = readString(buf);
+ ResourceLocation ident = new ResourceLocation(namespace, path);
+ BCLib.LOGGER.info("Receiving " + chunkCount + " + Packet-Chunks for " + ident);
+
+ receiver = PacketChunkReceiver.getOrCreate(uuid, chunkCount, ident);
+ } else {
+ receiver = PacketChunkReceiver.get(uuid);
+ if (receiver != null) {
+ final int size = buf.readInt();
+ receiver.processReceived(buf, serialNo, size);
+ } else {
+ BCLib.LOGGER.error("Unknown Packet-Chunk Transfer for " + uuid);
+ }
+ }
+ }
+
+ @Override
+ protected void runOnClientGameThread(Minecraft client) {
+ if (receiver != null) {
+ receiver.testFinished();
+ }
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/ChunkerProgress.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/ChunkerProgress.java
new file mode 100644
index 00000000..ae82fc38
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/ChunkerProgress.java
@@ -0,0 +1,28 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.client.gui.screens.ProgressScreen;
+
+import net.minecraft.util.ProgressListener;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+
+@Environment(EnvType.CLIENT)
+public class ChunkerProgress {
+ private static ProgressScreen progressScreen;
+
+ @Environment(EnvType.CLIENT)
+ public static void setProgressScreen(ProgressScreen scr) {
+ progressScreen = scr;
+ }
+
+ @Environment(EnvType.CLIENT)
+ public static ProgressScreen getProgressScreen() {
+ return progressScreen;
+ }
+
+ @Environment(EnvType.CLIENT)
+ public static ProgressListener getProgressListener() {
+ return progressScreen;
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/FileContentWrapper.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/FileContentWrapper.java
new file mode 100644
index 00000000..669fc955
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/FileContentWrapper.java
@@ -0,0 +1,75 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+public class FileContentWrapper {
+ private byte[] rawContent;
+ private ByteArrayOutputStream outputStream;
+
+ FileContentWrapper(byte[] content) {
+ this.rawContent = content;
+ this.outputStream = null;
+ }
+
+ public byte[] getOriginalContent() {
+ return rawContent;
+ }
+
+ public byte[] getRawContent() {
+ if (outputStream != null) {
+ return outputStream.toByteArray();
+ }
+ return rawContent;
+ }
+
+ private void invalidateOutputStream() {
+ if (this.outputStream != null) {
+ try {
+ this.outputStream.close();
+ } catch (IOException e) {
+ BCLib.LOGGER.debug(e);
+ }
+ }
+ this.outputStream = null;
+ }
+
+ public void setRawContent(byte[] rawContent) {
+ this.rawContent = rawContent;
+ invalidateOutputStream();
+ }
+
+ public void syncWithOutputStream() {
+ if (outputStream != null) {
+ try {
+ outputStream.flush();
+ } catch (IOException e) {
+ BCLib.LOGGER.error(e.getMessage());
+ e.printStackTrace();
+ }
+ setRawContent(getRawContent());
+ invalidateOutputStream();
+ }
+ }
+
+ public ByteArrayInputStream getInputStream() {
+ if (rawContent == null) return new ByteArrayInputStream(new byte[0]);
+ return new ByteArrayInputStream(rawContent);
+ }
+
+ public ByteArrayOutputStream getOrCreateOutputStream() {
+ if (this.outputStream == null) {
+ return this.getEmptyOutputStream();
+ }
+ return this.outputStream;
+ }
+
+ public ByteArrayOutputStream getEmptyOutputStream() {
+ invalidateOutputStream();
+ this.outputStream = new ByteArrayOutputStream(this.rawContent.length);
+ return this.outputStream;
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloClient.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloClient.java
new file mode 100644
index 00000000..ead95a39
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloClient.java
@@ -0,0 +1,544 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataExchangeAPI;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.DataHandlerDescriptor;
+import org.betterx.bclib.client.gui.screens.ModListScreen;
+import org.betterx.bclib.client.gui.screens.ProgressScreen;
+import org.betterx.bclib.client.gui.screens.SyncFilesScreen;
+import org.betterx.bclib.client.gui.screens.WarnBCLibVersionMismatch;
+import org.betterx.bclib.config.Configs;
+import org.betterx.bclib.config.ServerConfig;
+import org.betterx.worlds.together.util.ModUtil;
+import org.betterx.worlds.together.util.ModUtil.ModInfo;
+import org.betterx.worlds.together.util.PathUtil;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+import net.fabricmc.loader.api.metadata.ModEnvironment;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.*;
+import java.util.Map.Entry;
+import java.util.stream.Collectors;
+
+/**
+ * Sent from the Server to the Client.
+ *
+ * For Details refer to {@link HelloServer}
+ */
+public class HelloClient extends DataHandler.FromServer {
+ public record OfferedModInfo(String version, int size, boolean canDownload) {
+ }
+
+ public interface IServerModMap extends Map {
+ }
+
+ public static class ServerModMap extends HashMap implements IServerModMap {
+ }
+
+ public static final DataHandlerDescriptor DESCRIPTOR = new DataHandlerDescriptor(
+ new ResourceLocation(
+ BCLib.MOD_ID,
+ "hello_client"
+ ),
+ HelloClient::new,
+ false,
+ false
+ );
+
+ public HelloClient() {
+ super(DESCRIPTOR.IDENTIFIER);
+ }
+
+ static String getBCLibVersion() {
+ return ModUtil.getModVersion(BCLib.MOD_ID);
+ }
+
+ @Override
+ protected boolean prepareDataOnServer() {
+ if (!Configs.SERVER_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the server.");
+ return false;
+ }
+
+ AutoSync.loadSyncFolder();
+ return true;
+ }
+
+ @Override
+ protected void serializeDataOnServer(FriendlyByteBuf buf) {
+ final String vbclib = getBCLibVersion();
+ BCLib.LOGGER.info("Sending Hello to Client. (server=" + vbclib + ")");
+
+ //write BCLibVersion (=protocol version)
+ buf.writeInt(ModUtil.convertModVersion(vbclib));
+
+ if (Configs.SERVER_CONFIG.isOfferingMods() || Configs.SERVER_CONFIG.isOfferingInfosForMods()) {
+ List mods = DataExchangeAPI.registeredMods();
+ final List inmods = mods;
+ if (Configs.SERVER_CONFIG.isOfferingAllMods() || Configs.SERVER_CONFIG.isOfferingInfosForMods()) {
+ mods = new ArrayList<>(inmods.size());
+ mods.addAll(inmods);
+ mods.addAll(ModUtil
+ .getMods()
+ .entrySet()
+ .stream()
+ .filter(entry -> entry.getValue().metadata.getEnvironment() != ModEnvironment.SERVER && !inmods.contains(
+ entry.getKey()))
+ .map(entry -> entry.getKey())
+ .collect(Collectors.toList())
+ );
+ }
+
+ mods = mods
+ .stream()
+ .filter(entry -> !Configs.SERVER_CONFIG.get(ServerConfig.EXCLUDED_MODS).contains(entry))
+ .collect(Collectors.toList());
+
+ //write Plugin Versions
+ buf.writeInt(mods.size());
+ for (String modID : mods) {
+ final String ver = ModUtil.getModVersion(modID);
+ int size = 0;
+
+ final ModInfo mi = ModUtil.getModInfo(modID);
+ if (mi != null) {
+ try {
+ size = (int) Files.size(mi.jarPath);
+ } catch (IOException e) {
+ BCLib.LOGGER.error("Unable to get File Size: " + e.getMessage());
+ }
+ }
+
+
+ writeString(buf, modID);
+ buf.writeInt(ModUtil.convertModVersion(ver));
+ buf.writeInt(size);
+ final boolean canDownload = size > 0 && Configs.SERVER_CONFIG.isOfferingMods() && (Configs.SERVER_CONFIG.isOfferingAllMods() || inmods.contains(
+ modID));
+ buf.writeBoolean(canDownload);
+
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - Listing Mod " + modID + " v" + ver + " (size: " + PathUtil.humanReadableFileSize(
+ size) + ", download=" + canDownload + ")");
+ }
+ } else {
+ BCLib.LOGGER.info("Server will not list Mods.");
+ buf.writeInt(0);
+ }
+
+ if (Configs.SERVER_CONFIG.isOfferingFiles() || Configs.SERVER_CONFIG.isOfferingConfigs()) {
+ //do only include files that exist on the server
+ final List existingAutoSyncFiles = AutoSync.getAutoSyncFiles()
+ .stream()
+ .filter(e -> e.fileName.exists())
+ .filter(e -> (e.isConfigFile() && Configs.SERVER_CONFIG.isOfferingConfigs()) || (e instanceof AutoFileSyncEntry.ForDirectFileRequest && Configs.SERVER_CONFIG.isOfferingFiles()))
+ .collect(Collectors.toList());
+
+ //send config Data
+ buf.writeInt(existingAutoSyncFiles.size());
+ for (AutoFileSyncEntry entry : existingAutoSyncFiles) {
+ entry.serialize(buf);
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - Offering " + (entry.isConfigFile() ? "Config " : "File ") + entry);
+ }
+ } else {
+ BCLib.LOGGER.info("Server will neither offer Files nor Configs.");
+ buf.writeInt(0);
+ }
+
+ if (Configs.SERVER_CONFIG.isOfferingFiles()) {
+ buf.writeInt(AutoSync.syncFolderDescriptions.size());
+ AutoSync.syncFolderDescriptions.forEach(desc -> {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - Offering Folder " + desc.localFolder + " (allowDelete=" + desc.removeAdditionalFiles + ")");
+ desc.serialize(buf);
+ });
+ } else {
+ BCLib.LOGGER.info("Server will not offer Sync Folders.");
+ buf.writeInt(0);
+ }
+
+ buf.writeBoolean(Configs.SERVER_CONFIG.isOfferingInfosForMods());
+ }
+
+ String bclibVersion = "0.0.0";
+
+
+ IServerModMap modVersion = new ServerModMap();
+ List autoSyncedFiles = null;
+ List autoSynFolders = null;
+ boolean serverPublishedModInfo = false;
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void deserializeIncomingDataOnClient(FriendlyByteBuf buf, PacketSender responseSender) {
+ //read BCLibVersion (=protocol version)
+ bclibVersion = ModUtil.convertModVersion(buf.readInt());
+
+ //read Plugin Versions
+ modVersion = new ServerModMap();
+ int count = buf.readInt();
+ for (int i = 0; i < count; i++) {
+ final String id = readString(buf);
+ final String version = ModUtil.convertModVersion(buf.readInt());
+ final int size;
+ final boolean canDownload;
+ //since v0.4.1 we also send the size of the mod-File
+ size = buf.readInt();
+ canDownload = buf.readBoolean();
+ modVersion.put(id, new OfferedModInfo(version, size, canDownload));
+ }
+
+ //read config Data
+ count = buf.readInt();
+ autoSyncedFiles = new ArrayList<>(count);
+ for (int i = 0; i < count; i++) {
+ //System.out.println("Deserializing ");
+ AutoSync.AutoSyncTriple t = AutoFileSyncEntry.deserializeAndMatch(buf);
+ autoSyncedFiles.add(t);
+ //System.out.println(t.first);
+ }
+
+
+ autoSynFolders = new ArrayList<>(1);
+ //since v0.4.1 we also send the sync folders
+ final int folderCount = buf.readInt();
+ for (int i = 0; i < folderCount; i++) {
+ SyncFolderDescriptor desc = SyncFolderDescriptor.deserialize(buf);
+ autoSynFolders.add(desc);
+ }
+
+ serverPublishedModInfo = buf.readBoolean();
+ }
+
+ @Environment(EnvType.CLIENT)
+ private void processAutoSyncFolder(
+ final List filesToRequest,
+ final List filesToRemove
+ ) {
+ if (!Configs.CLIENT_CONFIG.isAcceptingFiles()) {
+ return;
+ }
+
+ if (autoSynFolders.size() > 0) {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info("Folders offered by Server:");
+ }
+
+ autoSynFolders.forEach(desc -> {
+ //desc contains the fileCache sent from the server, load the local version to get hold of the actual file cache on the client
+ SyncFolderDescriptor localDescriptor = AutoSync.getSyncFolderDescriptor(desc.folderID);
+ if (localDescriptor != null) {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - " + desc.folderID + " (" + desc.localFolder + ", allowRemove=" + desc.removeAdditionalFiles + ")");
+ localDescriptor.invalidateCache();
+
+ desc.relativeFilesStream()
+ .filter(desc::discardChildElements)
+ .forEach(subFile -> {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.warning(" * " + subFile.relPath + " (REJECTED)");
+ });
+
+
+ if (desc.removeAdditionalFiles) {
+ List additionalFiles = localDescriptor.relativeFilesStream()
+ .filter(subFile -> !desc.hasRelativeFile(
+ subFile))
+ .map(desc::mapAbsolute)
+ .filter(desc::acceptChildElements)
+ .map(absPath -> new AutoSyncID.ForDirectFileRequest(
+ desc.folderID,
+ absPath.toFile()
+ ))
+ .collect(Collectors.toList());
+
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ additionalFiles.forEach(aid -> BCLib.LOGGER.info(" * " + desc.localFolder.relativize(aid.relFile.toPath()) + " (missing on server)"));
+ filesToRemove.addAll(additionalFiles);
+ }
+
+ desc.relativeFilesStream()
+ .filter(desc::acceptChildElements)
+ .forEach(subFile -> {
+ SyncFolderDescriptor.SubFile localSubFile = localDescriptor.getLocalSubFile(subFile.relPath);
+ if (localSubFile != null) {
+ //the file exists locally, check if the hashes match
+ if (!localSubFile.hash.equals(subFile.hash)) {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" * " + subFile.relPath + " (changed)");
+ filesToRequest.add(new AutoSyncID.ForDirectFileRequest(
+ desc.folderID,
+ new File(subFile.relPath)
+ ));
+ } else {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" * " + subFile.relPath);
+ }
+ } else {
+ //the file is missing locally
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" * " + subFile.relPath + " (missing on client)");
+ filesToRequest.add(new AutoSyncID.ForDirectFileRequest(
+ desc.folderID,
+ new File(subFile.relPath)
+ ));
+ }
+ });
+
+ //free some memory
+ localDescriptor.invalidateCache();
+ } else {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - " + desc.folderID + " (Failed to find)");
+ }
+ });
+ }
+
+ @Environment(EnvType.CLIENT)
+ private void processSingleFileSync(final List filesToRequest) {
+ final boolean debugHashes = Configs.CLIENT_CONFIG.shouldPrintDebugHashes();
+
+ if (autoSyncedFiles.size() > 0) {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info("Files offered by Server:");
+ }
+
+ //Handle single sync files
+ //Single files need to be registered for sync on both client and server
+ //There are no restrictions to the target folder, but the client decides the final
+ //location.
+ for (AutoSync.AutoSyncTriple e : autoSyncedFiles) {
+ String actionString = "";
+ FileContentWrapper contentWrapper = new FileContentWrapper(e.serverContent);
+ if (e.localMatch == null) {
+ actionString = "(unknown source -> omitting)";
+ //filesToRequest.add(new AutoSyncID(e.serverHash.modID, e.serverHash.uniqueID));
+ } else if (e.localMatch.needTransfer.test(e.localMatch.getFileHash(), e.serverHash, contentWrapper)) {
+ actionString = "(prepare update)";
+ //we did not yet receive the new content
+ if (contentWrapper.getRawContent() == null) {
+ filesToRequest.add(new AutoSyncID(e.serverHash.modID, e.serverHash.uniqueID));
+ } else {
+ filesToRequest.add(new AutoSyncID.WithContentOverride(
+ e.serverHash.modID,
+ e.serverHash.uniqueID,
+ contentWrapper,
+ e.localMatch.fileName
+ ));
+ }
+ }
+ if (Configs.MAIN_CONFIG.verboseLogging()) {
+ BCLib.LOGGER.info(" - " + e + ": " + actionString);
+ if (debugHashes) {
+ BCLib.LOGGER.info(" * " + e.serverHash + " (Server)");
+ BCLib.LOGGER.info(" * " + e.localMatch.getFileHash() + " (Client)");
+ BCLib.LOGGER.info(" * local Content " + (contentWrapper.getRawContent() == null));
+ }
+ }
+ }
+ }
+
+
+ @Environment(EnvType.CLIENT)
+ private void processModFileSync(final List filesToRequest, final Set mismatchingMods) {
+ for (Entry e : modVersion.entrySet()) {
+ final String localVersion = ModUtil.convertModVersion(ModUtil.convertModVersion(ModUtil.getModVersion(e.getKey())));
+ final OfferedModInfo serverInfo = e.getValue();
+
+ ModInfo nfo = ModUtil.getModInfo(e.getKey());
+ final boolean clientOnly = nfo != null && nfo.metadata.getEnvironment() == ModEnvironment.CLIENT;
+ final boolean requestMod = !clientOnly && !serverInfo.version.equals(localVersion) && serverInfo.size > 0 && serverInfo.canDownload;
+
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - " + e.getKey() + " (client=" + localVersion + ", server=" + serverInfo.version + ", size=" + PathUtil.humanReadableFileSize(
+ serverInfo.size) + (requestMod ? ", requesting" : "") + (serverInfo.canDownload
+ ? ""
+ : ", not offered") + (clientOnly ? ", client only" : "") + ")");
+ if (requestMod) {
+ filesToRequest.add(new AutoSyncID.ForModFileRequest(e.getKey(), serverInfo.version));
+ }
+ if (!serverInfo.version.equals(localVersion)) {
+ mismatchingMods.add(e.getKey());
+ }
+ }
+
+ mismatchingMods.addAll(ModListScreen.localMissing(modVersion));
+ mismatchingMods.addAll(ModListScreen.serverMissing(modVersion));
+ }
+
+ @Override
+ protected boolean isBlocking() {
+ return true;
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void runOnClientGameThread(Minecraft client) {
+ if (!Configs.CLIENT_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the client.");
+ return;
+ }
+ final String localBclibVersion = getBCLibVersion();
+ BCLib.LOGGER.info("Received Hello from Server. (client=" + localBclibVersion + ", server=" + bclibVersion + ")");
+
+ if (ModUtil.convertModVersion(localBclibVersion) != ModUtil.convertModVersion(bclibVersion)) {
+ showBCLibError(client);
+ return;
+ }
+
+ final List filesToRequest = new ArrayList<>(2);
+ final List filesToRemove = new ArrayList<>(2);
+ final Set mismatchingMods = new HashSet<>(2);
+
+
+ processModFileSync(filesToRequest, mismatchingMods);
+ processSingleFileSync(filesToRequest);
+ processAutoSyncFolder(filesToRequest, filesToRemove);
+
+ //Handle folder sync
+ //Both client and server need to know about the folder you want to sync
+ //Files can only get placed within that folder
+
+ if ((filesToRequest.size() > 0 || filesToRemove.size() > 0) && (Configs.CLIENT_CONFIG.isAcceptingMods() || Configs.CLIENT_CONFIG.isAcceptingConfigs() || Configs.CLIENT_CONFIG.isAcceptingFiles())) {
+ showSyncFilesScreen(client, filesToRequest, filesToRemove);
+ return;
+ } else if (serverPublishedModInfo && mismatchingMods.size() > 0 && Configs.CLIENT_CONFIG.isShowingModInfo()) {
+ client.setScreen(new ModListScreen(
+ client.screen,
+ Component.translatable("title.bclib.modmissmatch"),
+ Component.translatable("message.bclib.modmissmatch"),
+ CommonComponents.GUI_PROCEED,
+ ModUtil.getMods(),
+ modVersion
+ ));
+ return;
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ protected void showBCLibError(Minecraft client) {
+ BCLib.LOGGER.error("BCLib differs on client and server.");
+ client.setScreen(new WarnBCLibVersionMismatch((download) -> {
+ if (download) {
+ requestBCLibDownload();
+
+ this.onCloseSyncFilesScreen();
+ } else {
+ Minecraft.getInstance()
+ .setScreen(null);
+ }
+ }));
+ }
+
+ @Environment(EnvType.CLIENT)
+ protected void showSyncFilesScreen(
+ Minecraft client,
+ List files,
+ final List filesToRemove
+ ) {
+ int configFiles = 0;
+ int singleFiles = 0;
+ int folderFiles = 0;
+ int modFiles = 0;
+
+ for (AutoSyncID aid : files) {
+ if (aid.isConfigFile()) {
+ configFiles++;
+ } else if (aid instanceof AutoSyncID.ForModFileRequest) {
+ modFiles++;
+ } else if (aid instanceof AutoSyncID.ForDirectFileRequest) {
+ folderFiles++;
+ } else {
+ singleFiles++;
+ }
+ }
+
+ client.setScreen(new SyncFilesScreen(
+ modFiles,
+ configFiles,
+ singleFiles,
+ folderFiles,
+ filesToRemove.size(),
+ modVersion,
+ (downloadMods, downloadConfigs, downloadFiles, removeFiles) -> {
+ if (downloadMods || downloadConfigs || downloadFiles) {
+ BCLib.LOGGER.info("Updating local Files:");
+ List localChanges = new ArrayList<>(
+ files.toArray().length);
+ List requestFiles = new ArrayList<>(files.toArray().length);
+
+ files.forEach(aid -> {
+ if (aid.isConfigFile() && downloadConfigs) {
+ processOfferedFile(requestFiles, aid);
+ } else if (aid instanceof AutoSyncID.ForModFileRequest && downloadMods) {
+ processOfferedFile(requestFiles, aid);
+ } else if (downloadFiles) {
+ processOfferedFile(requestFiles, aid);
+ }
+ });
+
+ requestFileDownloads(requestFiles);
+ }
+ if (removeFiles) {
+ filesToRemove.forEach(aid -> {
+ BCLib.LOGGER.info(" - " + aid.relFile + " (removing)");
+ aid.relFile.delete();
+ });
+ }
+
+ this.onCloseSyncFilesScreen();
+ }
+ ));
+ }
+
+ @Environment(EnvType.CLIENT)
+ private void onCloseSyncFilesScreen() {
+ Minecraft.getInstance()
+ .setScreen(ChunkerProgress.getProgressScreen());
+ }
+
+ private void processOfferedFile(List requestFiles, AutoSyncID aid) {
+ if (aid instanceof AutoSyncID.WithContentOverride) {
+ final AutoSyncID.WithContentOverride aidc = (AutoSyncID.WithContentOverride) aid;
+ BCLib.LOGGER.info(" - " + aid + " (updating Content)");
+
+ SendFiles.writeSyncedFile(aid, aidc.contentWrapper.getRawContent(), aidc.localFile);
+ } else {
+ requestFiles.add(aid);
+ BCLib.LOGGER.info(" - " + aid + " (requesting)");
+ }
+ }
+
+ private void requestBCLibDownload() {
+ BCLib.LOGGER.warning("Starting download of BCLib");
+ requestFileDownloads(List.of(new AutoSyncID.ForModFileRequest(BCLib.MOD_ID, bclibVersion)));
+ }
+
+ @Environment(EnvType.CLIENT)
+ private void requestFileDownloads(List files) {
+ BCLib.LOGGER.info("Starting download of Files:" + files.size());
+
+ final ProgressScreen progress = new ProgressScreen(
+ null,
+ Component.translatable("title.bclib.filesync.progress"),
+ Component.translatable("message.bclib.filesync.progress")
+ );
+ progress.progressStart(Component.translatable("message.bclib.filesync.progress.stage.empty"));
+ ChunkerProgress.setProgressScreen(progress);
+
+ DataExchangeAPI.send(new RequestFiles(files));
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloServer.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloServer.java
new file mode 100644
index 00000000..df1994fa
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloServer.java
@@ -0,0 +1,119 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataExchangeAPI;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.DataHandlerDescriptor;
+import org.betterx.bclib.config.Configs;
+import org.betterx.worlds.together.util.ModUtil;
+
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.world.entity.player.Player;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+
+import java.io.File;
+
+/**
+ * This message is sent once a player enters the world. It initiates a sequence of Messages that will sync files between both
+ * client and server.
+ *
+ * Description
+ *
+ * Server |
+ * |
+ * Client |
+ * |
+ *
+ *
+ * Player enters World |
+ *
+ *
+ * |
+ * <-- |
+ * {@link HelloServer} |
+ * Sends the current BLib-Version installed on the Client |
+ *
+ *
+ * {@link HelloClient} |
+ * --> |
+ * |
+ * Sends the current BClIb-Version, the Version of all Plugins and data for all AutpoSync-Files
+ * ({@link DataExchangeAPI#addAutoSyncFile(String, File)} on the Server |
+ *
+ *
+ * |
+ * <-- |
+ * {@link RequestFiles} |
+ * Request missing or out of sync Files from the Server |
+ *
+ *
+ * {@link SendFiles} |
+ * --> |
+ * |
+ * Send Files from the Server to the Client |
+ *
+ *
+ */
+public class HelloServer extends DataHandler.FromClient {
+ public static final DataHandlerDescriptor DESCRIPTOR = new DataHandlerDescriptor(
+ new ResourceLocation(
+ BCLib.MOD_ID,
+ "hello_server"
+ ),
+ HelloServer::new,
+ true,
+ false
+ );
+
+ protected String bclibVersion = "0.0.0";
+
+ public HelloServer() {
+ super(DESCRIPTOR.IDENTIFIER);
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected boolean prepareDataOnClient() {
+ if (!Configs.CLIENT_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the client.");
+ return false;
+ }
+
+ return true;
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void serializeDataOnClient(FriendlyByteBuf buf) {
+ BCLib.LOGGER.info("Sending hello to server.");
+ buf.writeInt(ModUtil.convertModVersion(HelloClient.getBCLibVersion()));
+ }
+
+ @Override
+ protected void deserializeIncomingDataOnServer(FriendlyByteBuf buf, Player player, PacketSender responseSender) {
+ bclibVersion = ModUtil.convertModVersion(buf.readInt());
+ }
+
+ @Override
+ protected void runOnServerGameThread(MinecraftServer server, Player player) {
+ if (!Configs.SERVER_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the server.");
+ return;
+ }
+
+ String localBclibVersion = HelloClient.getBCLibVersion();
+ BCLib.LOGGER.info("Received Hello from Client. (server=" + localBclibVersion + ", client=" + bclibVersion + ")");
+
+ if (!server.isPublished()) {
+ BCLib.LOGGER.info("Auto-Sync is disabled for Singleplayer worlds.");
+ return;
+ }
+
+ reply(new HelloClient(), server);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/RequestFiles.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/RequestFiles.java
new file mode 100644
index 00000000..ec4da863
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/RequestFiles.java
@@ -0,0 +1,111 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.DataHandlerDescriptor;
+import org.betterx.bclib.config.Configs;
+
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.world.entity.player.Player;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+public class RequestFiles extends DataHandler.FromClient {
+ public static final DataHandlerDescriptor DESCRIPTOR = new DataHandlerDescriptor(
+ new ResourceLocation(
+ BCLib.MOD_ID,
+ "request_files"
+ ),
+ RequestFiles::new,
+ false,
+ false
+ );
+ static String currentToken = "";
+
+ protected List files;
+
+ private RequestFiles() {
+ this(null);
+ }
+
+ public RequestFiles(List files) {
+ super(DESCRIPTOR.IDENTIFIER);
+ this.files = files;
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected boolean prepareDataOnClient() {
+ if (!Configs.CLIENT_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the client.");
+ return false;
+ }
+ return true;
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void serializeDataOnClient(FriendlyByteBuf buf) {
+ newToken();
+ writeString(buf, currentToken);
+
+ buf.writeInt(files.size());
+
+ for (AutoSyncID a : files) {
+ a.serializeData(buf);
+ }
+ }
+
+ String receivedToken = "";
+
+ @Override
+ protected void deserializeIncomingDataOnServer(FriendlyByteBuf buf, Player player, PacketSender responseSender) {
+ receivedToken = readString(buf);
+ int size = buf.readInt();
+ files = new ArrayList<>(size);
+
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info("Client requested " + size + " Files:");
+ for (int i = 0; i < size; i++) {
+ AutoSyncID asid = AutoSyncID.deserializeData(buf);
+ files.add(asid);
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - " + asid);
+ }
+
+
+ }
+
+ @Override
+ protected void runOnServerGameThread(MinecraftServer server, Player player) {
+ if (!Configs.SERVER_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the server.");
+ return;
+ }
+
+ List syncEntries = files.stream()
+ .map(asid -> AutoFileSyncEntry.findMatching(asid))
+ .filter(e -> e != null)
+ .collect(Collectors.toList());
+
+ reply(new SendFiles(syncEntries, receivedToken), server);
+ }
+
+ public static void newToken() {
+ currentToken = UUID.randomUUID()
+ .toString();
+ }
+
+ static {
+ newToken();
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SendFiles.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SendFiles.java
new file mode 100644
index 00000000..531a7cf2
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SendFiles.java
@@ -0,0 +1,230 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.DataHandlerDescriptor;
+import org.betterx.bclib.client.gui.screens.ConfirmRestartScreen;
+import org.betterx.bclib.config.Configs;
+import org.betterx.bclib.util.Pair;
+import org.betterx.bclib.util.Triple;
+import org.betterx.worlds.together.util.PathUtil;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class SendFiles extends DataHandler.FromServer {
+ public static final DataHandlerDescriptor DESCRIPTOR = new DataHandlerDescriptor(
+ new ResourceLocation(
+ BCLib.MOD_ID,
+ "send_files"
+ ),
+ SendFiles::new,
+ false,
+ false
+ );
+
+ protected List files;
+ private String token;
+
+ public SendFiles() {
+ this(null, "");
+ }
+
+ public SendFiles(List files, String token) {
+ super(DESCRIPTOR.IDENTIFIER);
+ this.files = files;
+ this.token = token;
+ }
+
+ @Override
+ protected boolean prepareDataOnServer() {
+ if (!Configs.SERVER_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the server.");
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ protected void serializeDataOnServer(FriendlyByteBuf buf) {
+ List existingFiles = files.stream()
+ .filter(e -> e != null && e.fileName != null && e.fileName.exists())
+ .collect(Collectors.toList());
+ /*
+ //this will try to send a file that was not registered or requested by the client
+ existingFiles.add(new AutoFileSyncEntry("none", new File("D:\\MinecraftPlugins\\BetterNether\\run\\server.properties"),true,(a, b, content) -> {
+ System.out.println("Got Content:" + content.length);
+ return true;
+ }));*/
+
+ /*//this will try to send a folder-file that was not registered or requested by the client
+ existingFiles.add(new AutoFileSyncEntry.ForDirectFileRequest(DataExchange.SYNC_FOLDER.folderID, new File("test.json"), DataExchange.SYNC_FOLDER.mapAbsolute("test.json").toFile()));*/
+
+ /*//this will try to send a folder-file that was not registered or requested by the client and is outside the base-folder
+ existingFiles.add(new AutoFileSyncEntry.ForDirectFileRequest(DataExchange.SYNC_FOLDER.folderID, new File("../breakout.json"), DataExchange.SYNC_FOLDER.mapAbsolute("../breakout.json").toFile()));*/
+
+
+ writeString(buf, token);
+ buf.writeInt(existingFiles.size());
+
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info("Sending " + existingFiles.size() + " Files to Client:");
+ for (AutoFileSyncEntry entry : existingFiles) {
+ int length = entry.serializeContent(buf);
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - " + entry + " (" + PathUtil.humanReadableFileSize(length) + ")");
+ }
+ }
+
+ private List> receivedFiles;
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void deserializeIncomingDataOnClient(FriendlyByteBuf buf, PacketSender responseSender) {
+ if (Configs.CLIENT_CONFIG.isAcceptingConfigs() || Configs.CLIENT_CONFIG.isAcceptingFiles() || Configs.CLIENT_CONFIG.isAcceptingMods()) {
+ token = readString(buf);
+ if (!token.equals(RequestFiles.currentToken)) {
+ RequestFiles.newToken();
+ BCLib.LOGGER.error("Unrequested File Transfer!");
+ receivedFiles = new ArrayList<>(0);
+ return;
+ }
+ RequestFiles.newToken();
+
+ int size = buf.readInt();
+ receivedFiles = new ArrayList<>(size);
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info("Server sent " + size + " Files:");
+ for (int i = 0; i < size; i++) {
+ Triple p = AutoFileSyncEntry.deserializeContent(buf);
+ if (p.first != null) {
+ final String type;
+ if (p.first.isConfigFile() && Configs.CLIENT_CONFIG.isAcceptingConfigs()) {
+ receivedFiles.add(p);
+ type = "Accepted Config ";
+ } else if (p.first instanceof AutoFileSyncEntry.ForModFileRequest && Configs.CLIENT_CONFIG.isAcceptingMods()) {
+ receivedFiles.add(p);
+ type = "Accepted Mod ";
+ } else if (Configs.CLIENT_CONFIG.isAcceptingFiles()) {
+ receivedFiles.add(p);
+ type = "Accepted File ";
+ } else {
+ type = "Ignoring ";
+ }
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - " + type + p.first + " (" + PathUtil.humanReadableFileSize(p.second.length) + ")");
+ } else {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.error(" - Failed to receive File " + p.third + ", possibly sent from a Mod that is not installed on the client.");
+ }
+ }
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void runOnClientGameThread(Minecraft client) {
+ if (Configs.CLIENT_CONFIG.isAcceptingConfigs() || Configs.CLIENT_CONFIG.isAcceptingFiles() || Configs.CLIENT_CONFIG.isAcceptingMods()) {
+ BCLib.LOGGER.info("Writing Files:");
+
+ for (Pair entry : receivedFiles) {
+ final AutoFileSyncEntry e = entry.first;
+ final byte[] data = entry.second;
+
+ writeSyncedFile(e, data, e.fileName);
+ }
+
+ showConfirmRestart(client);
+ }
+ }
+
+
+ @Environment(EnvType.CLIENT)
+ static void writeSyncedFile(AutoSyncID e, byte[] data, File fileName) {
+ if (fileName != null && !PathUtil.isChildOf(PathUtil.GAME_FOLDER, fileName.toPath())) {
+ BCLib.LOGGER.error(fileName + " is not within game folder " + PathUtil.GAME_FOLDER);
+ return;
+ }
+
+ if (!PathUtil.MOD_BAK_FOLDER.toFile().exists()) {
+ PathUtil.MOD_BAK_FOLDER.toFile().mkdirs();
+ }
+
+ Path path = fileName != null ? fileName.toPath() : null;
+ Path removeAfter = null;
+ if (e instanceof AutoFileSyncEntry.ForModFileRequest mase) {
+ removeAfter = path;
+ int count = 0;
+ final String prefix = "_bclib_synced_";
+ String name = prefix + mase.modID + "_" + mase.version.replace(".", "_") + ".jar";
+ do {
+ if (path != null) {
+ //move to the same directory as the existing Mod
+ path = path.getParent()
+ .resolve(name);
+ } else {
+ //move to the default mode location
+ path = PathUtil.MOD_FOLDER.resolve(name);
+ }
+ count++;
+ name = prefix + mase.modID + "_" + mase.version.replace(".", "_") + "__" + String.format(
+ "%03d",
+ count
+ ) + ".jar";
+ } while (path.toFile().exists());
+ }
+
+ BCLib.LOGGER.info(" - Writing " + path + " (" + PathUtil.humanReadableFileSize(data.length) + ")");
+ try {
+ final File parentFile = path.getParent()
+ .toFile();
+ if (!parentFile.exists()) {
+ parentFile.mkdirs();
+ }
+ Files.write(path, data);
+ if (removeAfter != null) {
+ final String bakFileName = removeAfter.toFile().getName();
+ String collisionFreeName = bakFileName;
+ Path targetPath;
+ int count = 0;
+ do {
+ targetPath = PathUtil.MOD_BAK_FOLDER.resolve(collisionFreeName);
+ count++;
+ collisionFreeName = String.format("%03d", count) + "_" + bakFileName;
+ } while (targetPath.toFile().exists());
+
+ BCLib.LOGGER.info(" - Moving " + removeAfter + " to " + targetPath);
+ removeAfter.toFile().renameTo(targetPath.toFile());
+ }
+ AutoSync.didReceiveFile(e, fileName);
+
+
+ } catch (IOException ioException) {
+ BCLib.LOGGER.error(" --> Writing " + fileName + " failed: " + ioException);
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ protected void showConfirmRestart(Minecraft client) {
+ client.setScreen(new ConfirmRestartScreen(() -> {
+ Minecraft.getInstance()
+ .setScreen(null);
+ client.stop();
+ }));
+
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SyncFolderDescriptor.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SyncFolderDescriptor.java
new file mode 100644
index 00000000..bc4bd275
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SyncFolderDescriptor.java
@@ -0,0 +1,220 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.FileHash;
+import org.betterx.bclib.api.v2.dataexchange.handler.autosync.AutoSyncID.ForDirectFileRequest;
+import org.betterx.bclib.config.Configs;
+import org.betterx.worlds.together.util.PathUtil;
+
+import net.minecraft.network.FriendlyByteBuf;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+import org.jetbrains.annotations.NotNull;
+
+public class SyncFolderDescriptor {
+ static class SubFile {
+ public final String relPath;
+ public final FileHash hash;
+
+
+ SubFile(String relPath, FileHash hash) {
+ this.relPath = relPath;
+ this.hash = hash;
+ }
+
+ @Override
+ public String toString() {
+ return relPath;
+ }
+
+ public void serialize(FriendlyByteBuf buf) {
+ DataHandler.writeString(buf, relPath);
+ hash.serialize(buf);
+ }
+
+ public static SubFile deserialize(FriendlyByteBuf buf) {
+ final String relPath = DataHandler.readString(buf);
+ FileHash hash = FileHash.deserialize(buf);
+ return new SubFile(relPath, hash);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o instanceof String) return relPath.equals(o);
+ if (!(o instanceof SubFile)) return false;
+ SubFile subFile = (SubFile) o;
+ return relPath.equals(subFile.relPath);
+ }
+
+ @Override
+ public int hashCode() {
+ return relPath.hashCode();
+ }
+ }
+
+ @NotNull
+ public final String folderID;
+ public final boolean removeAdditionalFiles;
+ @NotNull
+ public final Path localFolder;
+
+ private List fileCache;
+
+ public SyncFolderDescriptor(String folderID, Path localFolder, boolean removeAdditionalFiles) {
+ this.removeAdditionalFiles = removeAdditionalFiles;
+ this.folderID = folderID;
+ this.localFolder = localFolder;
+ fileCache = null;
+ }
+
+ @Override
+ public String toString() {
+ return "SyncFolderDescriptor{" + "folderID='" + folderID + '\'' + ", removeAdditionalFiles=" + removeAdditionalFiles + ", localFolder=" + localFolder + ", files=" + (
+ fileCache == null
+ ? "?"
+ : fileCache.size()) + "}";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o instanceof String) {
+ return folderID.equals(o);
+ }
+ if (o instanceof ForDirectFileRequest) {
+ return folderID.equals(((ForDirectFileRequest) o).uniqueID);
+ }
+ if (!(o instanceof SyncFolderDescriptor)) return false;
+ SyncFolderDescriptor that = (SyncFolderDescriptor) o;
+ return folderID.equals(that.folderID);
+ }
+
+ @Override
+ public int hashCode() {
+ return folderID.hashCode();
+ }
+
+ public int fileCount() {
+ return fileCache == null ? 0 : fileCache.size();
+ }
+
+ public void invalidateCache() {
+ fileCache = null;
+ }
+
+ public void loadCache() {
+ if (fileCache == null) {
+ fileCache = new ArrayList<>(8);
+ PathUtil.fileWalker(localFolder.toFile(), p -> fileCache.add(new SubFile(
+ localFolder.relativize(p)
+ .toString(),
+ FileHash.create(p.toFile())
+ )));
+
+ /*//this tests if we can trick the system to load files that are not beneath the base-folder
+ if (!BCLib.isClient()) {
+ fileCache.add(new SubFile("../breakout.json", FileHash.create(mapAbsolute("../breakout.json").toFile())));
+ }*/
+ }
+ }
+
+ public void serialize(FriendlyByteBuf buf) {
+ final boolean debugHashes = Configs.CLIENT_CONFIG.getBoolean(AutoSync.SYNC_CATEGORY, "debugHashes", false);
+ loadCache();
+
+ DataHandler.writeString(buf, folderID);
+ buf.writeBoolean(removeAdditionalFiles);
+ buf.writeInt(fileCache.size());
+ fileCache.forEach(fl -> {
+ if (Configs.MAIN_CONFIG.verboseLogging()) {
+ BCLib.LOGGER.info(" - " + fl.relPath);
+ if (debugHashes) {
+ BCLib.LOGGER.info(" " + fl.hash);
+ }
+ }
+ fl.serialize(buf);
+ });
+ }
+
+ public static SyncFolderDescriptor deserialize(FriendlyByteBuf buf) {
+ final String folderID = DataHandler.readString(buf);
+ final boolean remAddFiles = buf.readBoolean();
+ final int count = buf.readInt();
+ SyncFolderDescriptor localDescriptor = AutoSync.getSyncFolderDescriptor(folderID);
+
+ final SyncFolderDescriptor desc;
+ if (localDescriptor != null) {
+ desc = new SyncFolderDescriptor(
+ folderID,
+ localDescriptor.localFolder,
+ localDescriptor.removeAdditionalFiles && remAddFiles
+ );
+ desc.fileCache = new ArrayList<>(count);
+ } else {
+ BCLib.LOGGER.warning(BCLib.isClient()
+ ? "Client"
+ : "Server" + " does not know Sync-Folder ID '" + folderID + "'");
+ desc = null;
+ }
+
+ for (int i = 0; i < count; i++) {
+ SubFile relPath = SubFile.deserialize(buf);
+ if (desc != null) desc.fileCache.add(relPath);
+ }
+
+ return desc;
+ }
+
+ //Note: make sure loadCache was called before using this
+ boolean hasRelativeFile(String relFile) {
+ return fileCache.stream()
+ .filter(sf -> sf.equals(relFile))
+ .findFirst()
+ .isPresent();
+ }
+
+ //Note: make sure loadCache was called before using this
+ boolean hasRelativeFile(SubFile subFile) {
+ return hasRelativeFile(subFile.relPath);
+ }
+
+ //Note: make sure loadCache was called before using this
+ SubFile getLocalSubFile(String relPath) {
+ return fileCache.stream()
+ .filter(sf -> sf.relPath.equals(relPath))
+ .findFirst()
+ .orElse(null);
+ }
+
+ Stream relativeFilesStream() {
+ loadCache();
+ return fileCache.stream();
+ }
+
+ public Path mapAbsolute(String relPath) {
+ return this.localFolder.resolve(relPath)
+ .normalize();
+ }
+
+ public Path mapAbsolute(SubFile subFile) {
+ return this.localFolder.resolve(subFile.relPath)
+ .normalize();
+ }
+
+ public boolean acceptChildElements(Path absPath) {
+ return PathUtil.isChildOf(this.localFolder, absPath);
+ }
+
+ public boolean acceptChildElements(SubFile subFile) {
+ return acceptChildElements(mapAbsolute(subFile));
+ }
+
+ public boolean discardChildElements(SubFile subFile) {
+ return !acceptChildElements(subFile);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/datafixer/DataFixerAPI.java b/src/main/java/org/betterx/bclib/api/v2/datafixer/DataFixerAPI.java
new file mode 100644
index 00000000..07a9cf20
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/datafixer/DataFixerAPI.java
@@ -0,0 +1,598 @@
+package org.betterx.bclib.api.v2.datafixer;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.client.gui.screens.AtomicProgressListener;
+import org.betterx.bclib.client.gui.screens.ConfirmFixScreen;
+import org.betterx.bclib.client.gui.screens.LevelFixErrorScreen;
+import org.betterx.bclib.client.gui.screens.LevelFixErrorScreen.Listener;
+import org.betterx.bclib.client.gui.screens.ProgressScreen;
+import org.betterx.bclib.config.Configs;
+import org.betterx.worlds.together.util.Logger;
+import org.betterx.worlds.together.world.WorldConfig;
+
+import net.minecraft.Util;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.components.toasts.SystemToast;
+import net.minecraft.client.gui.screens.worldselection.EditWorldScreen;
+import net.minecraft.nbt.*;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.level.ChunkPos;
+import net.minecraft.world.level.chunk.storage.RegionFile;
+import net.minecraft.world.level.storage.LevelResource;
+import net.minecraft.world.level.storage.LevelStorageSource;
+import net.minecraft.world.level.storage.LevelStorageSource.LevelStorageAccess;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+
+import java.io.*;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.zip.ZipException;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * API to manage Patches that need to get applied to a world
+ */
+public class DataFixerAPI {
+ static final Logger LOGGER = new Logger("DataFixerAPI");
+
+ static class State {
+ public boolean didFail = false;
+ protected ArrayList errors = new ArrayList<>();
+
+ public void addError(String s) {
+ errors.add(s);
+ }
+
+ public boolean hasError() {
+ return errors.size() > 0;
+ }
+
+ public String getErrorMessage() {
+ return errors.stream().reduce("", (a, b) -> a + " - " + b + "\n");
+ }
+
+ public String[] getErrorMessages() {
+ String[] res = new String[errors.size()];
+ return errors.toArray(res);
+ }
+ }
+
+ @FunctionalInterface
+ public interface Callback {
+ void call();
+ }
+
+ private static boolean wrapCall(
+ LevelStorageSource levelSource,
+ String levelID,
+ Function runWithLevel
+ ) {
+ LevelStorageSource.LevelStorageAccess levelStorageAccess;
+ try {
+ levelStorageAccess = levelSource.createAccess(levelID);
+ } catch (IOException e) {
+ BCLib.LOGGER.warning("Failed to read level {} data", levelID, e);
+ SystemToast.onWorldAccessFailure(Minecraft.getInstance(), levelID);
+ Minecraft.getInstance().setScreen(null);
+ return true;
+ }
+
+ boolean returnValue = runWithLevel.apply(levelStorageAccess);
+
+ try {
+ levelStorageAccess.close();
+ } catch (IOException e) {
+ BCLib.LOGGER.warning("Failed to unlock access to level {}", levelID, e);
+ }
+
+ return returnValue;
+ }
+
+ /**
+ * Will apply necessary Patches to the world.
+ *
+ * @param levelSource The SourceStorage for this Minecraft instance, You can get this using
+ * {@code Minecraft.getInstance().getLevelSource()}
+ * @param levelID The ID of the Level you want to patch
+ * @param showUI {@code true}, if you want to present the user with a Screen that offers to backup the world
+ * before applying the patches
+ * @param onResume When this method retursn {@code true}, this function will be called when the world is ready
+ * @return {@code true} if the UI was displayed. The UI is only displayed if {@code showUI} was {@code true} and
+ * patches were enabled in the config and the Guardian did find any patches that need to be applied to the world.
+ */
+ public static boolean fixData(
+ LevelStorageSource levelSource,
+ String levelID,
+ boolean showUI,
+ Consumer onResume
+ ) {
+ return wrapCall(levelSource, levelID, (levelStorageAccess) -> fixData(levelStorageAccess, showUI, onResume));
+ }
+
+ /**
+ * Will apply necessary Patches to the world.
+ *
+ * @param levelStorageAccess The access class of the level you want to patch
+ * @param showUI {@code true}, if you want to present the user with a Screen that offers to backup the world
+ * before applying the patches
+ * @param onResume When this method retursn {@code true}, this function will be called when the world is ready
+ * @return {@code true} if the UI was displayed. The UI is only displayed if {@code showUI} was {@code true} and
+ * patches were enabled in the config and the Guardian did find any patches that need to be applied to the world.
+ */
+ public static boolean fixData(
+ LevelStorageSource.LevelStorageAccess levelStorageAccess,
+ boolean showUI,
+ Consumer onResume
+ ) {
+ File levelPath = levelStorageAccess.getLevelPath(LevelResource.ROOT).toFile();
+ return fixData(levelPath, levelStorageAccess.getLevelId(), showUI, onResume);
+ }
+
+ /**
+ * Creates the patch level file for new worlds
+ */
+ public static void initializePatchData() {
+ getMigrationProfile().markApplied();
+ WorldConfig.saveFile(BCLib.MOD_ID);
+ }
+
+
+ @Environment(EnvType.CLIENT)
+ private static AtomicProgressListener showProgressScreen() {
+ ProgressScreen ps = new ProgressScreen(
+ Minecraft.getInstance().screen,
+ Component.translatable("title.bclib.datafixer.progress"),
+ Component.translatable("message.bclib.datafixer.progress")
+ );
+ Minecraft.getInstance().setScreen(ps);
+ return ps;
+ }
+
+ private static boolean fixData(File dir, String levelID, boolean showUI, Consumer onResume) {
+ MigrationProfile profile = loadProfileIfNeeded(dir);
+
+ BiConsumer runFixes = (createBackup, applyFixes) -> {
+ final AtomicProgressListener progress;
+ if (applyFixes) {
+ if (showUI) {
+ progress = showProgressScreen();
+ } else {
+ progress = new AtomicProgressListener() {
+ private long timeStamp = Util.getMillis();
+ private AtomicInteger counter = new AtomicInteger(0);
+
+ @Override
+ public void incAtomic(int maxProgress) {
+ int percentage = (100 * counter.incrementAndGet()) / maxProgress;
+ if (Util.getMillis() - this.timeStamp >= 1000L) {
+ this.timeStamp = Util.getMillis();
+ BCLib.LOGGER.info("Patching... {}%", percentage);
+ }
+ }
+
+ @Override
+ public void resetAtomic() {
+ counter = new AtomicInteger(0);
+ }
+
+ public void stop() {
+ }
+
+ public void progressStage(Component component) {
+ BCLib.LOGGER.info("Patcher Stage... {}%", component.getString());
+ }
+ };
+ }
+ } else {
+ progress = null;
+ }
+
+ Supplier runner = () -> {
+ if (createBackup) {
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.waitbackup"));
+ EditWorldScreen.makeBackupAndShowToast(Minecraft.getInstance().getLevelSource(), levelID);
+ }
+
+ if (applyFixes) {
+ return runDataFixes(dir, profile, progress);
+ }
+
+ return new State();
+ };
+
+ if (showUI) {
+ Thread fixerThread = new Thread(() -> {
+ final State state = runner.get();
+
+ Minecraft.getInstance()
+ .execute(() -> {
+ if (profile != null && showUI) {
+ //something went wrong, show the user our error
+ if (state.didFail || state.hasError()) {
+ showLevelFixErrorScreen(state, (markFixed) -> {
+ if (markFixed) {
+ profile.markApplied();
+ }
+ onResume.accept(applyFixes);
+ });
+ } else {
+ onResume.accept(applyFixes);
+ }
+ }
+ });
+
+ });
+ fixerThread.start();
+ } else {
+ State state = runner.get();
+ if (state.hasError()) {
+ LOGGER.error("There were Errors while fixing the Level:");
+ LOGGER.error(state.getErrorMessage());
+ }
+ }
+ };
+
+ //we have some migrations
+ if (profile != null) {
+ //display the confirm UI.
+ if (showUI) {
+ showBackupWarning(levelID, runFixes);
+ return true;
+ } else {
+ BCLib.LOGGER.warning("Applying Fixes on Level");
+ runFixes.accept(false, true);
+ }
+ }
+ return false;
+ }
+
+ @Environment(EnvType.CLIENT)
+ private static void showLevelFixErrorScreen(State state, Listener onContinue) {
+ Minecraft.getInstance()
+ .setScreen(new LevelFixErrorScreen(
+ Minecraft.getInstance().screen,
+ state.getErrorMessages(),
+ onContinue
+ ));
+ }
+
+ private static MigrationProfile loadProfileIfNeeded(File levelBaseDir) {
+ if (!Configs.MAIN_CONFIG.applyPatches()) {
+ LOGGER.info("World Patches are disabled");
+ return null;
+ }
+
+ MigrationProfile profile = getMigrationProfile();
+ profile.runPrePatches(levelBaseDir);
+
+ if (!profile.hasAnyFixes()) {
+ LOGGER.info("Everything up to date");
+ return null;
+ }
+
+ return profile;
+ }
+
+ @NotNull
+ private static MigrationProfile getMigrationProfile() {
+ final CompoundTag patchConfig = WorldConfig.getCompoundTag(BCLib.MOD_ID, Configs.MAIN_PATCH_CATEGORY);
+ MigrationProfile profile = Patch.createMigrationData(patchConfig);
+ return profile;
+ }
+
+ @Environment(EnvType.CLIENT)
+ static void showBackupWarning(String levelID, BiConsumer whenFinished) {
+ Minecraft.getInstance().setScreen(new ConfirmFixScreen(null, whenFinished::accept));
+ }
+
+ private static State runDataFixes(File dir, MigrationProfile profile, AtomicProgressListener progress) {
+ State state = new State();
+ progress.resetAtomic();
+
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.reading"));
+ List players = getAllPlayers(dir);
+ List regions = getAllRegions(dir, null);
+ final int maxProgress = players.size() + regions.size() + 4;
+ progress.incAtomic(maxProgress);
+
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.players"));
+ players.parallelStream().forEach((file) -> {
+ fixPlayer(profile, state, file);
+ progress.incAtomic(maxProgress);
+ });
+
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.level"));
+ fixLevel(profile, state, dir);
+ progress.incAtomic(maxProgress);
+
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.worlddata"));
+ try {
+ profile.patchWorldData();
+ } catch (PatchDidiFailException e) {
+ state.didFail = true;
+ state.addError("Failed fixing worldconfig (" + e.getMessage() + ")");
+ BCLib.LOGGER.error(e.getMessage());
+ }
+ progress.incAtomic(maxProgress);
+
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.regions"));
+ regions.parallelStream().forEach((file) -> {
+ fixRegion(profile, state, file);
+ progress.incAtomic(maxProgress);
+ });
+
+ if (!state.didFail) {
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.saving"));
+ profile.markApplied();
+ WorldConfig.saveFile(BCLib.MOD_ID);
+ }
+ progress.incAtomic(maxProgress);
+
+ progress.stop();
+
+ return state;
+ }
+
+ private static void fixLevel(MigrationProfile profile, State state, File levelBaseDir) {
+ try {
+ LOGGER.info("Inspecting level.dat in " + levelBaseDir);
+
+ //load the level (could already contain patches applied by patchLevelDat)
+ CompoundTag level = profile.getLevelDat(levelBaseDir);
+ boolean[] changed = {profile.isLevelDatChanged()};
+
+ if (profile.getPrePatchException() != null) {
+ throw profile.getPrePatchException();
+ }
+
+ if (level.contains("Data")) {
+ CompoundTag dataTag = (CompoundTag) level.get("Data");
+ if (dataTag.contains("Player")) {
+ CompoundTag player = (CompoundTag) dataTag.get("Player");
+ fixPlayerNbt(player, changed, profile);
+ }
+ }
+
+ if (changed[0]) {
+ LOGGER.warning("Writing '{}'", profile.getLevelDatFile());
+ NbtIo.writeCompressed(level, profile.getLevelDatFile());
+ }
+ } catch (Exception e) {
+ BCLib.LOGGER.error("Failed fixing Level-Data.");
+ state.addError("Failed fixing Level-Data in level.dat (" + e.getMessage() + ")");
+ state.didFail = true;
+ e.printStackTrace();
+ }
+ }
+
+ private static void fixPlayer(MigrationProfile data, State state, File file) {
+ try {
+ LOGGER.info("Inspecting " + file);
+
+ CompoundTag player = readNbt(file);
+ boolean[] changed = {false};
+ fixPlayerNbt(player, changed, data);
+
+ if (changed[0]) {
+ LOGGER.warning("Writing '{}'", file);
+ NbtIo.writeCompressed(player, file);
+ }
+ } catch (Exception e) {
+ BCLib.LOGGER.error("Failed fixing Player-Data.");
+ state.addError("Failed fixing Player-Data in " + file.getName() + " (" + e.getMessage() + ")");
+ state.didFail = true;
+ e.printStackTrace();
+ }
+ }
+
+ private static void fixPlayerNbt(CompoundTag player, boolean[] changed, MigrationProfile data) {
+ //Checking Inventory
+ ListTag inventory = player.getList("Inventory", Tag.TAG_COMPOUND);
+ fixItemArrayWithID(inventory, changed, data, true);
+
+ //Checking EnderChest
+ ListTag enderitems = player.getList("EnderItems", Tag.TAG_COMPOUND);
+ fixItemArrayWithID(enderitems, changed, data, true);
+
+ //Checking ReceipBook
+ if (player.contains("recipeBook")) {
+ CompoundTag recipeBook = player.getCompound("recipeBook");
+ changed[0] |= fixStringIDList(recipeBook, "recipes", data);
+ changed[0] |= fixStringIDList(recipeBook, "toBeDisplayed", data);
+ }
+ }
+
+ static boolean fixStringIDList(CompoundTag root, String name, MigrationProfile data) {
+ boolean _changed = false;
+ if (root.contains(name)) {
+ ListTag items = root.getList(name, Tag.TAG_STRING);
+ ListTag newItems = new ListTag();
+
+ for (Tag tag : items) {
+ final StringTag str = (StringTag) tag;
+ final String replace = data.replaceStringFromIDs(str.getAsString());
+ if (replace != null) {
+ _changed = true;
+ newItems.add(StringTag.valueOf(replace));
+ } else {
+ newItems.add(tag);
+ }
+ }
+ if (_changed) {
+ root.put(name, newItems);
+ }
+ }
+ return _changed;
+ }
+
+ private static void fixRegion(MigrationProfile data, State state, File file) {
+ try {
+ Path path = file.toPath();
+ LOGGER.info("Inspecting " + path);
+ boolean[] changed = new boolean[1];
+ RegionFile region = new RegionFile(path, path.getParent(), true);
+
+ for (int x = 0; x < 32; x++) {
+ for (int z = 0; z < 32; z++) {
+ ChunkPos pos = new ChunkPos(x, z);
+ changed[0] = false;
+ if (region.hasChunk(pos) && !state.didFail) {
+ DataInputStream input = region.getChunkDataInputStream(pos);
+ CompoundTag root = NbtIo.read(input);
+ // if ((root.toString().contains("betternether:chest") || root.toString().contains("bclib:chest"))) {
+ // NbtIo.write(root, new File(file.toString() + "-" + x + "-" + z + ".nbt"));
+ // }
+ input.close();
+
+ //Checking TileEntities
+ ListTag tileEntities = root.getCompound("Level")
+ .getList("TileEntities", Tag.TAG_COMPOUND);
+ fixItemArrayWithID(tileEntities, changed, data, true);
+
+ //Checking Entities
+ ListTag entities = root.getList("Entities", Tag.TAG_COMPOUND);
+ fixItemArrayWithID(entities, changed, data, true);
+
+ //Checking Block Palette
+ ListTag sections = root.getCompound("Level")
+ .getList("Sections", Tag.TAG_COMPOUND);
+ sections.forEach((tag) -> {
+ ListTag palette = ((CompoundTag) tag).getList("Palette", Tag.TAG_COMPOUND);
+ palette.forEach((blockTag) -> {
+ CompoundTag blockTagCompound = ((CompoundTag) blockTag);
+ changed[0] |= data.replaceStringFromIDs(blockTagCompound, "Name");
+ });
+
+ try {
+ changed[0] |= data.patchBlockState(
+ palette,
+ ((CompoundTag) tag).getList(
+ "BlockStates",
+ Tag.TAG_LONG
+ )
+ );
+ } catch (PatchDidiFailException e) {
+ BCLib.LOGGER.error("Failed fixing BlockState in " + pos);
+ state.addError("Failed fixing BlockState in " + pos + " (" + e.getMessage() + ")");
+ state.didFail = true;
+ changed[0] = false;
+ e.printStackTrace();
+ }
+ });
+
+ if (changed[0]) {
+ LOGGER.warning("Writing '{}': {}/{}", file, x, z);
+ // NbtIo.write(root, new File(file.toString() + "-" + x + "-" + z + "-changed.nbt"));
+ DataOutputStream output = region.getChunkDataOutputStream(pos);
+ NbtIo.write(root, output);
+ output.close();
+ }
+ }
+ }
+ }
+ region.close();
+ } catch (Exception e) {
+ BCLib.LOGGER.error("Failed fixing Region.");
+ state.addError("Failed fixing Region in " + file.getName() + " (" + e.getMessage() + ")");
+ state.didFail = true;
+ e.printStackTrace();
+ }
+ }
+
+ static CompoundTag patchConfTag = null;
+
+ static CompoundTag getPatchData() {
+ if (patchConfTag == null) {
+ patchConfTag = WorldConfig.getCompoundTag(BCLib.MOD_ID, Configs.MAIN_PATCH_CATEGORY);
+ }
+ return patchConfTag;
+ }
+
+ static void fixItemArrayWithID(ListTag items, boolean[] changed, MigrationProfile data, boolean recursive) {
+ items.forEach(inTag -> {
+ fixID((CompoundTag) inTag, changed, data, recursive);
+ });
+ }
+
+
+ static void fixID(CompoundTag inTag, boolean[] changed, MigrationProfile data, boolean recursive) {
+ final CompoundTag tag = inTag;
+
+ changed[0] |= data.replaceStringFromIDs(tag, "id");
+ if (tag.contains("Item")) {
+ CompoundTag item = (CompoundTag) tag.get("Item");
+ fixID(item, changed, data, recursive);
+ }
+
+ if (recursive && tag.contains("Items")) {
+ fixItemArrayWithID(tag.getList("Items", Tag.TAG_COMPOUND), changed, data, true);
+ }
+ if (recursive && tag.contains("Inventory")) {
+ ListTag inventory = tag.getList("Inventory", Tag.TAG_COMPOUND);
+ fixItemArrayWithID(inventory, changed, data, true);
+ }
+ if (tag.contains("tag")) {
+ CompoundTag entityTag = (CompoundTag) tag.get("tag");
+ if (entityTag.contains("BlockEntityTag")) {
+ CompoundTag blockEntityTag = (CompoundTag) entityTag.get("BlockEntityTag");
+ fixID(blockEntityTag, changed, data, recursive);
+ /*ListTag items = blockEntityTag.getList("Items", Tag.TAG_COMPOUND);
+ fixItemArrayWithID(items, changed, data, recursive);*/
+ }
+ }
+ }
+
+ private static List getAllPlayers(File dir) {
+ List list = new ArrayList<>();
+ dir = new File(dir, "playerdata");
+ if (!dir.exists() || !dir.isDirectory()) {
+ return list;
+ }
+ for (File file : dir.listFiles()) {
+ if (file.isFile() && file.getName().endsWith(".dat")) {
+ list.add(file);
+ }
+ }
+ return list;
+ }
+
+ private static List getAllRegions(File dir, List list) {
+ if (list == null) {
+ list = new ArrayList<>();
+ }
+ for (File file : dir.listFiles()) {
+ if (file.isDirectory()) {
+ getAllRegions(file, list);
+ } else if (file.isFile() && file.getName().endsWith(".mca")) {
+ list.add(file);
+ }
+ }
+ return list;
+ }
+
+ /**
+ * register a new Patch
+ *
+ * @param patch A #Supplier that will instantiate the new Patch Object
+ */
+ public static void registerPatch(Supplier patch) {
+ Patch.getALL().add(patch.get());
+ }
+
+ private static CompoundTag readNbt(File file) throws IOException {
+ try {
+ return NbtIo.readCompressed(file);
+ } catch (ZipException | EOFException e) {
+ return NbtIo.read(file);
+ }
+ }
+
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/datafixer/ForcedLevelPatch.java b/src/main/java/org/betterx/bclib/api/v2/datafixer/ForcedLevelPatch.java
new file mode 100644
index 00000000..0d18a7e0
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/datafixer/ForcedLevelPatch.java
@@ -0,0 +1,57 @@
+package org.betterx.bclib.api.v2.datafixer;
+
+import org.betterx.bclib.interfaces.PatchBiFunction;
+import org.betterx.bclib.interfaces.PatchFunction;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.jetbrains.annotations.NotNull;
+
+
+/**
+ * A Patch for level.dat that is always executed no matter what Patchlevel is set in a world.
+ */
+public abstract class ForcedLevelPatch extends Patch {
+ protected ForcedLevelPatch(@NotNull String modID, String version) {
+ super(modID, version, true);
+ }
+
+ @Override
+ public final Map getIDReplacements() {
+ return new HashMap();
+ }
+
+ @Override
+ public final PatchFunction getWorldDataPatcher() {
+ return null;
+ }
+
+ @Override
+ public final PatchBiFunction getBlockStatePatcher() {
+ return null;
+ }
+
+ @Override
+ public final List getWorldDataIDPaths() {
+ return null;
+ }
+
+ @Override
+ public PatchFunction getLevelDatPatcher() {
+ return this::runLevelDatPatch;
+ }
+
+ /**
+ * Called with the contents of level.dat in {@code root}
+ *
+ * @param root The contents of level.dat
+ * @param profile The active migration profile
+ * @return true, if the run did change the contents of root
+ */
+ abstract protected Boolean runLevelDatPatch(CompoundTag root, MigrationProfile profile);
+}
+
diff --git a/src/main/java/org/betterx/bclib/api/v2/datafixer/MigrationProfile.java b/src/main/java/org/betterx/bclib/api/v2/datafixer/MigrationProfile.java
new file mode 100644
index 00000000..931773ca
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/datafixer/MigrationProfile.java
@@ -0,0 +1,374 @@
+package org.betterx.bclib.api.v2.datafixer;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.interfaces.PatchBiFunction;
+import org.betterx.bclib.interfaces.PatchFunction;
+import org.betterx.worlds.together.util.ModUtil;
+import org.betterx.worlds.together.world.WorldConfig;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtIo;
+import net.minecraft.nbt.Tag;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.*;
+import java.util.stream.Collectors;
+import org.jetbrains.annotations.NotNull;
+
+public class MigrationProfile {
+ final Set mods;
+ final Map idReplacements;
+ final List> levelPatchers;
+ final List> statePatchers;
+ final List worldDataPatchers;
+ final Map> worldDataIDPaths;
+
+ private final CompoundTag config;
+ private CompoundTag level;
+ private File levelBaseDir;
+ private boolean prePatchChangedLevelDat;
+ private boolean didRunPrePatch;
+ private Exception prePatchException;
+
+ MigrationProfile(CompoundTag config, boolean applyAll) {
+ this.config = config;
+
+ this.mods = Collections.unmodifiableSet(Patch.getALL()
+ .stream()
+ .map(p -> p.modID)
+ .collect(Collectors.toSet()));
+
+ HashMap replacements = new HashMap();
+ List> levelPatches = new LinkedList<>();
+ List worldDataPatches = new LinkedList<>();
+ List> statePatches = new LinkedList<>();
+ HashMap> worldDataIDPaths = new HashMap<>();
+ for (String modID : mods) {
+
+ Patch.getALL()
+ .stream()
+ .filter(p -> p.modID.equals(modID))
+ .forEach(patch -> {
+ List paths = patch.getWorldDataIDPaths();
+ if (paths != null) worldDataIDPaths.put(modID, paths);
+
+ if (applyAll || currentPatchLevel(modID) < patch.level || patch.alwaysApply) {
+ replacements.putAll(patch.getIDReplacements());
+ if (patch.getLevelDatPatcher() != null)
+ levelPatches.add(patch.getLevelDatPatcher());
+ if (patch.getWorldDataPatcher() != null)
+ worldDataPatches.add(patch);
+ if (patch.getBlockStatePatcher() != null)
+ statePatches.add(patch.getBlockStatePatcher());
+ DataFixerAPI.LOGGER.info("Applying " + patch);
+ } else {
+ DataFixerAPI.LOGGER.info("Ignoring " + patch);
+ }
+ });
+ }
+
+ this.worldDataIDPaths = Collections.unmodifiableMap(worldDataIDPaths);
+ this.idReplacements = Collections.unmodifiableMap(replacements);
+ this.levelPatchers = Collections.unmodifiableList(levelPatches);
+ this.worldDataPatchers = Collections.unmodifiableList(worldDataPatches);
+ this.statePatchers = Collections.unmodifiableList(statePatches);
+ }
+
+ /**
+ * This method is supposed to be used by developers to apply id-patches to custom nbt structures. It is only
+ * available in Developer-Mode
+ */
+ public static void fixCustomFolder(File dir) {
+ if (!BCLib.isDevEnvironment()) return;
+ MigrationProfile profile = Patch.createMigrationData();
+ List nbts = getAllNbts(dir, null);
+ nbts.parallelStream().forEach((file) -> {
+ DataFixerAPI.LOGGER.info("Loading NBT " + file);
+ try {
+ CompoundTag root = NbtIo.readCompressed(file);
+ boolean[] changed = {false};
+ int spawnerIdx = -1;
+ if (root.contains("palette")) {
+ ListTag items = root.getList("palette", Tag.TAG_COMPOUND);
+ for (int idx = 0; idx < items.size(); idx++) {
+ final CompoundTag tag = (CompoundTag) items.get(idx);
+ if (tag.contains("Name") && tag.getString("Name").equals("minecraft:spawner"))
+ spawnerIdx = idx;
+ if (tag.contains("Name") && (tag.getString("Name").equals("minecraft:") || tag.getString("Name")
+ .equals(""))) {
+ System.out.println("Empty Name");
+ }
+ if (tag.contains("id") && (tag.getString("id").equals("minecraft:") || tag.getString("id")
+ .equals(""))) {
+ System.out.println("Empty ID");
+ }
+ changed[0] |= profile.replaceStringFromIDs(tag, "Name");
+ }
+ }
+
+ if (spawnerIdx >= 0 && root.contains("blocks")) {
+ ListTag items = root.getList("blocks", Tag.TAG_COMPOUND);
+ for (int idx = 0; idx < items.size(); idx++) {
+ final CompoundTag blockTag = (CompoundTag) items.get(idx);
+ if (blockTag.contains("state") && blockTag.getInt("state") == spawnerIdx && blockTag.contains(
+ "nbt")) {
+ CompoundTag nbt = blockTag.getCompound("nbt");
+ if (nbt.contains("SpawnData")) {
+ final CompoundTag entity = nbt.getCompound("SpawnData");
+ if (!entity.contains("entity")) {
+ CompoundTag data = new CompoundTag();
+ data.put("entity", entity);
+ nbt.put("SpawnData", data);
+
+ changed[0] = true;
+ }
+ }
+ if (nbt.contains("SpawnPotentials")) {
+ ListTag pots = nbt.getList("SpawnPotentials", Tag.TAG_COMPOUND);
+ for (Tag potItemIn : pots) {
+ final CompoundTag potItem = (CompoundTag) potItemIn;
+ if (potItem.contains("Weight")) {
+ int weight = potItem.getInt("Weight");
+ potItem.putInt("weight", weight);
+ potItem.remove("Weight");
+
+ changed[0] = true;
+ }
+
+ if (potItem.contains("Entity")) {
+ CompoundTag entity = potItem.getCompound("Entity");
+ CompoundTag data = new CompoundTag();
+ data.put("entity", entity);
+
+ potItem.put("data", data);
+ potItem.remove("Entity");
+
+ changed[0] = true;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (changed[0]) {
+ DataFixerAPI.LOGGER.info("Writing NBT " + file);
+ NbtIo.writeCompressed(root, file);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ });
+ }
+
+ private static List getAllNbts(File dir, List list) {
+ if (list == null) {
+ list = new ArrayList<>();
+ }
+ for (File file : dir.listFiles()) {
+ if (file.isDirectory()) {
+ getAllNbts(file, list);
+ } else if (file.isFile() && file.getName().endsWith(".nbt")) {
+ list.add(file);
+ }
+ }
+ return list;
+ }
+
+ final public CompoundTag getLevelDat(File levelBaseDir) {
+ if (level == null || this.levelBaseDir == null || !this.levelBaseDir.equals(levelBaseDir)) {
+ runPrePatches(levelBaseDir);
+ }
+ return level;
+ }
+
+ final public boolean isLevelDatChanged() {
+ return prePatchChangedLevelDat;
+ }
+
+ final public File getLevelDatFile() {
+ return new File(levelBaseDir, "level.dat");
+ }
+
+ final public Exception getPrePatchException() {
+ return prePatchException;
+ }
+
+
+ final public void runPrePatches(File levelBaseDir) {
+ if (didRunPrePatch) {
+ BCLib.LOGGER.warning("Already did run PrePatches for " + this.levelBaseDir + ".");
+ }
+ BCLib.LOGGER.info("Running Pre Patchers on " + levelBaseDir);
+
+ this.levelBaseDir = levelBaseDir;
+ this.level = null;
+ this.prePatchException = null;
+ didRunPrePatch = true;
+
+ this.prePatchChangedLevelDat = runPreLevelPatches(getLevelDatFile());
+ }
+
+ private boolean runPreLevelPatches(File levelDat) {
+ try {
+ level = NbtIo.readCompressed(levelDat);
+
+ boolean changed = patchLevelDat(level);
+ return changed;
+ } catch (IOException | PatchDidiFailException e) {
+ prePatchException = e;
+ return false;
+ }
+ }
+
+ final public void markApplied() {
+ for (String modID : mods) {
+ DataFixerAPI.LOGGER.info(
+ "Updating Patch-Level for '{}' from {} to {}",
+ modID,
+ ModUtil.convertModVersion(currentPatchLevel(modID)),
+ ModUtil.convertModVersion(Patch.maxPatchLevel(modID))
+ );
+ if (config != null)
+ config.putString(modID, Patch.maxPatchVersion(modID));
+ }
+ }
+
+ public String currentPatchVersion(@NotNull String modID) {
+ if (config == null || !config.contains(modID)) return "0.0.0";
+ return config.getString(modID);
+ }
+
+ public int currentPatchLevel(@NotNull String modID) {
+ return ModUtil.convertModVersion(currentPatchVersion(modID));
+ }
+
+ public boolean hasAnyFixes() {
+ boolean hasLevelDatPatches;
+ if (didRunPrePatch != false) {
+ hasLevelDatPatches = prePatchChangedLevelDat;
+ } else {
+ hasLevelDatPatches = levelPatchers.size() > 0;
+ }
+
+ return idReplacements.size() > 0 || hasLevelDatPatches || worldDataPatchers.size() > 0;
+ }
+
+ public String replaceStringFromIDs(@NotNull String val) {
+ final String replace = idReplacements.get(val);
+ return replace;
+ }
+
+ public boolean replaceStringFromIDs(@NotNull CompoundTag tag, @NotNull String key) {
+ if (!tag.contains(key)) return false;
+
+ final String val = tag.getString(key);
+ final String replace = idReplacements.get(val);
+
+ if (replace != null) {
+ DataFixerAPI.LOGGER.warning("Replacing ID '{}' with '{}'.", val, replace);
+ tag.putString(key, replace);
+ return true;
+ }
+
+ return false;
+ }
+
+ private boolean replaceIDatPath(@NotNull ListTag list, @NotNull String[] parts, int level) {
+ boolean[] changed = {false};
+ if (level == parts.length - 1) {
+ DataFixerAPI.fixItemArrayWithID(list, changed, this, true);
+ } else {
+ list.forEach(inTag -> changed[0] |= replaceIDatPath((CompoundTag) inTag, parts, level + 1));
+ }
+ return changed[0];
+ }
+
+ private boolean replaceIDatPath(@NotNull CompoundTag tag, @NotNull String[] parts, int level) {
+ boolean changed = false;
+ for (int i = level; i < parts.length - 1; i++) {
+ final String part = parts[i];
+ if (tag.contains(part)) {
+ final byte type = tag.getTagType(part);
+ if (type == Tag.TAG_LIST) {
+ ListTag list = tag.getList(part, Tag.TAG_COMPOUND);
+ return replaceIDatPath(list, parts, i);
+ } else if (type == Tag.TAG_COMPOUND) {
+ tag = tag.getCompound(part);
+ }
+ } else {
+ return false;
+ }
+ }
+
+ if (tag != null && parts.length > 0) {
+ final String key = parts[parts.length - 1];
+ final byte type = tag.getTagType(key);
+ if (type == Tag.TAG_LIST) {
+ final ListTag list = tag.getList(key, Tag.TAG_COMPOUND);
+ final boolean[] _changed = {false};
+ if (list.size() == 0) {
+ _changed[0] = DataFixerAPI.fixStringIDList(tag, key, this);
+ } else {
+ DataFixerAPI.fixItemArrayWithID(list, _changed, this, true);
+ }
+ return _changed[0];
+ } else if (type == Tag.TAG_STRING) {
+ return replaceStringFromIDs(tag, key);
+ } else if (type == Tag.TAG_COMPOUND) {
+ final CompoundTag cTag = tag.getCompound(key);
+ boolean[] _changed = {false};
+ DataFixerAPI.fixID(cTag, _changed, this, true);
+ return _changed[0];
+ }
+ }
+
+
+ return false;
+ }
+
+ public boolean replaceIDatPath(@NotNull CompoundTag root, @NotNull String path) {
+ String[] parts = path.split("\\.");
+ return replaceIDatPath(root, parts, 0);
+ }
+
+ public boolean patchLevelDat(@NotNull CompoundTag level) throws PatchDidiFailException {
+ boolean changed = false;
+ for (PatchFunction f : levelPatchers) {
+ changed |= f.apply(level, this);
+ }
+ return changed;
+ }
+
+ public void patchWorldData() throws PatchDidiFailException {
+ for (Patch patch : worldDataPatchers) {
+ CompoundTag root = WorldConfig.getRootTag(patch.modID);
+ boolean changed = patch.getWorldDataPatcher().apply(root, this);
+ if (changed) {
+ WorldConfig.saveFile(patch.modID);
+ }
+ }
+
+ for (Map.Entry> entry : worldDataIDPaths.entrySet()) {
+ CompoundTag root = WorldConfig.getRootTag(entry.getKey());
+ boolean[] changed = {false};
+ entry.getValue().forEach(path -> {
+ changed[0] |= replaceIDatPath(root, path);
+ });
+
+ if (changed[0]) {
+ WorldConfig.saveFile(entry.getKey());
+ }
+ }
+ }
+
+ public boolean patchBlockState(ListTag palette, ListTag states) throws PatchDidiFailException {
+ boolean changed = false;
+ for (PatchBiFunction f : statePatchers) {
+ changed |= f.apply(palette, states, this);
+ }
+ return changed;
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/datafixer/Patch.java b/src/main/java/org/betterx/bclib/api/v2/datafixer/Patch.java
new file mode 100644
index 00000000..cadf2ec3
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/datafixer/Patch.java
@@ -0,0 +1,237 @@
+package org.betterx.bclib.api.v2.datafixer;
+
+import org.betterx.bclib.interfaces.PatchBiFunction;
+import org.betterx.bclib.interfaces.PatchFunction;
+import org.betterx.worlds.together.util.ModUtil;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.jetbrains.annotations.NotNull;
+
+public abstract class Patch {
+ private static final List ALL = new ArrayList<>(10);
+
+ /**
+ * The Patch-Level derived from {@link #version}
+ */
+ public final int level;
+
+ /**
+ * The Patch-Version string
+ */
+ public final String version;
+
+ /**
+ * The Mod-ID that registered this Patch
+ */
+
+ @NotNull
+ public final String modID;
+
+ /**
+ * This Mod is tested for each level start
+ */
+ public final boolean alwaysApply;
+
+ static List getALL() {
+ return ALL;
+ }
+
+ /**
+ * Returns the highest Patch-Version that is available for the given mod. If no patches were
+ * registerd for the mod, this will return 0.0.0
+ *
+ * @param modID The ID of the mod you want to query
+ * @return The highest Patch-Version that was found
+ */
+ public static String maxPatchVersion(@NotNull String modID) {
+ return ALL.stream().filter(p -> p.modID.equals(modID)).map(p -> p.version).reduce((p, c) -> c).orElse("0.0.0");
+ }
+
+ /**
+ * Returns the highest patch-level that is available for the given mod. If no patches were
+ * registerd for the mod, this will return 0
+ *
+ * @param modID The ID of the mod you want to query
+ * @return The highest Patch-Level that was found
+ */
+ public static int maxPatchLevel(@NotNull String modID) {
+ return ALL.stream().filter(p -> p.modID.equals(modID)).mapToInt(p -> p.level).max().orElse(0);
+ }
+
+ /**
+ * Called by inheriting classes.
+ *
+ * Performs some sanity checks on the values and might throw a #RuntimeException if any
+ * inconsistencies are found.
+ *
+ * @param modID The ID of the Mod you want to register a patch for. This should be your
+ * ModID only. The ModID can not be {@code null} or an empty String.
+ * @param version The mod-version that introduces the patch. This needs Semantic-Version String
+ * like x.x.x. Developers are responsible for registering their patches in the correct
+ * order (with increasing versions). You are not allowed to register a new
+ * Patch with a version lower or equal than
+ * {@link Patch#maxPatchVersion(String)}
+ */
+ protected Patch(@NotNull String modID, String version) {
+ this(modID, version, false);
+ }
+
+ /**
+ * Internal Constructor used to create patches that can allways run (no matter what patchlevel a level has)
+ *
+ * @param modID The ID of the Mod
+ * @param version The mod-version that introduces the patch. When {@Code runAllways} is set, this version will
+ * determine the patchlevel that is written to the level
+ * @param alwaysApply When true, this patch is always active, no matter the patchlevel of the world.
+ * This should be used sparingly and just for patches that apply to level.dat (as they only take
+ * effect when changes are detected). Use {@link ForcedLevelPatch} to instatiate.
+ */
+ Patch(@NotNull String modID, String version, boolean alwaysApply) {
+ //Patchlevels need to be unique and registered in ascending order
+ if (modID == null || modID.isEmpty()) {
+ throw new RuntimeException("[INTERNAL ERROR] Patches need a valid modID!");
+ }
+
+ if (version == null || version.isEmpty()) {
+ throw new RuntimeException("Invalid Mod-Version");
+ }
+
+ this.version = version;
+ this.alwaysApply = alwaysApply;
+ this.level = ModUtil.convertModVersion(version);
+ if (!ALL.stream().filter(p -> p.modID.equals(modID)).noneMatch(p -> p.level >= this.level) || this.level <= 0) {
+ throw new RuntimeException(
+ "[INTERNAL ERROR] Patch-levels need to be created in ascending order beginning with 1.");
+ }
+
+ this.modID = modID;
+ }
+
+ @Override
+ public String toString() {
+ return "Patch{" + modID + ':' + version + ':' + level + '}';
+ }
+
+
+ /**
+ * Return block data fixes. Fixes will be applied on world load if current patch-level for
+ * the linked mod is lower than the {@link #level}.
+ *
+ * The default implementation of this method returns an empty map.
+ *
+ * @return The returned Map should contain the replacements. All occurences of the
+ * {@code KeySet} are replaced with the associated value.
+ */
+ public Map getIDReplacements() {
+ return new HashMap();
+ }
+
+ /**
+ * Return a {@link PatchFunction} that is called with the content of level.dat.
+ *
+ * The function needs to return {@code true}, if changes were made to the data.
+ * If an error occurs, the method should throw a {@link PatchDidiFailException}
+ *
+ * The default implementation of this method returns null.
+ *
+ * @return {@code true} if changes were applied and we need to save the data
+ */
+ public PatchFunction getLevelDatPatcher() {
+ return null;
+ }
+
+ /**
+ * Return a {@link PatchFunction} that is called with the content from the
+ * {@link org.betterx.worlds.together.world.WorldConfig} for this Mod.
+ * The function needs to return {@code true}, if changes were made to the data.
+ * If an error occurs, the method should throw a {@link PatchDidiFailException}
+ *
+ * The default implementation of this method returns null.
+ *
+ * @return {@code true} if changes were applied and we need to save the data
+ */
+ public PatchFunction getWorldDataPatcher() {
+ return null;
+ }
+
+ /**
+ * Return a {@link PatchBiFunction} that is called with pallette and blockstate of
+ * each chunk in every region. This method is called AFTER all ID replacements
+ * from {@link #getIDReplacements()} were applied to the pallete.
+ *
+ * The first parameter is the palette and the second is the blockstate.
+ *
+ * The function needs to return {@code true}, if changes were made to the data.
+ * If an error occurs, the method should throw a {@link PatchDidiFailException}
+ *
+ * The default implementation of this method returns null.
+ *
+ * @return {@code true} if changes were applied and we need to save the data
+ */
+ public PatchBiFunction getBlockStatePatcher() {
+ return null;
+ }
+
+ /**
+ * Generates ready to use data for all currently registered patches. The list of
+ * patches is selected by the current patch-level of the world.
+ *
+ * A {@link #Patch} with a given {@link #level} is only included if the patch-level of the
+ * world is less
+ *
+ * @param config The current patch-level configuration*
+ * @return a new {@link MigrationProfile} Object.
+ */
+ static MigrationProfile createMigrationData(CompoundTag config) {
+ return new MigrationProfile(config, false);
+ }
+
+ /**
+ * This method is supposed to be used by developers to apply id-patches to custom nbt structures. It is only
+ * available in Developer-Mode
+ */
+ static MigrationProfile createMigrationData() {
+ return new MigrationProfile(null, true);
+ }
+
+ /**
+ * Returns a list of paths where your mod stores IDs in your {@link org.betterx.worlds.together.world.WorldConfig}-File.
+ *
+ * {@link DataFixerAPI} will use information from the latest patch that returns a non-null-result. This list is used
+ * to automatically fix changed IDs from all active patches (see {@link Patch#getIDReplacements()}
+ *
+ * The end of the path can either be a {@link net.minecraft.nbt.StringTag}, a {@link net.minecraft.nbt.ListTag} or
+ * a {@link CompoundTag}. If the Path contains a non-leaf {@link net.minecraft.nbt.ListTag}, all members of that
+ * list will be processed. For example:
+ *
+ * - global +
+ * | - key (String)
+ * | - items (List) +
+ * | - { id (String) }
+ * | - { id (String) }
+ *
+ * The path global.items.id will fix all id-entries in the items-list, while the path
+ * global.key will only fix the key-entry.
+ *
+ * if the leaf-entry (= the last part of the path, which would be items in global.items) is a
+ * {@link CompoundTag}, the system will fix any id entry. If the {@link CompoundTag} contains an item
+ * or tag.BlockEntityTag entry, the system will recursivley continue with those. If an items
+ * or inventory-{@link net.minecraft.nbt.ListTag} was found, the system will continue recursivley with
+ * every item of that list.
+ *
+ * if the leaf-entry is a {@link net.minecraft.nbt.ListTag}, it is handle the same as a child items entry
+ * of a {@link CompoundTag}.
+ *
+ * @return {@code null} if nothing changes or a list of Paths in your {@link org.betterx.worlds.together.world.WorldConfig}-File.
+ * Paths are dot-seperated (see {@link org.betterx.worlds.together.world.WorldConfig#getCompoundTag(String, String)}).
+ */
+ public List getWorldDataIDPaths() {
+ return null;
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/datafixer/PatchDidiFailException.java b/src/main/java/org/betterx/bclib/api/v2/datafixer/PatchDidiFailException.java
new file mode 100644
index 00000000..053d29fe
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/datafixer/PatchDidiFailException.java
@@ -0,0 +1,11 @@
+package org.betterx.bclib.api.v2.datafixer;
+
+public class PatchDidiFailException extends Exception {
+ public PatchDidiFailException() {
+ super();
+ }
+
+ public PatchDidiFailException(Exception e) {
+ super(e);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/generator/BCLBiomeSource.java b/src/main/java/org/betterx/bclib/api/v2/generator/BCLBiomeSource.java
new file mode 100644
index 00000000..cd2c84ed
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/BCLBiomeSource.java
@@ -0,0 +1,286 @@
+package org.betterx.bclib.api.v2.generator;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.levelgen.biomes.BCLBiome;
+import org.betterx.bclib.api.v2.levelgen.biomes.BCLBiomeRegistry;
+import org.betterx.bclib.api.v2.levelgen.biomes.BiomeAPI;
+import org.betterx.bclib.config.Configs;
+import org.betterx.worlds.together.biomesource.BiomeSourceHelper;
+import org.betterx.worlds.together.biomesource.MergeableBiomeSource;
+import org.betterx.worlds.together.biomesource.ReloadableBiomeSource;
+import org.betterx.worlds.together.world.BiomeSourceWithNoiseRelatedSettings;
+import org.betterx.worlds.together.world.BiomeSourceWithSeed;
+import org.betterx.worlds.together.world.event.WorldBootstrap;
+
+import net.minecraft.core.Holder;
+import net.minecraft.core.Registry;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraft.world.level.biome.BiomeSource;
+import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
+
+import java.util.*;
+import java.util.stream.Stream;
+import org.jetbrains.annotations.NotNull;
+
+public abstract class BCLBiomeSource extends BiomeSource implements BiomeSourceWithSeed, MergeableBiomeSource, BiomeSourceWithNoiseRelatedSettings, ReloadableBiomeSource {
+ @FunctionalInterface
+ public interface PickerAdder {
+ boolean add(BCLBiome bclBiome, BiomeAPI.BiomeType type, BiomePicker picker);
+ }
+
+ @FunctionalInterface
+ public interface CustomTypeFinder {
+ BiomeAPI.BiomeType find(ResourceKey biomeKey, BiomeAPI.BiomeType defaultType);
+ }
+
+ protected long currentSeed;
+ protected int maxHeight;
+ private boolean didCreatePickers;
+ Set> dynamicPossibleBiomes;
+
+ protected BCLBiomeSource(long seed) {
+ super();
+ this.dynamicPossibleBiomes = Set.of();
+ this.currentSeed = seed;
+ this.didCreatePickers = false;
+ }
+
+ @Override
+ protected Stream> collectPossibleBiomes() {
+ reloadBiomes();
+ return dynamicPossibleBiomes.stream();
+ }
+
+ @Override
+ public Set> possibleBiomes() {
+ return dynamicPossibleBiomes;
+ }
+
+
+ protected boolean wasBound() {
+ return didCreatePickers;
+ }
+
+ final public void setSeed(long seed) {
+ if (seed != currentSeed) {
+ BCLib.LOGGER.debug(this + "\n --> new seed = " + seed);
+ this.currentSeed = seed;
+ initMap(seed);
+ }
+ }
+
+ /**
+ * Set world height
+ *
+ * @param maxHeight height of the World.
+ */
+ final public void setMaxHeight(int maxHeight) {
+ if (this.maxHeight != maxHeight) {
+ BCLib.LOGGER.debug(this + "\n --> new height = " + maxHeight);
+ this.maxHeight = maxHeight;
+ onHeightChange(maxHeight);
+ }
+ }
+
+ protected final void initMap(long seed) {
+ BCLib.LOGGER.debug(this + "\n --> Map Update");
+ onInitMap(seed);
+ }
+
+ protected abstract void onInitMap(long newSeed);
+ protected abstract void onHeightChange(int newHeight);
+
+
+ @NotNull
+ protected String getNamespaces() {
+ return BiomeSourceHelper.getNamespaces(possibleBiomes());
+ }
+
+ protected boolean addToPicker(BCLBiome bclBiome, BiomeAPI.BiomeType type, BiomePicker picker) {
+ picker.addBiome(bclBiome);
+ return true;
+ }
+
+ protected BiomeAPI.BiomeType typeForUnknownBiome(ResourceKey biomeKey, BiomeAPI.BiomeType defaultType) {
+ return defaultType;
+ }
+
+
+ protected static Set> populateBiomePickers(
+ Map acceptedBiomeTypes,
+ BiomeAPI.BiomeType exclusionListType,
+ PickerAdder pickerAdder,
+ CustomTypeFinder typeFinder
+ ) {
+ final RegistryAccess access = WorldBootstrap.getLastRegistryAccess();
+ if (access == null) {
+ if (Configs.MAIN_CONFIG.verboseLogging() && !BCLib.isDatagen()) {
+ BCLib.LOGGER.info("Unable to build Biome List yet");
+ }
+ return null;
+ }
+
+ final Set> allBiomes = new HashSet<>();
+ final Map> includeMap = Configs.BIOMES_CONFIG.getBiomeIncludeMap();
+ final List excludeList = Configs.BIOMES_CONFIG.getExcludeMatching(exclusionListType);
+ final Registry biomes = access.registryOrThrow(Registries.BIOME);
+ final Registry bclBiomes = access.registryOrThrow(BCLBiomeRegistry.BCL_BIOMES_REGISTRY);
+
+ final List, Biome>> sortedList = biomes
+ .entrySet()
+ .stream()
+ .sorted(Comparator.comparing(a -> a.getKey().location().toString()))
+ .toList();
+
+ for (Map.Entry, Biome> biomeEntry : sortedList) {
+ if (excludeList.contains(biomeEntry.getKey().location())) continue;
+
+ BiomeAPI.BiomeType type = BiomeAPI.BiomeType.NONE;
+ boolean foundBCLBiome = false;
+ if (BCLBiomeRegistry.hasBiome(biomeEntry.getKey(), bclBiomes)) {
+ foundBCLBiome = true;
+ type = BCLBiomeRegistry.getBiome(biomeEntry.getKey(), bclBiomes).getIntendedType();
+ } else {
+ type = typeFinder.find(biomeEntry.getKey(), type);
+ }
+
+ type = getBiomeType(includeMap, biomeEntry.getKey(), type);
+
+ for (Map.Entry pickerEntry : acceptedBiomeTypes.entrySet()) {
+ if (type.is(pickerEntry.getKey())) {
+ BCLBiome bclBiome;
+ if (foundBCLBiome) {
+ bclBiome = BCLBiomeRegistry.getBiome(biomeEntry.getKey(), bclBiomes);
+ } else {
+ //create and register a biome wrapper
+ bclBiome = new BCLBiome(biomeEntry.getKey().location(), type);
+ BCLBiomeRegistry.register(bclBiome);
+ foundBCLBiome = true;
+ }
+
+ boolean isPossible;
+ if (!bclBiome.hasParentBiome()) {
+ isPossible = pickerAdder.add(bclBiome, pickerEntry.getKey(), pickerEntry.getValue());
+ } else {
+ isPossible = true;
+ }
+
+ if (isPossible) {
+ allBiomes.add(biomes.getHolderOrThrow(biomeEntry.getKey()));
+ }
+ }
+ }
+ }
+
+
+ return allBiomes;
+ }
+
+ protected abstract BiomeAPI.BiomeType defaultBiomeType();
+ protected abstract Map createFreshPickerMap();
+
+ public abstract String toShortString();
+
+ protected void onFinishBiomeRebuild(Map pickerMap) {
+ for (var picker : pickerMap.values()) {
+ picker.rebuild();
+ }
+ }
+
+ protected final void rebuildBiomes(boolean force) {
+ if (!force && didCreatePickers) return;
+
+ if (Configs.MAIN_CONFIG.verboseLogging()) {
+ BCLib.LOGGER.info("Updating Pickers for " + this.toShortString());
+ }
+
+ Map pickerMap = createFreshPickerMap();
+ this.dynamicPossibleBiomes = populateBiomePickers(
+ pickerMap,
+ defaultBiomeType(),
+ this::addToPicker,
+ this::typeForUnknownBiome
+ );
+ if (this.dynamicPossibleBiomes == null) {
+ this.dynamicPossibleBiomes = Set.of();
+ } else {
+ this.didCreatePickers = true;
+ }
+
+ onFinishBiomeRebuild(pickerMap);
+ }
+
+ @Override
+ public BCLBiomeSource mergeWithBiomeSource(BiomeSource inputBiomeSource) {
+ final RegistryAccess access = WorldBootstrap.getLastRegistryAccess();
+ if (access == null) {
+ BCLib.LOGGER.error("Unable to merge Biomesources!");
+ return this;
+ }
+
+ final Map> includeMap = Configs.BIOMES_CONFIG.getBiomeIncludeMap();
+ final List excludeList = Configs.BIOMES_CONFIG.getExcludeMatching(defaultBiomeType());
+ final Registry bclBiomes = access.registryOrThrow(BCLBiomeRegistry.BCL_BIOMES_REGISTRY);
+
+ try {
+ for (Holder possibleBiome : inputBiomeSource.possibleBiomes()) {
+ ResourceKey key = possibleBiome.unwrapKey().orElse(null);
+ if (key != null) {
+ //skip over all biomes that were excluded in the config
+ if (excludeList.contains(key.location())) continue;
+
+ //this is a biome that has no type entry => create a new one for the default type of this registry
+ if (!BCLBiomeRegistry.hasBiome(key, bclBiomes)) {
+ BiomeAPI.BiomeType type = typeForUnknownBiome(key, defaultBiomeType());
+
+ //check if there was an override defined in the configs
+ type = getBiomeType(includeMap, key, type);
+
+ //create and register a biome wrapper
+ BCLBiome bclBiome = new BCLBiome(key.location(), type);
+ BCLBiomeRegistry.register(bclBiome);
+ }
+ }
+ }
+ } catch (RuntimeException e) {
+ BCLib.LOGGER.error("Error while rebuilding Biomesources!", e);
+ } catch (Exception e) {
+ BCLib.LOGGER.error("Error while rebuilding Biomesources!", e);
+ }
+
+ this.reloadBiomes();
+ return this;
+ }
+
+ private static BiomeAPI.BiomeType getBiomeType(
+ Map> includeMap,
+ ResourceKey biomeKey,
+ BiomeAPI.BiomeType defaultType
+ ) {
+ for (Map.Entry> includeList : includeMap.entrySet()) {
+ if (includeList.getValue().contains(biomeKey.location().toString())) {
+ return includeList.getKey();
+ }
+ }
+
+
+ return defaultType;
+ }
+
+ public void onLoadGeneratorSettings(NoiseGeneratorSettings generator) {
+ this.setMaxHeight(generator.noiseSettings().height());
+ }
+
+ protected void reloadBiomes(boolean force) {
+ rebuildBiomes(force);
+ this.initMap(currentSeed);
+ }
+
+ @Override
+ public void reloadBiomes() {
+ reloadBiomes(true);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/generator/BCLChunkGenerator.java b/src/main/java/org/betterx/bclib/api/v2/generator/BCLChunkGenerator.java
new file mode 100644
index 00000000..03c2eb03
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/BCLChunkGenerator.java
@@ -0,0 +1,195 @@
+package org.betterx.bclib.api.v2.generator;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.levelgen.LevelGenUtil;
+import org.betterx.bclib.interfaces.NoiseGeneratorSettingsProvider;
+import org.betterx.bclib.mixin.common.ChunkGeneratorAccessor;
+import org.betterx.worlds.together.WorldsTogether;
+import org.betterx.worlds.together.biomesource.MergeableBiomeSource;
+import org.betterx.worlds.together.biomesource.ReloadableBiomeSource;
+import org.betterx.worlds.together.chunkgenerator.EnforceableChunkGenerator;
+import org.betterx.worlds.together.chunkgenerator.InjectableSurfaceRules;
+import org.betterx.worlds.together.chunkgenerator.RestorableBiomeSource;
+import org.betterx.worlds.together.world.BiomeSourceWithNoiseRelatedSettings;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.core.Holder;
+import net.minecraft.core.HolderGetter;
+import net.minecraft.core.Registry;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.data.worldgen.BootstapContext;
+import net.minecraft.data.worldgen.SurfaceRuleData;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraft.world.level.biome.BiomeGenerationSettings;
+import net.minecraft.world.level.biome.BiomeSource;
+import net.minecraft.world.level.biome.FeatureSorter;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.chunk.ChunkGenerator;
+import net.minecraft.world.level.dimension.DimensionType;
+import net.minecraft.world.level.dimension.LevelStem;
+import net.minecraft.world.level.levelgen.*;
+
+import com.google.common.base.Suppliers;
+
+import java.util.List;
+import java.util.function.Function;
+
+public class BCLChunkGenerator extends NoiseBasedChunkGenerator implements RestorableBiomeSource, InjectableSurfaceRules, EnforceableChunkGenerator {
+
+ public static final Codec CODEC = RecordCodecBuilder
+ .create((RecordCodecBuilder.Instance builderInstance) -> {
+
+ RecordCodecBuilder biomeSourceCodec = BiomeSource.CODEC
+ .fieldOf("biome_source")
+ .forGetter((BCLChunkGenerator generator) -> generator.biomeSource);
+
+ RecordCodecBuilder> settingsCodec = NoiseGeneratorSettings.CODEC
+ .fieldOf("settings")
+ .forGetter((BCLChunkGenerator generator) -> generator.generatorSettings());
+
+
+ return builderInstance.group(biomeSourceCodec, settingsCodec)
+ .apply(builderInstance, builderInstance.stable(BCLChunkGenerator::new));
+ });
+ protected static final NoiseSettings NETHER_NOISE_SETTINGS_AMPLIFIED = NoiseSettings.create(0, 256, 1, 4);
+ public static final ResourceKey AMPLIFIED_NETHER = ResourceKey.create(
+ Registries.NOISE_SETTINGS,
+ BCLib.makeID("amplified_nether")
+ );
+
+ public final BiomeSource initialBiomeSource;
+
+ public BCLChunkGenerator(
+ BiomeSource biomeSource,
+ Holder holder
+ ) {
+ super(biomeSource, holder);
+ initialBiomeSource = biomeSource;
+ if (biomeSource instanceof BiomeSourceWithNoiseRelatedSettings bcl && holder.isBound()) {
+ bcl.onLoadGeneratorSettings(holder.value());
+ }
+
+ if (WorldsTogether.RUNS_TERRABLENDER) {
+ BCLib.LOGGER.info("Make sure features are loaded from terrablender:" + biomeSource.getClass().getName());
+
+ //terrablender is invalidating the feature initialization
+ //we redo it at this point, otherwise we will get blank biomes
+ rebuildFeaturesPerStep(biomeSource);
+ }
+ }
+
+ private void rebuildFeaturesPerStep(BiomeSource biomeSource) {
+ if (this instanceof ChunkGeneratorAccessor acc) {
+ Function, BiomeGenerationSettings> function = (Holder hh) -> hh.value()
+ .getGenerationSettings();
+
+ acc.bcl_setFeaturesPerStep(Suppliers.memoize(() -> FeatureSorter.buildFeaturesPerStep(
+ List.copyOf(biomeSource.possibleBiomes()),
+ (hh) -> function.apply(hh).features(),
+ true
+ )));
+ }
+ }
+
+ /**
+ * Other Mods like TerraBlender might inject new BiomeSources. We undo that change after the world setup did run.
+ *
+ * @param dimensionKey The Dimension where this ChunkGenerator is used from
+ */
+ @Override
+ public void restoreInitialBiomeSource(ResourceKey dimensionKey) {
+ if (initialBiomeSource != getBiomeSource()) {
+ if (this instanceof ChunkGeneratorAccessor acc) {
+ if (initialBiomeSource instanceof MergeableBiomeSource bs) {
+ acc.bcl_setBiomeSource(bs.mergeWithBiomeSource(getBiomeSource()));
+ } else if (initialBiomeSource instanceof ReloadableBiomeSource bs) {
+ bs.reloadBiomes();
+ }
+
+ rebuildFeaturesPerStep(getBiomeSource());
+ }
+ }
+ }
+
+
+ @Override
+ protected Codec extends ChunkGenerator> codec() {
+ return CODEC;
+ }
+
+ @Override
+ public String toString() {
+ return "BCLib - Chunk Generator (" + Integer.toHexString(hashCode()) + ")";
+ }
+
+ // This method is injected by Terrablender.
+ // We make sure terrablender does not rewrite the feature-set for our ChunkGenerator by overwriting the
+ // Mixin-Method with an empty implementation
+ public void appendFeaturesPerStep() {
+ }
+
+ @Override
+ public Registry enforceGeneratorInWorldGenSettings(
+ RegistryAccess access,
+ ResourceKey dimensionKey,
+ ResourceKey dimensionTypeKey,
+ ChunkGenerator loadedChunkGenerator,
+ Registry dimensionRegistry
+ ) {
+ BCLib.LOGGER.info("Enforcing Correct Generator for " + dimensionKey.location().toString() + ".");
+
+ ChunkGenerator referenceGenerator = this;
+ if (loadedChunkGenerator instanceof org.betterx.bclib.interfaces.ChunkGeneratorAccessor generator) {
+ if (loadedChunkGenerator instanceof NoiseGeneratorSettingsProvider noiseProvider) {
+ if (referenceGenerator instanceof NoiseGeneratorSettingsProvider referenceProvider) {
+ final BiomeSource bs;
+ if (referenceGenerator.getBiomeSource() instanceof MergeableBiomeSource mbs) {
+ bs = mbs.mergeWithBiomeSource(loadedChunkGenerator.getBiomeSource());
+ } else {
+ bs = referenceGenerator.getBiomeSource();
+ }
+
+ referenceProvider.bclib_getNoiseGeneratorSettingHolders();
+ referenceGenerator = new BCLChunkGenerator(
+ bs,
+ noiseProvider.bclib_getNoiseGeneratorSettingHolders()
+ );
+ }
+ }
+ }
+
+ return LevelGenUtil.replaceGenerator(
+ dimensionKey,
+ dimensionTypeKey,
+ access,
+ dimensionRegistry,
+ referenceGenerator
+ );
+
+ }
+
+
+ public static NoiseGeneratorSettings amplifiedNether(BootstapContext bootstapContext) {
+ HolderGetter densityGetter = bootstapContext.lookup(Registries.DENSITY_FUNCTION);
+ return new NoiseGeneratorSettings(
+ NETHER_NOISE_SETTINGS_AMPLIFIED,
+ Blocks.NETHERRACK.defaultBlockState(),
+ Blocks.LAVA.defaultBlockState(),
+ NoiseRouterData.noNewCaves(
+ densityGetter,
+ bootstapContext.lookup(Registries.NOISE),
+ NoiseRouterData.slideNetherLike(densityGetter, 0, 256)
+ ),
+ SurfaceRuleData.nether(),
+ List.of(),
+ 32,
+ false,
+ false,
+ false,
+ true
+ );
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/generator/BCLibEndBiomeSource.java b/src/main/java/org/betterx/bclib/api/v2/generator/BCLibEndBiomeSource.java
new file mode 100644
index 00000000..dc25a27b
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/BCLibEndBiomeSource.java
@@ -0,0 +1,330 @@
+package org.betterx.bclib.api.v2.generator;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.generator.config.BCLEndBiomeSourceConfig;
+import org.betterx.bclib.api.v2.levelgen.biomes.BCLBiome;
+import org.betterx.bclib.api.v2.levelgen.biomes.BiomeAPI;
+import org.betterx.bclib.config.Configs;
+import org.betterx.bclib.interfaces.BiomeMap;
+import org.betterx.worlds.together.biomesource.BiomeSourceWithConfig;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.core.Holder;
+import net.minecraft.core.QuartPos;
+import net.minecraft.core.Registry;
+import net.minecraft.core.SectionPos;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraft.world.level.biome.BiomeSource;
+import net.minecraft.world.level.biome.Biomes;
+import net.minecraft.world.level.biome.Climate;
+import net.minecraft.world.level.levelgen.DensityFunction;
+
+import java.awt.*;
+import java.util.List;
+import java.util.Map;
+import org.jetbrains.annotations.NotNull;
+
+public class BCLibEndBiomeSource extends BCLBiomeSource implements BiomeSourceWithConfig {
+ public static Codec CODEC
+ = RecordCodecBuilder.create((instance) -> instance
+ .group(
+ Codec
+ .LONG
+ .fieldOf("seed")
+ .stable()
+ .forGetter(source -> source.currentSeed),
+ BCLEndBiomeSourceConfig
+ .CODEC
+ .fieldOf("config")
+ .orElse(BCLEndBiomeSourceConfig.DEFAULT)
+ .forGetter(o -> o.config)
+ )
+ .apply(
+ instance,
+ instance.stable(BCLibEndBiomeSource::new)
+ )
+ );
+ private final Point pos;
+ private BiomeMap mapLand;
+ private BiomeMap mapVoid;
+ private BiomeMap mapCenter;
+ private BiomeMap mapBarrens;
+
+ private BiomePicker endLandBiomePicker;
+ private BiomePicker endVoidBiomePicker;
+ private BiomePicker endCenterBiomePicker;
+ private BiomePicker endBarrensBiomePicker;
+ private List deciders;
+
+ private BCLEndBiomeSourceConfig config;
+
+ private BCLibEndBiomeSource(
+ long seed,
+ BCLEndBiomeSourceConfig config
+ ) {
+ this(seed, config, true);
+ }
+
+ public BCLibEndBiomeSource(
+ BCLEndBiomeSourceConfig config
+ ) {
+ this(0, config, false);
+ }
+
+
+ private BCLibEndBiomeSource(
+ long seed,
+ BCLEndBiomeSourceConfig config,
+ boolean initMaps
+ ) {
+ super(seed);
+ this.config = config;
+ rebuildBiomes(false);
+
+ this.pos = new Point();
+
+ if (initMaps) {
+ initMap(seed);
+ }
+ }
+
+ @Override
+ protected BiomeAPI.BiomeType defaultBiomeType() {
+ return BiomeAPI.BiomeType.END_LAND;
+ }
+
+ @Override
+ protected Map createFreshPickerMap() {
+ this.deciders = BiomeDecider.DECIDERS.stream()
+ .filter(d -> d.canProvideFor(this))
+ .map(d -> d.createInstance(this))
+ .toList();
+
+ this.endLandBiomePicker = new BiomePicker();
+ this.endVoidBiomePicker = new BiomePicker();
+ this.endCenterBiomePicker = new BiomePicker();
+ this.endBarrensBiomePicker = new BiomePicker();
+
+ return Map.of(
+ BiomeAPI.BiomeType.END_LAND, endLandBiomePicker,
+ BiomeAPI.BiomeType.END_VOID, endVoidBiomePicker,
+ BiomeAPI.BiomeType.END_CENTER, endCenterBiomePicker,
+ BiomeAPI.BiomeType.END_BARRENS, endBarrensBiomePicker
+ );
+ }
+
+ protected boolean addToPicker(BCLBiome bclBiome, BiomeAPI.BiomeType type, BiomePicker picker) {
+ if (!config.withVoidBiomes) {
+ if (bclBiome.getID().equals(Biomes.SMALL_END_ISLANDS.location())) {
+ return false;
+ }
+ }
+
+ for (BiomeDecider decider : deciders) {
+ if (decider.addToPicker(bclBiome)) {
+ return true;
+ }
+ }
+
+ return super.addToPicker(bclBiome, type, picker);
+ }
+
+ @Override
+ protected BiomeAPI.BiomeType typeForUnknownBiome(ResourceKey biomeKey, BiomeAPI.BiomeType defaultType) {
+ if (TheEndBiomesHelper.canGenerateAsMainIslandBiome(biomeKey)) {
+ return BiomeAPI.BiomeType.END_CENTER;
+ } else if (TheEndBiomesHelper.canGenerateAsHighlandsBiome(biomeKey)) {
+ if (!config.withVoidBiomes) return BiomeAPI.BiomeType.END_VOID;
+ return BiomeAPI.BiomeType.END_LAND;
+ } else if (TheEndBiomesHelper.canGenerateAsEndBarrens(biomeKey)) {
+ return BiomeAPI.BiomeType.END_BARRENS;
+ } else if (TheEndBiomesHelper.canGenerateAsSmallIslandsBiome(biomeKey)) {
+ return BiomeAPI.BiomeType.END_VOID;
+ } else if (TheEndBiomesHelper.canGenerateAsEndMidlands(biomeKey)) {
+ return BiomeAPI.BiomeType.END_LAND;
+ }
+
+ return super.typeForUnknownBiome(biomeKey, defaultType);
+ }
+
+ @Override
+ protected void onFinishBiomeRebuild(Map pickerMap) {
+ super.onFinishBiomeRebuild(pickerMap);
+
+ for (BiomeDecider decider : deciders) {
+ decider.rebuild();
+ }
+
+ if (endVoidBiomePicker.isEmpty()) {
+ if (Configs.MAIN_CONFIG.verboseLogging() && !BCLib.isDatagen())
+ BCLib.LOGGER.info("No Void Biomes found. Disabling by using barrens");
+ endVoidBiomePicker = endBarrensBiomePicker;
+ }
+ if (endBarrensBiomePicker.isEmpty()) {
+ if (Configs.MAIN_CONFIG.verboseLogging() && !BCLib.isDatagen())
+ BCLib.LOGGER.info("No Barrens Biomes found. Disabling by using land Biomes");
+ endBarrensBiomePicker = endLandBiomePicker;
+ endVoidBiomePicker = endLandBiomePicker;
+ }
+ if (endCenterBiomePicker.isEmpty()) {
+ if (Configs.MAIN_CONFIG.verboseLogging() && !BCLib.isDatagen())
+ BCLib.LOGGER.warning("No Center Island Biomes found. Forcing use of vanilla center.");
+ endCenterBiomePicker.addBiome(BiomeAPI.THE_END);
+ endCenterBiomePicker.rebuild();
+ if (endCenterBiomePicker.isEmpty()) {
+ if (Configs.MAIN_CONFIG.verboseLogging() && !BCLib.isDatagen())
+ BCLib.LOGGER.error("Unable to force vanilla central Island. Falling back to land Biomes...");
+ endCenterBiomePicker = endLandBiomePicker;
+ }
+ }
+ }
+
+ public static void register() {
+ Registry.register(BuiltInRegistries.BIOME_SOURCE, BCLib.makeID("end_biome_source"), CODEC);
+ }
+
+ @Override
+ protected void onInitMap(long seed) {
+ for (BiomeDecider decider : deciders) {
+ decider.createMap((picker, size) -> config.mapVersion.mapBuilder.create(
+ seed,
+ size <= 0 ? config.landBiomesSize : size,
+ picker
+ ));
+ }
+ this.mapLand = config.mapVersion.mapBuilder.create(
+ seed,
+ config.landBiomesSize,
+ endLandBiomePicker
+ );
+
+ this.mapVoid = config.mapVersion.mapBuilder.create(
+ seed,
+ config.voidBiomesSize,
+ endVoidBiomePicker
+ );
+
+ this.mapCenter = config.mapVersion.mapBuilder.create(
+ seed,
+ config.centerBiomesSize,
+ endCenterBiomePicker
+ );
+
+ this.mapBarrens = config.mapVersion.mapBuilder.create(
+ seed,
+ config.barrensBiomesSize,
+ endBarrensBiomePicker
+ );
+ }
+
+ @Override
+ protected void onHeightChange(int newHeight) {
+
+ }
+
+ @Override
+ public Holder getNoiseBiome(int biomeX, int biomeY, int biomeZ, Climate.@NotNull Sampler sampler) {
+ if (!wasBound()) reloadBiomes(false);
+
+ if (mapLand == null || mapVoid == null || mapCenter == null || mapBarrens == null)
+ return this.possibleBiomes().stream().findFirst().orElseThrow();
+
+ int posX = QuartPos.toBlock(biomeX);
+ int posY = QuartPos.toBlock(biomeY);
+ int posZ = QuartPos.toBlock(biomeZ);
+
+ long dist = Math.abs(posX) + Math.abs(posZ) > (long) config.innerVoidRadiusSquared
+ ? ((long) config.innerVoidRadiusSquared + 1)
+ : (long) posX * (long) posX + (long) posZ * (long) posZ;
+
+
+ if ((biomeX & 63) == 0 || (biomeZ & 63) == 0) {
+ mapLand.clearCache();
+ mapVoid.clearCache();
+ mapCenter.clearCache();
+ mapVoid.clearCache();
+ for (BiomeDecider decider : deciders) {
+ decider.clearMapCache();
+ }
+ }
+
+ BiomeAPI.BiomeType suggestedType;
+
+
+ int x = (SectionPos.blockToSectionCoord(posX) * 2 + 1) * 8;
+ int z = (SectionPos.blockToSectionCoord(posZ) * 2 + 1) * 8;
+ double d = sampler.erosion().compute(new DensityFunction.SinglePointContext(x, posY, z));
+ if (dist <= (long) config.innerVoidRadiusSquared) {
+ suggestedType = BiomeAPI.BiomeType.END_CENTER;
+ } else {
+ if (d > 0.25) {
+ suggestedType = BiomeAPI.BiomeType.END_LAND; //highlands
+ } else if (d >= -0.0625) {
+ suggestedType = BiomeAPI.BiomeType.END_LAND; //midlands
+ } else {
+ suggestedType = d < -0.21875
+ ? BiomeAPI.BiomeType.END_VOID //small islands
+ : (config.withVoidBiomes
+ ? BiomeAPI.BiomeType.END_BARRENS
+ : BiomeAPI.BiomeType.END_LAND); //barrens
+ }
+ }
+
+ final BiomeAPI.BiomeType originalType = suggestedType;
+ for (BiomeDecider decider : deciders) {
+ suggestedType = decider
+ .suggestType(originalType, suggestedType, d, maxHeight, posX, posY, posZ, biomeX, biomeY, biomeZ);
+ }
+
+
+ BiomePicker.ActualBiome result;
+ for (BiomeDecider decider : deciders) {
+ if (decider.canProvideBiome(suggestedType)) {
+ result = decider.provideBiome(suggestedType, posX, posY, posZ);
+ if (result != null) return result.biome;
+ }
+ }
+
+ if (suggestedType.is(BiomeAPI.BiomeType.END_CENTER)) return mapCenter.getBiome(posX, posY, posZ).biome;
+ if (suggestedType.is(BiomeAPI.BiomeType.END_VOID)) return mapVoid.getBiome(posX, posY, posZ).biome;
+ if (suggestedType.is(BiomeAPI.BiomeType.END_BARRENS)) return mapBarrens.getBiome(posX, posY, posZ).biome;
+ return mapLand.getBiome(posX, posY, posZ).biome;
+ }
+
+
+ @Override
+ protected Codec extends BiomeSource> codec() {
+ return CODEC;
+ }
+
+ @Override
+ public String toShortString() {
+ return "BCLib - The End BiomeSource (" + Integer.toHexString(hashCode()) + ")";
+ }
+
+ @Override
+ public String toString() {
+ return "\n" + toShortString() +
+ "\n biomes = " + possibleBiomes().size() +
+ "\n namespaces = " + getNamespaces() +
+ "\n seed = " + currentSeed +
+ "\n height = " + maxHeight +
+ "\n deciders = " + deciders.size() +
+ "\n config = " + config;
+ }
+
+ @Override
+ public BCLEndBiomeSourceConfig getTogetherConfig() {
+ return config;
+ }
+
+ @Override
+ public void setTogetherConfig(BCLEndBiomeSourceConfig newConfig) {
+ this.config = newConfig;
+ rebuildBiomes(true);
+ this.initMap(currentSeed);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/generator/BCLibNetherBiomeSource.java b/src/main/java/org/betterx/bclib/api/v2/generator/BCLibNetherBiomeSource.java
new file mode 100644
index 00000000..5a36ec69
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/BCLibNetherBiomeSource.java
@@ -0,0 +1,170 @@
+package org.betterx.bclib.api.v2.generator;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.generator.config.BCLNetherBiomeSourceConfig;
+import org.betterx.bclib.api.v2.generator.config.MapBuilderFunction;
+import org.betterx.bclib.api.v2.generator.map.MapStack;
+import org.betterx.bclib.api.v2.levelgen.biomes.BiomeAPI;
+import org.betterx.bclib.interfaces.BiomeMap;
+import org.betterx.worlds.together.biomesource.BiomeSourceWithConfig;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.core.Holder;
+import net.minecraft.core.Registry;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraft.world.level.biome.BiomeSource;
+import net.minecraft.world.level.biome.Climate;
+
+import net.fabricmc.fabric.api.biome.v1.NetherBiomes;
+
+import java.util.Map;
+
+public class BCLibNetherBiomeSource extends BCLBiomeSource implements BiomeSourceWithConfig {
+ public static final Codec CODEC = RecordCodecBuilder
+ .create(instance -> instance
+ .group(
+ Codec
+ .LONG
+ .fieldOf("seed")
+ .stable()
+ .forGetter(source -> {
+ return source.currentSeed;
+ }),
+ BCLNetherBiomeSourceConfig
+ .CODEC
+ .fieldOf("config")
+ .orElse(BCLNetherBiomeSourceConfig.DEFAULT)
+ .forGetter(o -> o.config)
+ )
+ .apply(instance, instance.stable(BCLibNetherBiomeSource::new))
+ );
+ private BiomeMap biomeMap;
+ private BiomePicker biomePicker;
+ private BCLNetherBiomeSourceConfig config;
+
+ public BCLibNetherBiomeSource(
+ BCLNetherBiomeSourceConfig config
+ ) {
+ this(0, config, false);
+ }
+
+ private BCLibNetherBiomeSource(
+ long seed,
+ BCLNetherBiomeSourceConfig config
+ ) {
+ this(seed, config, true);
+ }
+
+
+ private BCLibNetherBiomeSource(
+ long seed,
+ BCLNetherBiomeSourceConfig config,
+ boolean initMaps
+ ) {
+ super(seed);
+ this.config = config;
+ rebuildBiomes(false);
+ if (initMaps) {
+ initMap(seed);
+ }
+ }
+
+ @Override
+ protected BiomeAPI.BiomeType defaultBiomeType() {
+ return BiomeAPI.BiomeType.NETHER;
+ }
+
+ @Override
+ protected Map createFreshPickerMap() {
+ this.biomePicker = new BiomePicker();
+ return Map.of(defaultBiomeType(), this.biomePicker);
+ }
+
+ @Override
+ protected BiomeAPI.BiomeType typeForUnknownBiome(ResourceKey biomeKey, BiomeAPI.BiomeType defaultType) {
+ //
+ if (NetherBiomes.canGenerateInNether(biomeKey)) {
+ return BiomeAPI.BiomeType.NETHER;
+ }
+
+ return super.typeForUnknownBiome(biomeKey, defaultType);
+ }
+
+ public static void register() {
+ Registry.register(BuiltInRegistries.BIOME_SOURCE, BCLib.makeID("nether_biome_source"), CODEC);
+ }
+
+ @Override
+ public Holder getNoiseBiome(int biomeX, int biomeY, int biomeZ, Climate.Sampler var4) {
+ if (!wasBound()) reloadBiomes(false);
+
+ if (biomeMap == null)
+ return this.possibleBiomes().stream().findFirst().get();
+
+ if ((biomeX & 63) == 0 && (biomeZ & 63) == 0) {
+ biomeMap.clearCache();
+ }
+ BiomePicker.ActualBiome bb = biomeMap.getBiome(biomeX << 2, biomeY << 2, biomeZ << 2);
+ return bb.biome;
+ }
+
+ @Override
+ protected Codec extends BiomeSource> codec() {
+ return CODEC;
+ }
+
+ @Override
+ protected void onInitMap(long seed) {
+ MapBuilderFunction mapConstructor = config.mapVersion.mapBuilder;
+ if (maxHeight > config.biomeSizeVertical * 1.5 && config.useVerticalBiomes) {
+ this.biomeMap = new MapStack(
+ seed,
+ config.biomeSize,
+ biomePicker,
+ config.biomeSizeVertical,
+ maxHeight,
+ mapConstructor
+ );
+ } else {
+ this.biomeMap = mapConstructor.create(
+ seed,
+ config.biomeSize,
+ biomePicker
+ );
+ }
+ }
+
+ @Override
+ protected void onHeightChange(int newHeight) {
+ initMap(currentSeed);
+ }
+
+ @Override
+ public String toShortString() {
+ return "BCLib - Nether BiomeSource (" + Integer.toHexString(hashCode()) + ")";
+ }
+
+ @Override
+ public String toString() {
+ return "\n" + toShortString() +
+ "\n biomes = " + possibleBiomes().size() +
+ "\n namespaces = " + getNamespaces() +
+ "\n seed = " + currentSeed +
+ "\n height = " + maxHeight +
+ "\n config = " + config;
+ }
+
+ @Override
+ public BCLNetherBiomeSourceConfig getTogetherConfig() {
+ return config;
+ }
+
+ @Override
+ public void setTogetherConfig(BCLNetherBiomeSourceConfig newConfig) {
+ this.config = newConfig;
+ initMap(currentSeed);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/generator/BiomeDecider.java b/src/main/java/org/betterx/bclib/api/v2/generator/BiomeDecider.java
new file mode 100644
index 00000000..d38e8bba
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/BiomeDecider.java
@@ -0,0 +1,266 @@
+package org.betterx.bclib.api.v2.generator;
+
+import org.betterx.bclib.api.v2.levelgen.biomes.BCLBiome;
+import org.betterx.bclib.api.v2.levelgen.biomes.BiomeAPI;
+import org.betterx.bclib.interfaces.BiomeMap;
+
+import net.minecraft.core.HolderGetter;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraft.world.level.biome.BiomeSource;
+
+import java.util.LinkedList;
+import java.util.List;
+
+
+/**
+ * Used to extend the BiomePlacement in the {@link BCLBiomeSource}
+ */
+public abstract class BiomeDecider {
+
+ /**
+ * used to create new {@link BiomeMap} instances
+ */
+ @FunctionalInterface
+ public interface BiomeMapBuilderFunction {
+ /**
+ * Constructs a new {@link BiomeMap}
+ *
+ * @param picker The picker the BiomeMap should use
+ * @param biomeSize The biomeSize the map will use or -1 for the default size
+ * @return a new {@link BiomeMap} instance
+ */
+ BiomeMap create(BiomePicker picker, int biomeSize);
+ }
+
+ /**
+ * used to determine wether or not a decider can provide this biome
+ */
+ @FunctionalInterface
+ public interface BiomePredicate {
+ boolean test(BCLBiome biome);
+ }
+
+ protected BiomePicker picker;
+ protected BiomeMap map;
+ private final BiomePredicate predicate;
+
+ static List DECIDERS = new LinkedList<>();
+
+ /**
+ * Register a high priority Decider for the {@link BCLibEndBiomeSource}.
+ * Normally you should not need to register a high priority decider and instead use
+ * {@link BiomeDecider#registerDecider(ResourceLocation, BiomeDecider)}.
+ * BetterEnd (for example) will add
+ *
+ * @param location The {@link ResourceLocation} for the decider
+ * @param decider The initial decider Instance. Each Instance of the {@link BCLibEndBiomeSource}
+ * will call {@link BiomeDecider#createInstance(BCLBiomeSource)} to build a
+ * new instance of this decider
+ */
+ public static void registerHighPriorityDecider(ResourceLocation location, BiomeDecider decider) {
+ if (DECIDERS.size() == 0) DECIDERS.add(decider);
+ else DECIDERS.add(0, decider);
+ }
+
+ /**
+ * Register a new Decider for the {@link BCLibEndBiomeSource}
+ *
+ * @param location The {@link ResourceLocation} for the decider
+ * @param decider The initial decider Instance. Each Instance of the {@link BCLibEndBiomeSource}
+ * will call {@link BiomeDecider#createInstance(BCLBiomeSource)} to build a
+ * new instance of this decider
+ */
+ public static void registerDecider(ResourceLocation location, BiomeDecider decider) {
+ DECIDERS.add(decider);
+ }
+
+ protected BiomeDecider(BiomePredicate predicate) {
+ this(null, predicate);
+ }
+
+ /**
+ * @param biomeRegistry The biome registry assigned to the creating BiomeSource
+ * @param predicate A predicate that decides if a given Biome can be provided by this decider
+ */
+ protected BiomeDecider(
+ HolderGetter biomeRegistry, BiomePredicate predicate
+ ) {
+ this.predicate = predicate;
+ this.map = null;
+ if (biomeRegistry == null) {
+ this.picker = null;
+ } else {
+ this.picker = new BiomePicker(biomeRegistry);
+ }
+ }
+
+ /**
+ * Called to test, if a decider is suitable for the given BiomeSource.
+ *
+ * @param source The BiomeSource that wants to use the decider
+ * @return true, if this decider is usable by that source
+ */
+ public abstract boolean canProvideFor(BiomeSource source);
+
+ /**
+ * Called from the BiomeSource whenever it needs to create a new instance of this decider.
+ *
+ * Inheriting classes should overwrite this method and return Instances of the class. For
+ * the base {@link BiomeDecider} you would return new BiomeDecider(biomeSource.biomeRegistry, this.predicate);
+ *
+ * @param biomeSource The biome source this decider is used from
+ * @return A new instance
+ */
+ public abstract BiomeDecider createInstance(BCLBiomeSource biomeSource);
+
+ /**
+ * Called when the BiomeSources needs to construct a new {@link BiomeMap} for the picker.
+ *
+ * The default implementation creates a new map with the instances picker and a default biome size
+ *
+ * @param mapBuilder A function you can use to create a new {@link BiomeMap} that conforms to the settings
+ * of the current BiomeSource.
+ */
+ public void createMap(BiomeMapBuilderFunction mapBuilder) {
+ this.map = mapBuilder.create(picker, -1);
+ }
+
+ /**
+ * called whenever the BiomeSource needs to clear caches
+ */
+ public void clearMapCache() {
+ map.clearCache();
+ }
+
+ /**
+ * This method get's called whenever the BiomeSource populates the Biome Pickers. You need to
+ * determine if the passed Biome is valid for your picker.
+ *
+ * If this method returns false, the Biome wil not get added to any other Deciders/Pickers.
+ *
+ * The default implementation will use the instances {@link BiomeDecider#predicate} to determine if
+ * a biome should get added and return true if it was added.
+ *
+ * @param biome The biome that should get added if it matches the criteria of the picker
+ * @return false, if other pickers/deciders are allowed to use the biome as well
+ */
+ public boolean addToPicker(BCLBiome biome) {
+ if (predicate.test(biome)) {
+ picker.addBiome(biome);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Called whenever the picker needs to rebuild it's contents
+ */
+ public void rebuild() {
+ //TODO: 1.19.3 test if this rebuilds once we have biomes
+ if (picker != null)
+ picker.rebuild();
+ }
+
+ /**
+ * Called from the BiomeSource to determine the type of Biome it needs to place.
+ *
+ * @param originalType The original biome type the source did select
+ * @param suggestedType The currently suggested type. This will differ from originalType if other
+ * {@link BiomeDecider} instances already had a new suggestion. You implementation should return the
+ * suggestedType if it does not want to provide the Biome for this location
+ * @param maxHeight The maximum terrain height for this world
+ * @param blockX The block coordinate where we are at
+ * @param blockY The block coordinate where we are at
+ * @param blockZ The block coordinate where we are at
+ * @param quarterX The quarter Block Coordinate (which is blockX/4)
+ * @param quarterY The quarter Block Coordinate (which is blockY/4)
+ * @param quarterZ The quarter Block Coordinate (which is blockZ/4)
+ * @return The suggestedType if this decider does not plan to provide a Biome, or a unique BiomeType.
+ * The Biome Source will call {@link BiomeDecider#canProvideBiome(BiomeAPI.BiomeType)} with the finally chosen type
+ * for all available Deciders.
+ */
+ public BiomeAPI.BiomeType suggestType(
+ BiomeAPI.BiomeType originalType,
+ BiomeAPI.BiomeType suggestedType,
+ int maxHeight,
+ int blockX,
+ int blockY,
+ int blockZ,
+ int quarterX,
+ int quarterY,
+ int quarterZ
+ ) {
+ return suggestType(
+ originalType,
+ suggestedType,
+ 0,
+ maxHeight,
+ blockX,
+ blockY,
+ blockZ,
+ quarterX,
+ quarterY,
+ quarterZ
+ );
+ }
+
+ /**
+ * Called from the BiomeSource to determine the type of Biome it needs to place.
+ *
+ * @param originalType The original biome type the source did select
+ * @param suggestedType The currently suggested type. This will differ from originalType if other
+ * {@link BiomeDecider} instances already had a new suggestion. You implementation should return the
+ * suggestedType if it does not want to provide the Biome for this location
+ * @param density The terrain density at this location. Currently only valid if for {@link BCLibEndBiomeSource}
+ * that use the {@link org.betterx.bclib.api.v2.generator.config.BCLEndBiomeSourceConfig.EndBiomeGeneratorType#VANILLA}
+ * @param maxHeight The maximum terrain height for this world
+ * @param blockX The block coordinate where we are at
+ * @param blockY The block coordinate where we are at
+ * @param blockZ The block coordinate where we are at
+ * @param quarterX The quarter Block Coordinate (which is blockX/4)
+ * @param quarterY The quarter Block Coordinate (which is blockY/4)
+ * @param quarterZ The quarter Block Coordinate (which is blockZ/4)
+ * @param maxHeight
+ * @return The suggestedType if this decider does not plan to provide a Biome, or a unique BiomeType.
+ * The Biome Source will call {@link BiomeDecider#canProvideBiome(BiomeAPI.BiomeType)} with the finally chosen type
+ * for all available Deciders.
+ */
+ public abstract BiomeAPI.BiomeType suggestType(
+ BiomeAPI.BiomeType originalType,
+ BiomeAPI.BiomeType suggestedType,
+ double density,
+ int maxHeight,
+ int blockX,
+ int blockY,
+ int blockZ,
+ int quarterX,
+ int quarterY,
+ int quarterZ
+ );
+
+
+ /**
+ * Called to check if this decider can place a biome for the specified type
+ *
+ * @param suggestedType The type of biome we need to place
+ * @return true, if this type of biome can be provided by the current picker. If true
+ * is returned, the BiomeSource will call {@link BiomeDecider#provideBiome(BiomeAPI.BiomeType, int, int, int)}
+ * next
+ */
+ public abstract boolean canProvideBiome(BiomeAPI.BiomeType suggestedType);
+
+ /**
+ * Called to check if this decider can place a biome for the specified type
+ *
+ * The default implementation will return map.getBiome(posX, posY, posZ)
+ *
+ * @param suggestedType The type of biome we need to place
+ * @return The methode should return a Biome from its {@link BiomeMap}. If null is returned, the next
+ * decider (or the default map) will provide the biome
+ */
+ public BiomePicker.ActualBiome provideBiome(BiomeAPI.BiomeType suggestedType, int posX, int posY, int posZ) {
+ return map.getBiome(posX, posY, posZ);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/generator/BiomePicker.java b/src/main/java/org/betterx/bclib/api/v2/generator/BiomePicker.java
new file mode 100644
index 00000000..213c462c
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/BiomePicker.java
@@ -0,0 +1,189 @@
+package org.betterx.bclib.api.v2.generator;
+
+import org.betterx.bclib.api.v2.levelgen.biomes.BCLBiome;
+import org.betterx.bclib.api.v2.levelgen.biomes.BCLBiomeRegistry;
+import org.betterx.bclib.util.WeighTree;
+import org.betterx.bclib.util.WeightedList;
+import org.betterx.worlds.together.world.event.WorldBootstrap;
+
+import net.minecraft.core.Holder;
+import net.minecraft.core.HolderGetter;
+import net.minecraft.core.Registry;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraft.world.level.levelgen.WorldgenRandom;
+
+import com.google.common.collect.Lists;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+public class BiomePicker {
+ private final Map all = new HashMap<>();
+ public final HolderGetter biomeRegistry;
+ private final List biomes = Lists.newArrayList();
+ private final List allowedBiomes;
+ public final ActualBiome fallbackBiome;
+ private WeighTree tree;
+
+ BiomePicker() {
+ this(WorldBootstrap.getLastRegistryAccess() == null
+ ? null
+ : WorldBootstrap.getLastRegistryAccess().registry(Registries.BIOME).orElse(null));
+ }
+
+ public BiomePicker(Registry biomeRegistry) {
+ this(biomeRegistry != null ? biomeRegistry.asLookup() : null, null);
+ }
+
+ public BiomePicker(HolderGetter biomeRegistry) {
+ this(biomeRegistry, null);
+ }
+
+ public BiomePicker(HolderGetter biomeRegistry, List> allowedBiomes) {
+ this.biomeRegistry = biomeRegistry;
+ this.allowedBiomes = allowedBiomes != null ? allowedBiomes
+ .stream()
+ .map(h -> h.unwrapKey())
+ .filter(o -> o.isPresent())
+ .map(o -> o.get().location().toString()).toList() : null;
+ this.fallbackBiome = create(BCLBiomeRegistry.EMPTY_BIOME);
+ }
+
+ private boolean isAllowed(BCLBiome b) {
+ if (allowedBiomes == null) return true;
+ return allowedBiomes.contains(b.getID().toString());
+ }
+
+ private ActualBiome create(BCLBiome bclBiome) {
+ ActualBiome e = all.get(bclBiome);
+ if (e != null) return e;
+ return new ActualBiome(bclBiome);
+ }
+
+ public void addBiome(BCLBiome biome) {
+ biomes.add(create(biome));
+ }
+
+ public ActualBiome getBiome(WorldgenRandom random) {
+ return biomes.isEmpty() ? fallbackBiome : tree.get(random);
+ }
+
+ public boolean isEmpty() {
+ return biomes.isEmpty();
+ }
+
+ public void rebuild() {
+ WeightedList list = new WeightedList<>();
+
+ biomes.forEach(biome -> {
+ if (biome.isValid)
+ list.add(biome, biome.bclBiome.settings.getGenChance());
+ });
+ //only a single biome, we need to add the edges as well
+ if (list.size() == 1) {
+ ActualBiome biome = list.get(0);
+
+ if (biome.getEdge() != null) {
+ float defaultBiomeSize = 128;
+ float edgeSize = (biome.bclBiome.settings.getEdgeSize() * list.getWeight(0)) / defaultBiomeSize;
+ list.add(biome.getEdge(), edgeSize);
+ }
+ }
+
+ //no Biome, make sure we add at least one, otherwise bad things will happen
+ if (list.isEmpty()) {
+ list.add(create(BCLBiomeRegistry.EMPTY_BIOME), 1);
+ }
+
+
+ tree = new WeighTree<>(list);
+ }
+
+ public class ActualBiome {
+ public final BCLBiome bclBiome;
+ public final Holder biome;
+ public final ResourceKey key;
+
+ private final WeightedList subbiomes = new WeightedList<>();
+ private final ActualBiome edge;
+ private final ActualBiome parent;
+ public final boolean isValid;
+
+ private ActualBiome(BCLBiome bclBiome) {
+ all.put(bclBiome, this);
+ this.bclBiome = bclBiome;
+
+ this.key = ResourceKey.create(
+ Registries.BIOME,
+ bclBiome.getID()
+ );
+ this.biome = (key != null && biomeRegistry != null) ? biomeRegistry.getOrThrow(key) : null;
+ this.isValid = key != null && biome != null && biome.isBound() && biomeRegistry.get(key).isPresent();
+ bclBiome.forEachSubBiome((b, w) -> {
+ if (isAllowed(b))
+ subbiomes.add(create(b), w);
+ });
+
+ if (bclBiome.hasEdge() && isAllowed(bclBiome.getEdge())) {
+ edge = create(bclBiome.getEdge());
+ } else {
+ edge = null;
+ }
+
+ parent = bclBiome.getParentBiome() != null ? create(bclBiome.getParentBiome()) : null;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ ActualBiome entry = (ActualBiome) o;
+ return bclBiome.equals(entry.bclBiome);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(bclBiome);
+ }
+
+ public ActualBiome getSubBiome(WorldgenRandom random) {
+ return subbiomes.get(random);
+ }
+
+ public ActualBiome getEdge() {
+ return edge;
+ }
+
+ public ActualBiome getParentBiome() {
+ return parent;
+ }
+
+ public boolean isSame(ActualBiome e) {
+ return bclBiome.isSame(e.bclBiome);
+ }
+
+ @Override
+ public String toString() {
+ return "ActualBiome{" +
+ "key=" + key.location() +
+ ", subbiomes=" + subbiomes.size() +
+ ", edge=" + (edge != null ? edge.key.location() : "null") +
+ ", parent=" + (parent != null ? parent.key.location() : "null") +
+ ", isValid=" + isValid +
+ '}';
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "BiomePicker{" +
+ "biomes=" + biomes.size() + " (" + all.size() + ")" +
+ ", biomeRegistry=" + biomeRegistry +
+ ", type=" + super.toString() +
+ '}';
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/generator/BiomeType.java b/src/main/java/org/betterx/bclib/api/v2/generator/BiomeType.java
new file mode 100644
index 00000000..8bfb3789
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/BiomeType.java
@@ -0,0 +1,5 @@
+package org.betterx.bclib.api.v2.generator;
+
+public enum BiomeType {
+ LAND, VOID
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/generator/GeneratorOptions.java b/src/main/java/org/betterx/bclib/api/v2/generator/GeneratorOptions.java
new file mode 100644
index 00000000..2c68c7ae
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/GeneratorOptions.java
@@ -0,0 +1,22 @@
+package org.betterx.bclib.api.v2.generator;
+
+import org.betterx.bclib.config.Configs;
+
+public class GeneratorOptions {
+ //private static BiFunction endLandFunction;
+ private static boolean fixEndBiomeSource = true;
+ private static boolean fixNetherBiomeSource = true;
+
+ public static void init() {
+ fixEndBiomeSource = Configs.GENERATOR_CONFIG.getBoolean("options.biomeSource", "fixEndBiomeSource", true);
+ fixNetherBiomeSource = Configs.GENERATOR_CONFIG.getBoolean("options.biomeSource", "fixNetherBiomeSource", true);
+ }
+
+ public static boolean fixEndBiomeSource() {
+ return fixEndBiomeSource;
+ }
+
+ public static boolean fixNetherBiomeSource() {
+ return fixNetherBiomeSource;
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/generator/TheEndBiomesHelper.java b/src/main/java/org/betterx/bclib/api/v2/generator/TheEndBiomesHelper.java
new file mode 100644
index 00000000..93fe1d91
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/TheEndBiomesHelper.java
@@ -0,0 +1,92 @@
+package org.betterx.bclib.api.v2.generator;
+
+import org.betterx.bclib.api.v2.levelgen.biomes.BiomeAPI;
+
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.world.level.biome.Biome;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.jetbrains.annotations.ApiStatus;
+
+
+/**
+ * Helper class until FAPI integrates this PR
+ */
+public class TheEndBiomesHelper {
+ @ApiStatus.Internal
+ private static Map>> END_BIOMES = new HashMap<>();
+
+ @ApiStatus.Internal
+ public static void add(BiomeAPI.BiomeType type, ResourceKey biome) {
+ if (biome == null) return;
+ END_BIOMES.computeIfAbsent(type, t -> new HashSet<>()).add(biome);
+ }
+
+ private static boolean has(BiomeAPI.BiomeType type, ResourceKey biome) {
+ if (biome == null) return false;
+ Set> set = END_BIOMES.get(type);
+ if (set == null) return false;
+ return set.contains(biome);
+ }
+
+ /**
+ * Returns true if the given biome was added as a main end Biome in the end, considering the Vanilla end biomes,
+ * and any biomes added to the End by mods.
+ *
+ * @param biome The biome to search for
+ */
+ public static boolean canGenerateAsMainIslandBiome(ResourceKey biome) {
+ return has(BiomeAPI.BiomeType.END_CENTER, biome);
+ }
+
+ /**
+ * Returns true if the given biome was added as a small end islands Biome in the end, considering the Vanilla end biomes,
+ * and any biomes added to the End by mods.
+ *
+ * @param biome The biome to search for
+ */
+ public static boolean canGenerateAsSmallIslandsBiome(ResourceKey biome) {
+ return has(BiomeAPI.BiomeType.END_VOID, biome);
+ }
+
+ /**
+ * Returns true if the given biome was added as a Highland Biome in the end, considering the Vanilla end biomes,
+ * and any biomes added to the End by mods.
+ *
+ * @param biome The biome to search for
+ */
+ public static boolean canGenerateAsHighlandsBiome(ResourceKey biome) {
+ return has(BiomeAPI.BiomeType.END_LAND, biome);
+ }
+
+ /**
+ * Returns true if the given biome was added as midland biome in the end, considering the Vanilla end biomes,
+ * and any biomes added to the End as midland biome by mods.
+ *
+ * @param biome The biome to search for
+ */
+ public static boolean canGenerateAsEndMidlands(ResourceKey biome) {
+ return false;
+ }
+
+ /**
+ * Returns true if the given biome was added as barrens biome in the end, considering the Vanilla end biomes,
+ * and any biomes added to the End as barrens biome by mods.
+ *
+ * @param biome The biome to search for
+ */
+ public static boolean canGenerateAsEndBarrens(ResourceKey biome) {
+ return has(BiomeAPI.BiomeType.END_BARRENS, biome);
+ }
+
+ public static boolean canGenerateInEnd(ResourceKey biome) {
+ return canGenerateAsHighlandsBiome(biome)
+ || canGenerateAsEndBarrens(biome)
+ || canGenerateAsEndMidlands(biome)
+ || canGenerateAsSmallIslandsBiome(biome)
+ || canGenerateAsMainIslandBiome(biome);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/generator/TypeBiomeDecider.java b/src/main/java/org/betterx/bclib/api/v2/generator/TypeBiomeDecider.java
new file mode 100644
index 00000000..1b812b89
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/TypeBiomeDecider.java
@@ -0,0 +1,24 @@
+package org.betterx.bclib.api.v2.generator;
+
+import org.betterx.bclib.api.v2.levelgen.biomes.BiomeAPI;
+
+import net.minecraft.core.HolderGetter;
+import net.minecraft.world.level.biome.Biome;
+
+public abstract class TypeBiomeDecider extends BiomeDecider {
+ protected final BiomeAPI.BiomeType assignedType;
+
+ public TypeBiomeDecider(BiomeAPI.BiomeType assignedType) {
+ this(null, assignedType);
+ }
+
+ protected TypeBiomeDecider(HolderGetter biomeRegistry, BiomeAPI.BiomeType assignedType) {
+ super(biomeRegistry, (biome) -> biome.getIntendedType().is(assignedType));
+ this.assignedType = assignedType;
+ }
+
+ @Override
+ public boolean canProvideBiome(BiomeAPI.BiomeType suggestedType) {
+ return suggestedType.equals(assignedType);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/generator/config/BCLEndBiomeSourceConfig.java b/src/main/java/org/betterx/bclib/api/v2/generator/config/BCLEndBiomeSourceConfig.java
new file mode 100644
index 00000000..903a7024
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/config/BCLEndBiomeSourceConfig.java
@@ -0,0 +1,268 @@
+package org.betterx.bclib.api.v2.generator.config;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.generator.BCLibEndBiomeSource;
+import org.betterx.bclib.api.v2.generator.map.hex.HexBiomeMap;
+import org.betterx.bclib.api.v2.generator.map.square.SquareBiomeMap;
+import org.betterx.worlds.together.biomesource.config.BiomeSourceConfig;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.util.Mth;
+import net.minecraft.util.StringRepresentable;
+
+import java.util.Objects;
+import org.jetbrains.annotations.NotNull;
+
+public class BCLEndBiomeSourceConfig implements BiomeSourceConfig {
+ public static final BCLEndBiomeSourceConfig VANILLA = new BCLEndBiomeSourceConfig(
+ EndBiomeMapType.VANILLA,
+ EndBiomeGeneratorType.VANILLA,
+ true,
+ 4096,
+ 128,
+ 128,
+ 128,
+ 128
+ );
+ public static final BCLEndBiomeSourceConfig MINECRAFT_17 = new BCLEndBiomeSourceConfig(
+ EndBiomeMapType.SQUARE,
+ EndBiomeGeneratorType.PAULEVS,
+ true,
+ VANILLA.innerVoidRadiusSquared * 16 * 16,
+ 256,
+ 256,
+ 256,
+ 256
+ );
+ public static final BCLEndBiomeSourceConfig MINECRAFT_18 = new BCLEndBiomeSourceConfig(
+ EndBiomeMapType.HEX,
+ BCLib.RUNS_NULLSCAPE ? EndBiomeGeneratorType.VANILLA : EndBiomeGeneratorType.PAULEVS,
+ BCLib.RUNS_NULLSCAPE ? false : true,
+ MINECRAFT_17.innerVoidRadiusSquared,
+ MINECRAFT_17.centerBiomesSize,
+ MINECRAFT_17.voidBiomesSize,
+ MINECRAFT_17.landBiomesSize,
+ MINECRAFT_17.barrensBiomesSize
+ );
+
+ public static final BCLEndBiomeSourceConfig MINECRAFT_18_LARGE = new BCLEndBiomeSourceConfig(
+ EndBiomeMapType.HEX,
+ BCLib.RUNS_NULLSCAPE ? EndBiomeGeneratorType.VANILLA : EndBiomeGeneratorType.PAULEVS,
+ BCLib.RUNS_NULLSCAPE ? false : true,
+ MINECRAFT_18.innerVoidRadiusSquared,
+ MINECRAFT_18.centerBiomesSize,
+ MINECRAFT_18.voidBiomesSize * 2,
+ MINECRAFT_18.landBiomesSize * 4,
+ MINECRAFT_18.barrensBiomesSize * 2
+ );
+
+ public static final BCLEndBiomeSourceConfig MINECRAFT_18_AMPLIFIED = new BCLEndBiomeSourceConfig(
+ EndBiomeMapType.HEX,
+ EndBiomeGeneratorType.PAULEVS,
+ true,
+ MINECRAFT_18.innerVoidRadiusSquared,
+ MINECRAFT_18.centerBiomesSize,
+ MINECRAFT_18.voidBiomesSize,
+ MINECRAFT_18.landBiomesSize,
+ MINECRAFT_18.barrensBiomesSize
+ );
+
+ public static final BCLEndBiomeSourceConfig MINECRAFT_20 = new BCLEndBiomeSourceConfig(
+ EndBiomeMapType.HEX,
+ EndBiomeGeneratorType.VANILLA,
+ BCLib.RUNS_NULLSCAPE ? false : true,
+ MINECRAFT_17.innerVoidRadiusSquared,
+ MINECRAFT_17.centerBiomesSize,
+ MINECRAFT_17.voidBiomesSize,
+ MINECRAFT_17.landBiomesSize,
+ MINECRAFT_17.barrensBiomesSize
+ );
+
+ public static final BCLEndBiomeSourceConfig MINECRAFT_20_LARGE = new BCLEndBiomeSourceConfig(
+ EndBiomeMapType.HEX,
+ EndBiomeGeneratorType.VANILLA,
+ BCLib.RUNS_NULLSCAPE ? false : true,
+ MINECRAFT_18.innerVoidRadiusSquared,
+ MINECRAFT_18.centerBiomesSize,
+ MINECRAFT_18.voidBiomesSize * 2,
+ MINECRAFT_18.landBiomesSize * 4,
+ MINECRAFT_18.barrensBiomesSize * 2
+ );
+
+ public static final BCLEndBiomeSourceConfig MINECRAFT_20_AMPLIFIED = new BCLEndBiomeSourceConfig(
+ EndBiomeMapType.HEX,
+ EndBiomeGeneratorType.VANILLA,
+ true,
+ MINECRAFT_18.innerVoidRadiusSquared,
+ MINECRAFT_18.centerBiomesSize,
+ MINECRAFT_18.voidBiomesSize,
+ MINECRAFT_18.landBiomesSize,
+ MINECRAFT_18.barrensBiomesSize
+ );
+ public static final BCLEndBiomeSourceConfig DEFAULT = MINECRAFT_20;
+
+ public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance
+ .group(
+ EndBiomeMapType.CODEC
+ .fieldOf("map_type")
+ .orElse(DEFAULT.mapVersion)
+ .forGetter(o -> o.mapVersion),
+ EndBiomeGeneratorType.CODEC
+ .fieldOf("generator_version")
+ .orElse(DEFAULT.generatorVersion)
+ .forGetter(o -> o.generatorVersion),
+ Codec.BOOL
+ .fieldOf("with_void_biomes")
+ .orElse(DEFAULT.withVoidBiomes)
+ .forGetter(o -> o.withVoidBiomes),
+ Codec.INT
+ .fieldOf("inner_void_radius_squared")
+ .orElse(DEFAULT.innerVoidRadiusSquared)
+ .forGetter(o -> o.innerVoidRadiusSquared),
+ Codec.INT
+ .fieldOf("center_biomes_size")
+ .orElse(DEFAULT.centerBiomesSize)
+ .forGetter(o -> o.centerBiomesSize),
+ Codec.INT
+ .fieldOf("void_biomes_size")
+ .orElse(DEFAULT.voidBiomesSize)
+ .forGetter(o -> o.voidBiomesSize),
+ Codec.INT
+ .fieldOf("land_biomes_size")
+ .orElse(DEFAULT.landBiomesSize)
+ .forGetter(o -> o.landBiomesSize),
+ Codec.INT
+ .fieldOf("barrens_biomes_size")
+ .orElse(DEFAULT.barrensBiomesSize)
+ .forGetter(o -> o.barrensBiomesSize)
+ )
+ .apply(instance, BCLEndBiomeSourceConfig::new));
+
+ public BCLEndBiomeSourceConfig(
+ @NotNull EndBiomeMapType mapVersion,
+ @NotNull EndBiomeGeneratorType generatorVersion,
+ boolean withVoidBiomes,
+ int innerVoidRadiusSquared,
+ int centerBiomesSize,
+ int voidBiomesSize,
+ int landBiomesSize,
+ int barrensBiomesSize
+ ) {
+ this.mapVersion = mapVersion;
+ this.generatorVersion = generatorVersion;
+ this.withVoidBiomes = withVoidBiomes;
+ this.innerVoidRadiusSquared = innerVoidRadiusSquared;
+ this.barrensBiomesSize = Mth.clamp(barrensBiomesSize, 1, 8192);
+ this.voidBiomesSize = Mth.clamp(voidBiomesSize, 1, 8192);
+ this.centerBiomesSize = Mth.clamp(centerBiomesSize, 1, 8192);
+ this.landBiomesSize = Mth.clamp(landBiomesSize, 1, 8192);
+ }
+
+ public enum EndBiomeMapType implements StringRepresentable {
+ VANILLA("vanilla", (seed, biomeSize, picker) -> new HexBiomeMap(seed, biomeSize, picker)),
+ SQUARE("square", (seed, biomeSize, picker) -> new SquareBiomeMap(seed, biomeSize, picker)),
+ HEX("hex", (seed, biomeSize, picker) -> new HexBiomeMap(seed, biomeSize, picker));
+
+ public static final Codec CODEC = StringRepresentable.fromEnum(EndBiomeMapType::values);
+ public final String name;
+ public final @NotNull MapBuilderFunction mapBuilder;
+
+ EndBiomeMapType(String name, @NotNull MapBuilderFunction mapBuilder) {
+ this.name = name;
+ this.mapBuilder = mapBuilder;
+ }
+
+ @Override
+ public String getSerializedName() {
+ return name;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+
+ public enum EndBiomeGeneratorType implements StringRepresentable {
+ VANILLA("vanilla"),
+ PAULEVS("paulevs");
+
+ public static final Codec CODEC = StringRepresentable.fromEnum(EndBiomeGeneratorType::values);
+ public final String name;
+
+ EndBiomeGeneratorType(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String getSerializedName() {
+ return name;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+
+
+ public final @NotNull EndBiomeMapType mapVersion;
+ public final @NotNull EndBiomeGeneratorType generatorVersion;
+ public final boolean withVoidBiomes;
+ public final int innerVoidRadiusSquared;
+
+ public final int voidBiomesSize;
+ public final int centerBiomesSize;
+ public final int landBiomesSize;
+ public final int barrensBiomesSize;
+
+ @Override
+ public String toString() {
+ return "BCLEndBiomeSourceConfig{" +
+ "mapVersion=" + mapVersion +
+ ", generatorVersion=" + generatorVersion +
+ ", withVoidBiomes=" + withVoidBiomes +
+ ", innerVoidRadiusSquared=" + innerVoidRadiusSquared +
+ ", voidBiomesSize=" + voidBiomesSize +
+ ", centerBiomesSize=" + centerBiomesSize +
+ ", landBiomesSize=" + landBiomesSize +
+ ", barrensBiomesSize=" + barrensBiomesSize +
+ '}';
+ }
+
+ @Override
+ public boolean couldSetWithoutRepair(BiomeSourceConfig> input) {
+ if (input instanceof BCLEndBiomeSourceConfig cfg) {
+ return withVoidBiomes == cfg.withVoidBiomes && mapVersion == cfg.mapVersion && generatorVersion == cfg.generatorVersion;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean sameConfig(BiomeSourceConfig> input) {
+ return this.equals(input);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ BCLEndBiomeSourceConfig that = (BCLEndBiomeSourceConfig) o;
+ return withVoidBiomes == that.withVoidBiomes && innerVoidRadiusSquared == that.innerVoidRadiusSquared && voidBiomesSize == that.voidBiomesSize && centerBiomesSize == that.centerBiomesSize && landBiomesSize == that.landBiomesSize && barrensBiomesSize == that.barrensBiomesSize && mapVersion == that.mapVersion && generatorVersion == that.generatorVersion;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ mapVersion,
+ generatorVersion,
+ withVoidBiomes,
+ innerVoidRadiusSquared,
+ voidBiomesSize,
+ centerBiomesSize,
+ landBiomesSize,
+ barrensBiomesSize
+ );
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/generator/config/BCLNetherBiomeSourceConfig.java b/src/main/java/org/betterx/bclib/api/v2/generator/config/BCLNetherBiomeSourceConfig.java
new file mode 100644
index 00000000..7aa896c7
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/config/BCLNetherBiomeSourceConfig.java
@@ -0,0 +1,157 @@
+package org.betterx.bclib.api.v2.generator.config;
+
+import org.betterx.bclib.api.v2.generator.BCLibNetherBiomeSource;
+import org.betterx.bclib.api.v2.generator.map.hex.HexBiomeMap;
+import org.betterx.bclib.api.v2.generator.map.square.SquareBiomeMap;
+import org.betterx.worlds.together.biomesource.config.BiomeSourceConfig;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.util.Mth;
+import net.minecraft.util.StringRepresentable;
+
+import java.util.Objects;
+import org.jetbrains.annotations.NotNull;
+
+public class BCLNetherBiomeSourceConfig implements BiomeSourceConfig