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..31e883f6
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloClient.java
@@ -0,0 +1,530 @@
+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);
+
+ 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);
+ 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 -> {
+ 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) {
+ 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) {
+ BCLib.LOGGER.info(" - " + desc.folderID + " (" + desc.localFolder + ", allowRemove=" + desc.removeAdditionalFiles + ")");
+ localDescriptor.invalidateCache();
+
+ desc.relativeFilesStream()
+ .filter(desc::discardChildElements)
+ .forEach(subFile -> {
+ 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());
+
+ 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)) {
+ BCLib.LOGGER.info(" * " + subFile.relPath + " (changed)");
+ filesToRequest.add(new AutoSyncID.ForDirectFileRequest(
+ desc.folderID,
+ new File(subFile.relPath)
+ ));
+ } else {
+ BCLib.LOGGER.info(" * " + subFile.relPath);
+ }
+ } else {
+ //the file is missing locally
+ 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 {
+ 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) {
+ 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
+ ));
+ }
+ }
+
+ 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;
+
+ 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..35c4ecbe
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/RequestFiles.java
@@ -0,0 +1,109 @@
+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);
+
+ BCLib.LOGGER.info("Client requested " + size + " Files:");
+ for (int i = 0; i < size; i++) {
+ AutoSyncID asid = AutoSyncID.deserializeData(buf);
+ files.add(asid);
+ 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..5d16a6f5
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SendFiles.java
@@ -0,0 +1,225 @@
+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());
+
+ BCLib.LOGGER.info("Sending " + existingFiles.size() + " Files to Client:");
+ for (AutoFileSyncEntry entry : existingFiles) {
+ int length = entry.serializeContent(buf);
+ 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);
+ 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 ";
+ }
+ BCLib.LOGGER.info(" - " + type + p.first + " (" + PathUtil.humanReadableFileSize(p.second.length) + ")");
+ } else {
+ 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..b214047b
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SyncFolderDescriptor.java
@@ -0,0 +1,218 @@
+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 -> {
+ 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..8c406bb8
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/BCLBiomeSource.java
@@ -0,0 +1,124 @@
+package org.betterx.bclib.api.v2.generator;
+
+import org.betterx.bclib.api.v2.levelgen.biomes.BiomeAPI;
+import org.betterx.worlds.together.biomesource.BiomeSourceFromRegistry;
+import org.betterx.worlds.together.biomesource.MergeableBiomeSource;
+import org.betterx.worlds.together.world.BiomeSourceWithNoiseRelatedSettings;
+import org.betterx.worlds.together.world.BiomeSourceWithSeed;
+
+import net.minecraft.core.Holder;
+import net.minecraft.core.Registry;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraft.world.level.biome.BiomeSource;
+import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
+
+import com.google.common.collect.Sets;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+
+public abstract class BCLBiomeSource extends BiomeSource implements BiomeSourceWithSeed, MergeableBiomeSource, BiomeSourceWithNoiseRelatedSettings, BiomeSourceFromRegistry {
+ protected final Registry biomeRegistry;
+ protected long currentSeed;
+ protected int maxHeight;
+
+ private static List> preInit(Registry biomeRegistry, List> biomes) {
+ biomes = biomes.stream().sorted(Comparator.comparing(holder -> holder.unwrapKey()
+ .get()
+ .location()
+ .toString()))
+ .toList();
+ biomes.forEach(biome -> BiomeAPI.sortBiomeFeatures(biome));
+ return biomes;
+ }
+
+ protected BCLBiomeSource(
+ Registry biomeRegistry,
+ List> list,
+ long seed
+ ) {
+ super(preInit(biomeRegistry, list));
+
+ this.biomeRegistry = biomeRegistry;
+ this.currentSeed = seed;
+ }
+
+ final public void setSeed(long seed) {
+ if (seed != currentSeed) {
+ System.out.println(this + " set 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) {
+ System.out.println(this + " set Max Height: " + maxHeight);
+ this.maxHeight = maxHeight;
+ onHeightChange(maxHeight);
+ }
+ }
+
+ protected final void initMap(long seed) {
+ System.out.println(this + " updates Map");
+ onInitMap(seed);
+ }
+
+ protected abstract void onInitMap(long newSeed);
+ protected abstract void onHeightChange(int newHeight);
+
+ public BCLBiomeSource createCopyForDatapack(Set> datapackBiomes) {
+ Set> mutableSet = Sets.newHashSet();
+ mutableSet.addAll(datapackBiomes);
+ return cloneForDatapack(mutableSet);
+ }
+
+ protected abstract BCLBiomeSource cloneForDatapack(Set> datapackBiomes);
+
+ public interface ValidBiomePredicate {
+ boolean isValid(Holder biome, ResourceLocation location);
+ }
+
+ protected static List> getBiomes(
+ Registry biomeRegistry,
+ List exclude,
+ List include,
+ BCLibNetherBiomeSource.ValidBiomePredicate test
+ ) {
+ return biomeRegistry.stream()
+ .filter(biome -> biomeRegistry.getResourceKey(biome).isPresent())
+
+ .map(biome -> biomeRegistry.getOrCreateHolderOrThrow(biomeRegistry.getResourceKey(biome)
+ .get()))
+ .filter(biome -> {
+ ResourceLocation location = biome.unwrapKey().orElseThrow().location();
+ final String strLocation = location.toString();
+ if (exclude.contains(strLocation)) return false;
+ if (include.contains(strLocation)) return true;
+
+ return test.isValid(biome, location);
+ })
+ .toList();
+ }
+
+ @Override
+ public BCLBiomeSource mergeWithBiomeSource(BiomeSource inputBiomeSource) {
+ final Set> datapackBiomes = inputBiomeSource.possibleBiomes();
+ return this.createCopyForDatapack(datapackBiomes);
+ }
+
+ public void onLoadGeneratorSettings(NoiseGeneratorSettings generator) {
+ this.setMaxHeight(generator.noiseSettings().height());
+ }
+
+ public Registry getBiomeRegistry() {
+ return biomeRegistry;
+ }
+}
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..ac69013c
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/BCLChunkGenerator.java
@@ -0,0 +1,230 @@
+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.Registry;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.resources.RegistryOps;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.server.level.ServerLevel;
+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.chunk.ChunkGenerator;
+import net.minecraft.world.level.dimension.DimensionType;
+import net.minecraft.world.level.dimension.LevelStem;
+import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
+import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
+import net.minecraft.world.level.levelgen.RandomState;
+import net.minecraft.world.level.levelgen.WorldGenSettings;
+import net.minecraft.world.level.levelgen.structure.StructureSet;
+import net.minecraft.world.level.levelgen.synth.NormalNoise;
+
+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) -> {
+ final RecordCodecBuilder> noiseGetter = RegistryOps
+ .retrieveRegistry(
+ Registry.NOISE_REGISTRY)
+ .forGetter(
+ BCLChunkGenerator::getNoises);
+
+ RecordCodecBuilder biomeSourceCodec = BiomeSource.CODEC
+ .fieldOf("biome_source")
+ .forGetter((BCLChunkGenerator generator) -> generator.biomeSource);
+
+ RecordCodecBuilder> settingsCodec = NoiseGeneratorSettings.CODEC
+ .fieldOf("settings")
+ .forGetter((BCLChunkGenerator generator) -> generator.settings);
+
+
+ return NoiseBasedChunkGenerator
+ .commonCodec(builderInstance)
+ .and(builderInstance.group(noiseGetter, biomeSourceCodec, settingsCodec))
+ .apply(builderInstance, builderInstance.stable(BCLChunkGenerator::new));
+ });
+ public final BiomeSource initialBiomeSource;
+
+ public BCLChunkGenerator(
+ Registry registry,
+ Registry registry2,
+ BiomeSource biomeSource,
+ Holder holder
+ ) {
+ super(registry, registry2, biomeSource, holder);
+ initialBiomeSource = biomeSource;
+ if (biomeSource instanceof BiomeSourceWithNoiseRelatedSettings bcl) {
+ bcl.onLoadGeneratorSettings(holder.value());
+ }
+
+ if (WorldsTogether.RUNS_TERRABLENDER) {
+ BCLib.LOGGER.info("Make sure features are loaded from terrablender for " + biomeSource);
+
+ //terrablender is invalidating the feature initialization
+ //we redo it at this point, otherwise we will get blank biomes
+ rebuildFeaturesPerStep(biomeSource);
+ }
+ System.out.println("Chunk Generator: " + this + " (biomeSource: " + 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 und 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;
+ }
+
+
+ private Registry getNoises() {
+ if (this instanceof NoiseGeneratorSettingsProvider p) {
+ return p.bclib_getNoises();
+ }
+ return null;
+ }
+
+ @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() {
+ }
+
+ public static RandomState createRandomState(ServerLevel level, ChunkGenerator generator) {
+ if (generator instanceof NoiseBasedChunkGenerator noiseBasedChunkGenerator) {
+ return RandomState.create(
+ noiseBasedChunkGenerator.generatorSettings().value(),
+ level.registryAccess().registryOrThrow(Registry.NOISE_REGISTRY),
+ level.getSeed()
+ );
+ } else {
+ return RandomState.create(level.registryAccess(), NoiseGeneratorSettings.OVERWORLD, level.getSeed());
+ }
+ }
+
+ @Override
+ public WorldGenSettings enforceGeneratorInWorldGenSettings(
+ RegistryAccess access,
+ ResourceKey dimensionKey,
+ ResourceKey dimensionTypeKey,
+ ChunkGenerator loadedChunkGenerator,
+ WorldGenSettings settings
+ ) {
+ 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();
+ }
+
+ referenceGenerator = new BCLChunkGenerator(
+ generator.bclib_getStructureSetsRegistry(),
+ noiseProvider.bclib_getNoises(),
+ bs,
+ buildGeneratorSettings(
+ referenceProvider.bclib_getNoiseGeneratorSettingHolders(),
+ noiseProvider.bclib_getNoiseGeneratorSettingHolders(),
+ bs
+ )
+ );
+ }
+ }
+ }
+
+ return LevelGenUtil.replaceGenerator(
+ dimensionKey,
+ dimensionTypeKey,
+ access,
+ settings,
+ referenceGenerator
+ );
+
+ }
+
+ private static Holder buildGeneratorSettings(
+ Holder reference,
+ Holder settings,
+ BiomeSource biomeSource
+ ) {
+ return settings;
+// NoiseGeneratorSettings old = settings.value();
+// NoiseGeneratorSettings noise = new NoiseGeneratorSettings(
+// old.noiseSettings(),
+// old.defaultBlock(),
+// old.defaultFluid(),
+// old.noiseRouter(),
+// SurfaceRuleRegistry.mergeSurfaceRulesFromBiomes(old.surfaceRule(), biomeSource),
+// //SurfaceRuleUtil.addRulesForBiomeSource(old.surfaceRule(), biomeSource),
+// old.spawnTarget(),
+// old.seaLevel(),
+// old.disableMobGeneration(),
+// old.aquifersEnabled(),
+// old.oreVeinsEnabled(),
+// old.useLegacyRandom()
+// );
+//
+//
+// return Holder.direct(noise);
+ }
+}
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..927c3709
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/BCLibEndBiomeSource.java
@@ -0,0 +1,444 @@
+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.BCLBiomeRegistry;
+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 org.betterx.worlds.together.biomesource.ReloadableBiomeSource;
+
+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.resources.RegistryOps;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.tags.BiomeTags;
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.BiFunction;
+import org.jetbrains.annotations.NotNull;
+
+public class BCLibEndBiomeSource extends BCLBiomeSource implements BiomeSourceWithConfig, ReloadableBiomeSource {
+ public static Codec CODEC
+ = RecordCodecBuilder.create((instance) -> instance.group(
+ RegistryOps
+ .retrieveRegistry(Registry.BIOME_REGISTRY)
+ .forGetter((theEndBiomeSource) -> theEndBiomeSource.biomeRegistry),
+ 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 final BiFunction endLandFunction;
+ 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;
+
+ public BCLibEndBiomeSource(Registry biomeRegistry, long seed, BCLEndBiomeSourceConfig config) {
+ this(biomeRegistry, seed, config, true);
+ }
+
+ public BCLibEndBiomeSource(Registry biomeRegistry, BCLEndBiomeSourceConfig config) {
+ this(biomeRegistry, 0, config, false);
+ }
+
+ private BCLibEndBiomeSource(
+ Registry biomeRegistry,
+ long seed,
+ BCLEndBiomeSourceConfig config,
+ boolean initMaps
+ ) {
+ this(biomeRegistry, getBiomes(biomeRegistry), seed, config, initMaps);
+ }
+
+ private BCLibEndBiomeSource(
+ Registry biomeRegistry,
+ List> list,
+ long seed,
+ BCLEndBiomeSourceConfig config,
+ boolean initMaps
+ ) {
+ super(biomeRegistry, list, seed);
+ this.config = config;
+ rebuildBiomePickers();
+
+ this.endLandFunction = GeneratorOptions.getEndLandFunction();
+ this.pos = new Point();
+
+ if (initMaps) {
+ initMap(seed);
+ }
+ }
+
+ @NotNull
+ private void rebuildBiomePickers() {
+ var includeMap = Configs.BIOMES_CONFIG.getBiomeIncludeMap();
+ var excludeList = Configs.BIOMES_CONFIG.getExcludeMatching(BiomeAPI.BiomeType.END);
+
+ this.deciders = BiomeDecider.DECIDERS.stream()
+ .filter(d -> d.canProvideFor(this))
+ .map(d -> d.createInstance(this))
+ .toList();
+
+ this.endLandBiomePicker = new BiomePicker(biomeRegistry);
+ this.endVoidBiomePicker = new BiomePicker(biomeRegistry);
+ this.endCenterBiomePicker = new BiomePicker(biomeRegistry);
+ this.endBarrensBiomePicker = new BiomePicker(biomeRegistry);
+ Map pickerMap = new HashMap<>();
+ pickerMap.put(BiomeAPI.BiomeType.END_LAND, endLandBiomePicker);
+ pickerMap.put(BiomeAPI.BiomeType.END_VOID, endVoidBiomePicker);
+ pickerMap.put(BiomeAPI.BiomeType.END_CENTER, endCenterBiomePicker);
+ pickerMap.put(BiomeAPI.BiomeType.END_BARRENS, endBarrensBiomePicker);
+
+
+ this.possibleBiomes().forEach(biome -> {
+ ResourceKey key = biome.unwrapKey().orElseThrow();
+ ResourceLocation biomeID = key.location();
+ String biomeStr = biomeID.toString();
+ //exclude everything that was listed
+ if (excludeList != null && excludeList.contains(biomeStr)) return;
+ if (!biome.isBound()) {
+ BCLib.LOGGER.warning("Biome " + biomeStr + " is requested but not yet bound.");
+ return;
+ }
+ final BCLBiome bclBiome;
+ if (!BiomeAPI.hasBiome(biomeID)) {
+ bclBiome = new BCLBiome(biomeID, biome.value());
+ } else {
+ bclBiome = BiomeAPI.getBiome(biomeID);
+ }
+
+
+ if (bclBiome != null || bclBiome != BCLBiomeRegistry.EMPTY_BIOME) {
+ if (bclBiome.getParentBiome() == null) {
+ //ignore small islands when void biomes are disabled
+ if (!config.withVoidBiomes) {
+ if (biomeID.equals(Biomes.SMALL_END_ISLANDS.location())) {
+ return;
+ }
+ }
+
+ //force include biomes
+ boolean didForceAdd = false;
+ for (var entry : pickerMap.entrySet()) {
+ var includeList = includeMap == null ? null : includeMap.get(entry.getKey());
+ if (includeList != null && includeList.contains(biomeStr)) {
+ entry.getValue().addBiome(bclBiome);
+ didForceAdd = true;
+ }
+ }
+
+ if (!didForceAdd) {
+ if (biomeID.equals(BCLBiomeRegistry.EMPTY_BIOME.getID())
+ || bclBiome.getIntendedType().is(BiomeAPI.BiomeType.END_IGNORE)) {
+ //we should not add this biome anywhere, so just ignore it
+ } else {
+ didForceAdd = false;
+ for (BiomeDecider decider : deciders) {
+ if (decider.addToPicker(bclBiome)) {
+ didForceAdd = true;
+ break;
+ }
+ }
+ if (!didForceAdd) {
+ if (bclBiome.getIntendedType().is(BiomeAPI.BiomeType.END_CENTER)
+ || TheEndBiomesHelper.canGenerateAsMainIslandBiome(key)) {
+ endCenterBiomePicker.addBiome(bclBiome);
+ } else if (bclBiome.getIntendedType().is(BiomeAPI.BiomeType.END_LAND)
+ || TheEndBiomesHelper.canGenerateAsHighlandsBiome(key)) {
+ if (!config.withVoidBiomes) endVoidBiomePicker.addBiome(bclBiome);
+ endLandBiomePicker.addBiome(bclBiome);
+ } else if (bclBiome.getIntendedType().is(BiomeAPI.BiomeType.END_BARRENS)
+ || TheEndBiomesHelper.canGenerateAsEndBarrens(key)) {
+ endBarrensBiomePicker.addBiome(bclBiome);
+ } else if (bclBiome.getIntendedType().is(BiomeAPI.BiomeType.END_VOID)
+ || TheEndBiomesHelper.canGenerateAsSmallIslandsBiome(key)) {
+ endVoidBiomePicker.addBiome(bclBiome);
+ } else {
+ BCLib.LOGGER.info("Found End Biome " + biomeStr + " that was not registers with fabric or bclib. Assuming end-land Biome...");
+ endLandBiomePicker.addBiome(bclBiome);
+ }
+ }
+ }
+ }
+ }
+ }
+ });
+
+ endLandBiomePicker.rebuild();
+ endVoidBiomePicker.rebuild();
+ endBarrensBiomePicker.rebuild();
+ endCenterBiomePicker.rebuild();
+
+ for (BiomeDecider decider : deciders) {
+ decider.rebuild();
+ }
+
+ if (endVoidBiomePicker.isEmpty()) {
+ BCLib.LOGGER.info("No Void Biomes found. Disabling by using barrens");
+ endVoidBiomePicker = endBarrensBiomePicker;
+ }
+ if (endBarrensBiomePicker.isEmpty()) {
+ BCLib.LOGGER.info("No Barrens Biomes found. Disabling by using land Biomes");
+ endBarrensBiomePicker = endLandBiomePicker;
+ endVoidBiomePicker = endLandBiomePicker;
+ }
+ if (endCenterBiomePicker.isEmpty()) {
+ BCLib.LOGGER.warning("No Center Island Biomes found. Forcing use of vanilla center.");
+ endCenterBiomePicker.addBiome(BiomeAPI.THE_END);
+ endCenterBiomePicker.rebuild();
+ if (endCenterBiomePicker.isEmpty()) {
+ BCLib.LOGGER.error("Unable to force vanilla central Island. Falling back to land Biomes...");
+ endCenterBiomePicker = endLandBiomePicker;
+ }
+ }
+ }
+
+ protected BCLBiomeSource cloneForDatapack(Set> datapackBiomes) {
+ datapackBiomes.addAll(getBclBiomes(this.biomeRegistry));
+ return new BCLibEndBiomeSource(
+ this.biomeRegistry,
+ datapackBiomes.stream()
+ .filter(b -> b.unwrapKey().orElse(null) != BCLBiomeRegistry.EMPTY_BIOME.getBiomeKey())
+ .toList(),
+ this.currentSeed,
+ this.config,
+ true
+ );
+ }
+
+ private static List> getBclBiomes(Registry biomeRegistry) {
+ return getBiomes(
+ biomeRegistry,
+ Configs.BIOMES_CONFIG.getExcludeMatching(BiomeAPI.BiomeType.END),
+ Configs.BIOMES_CONFIG.getIncludeMatching(BiomeAPI.BiomeType.END),
+ BCLibEndBiomeSource::isValidNonVanillaEndBiome
+ );
+ }
+
+ private static List> getBiomes(Registry biomeRegistry) {
+ return getBiomes(
+ biomeRegistry,
+ Configs.BIOMES_CONFIG.getExcludeMatching(BiomeAPI.BiomeType.END),
+ Configs.BIOMES_CONFIG.getIncludeMatching(BiomeAPI.BiomeType.END),
+ BCLibEndBiomeSource::isValidEndBiome
+ );
+ }
+
+
+ private static boolean isValidEndBiome(Holder biome, ResourceLocation location) {
+ if (BiomeAPI.wasRegisteredAs(location, BiomeAPI.BiomeType.END_IGNORE)) return false;
+
+ return biome.is(BiomeTags.IS_END) ||
+ BiomeAPI.wasRegisteredAsEndBiome(location) ||
+ TheEndBiomesHelper.canGenerateInEnd(biome.unwrapKey().orElse(null));
+ }
+
+ private static boolean isValidNonVanillaEndBiome(Holder biome, ResourceLocation location) {
+ if (BiomeAPI.wasRegisteredAs(location, BiomeAPI.BiomeType.END_IGNORE)) return false;
+
+ return biome.is(BiomeTags.IS_END) ||
+ BiomeAPI.wasRegisteredAs(location, BiomeAPI.BiomeType.BCL_END_LAND) ||
+ BiomeAPI.wasRegisteredAs(location, BiomeAPI.BiomeType.BCL_END_VOID) ||
+ BiomeAPI.wasRegisteredAs(location, BiomeAPI.BiomeType.BCL_END_CENTER) ||
+ BiomeAPI.wasRegisteredAs(location, BiomeAPI.BiomeType.BCL_END_BARRENS) ||
+ TheEndBiomesHelper.canGenerateInEnd(biome.unwrapKey().orElse(null));
+ }
+
+ public static void register() {
+ Registry.register(Registry.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 (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;
+
+ if (config.generatorVersion == BCLEndBiomeSourceConfig.EndBiomeGeneratorType.VANILLA) {
+ 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
+ );
+ }
+ } else {
+ pos.setLocation(biomeX, biomeZ);
+ final BiomeAPI.BiomeType originalType = (dist <= (long) config.innerVoidRadiusSquared
+ ? BiomeAPI.BiomeType.END_CENTER
+ : BiomeAPI.BiomeType.END_LAND);
+ suggestedType = originalType;
+
+ for (BiomeDecider decider : deciders) {
+ suggestedType = decider
+ .suggestType(originalType, suggestedType, 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 toString() {
+ return "BCLib - The End BiomeSource (" + Integer.toHexString(hashCode()) + ", config=" + config + ", seed=" + currentSeed + ", height=" + maxHeight + ", customLand=" + (endLandFunction != null) + ", biomes=" + possibleBiomes().size() + ")";
+ }
+
+ @Override
+ public BCLEndBiomeSourceConfig getTogetherConfig() {
+ return config;
+ }
+
+ @Override
+ public void setTogetherConfig(BCLEndBiomeSourceConfig newConfig) {
+ this.config = newConfig;
+ this.initMap(currentSeed);
+ }
+
+ @Override
+ public void reloadBiomes() {
+ rebuildBiomePickers();
+ 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..60e87d0a
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/generator/BCLibNetherBiomeSource.java
@@ -0,0 +1,229 @@
+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.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.bclib.interfaces.BiomeMap;
+import org.betterx.worlds.together.biomesource.BiomeSourceWithConfig;
+import org.betterx.worlds.together.biomesource.ReloadableBiomeSource;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.core.Holder;
+import net.minecraft.core.Registry;
+import net.minecraft.resources.RegistryOps;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.tags.BiomeTags;
+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.List;
+import java.util.Set;
+
+public class BCLibNetherBiomeSource extends BCLBiomeSource implements BiomeSourceWithConfig, ReloadableBiomeSource {
+ public static final Codec CODEC = RecordCodecBuilder
+ .create(instance -> instance
+ .group(
+ RegistryOps
+ .retrieveRegistry(Registry.BIOME_REGISTRY)
+ .forGetter(source -> source.biomeRegistry),
+ 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(Registry biomeRegistry, BCLNetherBiomeSourceConfig config) {
+ this(biomeRegistry, 0, config, false);
+ }
+
+ public BCLibNetherBiomeSource(Registry biomeRegistry, long seed, BCLNetherBiomeSourceConfig config) {
+ this(biomeRegistry, seed, config, true);
+ }
+
+ private BCLibNetherBiomeSource(
+ Registry biomeRegistry,
+ long seed,
+ BCLNetherBiomeSourceConfig config,
+ boolean initMaps
+ ) {
+ this(biomeRegistry, getBiomes(biomeRegistry), seed, config, initMaps);
+ }
+
+ private BCLibNetherBiomeSource(
+ Registry biomeRegistry,
+ List> list,
+ long seed,
+ BCLNetherBiomeSourceConfig config,
+ boolean initMaps
+ ) {
+ super(biomeRegistry, list, seed);
+ this.config = config;
+ rebuildBiomePicker();
+ if (initMaps) {
+ initMap(seed);
+ }
+ }
+
+ private void rebuildBiomePicker() {
+ biomePicker = new BiomePicker(biomeRegistry);
+
+ this.possibleBiomes().forEach(biome -> {
+ ResourceLocation biomeID = biome.unwrapKey().orElseThrow().location();
+ if (!biome.isBound()) {
+ BCLib.LOGGER.warning("Biome " + biomeID.toString() + " is requested but not yet bound.");
+ return;
+ }
+ if (!BiomeAPI.hasBiome(biomeID)) {
+
+ BCLBiome bclBiome = new BCLBiome(biomeID, biome.value());
+ biomePicker.addBiome(bclBiome);
+ } else {
+ BCLBiome bclBiome = BiomeAPI.getBiome(biomeID);
+
+ if (bclBiome != BCLBiomeRegistry.EMPTY_BIOME) {
+ if (bclBiome.getParentBiome() == null) {
+ biomePicker.addBiome(bclBiome);
+ }
+ }
+ }
+ });
+
+ biomePicker.rebuild();
+ }
+
+ protected BCLBiomeSource cloneForDatapack(Set> datapackBiomes) {
+ datapackBiomes.addAll(getBclBiomes(this.biomeRegistry));
+ return new BCLibNetherBiomeSource(
+ this.biomeRegistry,
+ datapackBiomes.stream().toList(),
+ this.currentSeed,
+ config,
+ true
+ );
+ }
+
+ private static List> getBclBiomes(Registry biomeRegistry) {
+ List include = Configs.BIOMES_CONFIG.getIncludeMatching(BiomeAPI.BiomeType.NETHER);
+ List exclude = Configs.BIOMES_CONFIG.getExcludeMatching(BiomeAPI.BiomeType.NETHER);
+
+ return getBiomes(biomeRegistry, exclude, include, BCLibNetherBiomeSource::isValidNonVanillaNetherBiome);
+ }
+
+
+ private static List> getBiomes(Registry biomeRegistry) {
+ List include = Configs.BIOMES_CONFIG.getIncludeMatching(BiomeAPI.BiomeType.NETHER);
+ List exclude = Configs.BIOMES_CONFIG.getExcludeMatching(BiomeAPI.BiomeType.NETHER);
+
+ return getBiomes(biomeRegistry, exclude, include, BCLibNetherBiomeSource::isValidNetherBiome);
+ }
+
+
+ private static boolean isValidNetherBiome(Holder biome, ResourceLocation location) {
+ return NetherBiomes.canGenerateInNether(biome.unwrapKey().get()) ||
+ biome.is(BiomeTags.IS_NETHER) ||
+ BiomeAPI.wasRegisteredAsNetherBiome(location);
+ }
+
+ private static boolean isValidNonVanillaNetherBiome(Holder biome, ResourceLocation location) {
+ return (
+ !"minecraft".equals(location.getNamespace()) &&
+ NetherBiomes.canGenerateInNether(biome.unwrapKey().get())) ||
+ BiomeAPI.wasRegisteredAs(location, BiomeAPI.BiomeType.BCL_NETHER);
+ }
+
+ public static void debug(Object el, Registry