desc) {
+ DataExchange api = DataExchange.getInstance();
+ api.getDescriptors()
+ .addAll(desc);
+ }
+
+ /**
+ * Sends the Handler.
+ *
+ * Depending on what the result of {@link DataHandler#getOriginatesOnServer()}, the Data is sent from the server
+ * to the client (if {@code true}) or the other way around.
+ *
+ * The method {@link DataHandler#serializeData(FriendlyByteBuf, boolean)} is called just before the data is sent. You should
+ * use this method to add the Data you need to the communication.
+ *
+ * @param h The Data that you want to send
+ */
+ public static void send(BaseDataHandler h) {
+ if (h.getOriginatesOnServer()) {
+ DataExchangeAPI.getInstance().server.sendToClient(h);
+ } else {
+ DataExchangeAPI.getInstance().client.sendToServer(h);
+ }
+ }
+
+ /**
+ * Registers a File for automatic client syncing.
+ *
+ * @param modID The ID of the calling Mod
+ * @param fileName The name of the File
+ */
+ public static void addAutoSyncFile(String modID, File fileName) {
+ AutoSync.addAutoSyncFileData(modID, fileName, false, SyncFileHash.NEED_TRANSFER);
+ }
+
+ /**
+ * Registers a File for automatic client syncing.
+ *
+ * The file is synced of the {@link SyncFileHash} on client and server are not equal. This method will not copy the
+ * configs content from the client to the server.
+ *
+ * @param modID The ID of the calling Mod
+ * @param uniqueID A unique Identifier for the File. (see {@link SyncFileHash#uniqueID} for
+ * Details
+ * @param fileName The name of the File
+ */
+ public static void addAutoSyncFile(String modID, String uniqueID, File fileName) {
+ AutoSync.addAutoSyncFileData(modID, uniqueID, fileName, false, SyncFileHash.NEED_TRANSFER);
+ }
+
+ /**
+ * Registers a File for automatic client syncing.
+ *
+ * The content of the file is requested for comparison. This will copy the
+ * entire file from the client to the server.
+ *
+ * You should only use this option, if you need to compare parts of the file in order to decide
+ * if the File needs to be copied. Normally using the {@link SyncFileHash}
+ * for comparison is sufficient.
+ *
+ * @param modID The ID of the calling Mod
+ * @param fileName The name of the File
+ * @param needTransfer If the predicate returns true, the file needs to get copied to the server.
+ */
+ public static void addAutoSyncFile(String modID, File fileName, AutoSync.NeedTransferPredicate needTransfer) {
+ AutoSync.addAutoSyncFileData(modID, fileName, true, needTransfer);
+ }
+
+ /**
+ * Registers a File for automatic client syncing.
+ *
+ * The content of the file is requested for comparison. This will copy the
+ * entire file from the client to the server.
+ *
+ * You should only use this option, if you need to compare parts of the file in order to decide
+ * if the File needs to be copied. Normally using the {@link SyncFileHash}
+ * for comparison is sufficient.
+ *
+ * @param modID The ID of the calling Mod
+ * @param uniqueID A unique Identifier for the File. (see {@link SyncFileHash#uniqueID} for
+ * Details
+ * @param fileName The name of the File
+ * @param needTransfer If the predicate returns true, the file needs to get copied to the server.
+ */
+ public static void addAutoSyncFile(
+ String modID,
+ String uniqueID,
+ File fileName,
+ AutoSync.NeedTransferPredicate needTransfer
+ ) {
+ AutoSync.addAutoSyncFileData(modID, uniqueID, fileName, true, needTransfer);
+ }
+
+ /**
+ * Register a function that is called whenever the client receives a file from the server and replaced toe local
+ * file with the new content.
+ *
+ * This callback is usefull if you need to reload the new content before the game is quit.
+ *
+ * @param callback A Function that receives the AutoSyncID as well as the Filename.
+ */
+ public static void addOnWriteCallback(BiConsumer callback) {
+ AutoSync.addOnWriteCallback(callback);
+ }
+
+ /**
+ * Returns the sync-folder for a given Mod.
+ *
+ * BCLib will ensure that the contents of sync-folder on the client is the same as the one on the server.
+ *
+ * @param modID ID of the Mod
+ * @return The path to the sync-folder
+ */
+ public static File getModSyncFolder(String modID) {
+ File fl = AutoSync.SYNC_FOLDER.localFolder.resolve(modID.replace(".", "-")
+ .replace(":", "-")
+ .replace("\\", "-")
+ .replace("/", "-"))
+ .normalize()
+ .toFile();
+
+ if (!fl.exists()) {
+ fl.mkdirs();
+ }
+ return fl;
+ }
+
+ static {
+ addOnWriteCallback(Config::reloadSyncedConfig);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/DataHandler.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/DataHandler.java
new file mode 100644
index 00000000..649956af
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/DataHandler.java
@@ -0,0 +1,332 @@
+package org.betterx.bclib.api.v2.dataexchange;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.handler.autosync.Chunker;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.multiplayer.ClientPacketListener;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.network.ServerGamePacketListenerImpl;
+import net.minecraft.world.entity.player.Player;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
+import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
+import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
+
+import java.util.Collection;
+import java.util.List;
+
+public abstract class DataHandler extends BaseDataHandler {
+ public abstract static class WithoutPayload extends DataHandler {
+ protected WithoutPayload(ResourceLocation identifier, boolean originatesOnServer) {
+ super(identifier, originatesOnServer);
+ }
+
+ @Override
+ protected boolean prepareData(boolean isClient) {
+ return true;
+ }
+
+ @Override
+ protected void serializeData(FriendlyByteBuf buf, boolean isClient) {
+ }
+
+ @Override
+ protected void deserializeIncomingData(FriendlyByteBuf buf, PacketSender responseSender, boolean isClient) {
+ }
+ }
+
+ protected DataHandler(ResourceLocation identifier, boolean originatesOnServer) {
+ super(identifier, originatesOnServer);
+ }
+
+ protected boolean prepareData(boolean isClient) {
+ return true;
+ }
+
+ abstract protected void serializeData(FriendlyByteBuf buf, boolean isClient);
+
+ abstract protected void deserializeIncomingData(FriendlyByteBuf buf, PacketSender responseSender, boolean isClient);
+
+ abstract protected void runOnGameThread(Minecraft client, MinecraftServer server, boolean isClient);
+
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ void receiveFromServer(
+ Minecraft client,
+ ClientPacketListener handler,
+ FriendlyByteBuf buf,
+ PacketSender responseSender
+ ) {
+ deserializeIncomingData(buf, responseSender, true);
+ final Runnable runner = () -> runOnGameThread(client, null, true);
+
+ if (isBlocking()) client.executeBlocking(runner);
+ else client.execute(runner);
+ }
+
+ @Override
+ void receiveFromClient(
+ MinecraftServer server,
+ ServerPlayer player,
+ ServerGamePacketListenerImpl handler,
+ FriendlyByteBuf buf,
+ PacketSender responseSender
+ ) {
+ super.receiveFromClient(server, player, handler, buf, responseSender);
+
+ deserializeIncomingData(buf, responseSender, false);
+ final Runnable runner = () -> runOnGameThread(null, server, false);
+
+ if (isBlocking()) server.executeBlocking(runner);
+ else server.execute(runner);
+ }
+
+ @Override
+ void sendToClient(MinecraftServer server) {
+ if (prepareData(false)) {
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ serializeData(buf, false);
+
+ _sendToClient(getIdentifier(), server, PlayerLookup.all(server), buf);
+ }
+ }
+
+ @Override
+ void sendToClient(MinecraftServer server, ServerPlayer player) {
+ if (prepareData(false)) {
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ serializeData(buf, false);
+
+ _sendToClient(getIdentifier(), server, List.of(player), buf);
+ }
+ }
+
+
+ public static void _sendToClient(
+ ResourceLocation identifier,
+ MinecraftServer server,
+ Collection players,
+ FriendlyByteBuf buf
+ ) {
+ if (buf.readableBytes() > Chunker.MAX_PACKET_SIZE) {
+ final Chunker.PacketChunkSender sender = new Chunker.PacketChunkSender(buf, identifier);
+ sender.sendChunks(players);
+ } else {
+ for (ServerPlayer player : players) {
+ ServerPlayNetworking.send(player, identifier, buf);
+ }
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ void sendToServer(Minecraft client) {
+ if (prepareData(true)) {
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ serializeData(buf, true);
+ ClientPlayNetworking.send(getIdentifier(), buf);
+ }
+ }
+
+ /**
+ * A Message that always originates on the Client
+ */
+ public abstract static class FromClient extends BaseDataHandler {
+ public abstract static class WithoutPayload extends FromClient {
+ protected WithoutPayload(ResourceLocation identifier) {
+ super(identifier);
+ }
+
+ @Override
+ protected boolean prepareDataOnClient() {
+ return true;
+ }
+
+ @Override
+ protected void serializeDataOnClient(FriendlyByteBuf buf) {
+ }
+
+ @Override
+ protected void deserializeIncomingDataOnServer(
+ FriendlyByteBuf buf,
+ Player player,
+ PacketSender responseSender
+ ) {
+ }
+ }
+
+ protected FromClient(ResourceLocation identifier) {
+ super(identifier, false);
+ }
+
+ @Environment(EnvType.CLIENT)
+ protected boolean prepareDataOnClient() {
+ return true;
+ }
+
+ @Environment(EnvType.CLIENT)
+ abstract protected void serializeDataOnClient(FriendlyByteBuf buf);
+
+ protected abstract void deserializeIncomingDataOnServer(
+ FriendlyByteBuf buf,
+ Player player,
+ PacketSender responseSender
+ );
+ protected abstract void runOnServerGameThread(MinecraftServer server, Player player);
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ void receiveFromServer(
+ Minecraft client,
+ ClientPacketListener handler,
+ FriendlyByteBuf buf,
+ PacketSender responseSender
+ ) {
+ BCLib.LOGGER.error("[Internal Error] The message '" + getIdentifier() + "' must originate from the client!");
+ }
+
+ @Override
+ void receiveFromClient(
+ MinecraftServer server,
+ ServerPlayer player,
+ ServerGamePacketListenerImpl handler,
+ FriendlyByteBuf buf,
+ PacketSender responseSender
+ ) {
+ super.receiveFromClient(server, player, handler, buf, responseSender);
+
+ deserializeIncomingDataOnServer(buf, player, responseSender);
+ final Runnable runner = () -> runOnServerGameThread(server, player);
+
+ if (isBlocking()) server.executeBlocking(runner);
+ else server.execute(runner);
+ }
+
+ @Override
+ void sendToClient(MinecraftServer server) {
+ BCLib.LOGGER.error("[Internal Error] The message '" + getIdentifier() + "' must originate from the client!");
+ }
+
+ @Override
+ void sendToClient(MinecraftServer server, ServerPlayer player) {
+ BCLib.LOGGER.error("[Internal Error] The message '" + getIdentifier() + "' must originate from the client!");
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ void sendToServer(Minecraft client) {
+ if (prepareDataOnClient()) {
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ serializeDataOnClient(buf);
+ ClientPlayNetworking.send(getIdentifier(), buf);
+ }
+ }
+ }
+
+ /**
+ * A Message that always originates on the Server
+ */
+ public abstract static class FromServer extends BaseDataHandler {
+ public abstract static class WithoutPayload extends FromServer {
+ protected WithoutPayload(ResourceLocation identifier) {
+ super(identifier);
+ }
+
+ @Override
+ protected boolean prepareDataOnServer() {
+ return true;
+ }
+
+ @Override
+ protected void serializeDataOnServer(FriendlyByteBuf buf) {
+ }
+
+ @Override
+ protected void deserializeIncomingDataOnClient(FriendlyByteBuf buf, PacketSender responseSender) {
+ }
+ }
+
+ protected FromServer(ResourceLocation identifier) {
+ super(identifier, true);
+ }
+
+ protected boolean prepareDataOnServer() {
+ return true;
+ }
+
+ abstract protected void serializeDataOnServer(FriendlyByteBuf buf);
+
+ @Environment(EnvType.CLIENT)
+ abstract protected void deserializeIncomingDataOnClient(FriendlyByteBuf buf, PacketSender responseSender);
+
+ @Environment(EnvType.CLIENT)
+ abstract protected void runOnClientGameThread(Minecraft client);
+
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ final void receiveFromServer(
+ Minecraft client,
+ ClientPacketListener handler,
+ FriendlyByteBuf buf,
+ PacketSender responseSender
+ ) {
+ deserializeIncomingDataOnClient(buf, responseSender);
+ final Runnable runner = () -> runOnClientGameThread(client);
+
+ if (isBlocking()) client.executeBlocking(runner);
+ else client.execute(runner);
+ }
+
+ @Override
+ final void receiveFromClient(
+ MinecraftServer server,
+ ServerPlayer player,
+ ServerGamePacketListenerImpl handler,
+ FriendlyByteBuf buf,
+ PacketSender responseSender
+ ) {
+ super.receiveFromClient(server, player, handler, buf, responseSender);
+ BCLib.LOGGER.error("[Internal Error] The message '" + getIdentifier() + "' must originate from the server!");
+ }
+
+ public void receiveFromMemory(FriendlyByteBuf buf) {
+ receiveFromServer(Minecraft.getInstance(), null, buf, null);
+ }
+
+ @Override
+ final void sendToClient(MinecraftServer server) {
+ if (prepareDataOnServer()) {
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ serializeDataOnServer(buf);
+
+ _sendToClient(getIdentifier(), server, PlayerLookup.all(server), buf);
+ }
+ }
+
+ @Override
+ final void sendToClient(MinecraftServer server, ServerPlayer player) {
+ if (prepareDataOnServer()) {
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ serializeDataOnServer(buf);
+
+ _sendToClient(getIdentifier(), server, List.of(player), buf);
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ final void sendToServer(Minecraft client) {
+ BCLib.LOGGER.error("[Internal Error] The message '" + getIdentifier() + "' must originate from the server!");
+ }
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/DataHandlerDescriptor.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/DataHandlerDescriptor.java
new file mode 100644
index 00000000..b572bc31
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/DataHandlerDescriptor.java
@@ -0,0 +1,61 @@
+package org.betterx.bclib.api.v2.dataexchange;
+
+import net.minecraft.resources.ResourceLocation;
+
+import java.util.Objects;
+import java.util.function.Supplier;
+import org.jetbrains.annotations.NotNull;
+
+public class DataHandlerDescriptor {
+ public DataHandlerDescriptor(@NotNull ResourceLocation identifier, @NotNull Supplier instancer) {
+ this(identifier, instancer, instancer, false, false);
+ }
+
+ public DataHandlerDescriptor(
+ @NotNull ResourceLocation identifier,
+ @NotNull Supplier instancer,
+ boolean sendOnJoin,
+ boolean sendBeforeEnter
+ ) {
+ this(identifier, instancer, instancer, sendOnJoin, sendBeforeEnter);
+ }
+
+ public DataHandlerDescriptor(
+ @NotNull ResourceLocation identifier,
+ @NotNull Supplier receiv_instancer,
+ @NotNull Supplier join_instancer,
+ boolean sendOnJoin,
+ boolean sendBeforeEnter
+ ) {
+ this.INSTANCE = receiv_instancer;
+ this.JOIN_INSTANCE = join_instancer;
+ this.IDENTIFIER = identifier;
+
+ this.sendOnJoin = sendOnJoin;
+ this.sendBeforeEnter = sendBeforeEnter;
+ }
+
+ public final boolean sendOnJoin;
+ public final boolean sendBeforeEnter;
+ @NotNull
+ public final ResourceLocation IDENTIFIER;
+ @NotNull
+ public final Supplier INSTANCE;
+ @NotNull
+ public final Supplier JOIN_INSTANCE;
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o instanceof ResourceLocation) {
+ return o.equals(IDENTIFIER);
+ }
+ if (!(o instanceof DataHandlerDescriptor that)) return false;
+ return IDENTIFIER.equals(that.IDENTIFIER);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(IDENTIFIER);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/FileHash.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/FileHash.java
new file mode 100644
index 00000000..dcbef098
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/FileHash.java
@@ -0,0 +1,160 @@
+package org.betterx.bclib.api.v2.dataexchange;
+
+import org.betterx.bclib.BCLib;
+
+import net.minecraft.network.FriendlyByteBuf;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.Objects;
+import org.jetbrains.annotations.NotNull;
+
+public class FileHash {
+ private static final int ERR_DOES_NOT_EXIST = -10;
+ private static final int ERR_IO_ERROR = -20;
+
+ /**
+ * The md5-hash of the file
+ */
+ @NotNull
+ public final byte[] md5;
+
+ /**
+ * The size (in bytes) of the input.
+ */
+ public final int size;
+
+ /**
+ * a value that is directly calculated from defined byte positions.
+ */
+ public final int value;
+
+ FileHash(byte[] md5, int size, int value) {
+ Objects.nonNull(md5);
+
+ this.md5 = md5;
+ this.size = size;
+ this.value = value;
+ }
+
+ static FileHash createForEmpty(int errCode) {
+ return new FileHash(new byte[0], 0, errCode);
+ }
+
+ public boolean noFile() {
+ return md5.length == 0;
+ }
+
+ /**
+ * Serializes the Object to a buffer
+ *
+ * @param buf The buffer to write to
+ */
+ public void serialize(FriendlyByteBuf buf) {
+ buf.writeInt(size);
+ buf.writeInt(value);
+ buf.writeByteArray(md5);
+ }
+
+ /**
+ * Deserialize a Buffer to a new {@link SyncFileHash}-Object
+ *
+ * @param buf Thea buffer to read from
+ * @return The received String
+ */
+ public static FileHash deserialize(FriendlyByteBuf buf) {
+ final int size = buf.readInt();
+ final int value = buf.readInt();
+ final byte[] md5 = buf.readByteArray();
+
+ return new FileHash(md5, size, value);
+ }
+
+ /**
+ * Convert the md5-hash to a human readable string
+ *
+ * @return The converted String
+ */
+ public String getMd5String() {
+ return toHexString(md5);
+ }
+
+ /**
+ * Converts a byte-array to a hex-string representation
+ *
+ * @param bytes The source array
+ * @return The resulting string, or an empty String if the input was {@code null}
+ */
+ public static String toHexString(byte[] bytes) {
+ if (bytes == null) return "";
+
+ StringBuilder sb = new StringBuilder();
+ for (byte b : bytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Create a new {@link FileHash}.
+ *
+ * @param file The input file
+ * @return A new Instance. You can compare instances using {@link #equals(Object)} to determine if two files are
+ * identical. Will return {@code null} when an error occurs or the File does not exist
+ */
+ public static FileHash create(File file) {
+ if (!file.exists()) return createForEmpty(ERR_DOES_NOT_EXIST);
+ final Path path = file.toPath();
+
+ int size = 0;
+ byte[] md5 = new byte[0];
+ int value = 0;
+
+ try {
+ byte[] data = Files.readAllBytes(path);
+
+ size = data.length;
+
+ value = size > 0 ? (data[size / 3] | (data[size / 2] << 8) | (data[size / 5] << 16)) : -1;
+ if (size > 20) value |= data[20] << 24;
+
+ MessageDigest md = MessageDigest.getInstance("MD5");
+ md.update(data);
+ md5 = md.digest();
+
+ return new FileHash(md5, size, value);
+ } catch (IOException e) {
+ BCLib.LOGGER.error("Failed to read file: " + file);
+ return null;
+ } catch (NoSuchAlgorithmException e) {
+ BCLib.LOGGER.error("Unable to build hash for file: " + file);
+ }
+
+ return createForEmpty(ERR_IO_ERROR);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof FileHash)) return false;
+ FileHash fileHash = (FileHash) o;
+ return size == fileHash.size && value == fileHash.value && Arrays.equals(md5, fileHash.md5);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Objects.hash(size, value);
+ result = 31 * result + Arrays.hashCode(md5);
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("%08x", size) + "-" + String.format("%08x", value) + "-" + getMd5String();
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/SyncFileHash.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/SyncFileHash.java
new file mode 100644
index 00000000..da416911
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/SyncFileHash.java
@@ -0,0 +1,113 @@
+package org.betterx.bclib.api.v2.dataexchange;
+
+import org.betterx.bclib.api.v2.dataexchange.handler.autosync.AutoSync;
+import org.betterx.bclib.api.v2.dataexchange.handler.autosync.AutoSyncID;
+
+import net.minecraft.network.FriendlyByteBuf;
+
+import java.io.File;
+import java.util.Objects;
+
+/**
+ * Calculates a hash based on the contents of a File.
+ *
+ * A File-Hash contains the md5-sum of the File, as well as its size and byte-values from defined positions
+ *
+ * You can compare instances using {@link #equals(Object)} to determine if two files are
+ * identical.
+ */
+public class SyncFileHash extends AutoSyncID {
+ public final FileHash hash;
+
+ SyncFileHash(String modID, File file, byte[] md5, int size, int value) {
+ this(modID, file.getName(), md5, size, value);
+ }
+
+ SyncFileHash(String modID, String uniqueID, byte[] md5, int size, int value) {
+ this(modID, uniqueID, new FileHash(md5, size, value));
+ }
+
+ SyncFileHash(String modID, File file, FileHash hash) {
+ this(modID, file.getName(), hash);
+ }
+
+ SyncFileHash(String modID, String uniqueID, FileHash hash) {
+ super(modID, uniqueID);
+ this.hash = hash;
+ }
+
+
+ final static AutoSync.NeedTransferPredicate NEED_TRANSFER = (clientHash, serverHash, content) -> !clientHash.equals(
+ serverHash);
+
+ @Override
+ public String toString() {
+ return super.toString() + ": " + hash.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof SyncFileHash)) return false;
+ if (!super.equals(o)) return false;
+ SyncFileHash that = (SyncFileHash) o;
+ return hash.equals(that.hash);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(super.hashCode(), hash);
+ }
+
+ /**
+ * Serializes the Object to a buffer
+ *
+ * @param buf The buffer to write to
+ */
+ public void serialize(FriendlyByteBuf buf) {
+ hash.serialize(buf);
+ DataHandler.writeString(buf, modID);
+ DataHandler.writeString(buf, uniqueID);
+ }
+
+ /**
+ * Deserialize a Buffer to a new {@link SyncFileHash}-Object
+ *
+ * @param buf Thea buffer to read from
+ * @return The received String
+ */
+ public static SyncFileHash deserialize(FriendlyByteBuf buf) {
+ final FileHash hash = FileHash.deserialize(buf);
+ final String modID = DataHandler.readString(buf);
+ final String uniqueID = DataHandler.readString(buf);
+
+ return new SyncFileHash(modID, uniqueID, hash);
+ }
+
+ /**
+ * Create a new {@link SyncFileHash}.
+ *
+ * Will call {@link #create(String, File, String)} using the name of the File as {@code uniqueID}.
+ *
+ * @param modID ID of the calling Mod
+ * @param file The input file
+ * @return A new Instance. You can compare instances using {@link #equals(Object)} to determine if two files are
+ * identical. Will return {@code null} when an error occurs or the File does not exist
+ */
+ public static SyncFileHash create(String modID, File file) {
+ return create(modID, file, file.getName());
+ }
+
+ /**
+ * Create a new {@link SyncFileHash}.
+ *
+ * @param modID ID of the calling Mod
+ * @param file The input file
+ * @param uniqueID The unique ID that is used for this File (see {@link SyncFileHash#uniqueID} for Details.
+ * @return A new Instance. You can compare instances using {@link #equals(Object)} to determine if two files are
+ * identical. Will return {@code null} when an error occurs or the File does not exist
+ */
+ public static SyncFileHash create(String modID, File file, String uniqueID) {
+ return new SyncFileHash(modID, uniqueID, FileHash.create(file));
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/DataExchange.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/DataExchange.java
new file mode 100644
index 00000000..255b994b
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/DataExchange.java
@@ -0,0 +1,111 @@
+package org.betterx.bclib.api.v2.dataexchange.handler;
+
+import org.betterx.bclib.api.v2.dataexchange.*;
+
+import net.minecraft.resources.ResourceLocation;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
+import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
+
+import java.util.HashSet;
+import java.util.Set;
+
+abstract public class DataExchange {
+
+
+ private static DataExchangeAPI instance;
+
+ protected static DataExchangeAPI getInstance() {
+ if (instance == null) {
+ instance = new DataExchangeAPI();
+ }
+ return instance;
+ }
+
+ protected ConnectorServerside server;
+ protected ConnectorClientside client;
+ protected final Set descriptors;
+
+
+ private final boolean didLoadSyncFolder = false;
+
+ abstract protected ConnectorClientside clientSupplier(DataExchange api);
+
+ abstract protected ConnectorServerside serverSupplier(DataExchange api);
+
+ protected DataExchange() {
+ descriptors = new HashSet<>();
+ }
+
+ public Set getDescriptors() {
+ return descriptors;
+ }
+
+ public static DataHandlerDescriptor getDescriptor(ResourceLocation identifier) {
+ return getInstance().descriptors.stream().filter(d -> d.equals(identifier)).findFirst().orElse(null);
+ }
+
+ @Environment(EnvType.CLIENT)
+ protected void initClientside() {
+ if (client != null) return;
+ client = clientSupplier(this);
+
+ ClientPlayConnectionEvents.INIT.register(client::onPlayInit);
+ ClientPlayConnectionEvents.JOIN.register(client::onPlayReady);
+ ClientPlayConnectionEvents.DISCONNECT.register(client::onPlayDisconnect);
+ }
+
+ protected void initServerSide() {
+ if (server != null) return;
+ server = serverSupplier(this);
+
+ ServerPlayConnectionEvents.INIT.register(server::onPlayInit);
+ ServerPlayConnectionEvents.JOIN.register(server::onPlayReady);
+ ServerPlayConnectionEvents.DISCONNECT.register(server::onPlayDisconnect);
+ }
+
+ /**
+ * Initializes all datastructures that need to exist in the client component.
+ *
+ * This is automatically called by BCLib. You can register {@link DataHandler}-Objects before this Method is called
+ */
+ @Environment(EnvType.CLIENT)
+ public static void prepareClientside() {
+ DataExchange api = DataExchange.getInstance();
+ api.initClientside();
+
+ }
+
+ /**
+ * Initializes all datastructures that need to exist in the server component.
+ *
+ * This is automatically called by BCLib. You can register {@link DataHandler}-Objects before this Method is called
+ */
+ public static void prepareServerside() {
+ DataExchange api = DataExchange.getInstance();
+ api.initServerSide();
+ }
+
+
+ /**
+ * Automatically called before the player enters the world.
+ *
+ * This is automatically called by BCLib. It will send all {@link DataHandler}-Objects that have {@link DataHandlerDescriptor#sendBeforeEnter} set to*
+ * {@code true},
+ */
+ @Environment(EnvType.CLIENT)
+ public static void sendOnEnter() {
+ getInstance().descriptors.forEach((desc) -> {
+ if (desc.sendBeforeEnter) {
+ BaseDataHandler h = desc.JOIN_INSTANCE.get();
+ if (!h.getOriginatesOnServer()) {
+ getInstance().client.sendToServer(h);
+ }
+ }
+ });
+ }
+
+
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoFileSyncEntry.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoFileSyncEntry.java
new file mode 100644
index 00000000..17a3920f
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoFileSyncEntry.java
@@ -0,0 +1,262 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.SyncFileHash;
+import org.betterx.bclib.util.Pair;
+import org.betterx.bclib.util.Triple;
+import org.betterx.worlds.together.util.ModUtil;
+import org.betterx.worlds.together.util.ModUtil.ModInfo;
+import org.betterx.worlds.together.util.PathUtil;
+
+import net.minecraft.network.FriendlyByteBuf;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+class AutoFileSyncEntry extends AutoSyncID {
+ static class ForDirectFileRequest extends AutoFileSyncEntry {
+ final File relFile;
+
+ ForDirectFileRequest(String syncID, File relFile, File absFile) {
+ super(AutoSyncID.ForDirectFileRequest.MOD_ID, syncID, absFile, false, (a, b, c) -> false);
+ this.relFile = relFile;
+ }
+
+ @Override
+ public int serializeContent(FriendlyByteBuf buf) {
+ int res = super.serializeContent(buf);
+ DataHandler.writeString(buf, relFile.toString());
+
+ return res;
+ }
+
+ static AutoFileSyncEntry.ForDirectFileRequest finishDeserializeContent(String syncID, FriendlyByteBuf buf) {
+ final String relFile = DataHandler.readString(buf);
+ SyncFolderDescriptor desc = AutoSync.getSyncFolderDescriptor(syncID);
+ if (desc != null) {
+ //ensures that the file is not above the base-folder
+ if (desc.acceptChildElements(desc.mapAbsolute(relFile))) {
+ return new AutoFileSyncEntry.ForDirectFileRequest(
+ syncID,
+ new File(relFile),
+ desc.localFolder.resolve(relFile)
+ .normalize()
+ .toFile()
+ );
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public String toString() {
+ return uniqueID + " - " + relFile;
+ }
+ }
+
+ static class ForModFileRequest extends AutoFileSyncEntry {
+ public static File getLocalPathForID(String modID, boolean matchLocalVersion) {
+ ModInfo mi = ModUtil.getModInfo(modID, matchLocalVersion);
+ if (mi != null) {
+ return mi.jarPath.toFile();
+ }
+ return null;
+ }
+
+ public final String version;
+
+ ForModFileRequest(String modID, boolean matchLocalVersion, String version) {
+ super(
+ modID,
+ AutoSyncID.ForModFileRequest.UNIQUE_ID,
+ getLocalPathForID(modID, matchLocalVersion),
+ false,
+ (a, b, c) -> false
+ );
+ if (this.fileName == null && matchLocalVersion) {
+ BCLib.LOGGER.error("Unknown mod '" + modID + "'.");
+ }
+ if (version == null)
+ this.version = ModUtil.getModVersion(modID);
+ else
+ this.version = version;
+ }
+
+ @Override
+ public int serializeContent(FriendlyByteBuf buf) {
+ final int res = super.serializeContent(buf);
+ buf.writeInt(ModUtil.convertModVersion(version));
+ return res;
+ }
+
+ static AutoFileSyncEntry.ForModFileRequest finishDeserializeContent(String modID, FriendlyByteBuf buf) {
+ final String version = ModUtil.convertModVersion(buf.readInt());
+ return new AutoFileSyncEntry.ForModFileRequest(modID, false, version);
+ }
+
+ @Override
+ public String toString() {
+ return "Mod " + modID + " (v" + version + ")";
+ }
+ }
+
+ public final AutoSync.NeedTransferPredicate needTransfer;
+ public final File fileName;
+ public final boolean requestContent;
+ private SyncFileHash hash;
+
+ AutoFileSyncEntry(
+ String modID,
+ File fileName,
+ boolean requestContent,
+ AutoSync.NeedTransferPredicate needTransfer
+ ) {
+ this(modID, fileName.getName(), fileName, requestContent, needTransfer);
+ }
+
+ AutoFileSyncEntry(
+ String modID,
+ String uniqueID,
+ File fileName,
+ boolean requestContent,
+ AutoSync.NeedTransferPredicate needTransfer
+ ) {
+ super(modID, uniqueID);
+ this.needTransfer = needTransfer;
+ this.fileName = fileName;
+ this.requestContent = requestContent;
+ }
+
+
+ public SyncFileHash getFileHash() {
+ if (hash == null) {
+ hash = SyncFileHash.create(modID, fileName, uniqueID);
+ }
+ return hash;
+ }
+
+ public byte[] getContent() {
+ if (!fileName.exists()) return new byte[0];
+ final Path path = fileName.toPath();
+
+ try {
+ return Files.readAllBytes(path);
+ } catch (IOException e) {
+
+ }
+ return new byte[0];
+ }
+
+ public int serializeContent(FriendlyByteBuf buf) {
+ DataHandler.writeString(buf, modID);
+ DataHandler.writeString(buf, uniqueID);
+ return serializeFileContent(buf);
+ }
+
+ public static Triple deserializeContent(FriendlyByteBuf buf) {
+ final String modID = DataHandler.readString(buf);
+ final String uniqueID = DataHandler.readString(buf);
+ byte[] data = deserializeFileContent(buf);
+
+ AutoFileSyncEntry entry;
+ if (AutoSyncID.ForDirectFileRequest.MOD_ID.equals(modID)) {
+ entry = AutoFileSyncEntry.ForDirectFileRequest.finishDeserializeContent(uniqueID, buf);
+ } else if (AutoSyncID.ForModFileRequest.UNIQUE_ID.equals(uniqueID)) {
+ entry = AutoFileSyncEntry.ForModFileRequest.finishDeserializeContent(modID, buf);
+ } else {
+ entry = AutoFileSyncEntry.findMatching(modID, uniqueID);
+ }
+ return new Triple<>(entry, data, new AutoSyncID(modID, uniqueID));
+ }
+
+
+ public void serialize(FriendlyByteBuf buf) {
+ getFileHash().serialize(buf);
+ buf.writeBoolean(requestContent);
+
+ if (requestContent) {
+ serializeFileContent(buf);
+ }
+ }
+
+ public static AutoSync.AutoSyncTriple deserializeAndMatch(FriendlyByteBuf buf) {
+ Pair e = deserialize(buf);
+ AutoFileSyncEntry match = findMatching(e.first);
+ return new AutoSync.AutoSyncTriple(e.first, e.second, match);
+ }
+
+ public static Pair deserialize(FriendlyByteBuf buf) {
+ SyncFileHash hash = SyncFileHash.deserialize(buf);
+ boolean withContent = buf.readBoolean();
+ byte[] data = null;
+ if (withContent) {
+ data = deserializeFileContent(buf);
+ }
+
+ return new Pair(hash, data);
+ }
+
+ private int serializeFileContent(FriendlyByteBuf buf) {
+ if (!org.betterx.worlds.together.util.PathUtil.isChildOf(
+ org.betterx.worlds.together.util.PathUtil.GAME_FOLDER,
+ fileName.toPath()
+ )) {
+ BCLib.LOGGER.error(fileName + " is not within game folder " + PathUtil.GAME_FOLDER + ". Pretending it does not exist.");
+ buf.writeInt(0);
+ return 0;
+ }
+
+ byte[] content = getContent();
+ buf.writeInt(content.length);
+ buf.writeByteArray(content);
+ return content.length;
+ }
+
+ private static byte[] deserializeFileContent(FriendlyByteBuf buf) {
+ byte[] data;
+ int size = buf.readInt();
+ data = buf.readByteArray(size);
+ return data;
+ }
+
+
+ public static AutoFileSyncEntry findMatching(SyncFileHash hash) {
+ return findMatching(hash.modID, hash.uniqueID);
+ }
+
+ public static AutoFileSyncEntry findMatching(AutoSyncID aid) {
+ if (aid instanceof AutoSyncID.ForDirectFileRequest) {
+ AutoSyncID.ForDirectFileRequest freq = (AutoSyncID.ForDirectFileRequest) aid;
+ SyncFolderDescriptor desc = AutoSync.getSyncFolderDescriptor(freq.uniqueID);
+ if (desc != null) {
+ SyncFolderDescriptor.SubFile subFile = desc.getLocalSubFile(freq.relFile.toString());
+ if (subFile != null) {
+ final File absPath = desc.localFolder.resolve(subFile.relPath)
+ .normalize()
+ .toFile();
+ return new AutoFileSyncEntry.ForDirectFileRequest(
+ freq.uniqueID,
+ new File(subFile.relPath),
+ absPath
+ );
+ }
+ }
+ return null;
+ } else if (aid instanceof AutoSyncID.ForModFileRequest) {
+ AutoSyncID.ForModFileRequest mreq = (AutoSyncID.ForModFileRequest) aid;
+ return new AutoFileSyncEntry.ForModFileRequest(mreq.modID, true, null);
+ }
+ return findMatching(aid.modID, aid.uniqueID);
+ }
+
+ public static AutoFileSyncEntry findMatching(String modID, String uniqueID) {
+ return AutoSync.getAutoSyncFiles()
+ .stream()
+ .filter(asf -> asf.modID.equals(modID) && asf.uniqueID.equals(uniqueID))
+ .findFirst()
+ .orElse(null);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoSync.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoSync.java
new file mode 100644
index 00000000..64a3811d
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoSync.java
@@ -0,0 +1,207 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataExchangeAPI;
+import org.betterx.bclib.api.v2.dataexchange.SyncFileHash;
+import org.betterx.bclib.config.Configs;
+import org.betterx.bclib.config.ServerConfig;
+import org.betterx.worlds.together.util.PathUtil;
+
+import net.fabricmc.loader.api.FabricLoader;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.BiConsumer;
+
+public class AutoSync {
+ public static final String SYNC_CATEGORY = "auto_sync";
+ public final static SyncFolderDescriptor SYNC_FOLDER = new SyncFolderDescriptor(
+ "BCLIB-SYNC",
+ FabricLoader.getInstance()
+ .getGameDir()
+ .resolve("bclib-sync")
+ .normalize()
+ .toAbsolutePath(),
+ true
+ );
+
+ @FunctionalInterface
+ public interface NeedTransferPredicate {
+ boolean test(SyncFileHash clientHash, SyncFileHash serverHash, FileContentWrapper content);
+ }
+
+ final static class AutoSyncTriple {
+ public final SyncFileHash serverHash;
+ public final byte[] serverContent;
+ public final AutoFileSyncEntry localMatch;
+
+ public AutoSyncTriple(SyncFileHash serverHash, byte[] serverContent, AutoFileSyncEntry localMatch) {
+ this.serverHash = serverHash;
+ this.serverContent = serverContent;
+ this.localMatch = localMatch;
+ }
+
+ @Override
+ public String toString() {
+ return serverHash.modID + "." + serverHash.uniqueID;
+ }
+ }
+
+
+ // ##### File Syncing
+ protected final static List> onWriteCallbacks = new ArrayList<>(2);
+
+ /**
+ * Register a function that is called whenever the client receives a file from the server and replaced toe local
+ * file with the new content.
+ *
+ * This callback is usefull if you need to reload the new content before the game is quit.
+ *
+ * @param callback A Function that receives the AutoSyncID as well as the Filename.
+ */
+ public static void addOnWriteCallback(BiConsumer callback) {
+ onWriteCallbacks.add(callback);
+ }
+
+ private static final List autoSyncFiles = new ArrayList<>(4);
+
+ public static List getAutoSyncFiles() {
+ return autoSyncFiles;
+ }
+
+ /**
+ * Registers a File for automatic client syncing.
+ *
+ * @param modID The ID of the calling Mod
+ * @param needTransfer If the predicate returns true, the file needs to get copied to the server.
+ * @param fileName The name of the File
+ * @param requestContent When {@code true} the content of the file is requested for comparison. This will copy the
+ * entire file from the client to the server.
+ *
+ * You should only use this option, if you need to compare parts of the file in order to decide
+ * If the File needs to be copied. Normally using the {@link SyncFileHash}
+ * for comparison is sufficient.
+ */
+ public static void addAutoSyncFileData(
+ String modID,
+ File fileName,
+ boolean requestContent,
+ NeedTransferPredicate needTransfer
+ ) {
+ if (!PathUtil.isChildOf(PathUtil.GAME_FOLDER, fileName.toPath())) {
+ BCLib.LOGGER.error(fileName + " is outside of Game Folder " + PathUtil.GAME_FOLDER);
+ } else {
+ autoSyncFiles.add(new AutoFileSyncEntry(modID, fileName, requestContent, needTransfer));
+ }
+ }
+
+ /**
+ * Registers a File for automatic client syncing.
+ *
+ * @param modID The ID of the calling Mod
+ * @param uniqueID A unique Identifier for the File. (see {@link SyncFileHash#uniqueID} for
+ * Details
+ * @param needTransfer If the predicate returns true, the file needs to get copied to the server.
+ * @param fileName The name of the File
+ * @param requestContent When {@code true} the content of the file is requested for comparison. This will copy the
+ * entire file from the client to the server.
+ *
+ * You should only use this option, if you need to compare parts of the file in order to decide
+ * If the File needs to be copied. Normally using the {@link SyncFileHash}
+ * for comparison is sufficient.
+ */
+ public static void addAutoSyncFileData(
+ String modID,
+ String uniqueID,
+ File fileName,
+ boolean requestContent,
+ NeedTransferPredicate needTransfer
+ ) {
+ if (!PathUtil.isChildOf(PathUtil.GAME_FOLDER, fileName.toPath())) {
+ BCLib.LOGGER.error(fileName + " is outside of Game Folder " + PathUtil.GAME_FOLDER);
+ } else {
+ autoSyncFiles.add(new AutoFileSyncEntry(modID, uniqueID, fileName, requestContent, needTransfer));
+ }
+ }
+
+ /**
+ * Called when {@code SendFiles} received a File on the Client and wrote it to the FileSystem.
+ *
+ * This is the place where reload Code should go.
+ *
+ * @param aid The ID of the received File
+ * @param file The location of the FIle on the client
+ */
+ static void didReceiveFile(AutoSyncID aid, File file) {
+ onWriteCallbacks.forEach(fkt -> fkt.accept(aid, file));
+ }
+
+
+ // ##### Folder Syncing
+ static final List syncFolderDescriptions = Arrays.asList(SYNC_FOLDER);
+
+ private List syncFolderContent;
+
+ protected List getSyncFolderContent() {
+ if (syncFolderContent == null) {
+ return new ArrayList<>(0);
+ }
+ return syncFolderContent;
+ }
+
+ private static boolean didRegisterAdditionalMods = false;
+
+ //we call this from HelloClient on the Server to prepare transfer
+ protected static void loadSyncFolder() {
+ if (Configs.SERVER_CONFIG.isOfferingFiles()) {
+ syncFolderDescriptions.forEach(desc -> desc.loadCache());
+ }
+
+ if (!didRegisterAdditionalMods && Configs.SERVER_CONFIG.isOfferingMods()) {
+ didRegisterAdditionalMods = true;
+ List modIDs = Configs.SERVER_CONFIG.get(ServerConfig.ADDITIONAL_MODS);
+ if (modIDs != null) {
+ modIDs.stream().forEach(modID -> DataExchangeAPI.registerModDependency(modID));
+ }
+ }
+
+ }
+
+ protected static SyncFolderDescriptor getSyncFolderDescriptor(String folderID) {
+ return syncFolderDescriptions.stream()
+ .filter(d -> d.equals(folderID))
+ .findFirst()
+ .orElse(null);
+ }
+
+ protected static Path localBasePathForFolderID(String folderID) {
+ final SyncFolderDescriptor desc = getSyncFolderDescriptor(folderID);
+ if (desc != null) {
+ return desc.localFolder;
+ } else {
+ BCLib.LOGGER.warning("Unknown Sync-Folder ID '" + folderID + "'");
+ return null;
+ }
+ }
+
+ public static void registerSyncFolder(String folderID, Path localBaseFolder, boolean removeAdditionalFiles) {
+ localBaseFolder = localBaseFolder.normalize();
+ if (PathUtil.isChildOf(PathUtil.GAME_FOLDER, localBaseFolder)) {
+ final SyncFolderDescriptor desc = new SyncFolderDescriptor(
+ folderID,
+ localBaseFolder,
+ removeAdditionalFiles
+ );
+ if (syncFolderDescriptions.contains(desc)) {
+ BCLib.LOGGER.warning("Tried to override Folder Sync '" + folderID + "' again.");
+ } else {
+ syncFolderDescriptions.add(desc);
+ }
+ } else {
+ BCLib.LOGGER.error(localBaseFolder + " (from " + folderID + ") is outside the game directory " + PathUtil.GAME_FOLDER + ". Sync is not allowed.");
+ }
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoSyncID.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoSyncID.java
new file mode 100644
index 00000000..b8ace9bc
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/AutoSyncID.java
@@ -0,0 +1,144 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.config.Config;
+import org.betterx.worlds.together.util.ModUtil;
+
+import net.minecraft.network.FriendlyByteBuf;
+
+import java.io.File;
+import java.util.Objects;
+import org.jetbrains.annotations.NotNull;
+
+public class AutoSyncID {
+ static class WithContentOverride extends AutoSyncID {
+ final FileContentWrapper contentWrapper;
+ final File localFile;
+
+ WithContentOverride(String modID, String uniqueID, FileContentWrapper contentWrapper, File localFile) {
+ super(modID, uniqueID);
+ this.contentWrapper = contentWrapper;
+ this.localFile = localFile;
+ }
+
+ @Override
+ public String toString() {
+ return super.toString() + " (Content override)";
+ }
+ }
+
+ static class ForDirectFileRequest extends AutoSyncID {
+ public final static String MOD_ID = "bclib::FILE";
+ final File relFile;
+
+ ForDirectFileRequest(String syncID, File relFile) {
+ super(ForDirectFileRequest.MOD_ID, syncID);
+ this.relFile = relFile;
+ }
+
+ @Override
+ void serializeData(FriendlyByteBuf buf) {
+ super.serializeData(buf);
+ DataHandler.writeString(buf, relFile.toString());
+ }
+
+ static ForDirectFileRequest finishDeserialize(String modID, String uniqueID, FriendlyByteBuf buf) {
+ final File fl = new File(DataHandler.readString(buf));
+ return new ForDirectFileRequest(uniqueID, fl);
+ }
+
+ @Override
+ public String toString() {
+ return super.uniqueID + " (" + this.relFile + ")";
+ }
+ }
+
+ static class ForModFileRequest extends AutoSyncID {
+ public final static String UNIQUE_ID = "bclib::MOD";
+ private final String version;
+
+ ForModFileRequest(String modID, String version) {
+ super(modID, ForModFileRequest.UNIQUE_ID);
+ this.version = version;
+ }
+
+ @Override
+ void serializeData(FriendlyByteBuf buf) {
+ super.serializeData(buf);
+ buf.writeInt(ModUtil.convertModVersion(version));
+ }
+
+ static ForModFileRequest finishDeserialize(String modID, String uniqueID, FriendlyByteBuf buf) {
+ final String version = ModUtil.convertModVersion(buf.readInt());
+ return new ForModFileRequest(modID, version);
+ }
+
+ @Override
+ public String toString() {
+ return super.modID + " (v" + this.version + ")";
+ }
+ }
+
+ /**
+ * A Unique ID for the referenced File.
+ *
+ * Files with the same {@link #modID} need to have a unique IDs. Normally the filename from FileHash(String, File, byte[], int, int)
+ * is used to generated that ID, but you can directly specify one using FileHash(String, String, byte[], int, int).
+ */
+ @NotNull
+ public final String uniqueID;
+
+ /**
+ * The ID of the Mod that is registering the File
+ */
+ @NotNull
+ public final String modID;
+
+ public AutoSyncID(String modID, String uniqueID) {
+ Objects.nonNull(modID);
+ Objects.nonNull(uniqueID);
+
+ this.modID = modID;
+ this.uniqueID = uniqueID;
+ }
+
+ @Override
+ public String toString() {
+ return modID + "." + uniqueID;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof AutoSyncID)) return false;
+ AutoSyncID that = (AutoSyncID) o;
+ return uniqueID.equals(that.uniqueID) && modID.equals(that.modID);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uniqueID, modID);
+ }
+
+ void serializeData(FriendlyByteBuf buf) {
+ DataHandler.writeString(buf, modID);
+ DataHandler.writeString(buf, uniqueID);
+ }
+
+ static AutoSyncID deserializeData(FriendlyByteBuf buf) {
+ String modID = DataHandler.readString(buf);
+ String uID = DataHandler.readString(buf);
+
+ if (ForDirectFileRequest.MOD_ID.equals(modID)) {
+ return ForDirectFileRequest.finishDeserialize(modID, uID, buf);
+ } else if (ForModFileRequest.UNIQUE_ID.equals(uID)) {
+ return ForModFileRequest.finishDeserialize(modID, uID, buf);
+ } else {
+ return new AutoSyncID(modID, uID);
+ }
+ }
+
+ public boolean isConfigFile() {
+ return this.uniqueID.startsWith(Config.CONFIG_SYNC_PREFIX);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/Chunker.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/Chunker.java
new file mode 100644
index 00000000..d9eab98e
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/Chunker.java
@@ -0,0 +1,280 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.BaseDataHandler;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.DataHandlerDescriptor;
+import org.betterx.bclib.api.v2.dataexchange.handler.DataExchange;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.util.ProgressListener;
+
+import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
+
+import java.util.*;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Used to seperate large data transfers into multiple smaller messages.
+ *
+ * {@link DataHandler} will automatically convert larger messages into Chunks on the Server
+ * and assemble the original message from those chunks on the client.
+ */
+public class Chunker extends DataHandler.FromServer {
+
+ /**
+ * Responsible for assembling the original ByteBuffer created by {@link PacketChunkSender} on the
+ * receiving end. Automatically created from the header {@link Chunker}-Message (where the serialNo==-1)
+ */
+ static class PacketChunkReceiver {
+ @NotNull
+ public final UUID uuid;
+ public final int chunkCount;
+ @NotNull
+ private final FriendlyByteBuf networkedBuf;
+ @Nullable
+ private final DataHandlerDescriptor descriptor;
+
+ private static final List active = new ArrayList<>(1);
+
+ private static PacketChunkReceiver newReceiver(@NotNull UUID uuid, int chunkCount, ResourceLocation origin) {
+ DataHandlerDescriptor desc = DataExchange.getDescriptor(origin);
+ final PacketChunkReceiver r = new PacketChunkReceiver(uuid, chunkCount, desc);
+ active.add(r);
+ return r;
+ }
+
+ private static PacketChunkReceiver getOrCreate(@NotNull UUID uuid, int chunkCount, ResourceLocation origin) {
+ return active.stream()
+ .filter(r -> r.uuid.equals(uuid))
+ .findFirst()
+ .orElse(newReceiver(uuid, chunkCount, origin));
+ }
+
+ public static PacketChunkReceiver get(@NotNull UUID uuid) {
+ return active.stream().filter(r -> r.uuid.equals(uuid)).findFirst().orElse(null);
+ }
+
+ private PacketChunkReceiver(@NotNull UUID uuid, int chunkCount, @Nullable DataHandlerDescriptor descriptor) {
+ this.uuid = uuid;
+ this.chunkCount = chunkCount;
+ networkedBuf = PacketByteBufs.create();
+ this.descriptor = descriptor;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof PacketChunkReceiver)) return false;
+ PacketChunkReceiver that = (PacketChunkReceiver) o;
+ return uuid.equals(that.uuid);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uuid);
+ }
+
+ public boolean testFinished() {
+ ProgressListener listener = ChunkerProgress.getProgressListener();
+ if (listener != null) {
+ listener.progressStagePercentage((100 * receivedCount) / chunkCount);
+ }
+ if (incomingBuffer == null) {
+ return true;
+ }
+ if (lastReadSerial >= chunkCount - 1) {
+ onFinish();
+ return true;
+ }
+ return false;
+ }
+
+ private void addBuffer(FriendlyByteBuf input) {
+ final int size = input.readableBytes();
+ final int cap = networkedBuf.capacity() - networkedBuf.writerIndex();
+
+ if (cap < size) {
+ networkedBuf.capacity(networkedBuf.writerIndex() + size);
+ }
+ input.readBytes(networkedBuf, size);
+ input.clear();
+ }
+
+ protected void onFinish() {
+ incomingBuffer.clear();
+ incomingBuffer = null;
+
+ final BaseDataHandler baseHandler = descriptor.INSTANCE.get();
+ if (baseHandler instanceof DataHandler.FromServer handler) {
+ handler.receiveFromMemory(networkedBuf);
+ }
+ }
+
+ Map incomingBuffer = new HashMap<>();
+ int lastReadSerial = -1;
+ int receivedCount = 0;
+
+ public void processReceived(FriendlyByteBuf buf, int serialNo, int size) {
+ receivedCount++;
+
+ if (lastReadSerial == serialNo - 1) {
+ addBuffer(buf);
+ lastReadSerial = serialNo;
+ } else {
+ //not sure if order is guaranteed by the underlying system!
+ boolean haveAll = true;
+ for (int nr = lastReadSerial + 1; nr < serialNo - 1; nr++) {
+ if (incomingBuffer.get(nr) == null) {
+ haveAll = false;
+ break;
+ }
+ }
+
+ if (haveAll) {
+ for (int nr = lastReadSerial + 1; nr < serialNo - 1; nr++) {
+ addBuffer(incomingBuffer.get(nr));
+ incomingBuffer.put(nr, null);
+ }
+ addBuffer(buf);
+ lastReadSerial = serialNo;
+ } else {
+ incomingBuffer.put(serialNo, buf);
+ }
+ }
+ }
+ }
+
+ /**
+ * Responsible for splitting an outgoing ByteBuffer into several smaller Chunks and
+ * send them as seperate messages to the {@link Chunker}-Channel
+ */
+ public static class PacketChunkSender {
+ private final FriendlyByteBuf networkedBuf;
+ public final UUID uuid;
+ public final int chunkCount;
+ public final int size;
+ public final ResourceLocation origin;
+
+ public PacketChunkSender(FriendlyByteBuf buf, ResourceLocation origin) {
+ networkedBuf = buf;
+
+ size = buf.readableBytes();
+ chunkCount = (int) Math.ceil((double) size / MAX_PAYLOAD_SIZE);
+ uuid = UUID.randomUUID();
+ this.origin = origin;
+ }
+
+ public void sendChunks(Collection players) {
+ BCLib.LOGGER.info("Sending Request in " + chunkCount + " Packet-Chunks");
+ for (int i = -1; i < chunkCount; i++) {
+ Chunker c = new Chunker(i, uuid, networkedBuf, chunkCount, origin);
+ FriendlyByteBuf buf = PacketByteBufs.create();
+ c.serializeDataOnServer(buf);
+
+ for (ServerPlayer player : players) {
+ ServerPlayNetworking.send(player, DESCRIPTOR.IDENTIFIER, buf);
+ }
+ }
+ }
+ }
+
+ //header = version + UUID + serialNo + size, see serializeDataOnServer
+ private static final int HEADER_SIZE = 1 + 16 + 4 + 4;
+
+ public static final int MAX_PACKET_SIZE = 1024 * 1024;
+ private static final int MAX_PAYLOAD_SIZE = MAX_PACKET_SIZE - HEADER_SIZE;
+
+ public static final DataHandlerDescriptor DESCRIPTOR = new DataHandlerDescriptor(
+ new ResourceLocation(
+ BCLib.MOD_ID,
+ "chunker"
+ ),
+ Chunker::new,
+ false,
+ false
+ );
+
+ private int serialNo;
+ private UUID uuid;
+ private int chunkCount;
+ private FriendlyByteBuf networkedBuf;
+ private ResourceLocation origin;
+
+ protected Chunker(int serialNo, UUID uuid, FriendlyByteBuf networkedBuf, int chunkCount, ResourceLocation origin) {
+ super(DESCRIPTOR.IDENTIFIER);
+ this.serialNo = serialNo;
+ this.uuid = uuid;
+ this.networkedBuf = networkedBuf;
+ this.chunkCount = chunkCount;
+ this.origin = origin;
+ }
+
+ protected Chunker() {
+ super(DESCRIPTOR.IDENTIFIER);
+ }
+
+
+ @Override
+ protected void serializeDataOnServer(FriendlyByteBuf buf) {
+ //Sending Header. Make sure to change HEADER_SIZE if you change this!
+ buf.writeByte(0);
+ buf.writeLong(uuid.getMostSignificantBits());
+ buf.writeLong(uuid.getLeastSignificantBits());
+ buf.writeInt(serialNo);
+
+ //sending Payload
+ if (serialNo == -1) {
+ //this is our header-Chunk that transports status information
+ buf.writeInt(chunkCount);
+ writeString(buf, origin.getNamespace());
+ writeString(buf, origin.getPath());
+ } else {
+ //this is an actual payload chunk
+ buf.capacity(MAX_PACKET_SIZE);
+ final int size = Math.min(MAX_PAYLOAD_SIZE, networkedBuf.readableBytes());
+ buf.writeInt(size);
+ networkedBuf.readBytes(buf, size);
+ }
+ }
+
+ private PacketChunkReceiver receiver;
+
+ @Override
+ protected void deserializeIncomingDataOnClient(FriendlyByteBuf buf, PacketSender responseSender) {
+ final int version = buf.readByte();
+ uuid = new UUID(buf.readLong(), buf.readLong());
+ serialNo = buf.readInt();
+
+ if (serialNo == -1) {
+ chunkCount = buf.readInt();
+ final String namespace = readString(buf);
+ final String path = readString(buf);
+ ResourceLocation ident = new ResourceLocation(namespace, path);
+ BCLib.LOGGER.info("Receiving " + chunkCount + " + Packet-Chunks for " + ident);
+
+ receiver = PacketChunkReceiver.getOrCreate(uuid, chunkCount, ident);
+ } else {
+ receiver = PacketChunkReceiver.get(uuid);
+ if (receiver != null) {
+ final int size = buf.readInt();
+ receiver.processReceived(buf, serialNo, size);
+ } else {
+ BCLib.LOGGER.error("Unknown Packet-Chunk Transfer for " + uuid);
+ }
+ }
+ }
+
+ @Override
+ protected void runOnClientGameThread(Minecraft client) {
+ if (receiver != null) {
+ receiver.testFinished();
+ }
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/ChunkerProgress.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/ChunkerProgress.java
new file mode 100644
index 00000000..ae82fc38
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/ChunkerProgress.java
@@ -0,0 +1,28 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.client.gui.screens.ProgressScreen;
+
+import net.minecraft.util.ProgressListener;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+
+@Environment(EnvType.CLIENT)
+public class ChunkerProgress {
+ private static ProgressScreen progressScreen;
+
+ @Environment(EnvType.CLIENT)
+ public static void setProgressScreen(ProgressScreen scr) {
+ progressScreen = scr;
+ }
+
+ @Environment(EnvType.CLIENT)
+ public static ProgressScreen getProgressScreen() {
+ return progressScreen;
+ }
+
+ @Environment(EnvType.CLIENT)
+ public static ProgressListener getProgressListener() {
+ return progressScreen;
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/FileContentWrapper.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/FileContentWrapper.java
new file mode 100644
index 00000000..669fc955
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/FileContentWrapper.java
@@ -0,0 +1,75 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+public class FileContentWrapper {
+ private byte[] rawContent;
+ private ByteArrayOutputStream outputStream;
+
+ FileContentWrapper(byte[] content) {
+ this.rawContent = content;
+ this.outputStream = null;
+ }
+
+ public byte[] getOriginalContent() {
+ return rawContent;
+ }
+
+ public byte[] getRawContent() {
+ if (outputStream != null) {
+ return outputStream.toByteArray();
+ }
+ return rawContent;
+ }
+
+ private void invalidateOutputStream() {
+ if (this.outputStream != null) {
+ try {
+ this.outputStream.close();
+ } catch (IOException e) {
+ BCLib.LOGGER.debug(e);
+ }
+ }
+ this.outputStream = null;
+ }
+
+ public void setRawContent(byte[] rawContent) {
+ this.rawContent = rawContent;
+ invalidateOutputStream();
+ }
+
+ public void syncWithOutputStream() {
+ if (outputStream != null) {
+ try {
+ outputStream.flush();
+ } catch (IOException e) {
+ BCLib.LOGGER.error(e.getMessage());
+ e.printStackTrace();
+ }
+ setRawContent(getRawContent());
+ invalidateOutputStream();
+ }
+ }
+
+ public ByteArrayInputStream getInputStream() {
+ if (rawContent == null) return new ByteArrayInputStream(new byte[0]);
+ return new ByteArrayInputStream(rawContent);
+ }
+
+ public ByteArrayOutputStream getOrCreateOutputStream() {
+ if (this.outputStream == null) {
+ return this.getEmptyOutputStream();
+ }
+ return this.outputStream;
+ }
+
+ public ByteArrayOutputStream getEmptyOutputStream() {
+ invalidateOutputStream();
+ this.outputStream = new ByteArrayOutputStream(this.rawContent.length);
+ return this.outputStream;
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloClient.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloClient.java
new file mode 100644
index 00000000..ead95a39
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloClient.java
@@ -0,0 +1,544 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataExchangeAPI;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.DataHandlerDescriptor;
+import org.betterx.bclib.client.gui.screens.ModListScreen;
+import org.betterx.bclib.client.gui.screens.ProgressScreen;
+import org.betterx.bclib.client.gui.screens.SyncFilesScreen;
+import org.betterx.bclib.client.gui.screens.WarnBCLibVersionMismatch;
+import org.betterx.bclib.config.Configs;
+import org.betterx.bclib.config.ServerConfig;
+import org.betterx.worlds.together.util.ModUtil;
+import org.betterx.worlds.together.util.ModUtil.ModInfo;
+import org.betterx.worlds.together.util.PathUtil;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+import net.fabricmc.loader.api.metadata.ModEnvironment;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.*;
+import java.util.Map.Entry;
+import java.util.stream.Collectors;
+
+/**
+ * Sent from the Server to the Client.
+ *
+ * For Details refer to {@link HelloServer}
+ */
+public class HelloClient extends DataHandler.FromServer {
+ public record OfferedModInfo(String version, int size, boolean canDownload) {
+ }
+
+ public interface IServerModMap extends Map {
+ }
+
+ public static class ServerModMap extends HashMap implements IServerModMap {
+ }
+
+ public static final DataHandlerDescriptor DESCRIPTOR = new DataHandlerDescriptor(
+ new ResourceLocation(
+ BCLib.MOD_ID,
+ "hello_client"
+ ),
+ HelloClient::new,
+ false,
+ false
+ );
+
+ public HelloClient() {
+ super(DESCRIPTOR.IDENTIFIER);
+ }
+
+ static String getBCLibVersion() {
+ return ModUtil.getModVersion(BCLib.MOD_ID);
+ }
+
+ @Override
+ protected boolean prepareDataOnServer() {
+ if (!Configs.SERVER_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the server.");
+ return false;
+ }
+
+ AutoSync.loadSyncFolder();
+ return true;
+ }
+
+ @Override
+ protected void serializeDataOnServer(FriendlyByteBuf buf) {
+ final String vbclib = getBCLibVersion();
+ BCLib.LOGGER.info("Sending Hello to Client. (server=" + vbclib + ")");
+
+ //write BCLibVersion (=protocol version)
+ buf.writeInt(ModUtil.convertModVersion(vbclib));
+
+ if (Configs.SERVER_CONFIG.isOfferingMods() || Configs.SERVER_CONFIG.isOfferingInfosForMods()) {
+ List mods = DataExchangeAPI.registeredMods();
+ final List inmods = mods;
+ if (Configs.SERVER_CONFIG.isOfferingAllMods() || Configs.SERVER_CONFIG.isOfferingInfosForMods()) {
+ mods = new ArrayList<>(inmods.size());
+ mods.addAll(inmods);
+ mods.addAll(ModUtil
+ .getMods()
+ .entrySet()
+ .stream()
+ .filter(entry -> entry.getValue().metadata.getEnvironment() != ModEnvironment.SERVER && !inmods.contains(
+ entry.getKey()))
+ .map(entry -> entry.getKey())
+ .collect(Collectors.toList())
+ );
+ }
+
+ mods = mods
+ .stream()
+ .filter(entry -> !Configs.SERVER_CONFIG.get(ServerConfig.EXCLUDED_MODS).contains(entry))
+ .collect(Collectors.toList());
+
+ //write Plugin Versions
+ buf.writeInt(mods.size());
+ for (String modID : mods) {
+ final String ver = ModUtil.getModVersion(modID);
+ int size = 0;
+
+ final ModInfo mi = ModUtil.getModInfo(modID);
+ if (mi != null) {
+ try {
+ size = (int) Files.size(mi.jarPath);
+ } catch (IOException e) {
+ BCLib.LOGGER.error("Unable to get File Size: " + e.getMessage());
+ }
+ }
+
+
+ writeString(buf, modID);
+ buf.writeInt(ModUtil.convertModVersion(ver));
+ buf.writeInt(size);
+ final boolean canDownload = size > 0 && Configs.SERVER_CONFIG.isOfferingMods() && (Configs.SERVER_CONFIG.isOfferingAllMods() || inmods.contains(
+ modID));
+ buf.writeBoolean(canDownload);
+
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - Listing Mod " + modID + " v" + ver + " (size: " + PathUtil.humanReadableFileSize(
+ size) + ", download=" + canDownload + ")");
+ }
+ } else {
+ BCLib.LOGGER.info("Server will not list Mods.");
+ buf.writeInt(0);
+ }
+
+ if (Configs.SERVER_CONFIG.isOfferingFiles() || Configs.SERVER_CONFIG.isOfferingConfigs()) {
+ //do only include files that exist on the server
+ final List existingAutoSyncFiles = AutoSync.getAutoSyncFiles()
+ .stream()
+ .filter(e -> e.fileName.exists())
+ .filter(e -> (e.isConfigFile() && Configs.SERVER_CONFIG.isOfferingConfigs()) || (e instanceof AutoFileSyncEntry.ForDirectFileRequest && Configs.SERVER_CONFIG.isOfferingFiles()))
+ .collect(Collectors.toList());
+
+ //send config Data
+ buf.writeInt(existingAutoSyncFiles.size());
+ for (AutoFileSyncEntry entry : existingAutoSyncFiles) {
+ entry.serialize(buf);
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - Offering " + (entry.isConfigFile() ? "Config " : "File ") + entry);
+ }
+ } else {
+ BCLib.LOGGER.info("Server will neither offer Files nor Configs.");
+ buf.writeInt(0);
+ }
+
+ if (Configs.SERVER_CONFIG.isOfferingFiles()) {
+ buf.writeInt(AutoSync.syncFolderDescriptions.size());
+ AutoSync.syncFolderDescriptions.forEach(desc -> {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - Offering Folder " + desc.localFolder + " (allowDelete=" + desc.removeAdditionalFiles + ")");
+ desc.serialize(buf);
+ });
+ } else {
+ BCLib.LOGGER.info("Server will not offer Sync Folders.");
+ buf.writeInt(0);
+ }
+
+ buf.writeBoolean(Configs.SERVER_CONFIG.isOfferingInfosForMods());
+ }
+
+ String bclibVersion = "0.0.0";
+
+
+ IServerModMap modVersion = new ServerModMap();
+ List autoSyncedFiles = null;
+ List autoSynFolders = null;
+ boolean serverPublishedModInfo = false;
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void deserializeIncomingDataOnClient(FriendlyByteBuf buf, PacketSender responseSender) {
+ //read BCLibVersion (=protocol version)
+ bclibVersion = ModUtil.convertModVersion(buf.readInt());
+
+ //read Plugin Versions
+ modVersion = new ServerModMap();
+ int count = buf.readInt();
+ for (int i = 0; i < count; i++) {
+ final String id = readString(buf);
+ final String version = ModUtil.convertModVersion(buf.readInt());
+ final int size;
+ final boolean canDownload;
+ //since v0.4.1 we also send the size of the mod-File
+ size = buf.readInt();
+ canDownload = buf.readBoolean();
+ modVersion.put(id, new OfferedModInfo(version, size, canDownload));
+ }
+
+ //read config Data
+ count = buf.readInt();
+ autoSyncedFiles = new ArrayList<>(count);
+ for (int i = 0; i < count; i++) {
+ //System.out.println("Deserializing ");
+ AutoSync.AutoSyncTriple t = AutoFileSyncEntry.deserializeAndMatch(buf);
+ autoSyncedFiles.add(t);
+ //System.out.println(t.first);
+ }
+
+
+ autoSynFolders = new ArrayList<>(1);
+ //since v0.4.1 we also send the sync folders
+ final int folderCount = buf.readInt();
+ for (int i = 0; i < folderCount; i++) {
+ SyncFolderDescriptor desc = SyncFolderDescriptor.deserialize(buf);
+ autoSynFolders.add(desc);
+ }
+
+ serverPublishedModInfo = buf.readBoolean();
+ }
+
+ @Environment(EnvType.CLIENT)
+ private void processAutoSyncFolder(
+ final List filesToRequest,
+ final List filesToRemove
+ ) {
+ if (!Configs.CLIENT_CONFIG.isAcceptingFiles()) {
+ return;
+ }
+
+ if (autoSynFolders.size() > 0) {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info("Folders offered by Server:");
+ }
+
+ autoSynFolders.forEach(desc -> {
+ //desc contains the fileCache sent from the server, load the local version to get hold of the actual file cache on the client
+ SyncFolderDescriptor localDescriptor = AutoSync.getSyncFolderDescriptor(desc.folderID);
+ if (localDescriptor != null) {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - " + desc.folderID + " (" + desc.localFolder + ", allowRemove=" + desc.removeAdditionalFiles + ")");
+ localDescriptor.invalidateCache();
+
+ desc.relativeFilesStream()
+ .filter(desc::discardChildElements)
+ .forEach(subFile -> {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.warning(" * " + subFile.relPath + " (REJECTED)");
+ });
+
+
+ if (desc.removeAdditionalFiles) {
+ List additionalFiles = localDescriptor.relativeFilesStream()
+ .filter(subFile -> !desc.hasRelativeFile(
+ subFile))
+ .map(desc::mapAbsolute)
+ .filter(desc::acceptChildElements)
+ .map(absPath -> new AutoSyncID.ForDirectFileRequest(
+ desc.folderID,
+ absPath.toFile()
+ ))
+ .collect(Collectors.toList());
+
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ additionalFiles.forEach(aid -> BCLib.LOGGER.info(" * " + desc.localFolder.relativize(aid.relFile.toPath()) + " (missing on server)"));
+ filesToRemove.addAll(additionalFiles);
+ }
+
+ desc.relativeFilesStream()
+ .filter(desc::acceptChildElements)
+ .forEach(subFile -> {
+ SyncFolderDescriptor.SubFile localSubFile = localDescriptor.getLocalSubFile(subFile.relPath);
+ if (localSubFile != null) {
+ //the file exists locally, check if the hashes match
+ if (!localSubFile.hash.equals(subFile.hash)) {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" * " + subFile.relPath + " (changed)");
+ filesToRequest.add(new AutoSyncID.ForDirectFileRequest(
+ desc.folderID,
+ new File(subFile.relPath)
+ ));
+ } else {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" * " + subFile.relPath);
+ }
+ } else {
+ //the file is missing locally
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" * " + subFile.relPath + " (missing on client)");
+ filesToRequest.add(new AutoSyncID.ForDirectFileRequest(
+ desc.folderID,
+ new File(subFile.relPath)
+ ));
+ }
+ });
+
+ //free some memory
+ localDescriptor.invalidateCache();
+ } else {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - " + desc.folderID + " (Failed to find)");
+ }
+ });
+ }
+
+ @Environment(EnvType.CLIENT)
+ private void processSingleFileSync(final List filesToRequest) {
+ final boolean debugHashes = Configs.CLIENT_CONFIG.shouldPrintDebugHashes();
+
+ if (autoSyncedFiles.size() > 0) {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info("Files offered by Server:");
+ }
+
+ //Handle single sync files
+ //Single files need to be registered for sync on both client and server
+ //There are no restrictions to the target folder, but the client decides the final
+ //location.
+ for (AutoSync.AutoSyncTriple e : autoSyncedFiles) {
+ String actionString = "";
+ FileContentWrapper contentWrapper = new FileContentWrapper(e.serverContent);
+ if (e.localMatch == null) {
+ actionString = "(unknown source -> omitting)";
+ //filesToRequest.add(new AutoSyncID(e.serverHash.modID, e.serverHash.uniqueID));
+ } else if (e.localMatch.needTransfer.test(e.localMatch.getFileHash(), e.serverHash, contentWrapper)) {
+ actionString = "(prepare update)";
+ //we did not yet receive the new content
+ if (contentWrapper.getRawContent() == null) {
+ filesToRequest.add(new AutoSyncID(e.serverHash.modID, e.serverHash.uniqueID));
+ } else {
+ filesToRequest.add(new AutoSyncID.WithContentOverride(
+ e.serverHash.modID,
+ e.serverHash.uniqueID,
+ contentWrapper,
+ e.localMatch.fileName
+ ));
+ }
+ }
+ if (Configs.MAIN_CONFIG.verboseLogging()) {
+ BCLib.LOGGER.info(" - " + e + ": " + actionString);
+ if (debugHashes) {
+ BCLib.LOGGER.info(" * " + e.serverHash + " (Server)");
+ BCLib.LOGGER.info(" * " + e.localMatch.getFileHash() + " (Client)");
+ BCLib.LOGGER.info(" * local Content " + (contentWrapper.getRawContent() == null));
+ }
+ }
+ }
+ }
+
+
+ @Environment(EnvType.CLIENT)
+ private void processModFileSync(final List filesToRequest, final Set mismatchingMods) {
+ for (Entry e : modVersion.entrySet()) {
+ final String localVersion = ModUtil.convertModVersion(ModUtil.convertModVersion(ModUtil.getModVersion(e.getKey())));
+ final OfferedModInfo serverInfo = e.getValue();
+
+ ModInfo nfo = ModUtil.getModInfo(e.getKey());
+ final boolean clientOnly = nfo != null && nfo.metadata.getEnvironment() == ModEnvironment.CLIENT;
+ final boolean requestMod = !clientOnly && !serverInfo.version.equals(localVersion) && serverInfo.size > 0 && serverInfo.canDownload;
+
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - " + e.getKey() + " (client=" + localVersion + ", server=" + serverInfo.version + ", size=" + PathUtil.humanReadableFileSize(
+ serverInfo.size) + (requestMod ? ", requesting" : "") + (serverInfo.canDownload
+ ? ""
+ : ", not offered") + (clientOnly ? ", client only" : "") + ")");
+ if (requestMod) {
+ filesToRequest.add(new AutoSyncID.ForModFileRequest(e.getKey(), serverInfo.version));
+ }
+ if (!serverInfo.version.equals(localVersion)) {
+ mismatchingMods.add(e.getKey());
+ }
+ }
+
+ mismatchingMods.addAll(ModListScreen.localMissing(modVersion));
+ mismatchingMods.addAll(ModListScreen.serverMissing(modVersion));
+ }
+
+ @Override
+ protected boolean isBlocking() {
+ return true;
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void runOnClientGameThread(Minecraft client) {
+ if (!Configs.CLIENT_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the client.");
+ return;
+ }
+ final String localBclibVersion = getBCLibVersion();
+ BCLib.LOGGER.info("Received Hello from Server. (client=" + localBclibVersion + ", server=" + bclibVersion + ")");
+
+ if (ModUtil.convertModVersion(localBclibVersion) != ModUtil.convertModVersion(bclibVersion)) {
+ showBCLibError(client);
+ return;
+ }
+
+ final List filesToRequest = new ArrayList<>(2);
+ final List filesToRemove = new ArrayList<>(2);
+ final Set mismatchingMods = new HashSet<>(2);
+
+
+ processModFileSync(filesToRequest, mismatchingMods);
+ processSingleFileSync(filesToRequest);
+ processAutoSyncFolder(filesToRequest, filesToRemove);
+
+ //Handle folder sync
+ //Both client and server need to know about the folder you want to sync
+ //Files can only get placed within that folder
+
+ if ((filesToRequest.size() > 0 || filesToRemove.size() > 0) && (Configs.CLIENT_CONFIG.isAcceptingMods() || Configs.CLIENT_CONFIG.isAcceptingConfigs() || Configs.CLIENT_CONFIG.isAcceptingFiles())) {
+ showSyncFilesScreen(client, filesToRequest, filesToRemove);
+ return;
+ } else if (serverPublishedModInfo && mismatchingMods.size() > 0 && Configs.CLIENT_CONFIG.isShowingModInfo()) {
+ client.setScreen(new ModListScreen(
+ client.screen,
+ Component.translatable("title.bclib.modmissmatch"),
+ Component.translatable("message.bclib.modmissmatch"),
+ CommonComponents.GUI_PROCEED,
+ ModUtil.getMods(),
+ modVersion
+ ));
+ return;
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ protected void showBCLibError(Minecraft client) {
+ BCLib.LOGGER.error("BCLib differs on client and server.");
+ client.setScreen(new WarnBCLibVersionMismatch((download) -> {
+ if (download) {
+ requestBCLibDownload();
+
+ this.onCloseSyncFilesScreen();
+ } else {
+ Minecraft.getInstance()
+ .setScreen(null);
+ }
+ }));
+ }
+
+ @Environment(EnvType.CLIENT)
+ protected void showSyncFilesScreen(
+ Minecraft client,
+ List files,
+ final List filesToRemove
+ ) {
+ int configFiles = 0;
+ int singleFiles = 0;
+ int folderFiles = 0;
+ int modFiles = 0;
+
+ for (AutoSyncID aid : files) {
+ if (aid.isConfigFile()) {
+ configFiles++;
+ } else if (aid instanceof AutoSyncID.ForModFileRequest) {
+ modFiles++;
+ } else if (aid instanceof AutoSyncID.ForDirectFileRequest) {
+ folderFiles++;
+ } else {
+ singleFiles++;
+ }
+ }
+
+ client.setScreen(new SyncFilesScreen(
+ modFiles,
+ configFiles,
+ singleFiles,
+ folderFiles,
+ filesToRemove.size(),
+ modVersion,
+ (downloadMods, downloadConfigs, downloadFiles, removeFiles) -> {
+ if (downloadMods || downloadConfigs || downloadFiles) {
+ BCLib.LOGGER.info("Updating local Files:");
+ List localChanges = new ArrayList<>(
+ files.toArray().length);
+ List requestFiles = new ArrayList<>(files.toArray().length);
+
+ files.forEach(aid -> {
+ if (aid.isConfigFile() && downloadConfigs) {
+ processOfferedFile(requestFiles, aid);
+ } else if (aid instanceof AutoSyncID.ForModFileRequest && downloadMods) {
+ processOfferedFile(requestFiles, aid);
+ } else if (downloadFiles) {
+ processOfferedFile(requestFiles, aid);
+ }
+ });
+
+ requestFileDownloads(requestFiles);
+ }
+ if (removeFiles) {
+ filesToRemove.forEach(aid -> {
+ BCLib.LOGGER.info(" - " + aid.relFile + " (removing)");
+ aid.relFile.delete();
+ });
+ }
+
+ this.onCloseSyncFilesScreen();
+ }
+ ));
+ }
+
+ @Environment(EnvType.CLIENT)
+ private void onCloseSyncFilesScreen() {
+ Minecraft.getInstance()
+ .setScreen(ChunkerProgress.getProgressScreen());
+ }
+
+ private void processOfferedFile(List requestFiles, AutoSyncID aid) {
+ if (aid instanceof AutoSyncID.WithContentOverride) {
+ final AutoSyncID.WithContentOverride aidc = (AutoSyncID.WithContentOverride) aid;
+ BCLib.LOGGER.info(" - " + aid + " (updating Content)");
+
+ SendFiles.writeSyncedFile(aid, aidc.contentWrapper.getRawContent(), aidc.localFile);
+ } else {
+ requestFiles.add(aid);
+ BCLib.LOGGER.info(" - " + aid + " (requesting)");
+ }
+ }
+
+ private void requestBCLibDownload() {
+ BCLib.LOGGER.warning("Starting download of BCLib");
+ requestFileDownloads(List.of(new AutoSyncID.ForModFileRequest(BCLib.MOD_ID, bclibVersion)));
+ }
+
+ @Environment(EnvType.CLIENT)
+ private void requestFileDownloads(List files) {
+ BCLib.LOGGER.info("Starting download of Files:" + files.size());
+
+ final ProgressScreen progress = new ProgressScreen(
+ null,
+ Component.translatable("title.bclib.filesync.progress"),
+ Component.translatable("message.bclib.filesync.progress")
+ );
+ progress.progressStart(Component.translatable("message.bclib.filesync.progress.stage.empty"));
+ ChunkerProgress.setProgressScreen(progress);
+
+ DataExchangeAPI.send(new RequestFiles(files));
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloServer.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloServer.java
new file mode 100644
index 00000000..df1994fa
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/HelloServer.java
@@ -0,0 +1,119 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataExchangeAPI;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.DataHandlerDescriptor;
+import org.betterx.bclib.config.Configs;
+import org.betterx.worlds.together.util.ModUtil;
+
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.world.entity.player.Player;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+
+import java.io.File;
+
+/**
+ * This message is sent once a player enters the world. It initiates a sequence of Messages that will sync files between both
+ * client and server.
+ *
+ * Description
+ *
+ * Server |
+ * |
+ * Client |
+ * |
+ *
+ *
+ * Player enters World |
+ *
+ *
+ * |
+ * <-- |
+ * {@link HelloServer} |
+ * Sends the current BLib-Version installed on the Client |
+ *
+ *
+ * {@link HelloClient} |
+ * --> |
+ * |
+ * Sends the current BClIb-Version, the Version of all Plugins and data for all AutpoSync-Files
+ * ({@link DataExchangeAPI#addAutoSyncFile(String, File)} on the Server |
+ *
+ *
+ * |
+ * <-- |
+ * {@link RequestFiles} |
+ * Request missing or out of sync Files from the Server |
+ *
+ *
+ * {@link SendFiles} |
+ * --> |
+ * |
+ * Send Files from the Server to the Client |
+ *
+ *
+ */
+public class HelloServer extends DataHandler.FromClient {
+ public static final DataHandlerDescriptor DESCRIPTOR = new DataHandlerDescriptor(
+ new ResourceLocation(
+ BCLib.MOD_ID,
+ "hello_server"
+ ),
+ HelloServer::new,
+ true,
+ false
+ );
+
+ protected String bclibVersion = "0.0.0";
+
+ public HelloServer() {
+ super(DESCRIPTOR.IDENTIFIER);
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected boolean prepareDataOnClient() {
+ if (!Configs.CLIENT_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the client.");
+ return false;
+ }
+
+ return true;
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void serializeDataOnClient(FriendlyByteBuf buf) {
+ BCLib.LOGGER.info("Sending hello to server.");
+ buf.writeInt(ModUtil.convertModVersion(HelloClient.getBCLibVersion()));
+ }
+
+ @Override
+ protected void deserializeIncomingDataOnServer(FriendlyByteBuf buf, Player player, PacketSender responseSender) {
+ bclibVersion = ModUtil.convertModVersion(buf.readInt());
+ }
+
+ @Override
+ protected void runOnServerGameThread(MinecraftServer server, Player player) {
+ if (!Configs.SERVER_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the server.");
+ return;
+ }
+
+ String localBclibVersion = HelloClient.getBCLibVersion();
+ BCLib.LOGGER.info("Received Hello from Client. (server=" + localBclibVersion + ", client=" + bclibVersion + ")");
+
+ if (!server.isPublished()) {
+ BCLib.LOGGER.info("Auto-Sync is disabled for Singleplayer worlds.");
+ return;
+ }
+
+ reply(new HelloClient(), server);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/RequestFiles.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/RequestFiles.java
new file mode 100644
index 00000000..ec4da863
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/RequestFiles.java
@@ -0,0 +1,111 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.DataHandlerDescriptor;
+import org.betterx.bclib.config.Configs;
+
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.world.entity.player.Player;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+public class RequestFiles extends DataHandler.FromClient {
+ public static final DataHandlerDescriptor DESCRIPTOR = new DataHandlerDescriptor(
+ new ResourceLocation(
+ BCLib.MOD_ID,
+ "request_files"
+ ),
+ RequestFiles::new,
+ false,
+ false
+ );
+ static String currentToken = "";
+
+ protected List files;
+
+ private RequestFiles() {
+ this(null);
+ }
+
+ public RequestFiles(List files) {
+ super(DESCRIPTOR.IDENTIFIER);
+ this.files = files;
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected boolean prepareDataOnClient() {
+ if (!Configs.CLIENT_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the client.");
+ return false;
+ }
+ return true;
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void serializeDataOnClient(FriendlyByteBuf buf) {
+ newToken();
+ writeString(buf, currentToken);
+
+ buf.writeInt(files.size());
+
+ for (AutoSyncID a : files) {
+ a.serializeData(buf);
+ }
+ }
+
+ String receivedToken = "";
+
+ @Override
+ protected void deserializeIncomingDataOnServer(FriendlyByteBuf buf, Player player, PacketSender responseSender) {
+ receivedToken = readString(buf);
+ int size = buf.readInt();
+ files = new ArrayList<>(size);
+
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info("Client requested " + size + " Files:");
+ for (int i = 0; i < size; i++) {
+ AutoSyncID asid = AutoSyncID.deserializeData(buf);
+ files.add(asid);
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - " + asid);
+ }
+
+
+ }
+
+ @Override
+ protected void runOnServerGameThread(MinecraftServer server, Player player) {
+ if (!Configs.SERVER_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the server.");
+ return;
+ }
+
+ List syncEntries = files.stream()
+ .map(asid -> AutoFileSyncEntry.findMatching(asid))
+ .filter(e -> e != null)
+ .collect(Collectors.toList());
+
+ reply(new SendFiles(syncEntries, receivedToken), server);
+ }
+
+ public static void newToken() {
+ currentToken = UUID.randomUUID()
+ .toString();
+ }
+
+ static {
+ newToken();
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SendFiles.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SendFiles.java
new file mode 100644
index 00000000..531a7cf2
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SendFiles.java
@@ -0,0 +1,230 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.DataHandlerDescriptor;
+import org.betterx.bclib.client.gui.screens.ConfirmRestartScreen;
+import org.betterx.bclib.config.Configs;
+import org.betterx.bclib.util.Pair;
+import org.betterx.bclib.util.Triple;
+import org.betterx.worlds.together.util.PathUtil;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class SendFiles extends DataHandler.FromServer {
+ public static final DataHandlerDescriptor DESCRIPTOR = new DataHandlerDescriptor(
+ new ResourceLocation(
+ BCLib.MOD_ID,
+ "send_files"
+ ),
+ SendFiles::new,
+ false,
+ false
+ );
+
+ protected List files;
+ private String token;
+
+ public SendFiles() {
+ this(null, "");
+ }
+
+ public SendFiles(List files, String token) {
+ super(DESCRIPTOR.IDENTIFIER);
+ this.files = files;
+ this.token = token;
+ }
+
+ @Override
+ protected boolean prepareDataOnServer() {
+ if (!Configs.SERVER_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the server.");
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ protected void serializeDataOnServer(FriendlyByteBuf buf) {
+ List existingFiles = files.stream()
+ .filter(e -> e != null && e.fileName != null && e.fileName.exists())
+ .collect(Collectors.toList());
+ /*
+ //this will try to send a file that was not registered or requested by the client
+ existingFiles.add(new AutoFileSyncEntry("none", new File("D:\\MinecraftPlugins\\BetterNether\\run\\server.properties"),true,(a, b, content) -> {
+ System.out.println("Got Content:" + content.length);
+ return true;
+ }));*/
+
+ /*//this will try to send a folder-file that was not registered or requested by the client
+ existingFiles.add(new AutoFileSyncEntry.ForDirectFileRequest(DataExchange.SYNC_FOLDER.folderID, new File("test.json"), DataExchange.SYNC_FOLDER.mapAbsolute("test.json").toFile()));*/
+
+ /*//this will try to send a folder-file that was not registered or requested by the client and is outside the base-folder
+ existingFiles.add(new AutoFileSyncEntry.ForDirectFileRequest(DataExchange.SYNC_FOLDER.folderID, new File("../breakout.json"), DataExchange.SYNC_FOLDER.mapAbsolute("../breakout.json").toFile()));*/
+
+
+ writeString(buf, token);
+ buf.writeInt(existingFiles.size());
+
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info("Sending " + existingFiles.size() + " Files to Client:");
+ for (AutoFileSyncEntry entry : existingFiles) {
+ int length = entry.serializeContent(buf);
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - " + entry + " (" + PathUtil.humanReadableFileSize(length) + ")");
+ }
+ }
+
+ private List> receivedFiles;
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void deserializeIncomingDataOnClient(FriendlyByteBuf buf, PacketSender responseSender) {
+ if (Configs.CLIENT_CONFIG.isAcceptingConfigs() || Configs.CLIENT_CONFIG.isAcceptingFiles() || Configs.CLIENT_CONFIG.isAcceptingMods()) {
+ token = readString(buf);
+ if (!token.equals(RequestFiles.currentToken)) {
+ RequestFiles.newToken();
+ BCLib.LOGGER.error("Unrequested File Transfer!");
+ receivedFiles = new ArrayList<>(0);
+ return;
+ }
+ RequestFiles.newToken();
+
+ int size = buf.readInt();
+ receivedFiles = new ArrayList<>(size);
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info("Server sent " + size + " Files:");
+ for (int i = 0; i < size; i++) {
+ Triple p = AutoFileSyncEntry.deserializeContent(buf);
+ if (p.first != null) {
+ final String type;
+ if (p.first.isConfigFile() && Configs.CLIENT_CONFIG.isAcceptingConfigs()) {
+ receivedFiles.add(p);
+ type = "Accepted Config ";
+ } else if (p.first instanceof AutoFileSyncEntry.ForModFileRequest && Configs.CLIENT_CONFIG.isAcceptingMods()) {
+ receivedFiles.add(p);
+ type = "Accepted Mod ";
+ } else if (Configs.CLIENT_CONFIG.isAcceptingFiles()) {
+ receivedFiles.add(p);
+ type = "Accepted File ";
+ } else {
+ type = "Ignoring ";
+ }
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.info(" - " + type + p.first + " (" + PathUtil.humanReadableFileSize(p.second.length) + ")");
+ } else {
+ if (Configs.MAIN_CONFIG.verboseLogging())
+ BCLib.LOGGER.error(" - Failed to receive File " + p.third + ", possibly sent from a Mod that is not installed on the client.");
+ }
+ }
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void runOnClientGameThread(Minecraft client) {
+ if (Configs.CLIENT_CONFIG.isAcceptingConfigs() || Configs.CLIENT_CONFIG.isAcceptingFiles() || Configs.CLIENT_CONFIG.isAcceptingMods()) {
+ BCLib.LOGGER.info("Writing Files:");
+
+ for (Pair entry : receivedFiles) {
+ final AutoFileSyncEntry e = entry.first;
+ final byte[] data = entry.second;
+
+ writeSyncedFile(e, data, e.fileName);
+ }
+
+ showConfirmRestart(client);
+ }
+ }
+
+
+ @Environment(EnvType.CLIENT)
+ static void writeSyncedFile(AutoSyncID e, byte[] data, File fileName) {
+ if (fileName != null && !PathUtil.isChildOf(PathUtil.GAME_FOLDER, fileName.toPath())) {
+ BCLib.LOGGER.error(fileName + " is not within game folder " + PathUtil.GAME_FOLDER);
+ return;
+ }
+
+ if (!PathUtil.MOD_BAK_FOLDER.toFile().exists()) {
+ PathUtil.MOD_BAK_FOLDER.toFile().mkdirs();
+ }
+
+ Path path = fileName != null ? fileName.toPath() : null;
+ Path removeAfter = null;
+ if (e instanceof AutoFileSyncEntry.ForModFileRequest mase) {
+ removeAfter = path;
+ int count = 0;
+ final String prefix = "_bclib_synced_";
+ String name = prefix + mase.modID + "_" + mase.version.replace(".", "_") + ".jar";
+ do {
+ if (path != null) {
+ //move to the same directory as the existing Mod
+ path = path.getParent()
+ .resolve(name);
+ } else {
+ //move to the default mode location
+ path = PathUtil.MOD_FOLDER.resolve(name);
+ }
+ count++;
+ name = prefix + mase.modID + "_" + mase.version.replace(".", "_") + "__" + String.format(
+ "%03d",
+ count
+ ) + ".jar";
+ } while (path.toFile().exists());
+ }
+
+ BCLib.LOGGER.info(" - Writing " + path + " (" + PathUtil.humanReadableFileSize(data.length) + ")");
+ try {
+ final File parentFile = path.getParent()
+ .toFile();
+ if (!parentFile.exists()) {
+ parentFile.mkdirs();
+ }
+ Files.write(path, data);
+ if (removeAfter != null) {
+ final String bakFileName = removeAfter.toFile().getName();
+ String collisionFreeName = bakFileName;
+ Path targetPath;
+ int count = 0;
+ do {
+ targetPath = PathUtil.MOD_BAK_FOLDER.resolve(collisionFreeName);
+ count++;
+ collisionFreeName = String.format("%03d", count) + "_" + bakFileName;
+ } while (targetPath.toFile().exists());
+
+ BCLib.LOGGER.info(" - Moving " + removeAfter + " to " + targetPath);
+ removeAfter.toFile().renameTo(targetPath.toFile());
+ }
+ AutoSync.didReceiveFile(e, fileName);
+
+
+ } catch (IOException ioException) {
+ BCLib.LOGGER.error(" --> Writing " + fileName + " failed: " + ioException);
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ protected void showConfirmRestart(Minecraft client) {
+ client.setScreen(new ConfirmRestartScreen(() -> {
+ Minecraft.getInstance()
+ .setScreen(null);
+ client.stop();
+ }));
+
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SyncFolderDescriptor.java b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SyncFolderDescriptor.java
new file mode 100644
index 00000000..bc4bd275
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/dataexchange/handler/autosync/SyncFolderDescriptor.java
@@ -0,0 +1,220 @@
+package org.betterx.bclib.api.v2.dataexchange.handler.autosync;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.dataexchange.DataHandler;
+import org.betterx.bclib.api.v2.dataexchange.FileHash;
+import org.betterx.bclib.api.v2.dataexchange.handler.autosync.AutoSyncID.ForDirectFileRequest;
+import org.betterx.bclib.config.Configs;
+import org.betterx.worlds.together.util.PathUtil;
+
+import net.minecraft.network.FriendlyByteBuf;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+import org.jetbrains.annotations.NotNull;
+
+public class SyncFolderDescriptor {
+ static class SubFile {
+ public final String relPath;
+ public final FileHash hash;
+
+
+ SubFile(String relPath, FileHash hash) {
+ this.relPath = relPath;
+ this.hash = hash;
+ }
+
+ @Override
+ public String toString() {
+ return relPath;
+ }
+
+ public void serialize(FriendlyByteBuf buf) {
+ DataHandler.writeString(buf, relPath);
+ hash.serialize(buf);
+ }
+
+ public static SubFile deserialize(FriendlyByteBuf buf) {
+ final String relPath = DataHandler.readString(buf);
+ FileHash hash = FileHash.deserialize(buf);
+ return new SubFile(relPath, hash);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o instanceof String) return relPath.equals(o);
+ if (!(o instanceof SubFile)) return false;
+ SubFile subFile = (SubFile) o;
+ return relPath.equals(subFile.relPath);
+ }
+
+ @Override
+ public int hashCode() {
+ return relPath.hashCode();
+ }
+ }
+
+ @NotNull
+ public final String folderID;
+ public final boolean removeAdditionalFiles;
+ @NotNull
+ public final Path localFolder;
+
+ private List fileCache;
+
+ public SyncFolderDescriptor(String folderID, Path localFolder, boolean removeAdditionalFiles) {
+ this.removeAdditionalFiles = removeAdditionalFiles;
+ this.folderID = folderID;
+ this.localFolder = localFolder;
+ fileCache = null;
+ }
+
+ @Override
+ public String toString() {
+ return "SyncFolderDescriptor{" + "folderID='" + folderID + '\'' + ", removeAdditionalFiles=" + removeAdditionalFiles + ", localFolder=" + localFolder + ", files=" + (
+ fileCache == null
+ ? "?"
+ : fileCache.size()) + "}";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o instanceof String) {
+ return folderID.equals(o);
+ }
+ if (o instanceof ForDirectFileRequest) {
+ return folderID.equals(((ForDirectFileRequest) o).uniqueID);
+ }
+ if (!(o instanceof SyncFolderDescriptor)) return false;
+ SyncFolderDescriptor that = (SyncFolderDescriptor) o;
+ return folderID.equals(that.folderID);
+ }
+
+ @Override
+ public int hashCode() {
+ return folderID.hashCode();
+ }
+
+ public int fileCount() {
+ return fileCache == null ? 0 : fileCache.size();
+ }
+
+ public void invalidateCache() {
+ fileCache = null;
+ }
+
+ public void loadCache() {
+ if (fileCache == null) {
+ fileCache = new ArrayList<>(8);
+ PathUtil.fileWalker(localFolder.toFile(), p -> fileCache.add(new SubFile(
+ localFolder.relativize(p)
+ .toString(),
+ FileHash.create(p.toFile())
+ )));
+
+ /*//this tests if we can trick the system to load files that are not beneath the base-folder
+ if (!BCLib.isClient()) {
+ fileCache.add(new SubFile("../breakout.json", FileHash.create(mapAbsolute("../breakout.json").toFile())));
+ }*/
+ }
+ }
+
+ public void serialize(FriendlyByteBuf buf) {
+ final boolean debugHashes = Configs.CLIENT_CONFIG.getBoolean(AutoSync.SYNC_CATEGORY, "debugHashes", false);
+ loadCache();
+
+ DataHandler.writeString(buf, folderID);
+ buf.writeBoolean(removeAdditionalFiles);
+ buf.writeInt(fileCache.size());
+ fileCache.forEach(fl -> {
+ if (Configs.MAIN_CONFIG.verboseLogging()) {
+ BCLib.LOGGER.info(" - " + fl.relPath);
+ if (debugHashes) {
+ BCLib.LOGGER.info(" " + fl.hash);
+ }
+ }
+ fl.serialize(buf);
+ });
+ }
+
+ public static SyncFolderDescriptor deserialize(FriendlyByteBuf buf) {
+ final String folderID = DataHandler.readString(buf);
+ final boolean remAddFiles = buf.readBoolean();
+ final int count = buf.readInt();
+ SyncFolderDescriptor localDescriptor = AutoSync.getSyncFolderDescriptor(folderID);
+
+ final SyncFolderDescriptor desc;
+ if (localDescriptor != null) {
+ desc = new SyncFolderDescriptor(
+ folderID,
+ localDescriptor.localFolder,
+ localDescriptor.removeAdditionalFiles && remAddFiles
+ );
+ desc.fileCache = new ArrayList<>(count);
+ } else {
+ BCLib.LOGGER.warning(BCLib.isClient()
+ ? "Client"
+ : "Server" + " does not know Sync-Folder ID '" + folderID + "'");
+ desc = null;
+ }
+
+ for (int i = 0; i < count; i++) {
+ SubFile relPath = SubFile.deserialize(buf);
+ if (desc != null) desc.fileCache.add(relPath);
+ }
+
+ return desc;
+ }
+
+ //Note: make sure loadCache was called before using this
+ boolean hasRelativeFile(String relFile) {
+ return fileCache.stream()
+ .filter(sf -> sf.equals(relFile))
+ .findFirst()
+ .isPresent();
+ }
+
+ //Note: make sure loadCache was called before using this
+ boolean hasRelativeFile(SubFile subFile) {
+ return hasRelativeFile(subFile.relPath);
+ }
+
+ //Note: make sure loadCache was called before using this
+ SubFile getLocalSubFile(String relPath) {
+ return fileCache.stream()
+ .filter(sf -> sf.relPath.equals(relPath))
+ .findFirst()
+ .orElse(null);
+ }
+
+ Stream relativeFilesStream() {
+ loadCache();
+ return fileCache.stream();
+ }
+
+ public Path mapAbsolute(String relPath) {
+ return this.localFolder.resolve(relPath)
+ .normalize();
+ }
+
+ public Path mapAbsolute(SubFile subFile) {
+ return this.localFolder.resolve(subFile.relPath)
+ .normalize();
+ }
+
+ public boolean acceptChildElements(Path absPath) {
+ return PathUtil.isChildOf(this.localFolder, absPath);
+ }
+
+ public boolean acceptChildElements(SubFile subFile) {
+ return acceptChildElements(mapAbsolute(subFile));
+ }
+
+ public boolean discardChildElements(SubFile subFile) {
+ return !acceptChildElements(subFile);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/datafixer/DataFixerAPI.java b/src/main/java/org/betterx/bclib/api/v2/datafixer/DataFixerAPI.java
new file mode 100644
index 00000000..07a9cf20
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/datafixer/DataFixerAPI.java
@@ -0,0 +1,598 @@
+package org.betterx.bclib.api.v2.datafixer;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.client.gui.screens.AtomicProgressListener;
+import org.betterx.bclib.client.gui.screens.ConfirmFixScreen;
+import org.betterx.bclib.client.gui.screens.LevelFixErrorScreen;
+import org.betterx.bclib.client.gui.screens.LevelFixErrorScreen.Listener;
+import org.betterx.bclib.client.gui.screens.ProgressScreen;
+import org.betterx.bclib.config.Configs;
+import org.betterx.worlds.together.util.Logger;
+import org.betterx.worlds.together.world.WorldConfig;
+
+import net.minecraft.Util;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.components.toasts.SystemToast;
+import net.minecraft.client.gui.screens.worldselection.EditWorldScreen;
+import net.minecraft.nbt.*;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.level.ChunkPos;
+import net.minecraft.world.level.chunk.storage.RegionFile;
+import net.minecraft.world.level.storage.LevelResource;
+import net.minecraft.world.level.storage.LevelStorageSource;
+import net.minecraft.world.level.storage.LevelStorageSource.LevelStorageAccess;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+
+import java.io.*;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.zip.ZipException;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * API to manage Patches that need to get applied to a world
+ */
+public class DataFixerAPI {
+ static final Logger LOGGER = new Logger("DataFixerAPI");
+
+ static class State {
+ public boolean didFail = false;
+ protected ArrayList errors = new ArrayList<>();
+
+ public void addError(String s) {
+ errors.add(s);
+ }
+
+ public boolean hasError() {
+ return errors.size() > 0;
+ }
+
+ public String getErrorMessage() {
+ return errors.stream().reduce("", (a, b) -> a + " - " + b + "\n");
+ }
+
+ public String[] getErrorMessages() {
+ String[] res = new String[errors.size()];
+ return errors.toArray(res);
+ }
+ }
+
+ @FunctionalInterface
+ public interface Callback {
+ void call();
+ }
+
+ private static boolean wrapCall(
+ LevelStorageSource levelSource,
+ String levelID,
+ Function runWithLevel
+ ) {
+ LevelStorageSource.LevelStorageAccess levelStorageAccess;
+ try {
+ levelStorageAccess = levelSource.createAccess(levelID);
+ } catch (IOException e) {
+ BCLib.LOGGER.warning("Failed to read level {} data", levelID, e);
+ SystemToast.onWorldAccessFailure(Minecraft.getInstance(), levelID);
+ Minecraft.getInstance().setScreen(null);
+ return true;
+ }
+
+ boolean returnValue = runWithLevel.apply(levelStorageAccess);
+
+ try {
+ levelStorageAccess.close();
+ } catch (IOException e) {
+ BCLib.LOGGER.warning("Failed to unlock access to level {}", levelID, e);
+ }
+
+ return returnValue;
+ }
+
+ /**
+ * Will apply necessary Patches to the world.
+ *
+ * @param levelSource The SourceStorage for this Minecraft instance, You can get this using
+ * {@code Minecraft.getInstance().getLevelSource()}
+ * @param levelID The ID of the Level you want to patch
+ * @param showUI {@code true}, if you want to present the user with a Screen that offers to backup the world
+ * before applying the patches
+ * @param onResume When this method retursn {@code true}, this function will be called when the world is ready
+ * @return {@code true} if the UI was displayed. The UI is only displayed if {@code showUI} was {@code true} and
+ * patches were enabled in the config and the Guardian did find any patches that need to be applied to the world.
+ */
+ public static boolean fixData(
+ LevelStorageSource levelSource,
+ String levelID,
+ boolean showUI,
+ Consumer onResume
+ ) {
+ return wrapCall(levelSource, levelID, (levelStorageAccess) -> fixData(levelStorageAccess, showUI, onResume));
+ }
+
+ /**
+ * Will apply necessary Patches to the world.
+ *
+ * @param levelStorageAccess The access class of the level you want to patch
+ * @param showUI {@code true}, if you want to present the user with a Screen that offers to backup the world
+ * before applying the patches
+ * @param onResume When this method retursn {@code true}, this function will be called when the world is ready
+ * @return {@code true} if the UI was displayed. The UI is only displayed if {@code showUI} was {@code true} and
+ * patches were enabled in the config and the Guardian did find any patches that need to be applied to the world.
+ */
+ public static boolean fixData(
+ LevelStorageSource.LevelStorageAccess levelStorageAccess,
+ boolean showUI,
+ Consumer onResume
+ ) {
+ File levelPath = levelStorageAccess.getLevelPath(LevelResource.ROOT).toFile();
+ return fixData(levelPath, levelStorageAccess.getLevelId(), showUI, onResume);
+ }
+
+ /**
+ * Creates the patch level file for new worlds
+ */
+ public static void initializePatchData() {
+ getMigrationProfile().markApplied();
+ WorldConfig.saveFile(BCLib.MOD_ID);
+ }
+
+
+ @Environment(EnvType.CLIENT)
+ private static AtomicProgressListener showProgressScreen() {
+ ProgressScreen ps = new ProgressScreen(
+ Minecraft.getInstance().screen,
+ Component.translatable("title.bclib.datafixer.progress"),
+ Component.translatable("message.bclib.datafixer.progress")
+ );
+ Minecraft.getInstance().setScreen(ps);
+ return ps;
+ }
+
+ private static boolean fixData(File dir, String levelID, boolean showUI, Consumer onResume) {
+ MigrationProfile profile = loadProfileIfNeeded(dir);
+
+ BiConsumer runFixes = (createBackup, applyFixes) -> {
+ final AtomicProgressListener progress;
+ if (applyFixes) {
+ if (showUI) {
+ progress = showProgressScreen();
+ } else {
+ progress = new AtomicProgressListener() {
+ private long timeStamp = Util.getMillis();
+ private AtomicInteger counter = new AtomicInteger(0);
+
+ @Override
+ public void incAtomic(int maxProgress) {
+ int percentage = (100 * counter.incrementAndGet()) / maxProgress;
+ if (Util.getMillis() - this.timeStamp >= 1000L) {
+ this.timeStamp = Util.getMillis();
+ BCLib.LOGGER.info("Patching... {}%", percentage);
+ }
+ }
+
+ @Override
+ public void resetAtomic() {
+ counter = new AtomicInteger(0);
+ }
+
+ public void stop() {
+ }
+
+ public void progressStage(Component component) {
+ BCLib.LOGGER.info("Patcher Stage... {}%", component.getString());
+ }
+ };
+ }
+ } else {
+ progress = null;
+ }
+
+ Supplier runner = () -> {
+ if (createBackup) {
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.waitbackup"));
+ EditWorldScreen.makeBackupAndShowToast(Minecraft.getInstance().getLevelSource(), levelID);
+ }
+
+ if (applyFixes) {
+ return runDataFixes(dir, profile, progress);
+ }
+
+ return new State();
+ };
+
+ if (showUI) {
+ Thread fixerThread = new Thread(() -> {
+ final State state = runner.get();
+
+ Minecraft.getInstance()
+ .execute(() -> {
+ if (profile != null && showUI) {
+ //something went wrong, show the user our error
+ if (state.didFail || state.hasError()) {
+ showLevelFixErrorScreen(state, (markFixed) -> {
+ if (markFixed) {
+ profile.markApplied();
+ }
+ onResume.accept(applyFixes);
+ });
+ } else {
+ onResume.accept(applyFixes);
+ }
+ }
+ });
+
+ });
+ fixerThread.start();
+ } else {
+ State state = runner.get();
+ if (state.hasError()) {
+ LOGGER.error("There were Errors while fixing the Level:");
+ LOGGER.error(state.getErrorMessage());
+ }
+ }
+ };
+
+ //we have some migrations
+ if (profile != null) {
+ //display the confirm UI.
+ if (showUI) {
+ showBackupWarning(levelID, runFixes);
+ return true;
+ } else {
+ BCLib.LOGGER.warning("Applying Fixes on Level");
+ runFixes.accept(false, true);
+ }
+ }
+ return false;
+ }
+
+ @Environment(EnvType.CLIENT)
+ private static void showLevelFixErrorScreen(State state, Listener onContinue) {
+ Minecraft.getInstance()
+ .setScreen(new LevelFixErrorScreen(
+ Minecraft.getInstance().screen,
+ state.getErrorMessages(),
+ onContinue
+ ));
+ }
+
+ private static MigrationProfile loadProfileIfNeeded(File levelBaseDir) {
+ if (!Configs.MAIN_CONFIG.applyPatches()) {
+ LOGGER.info("World Patches are disabled");
+ return null;
+ }
+
+ MigrationProfile profile = getMigrationProfile();
+ profile.runPrePatches(levelBaseDir);
+
+ if (!profile.hasAnyFixes()) {
+ LOGGER.info("Everything up to date");
+ return null;
+ }
+
+ return profile;
+ }
+
+ @NotNull
+ private static MigrationProfile getMigrationProfile() {
+ final CompoundTag patchConfig = WorldConfig.getCompoundTag(BCLib.MOD_ID, Configs.MAIN_PATCH_CATEGORY);
+ MigrationProfile profile = Patch.createMigrationData(patchConfig);
+ return profile;
+ }
+
+ @Environment(EnvType.CLIENT)
+ static void showBackupWarning(String levelID, BiConsumer whenFinished) {
+ Minecraft.getInstance().setScreen(new ConfirmFixScreen(null, whenFinished::accept));
+ }
+
+ private static State runDataFixes(File dir, MigrationProfile profile, AtomicProgressListener progress) {
+ State state = new State();
+ progress.resetAtomic();
+
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.reading"));
+ List players = getAllPlayers(dir);
+ List regions = getAllRegions(dir, null);
+ final int maxProgress = players.size() + regions.size() + 4;
+ progress.incAtomic(maxProgress);
+
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.players"));
+ players.parallelStream().forEach((file) -> {
+ fixPlayer(profile, state, file);
+ progress.incAtomic(maxProgress);
+ });
+
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.level"));
+ fixLevel(profile, state, dir);
+ progress.incAtomic(maxProgress);
+
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.worlddata"));
+ try {
+ profile.patchWorldData();
+ } catch (PatchDidiFailException e) {
+ state.didFail = true;
+ state.addError("Failed fixing worldconfig (" + e.getMessage() + ")");
+ BCLib.LOGGER.error(e.getMessage());
+ }
+ progress.incAtomic(maxProgress);
+
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.regions"));
+ regions.parallelStream().forEach((file) -> {
+ fixRegion(profile, state, file);
+ progress.incAtomic(maxProgress);
+ });
+
+ if (!state.didFail) {
+ progress.progressStage(Component.translatable("message.bclib.datafixer.progress.saving"));
+ profile.markApplied();
+ WorldConfig.saveFile(BCLib.MOD_ID);
+ }
+ progress.incAtomic(maxProgress);
+
+ progress.stop();
+
+ return state;
+ }
+
+ private static void fixLevel(MigrationProfile profile, State state, File levelBaseDir) {
+ try {
+ LOGGER.info("Inspecting level.dat in " + levelBaseDir);
+
+ //load the level (could already contain patches applied by patchLevelDat)
+ CompoundTag level = profile.getLevelDat(levelBaseDir);
+ boolean[] changed = {profile.isLevelDatChanged()};
+
+ if (profile.getPrePatchException() != null) {
+ throw profile.getPrePatchException();
+ }
+
+ if (level.contains("Data")) {
+ CompoundTag dataTag = (CompoundTag) level.get("Data");
+ if (dataTag.contains("Player")) {
+ CompoundTag player = (CompoundTag) dataTag.get("Player");
+ fixPlayerNbt(player, changed, profile);
+ }
+ }
+
+ if (changed[0]) {
+ LOGGER.warning("Writing '{}'", profile.getLevelDatFile());
+ NbtIo.writeCompressed(level, profile.getLevelDatFile());
+ }
+ } catch (Exception e) {
+ BCLib.LOGGER.error("Failed fixing Level-Data.");
+ state.addError("Failed fixing Level-Data in level.dat (" + e.getMessage() + ")");
+ state.didFail = true;
+ e.printStackTrace();
+ }
+ }
+
+ private static void fixPlayer(MigrationProfile data, State state, File file) {
+ try {
+ LOGGER.info("Inspecting " + file);
+
+ CompoundTag player = readNbt(file);
+ boolean[] changed = {false};
+ fixPlayerNbt(player, changed, data);
+
+ if (changed[0]) {
+ LOGGER.warning("Writing '{}'", file);
+ NbtIo.writeCompressed(player, file);
+ }
+ } catch (Exception e) {
+ BCLib.LOGGER.error("Failed fixing Player-Data.");
+ state.addError("Failed fixing Player-Data in " + file.getName() + " (" + e.getMessage() + ")");
+ state.didFail = true;
+ e.printStackTrace();
+ }
+ }
+
+ private static void fixPlayerNbt(CompoundTag player, boolean[] changed, MigrationProfile data) {
+ //Checking Inventory
+ ListTag inventory = player.getList("Inventory", Tag.TAG_COMPOUND);
+ fixItemArrayWithID(inventory, changed, data, true);
+
+ //Checking EnderChest
+ ListTag enderitems = player.getList("EnderItems", Tag.TAG_COMPOUND);
+ fixItemArrayWithID(enderitems, changed, data, true);
+
+ //Checking ReceipBook
+ if (player.contains("recipeBook")) {
+ CompoundTag recipeBook = player.getCompound("recipeBook");
+ changed[0] |= fixStringIDList(recipeBook, "recipes", data);
+ changed[0] |= fixStringIDList(recipeBook, "toBeDisplayed", data);
+ }
+ }
+
+ static boolean fixStringIDList(CompoundTag root, String name, MigrationProfile data) {
+ boolean _changed = false;
+ if (root.contains(name)) {
+ ListTag items = root.getList(name, Tag.TAG_STRING);
+ ListTag newItems = new ListTag();
+
+ for (Tag tag : items) {
+ final StringTag str = (StringTag) tag;
+ final String replace = data.replaceStringFromIDs(str.getAsString());
+ if (replace != null) {
+ _changed = true;
+ newItems.add(StringTag.valueOf(replace));
+ } else {
+ newItems.add(tag);
+ }
+ }
+ if (_changed) {
+ root.put(name, newItems);
+ }
+ }
+ return _changed;
+ }
+
+ private static void fixRegion(MigrationProfile data, State state, File file) {
+ try {
+ Path path = file.toPath();
+ LOGGER.info("Inspecting " + path);
+ boolean[] changed = new boolean[1];
+ RegionFile region = new RegionFile(path, path.getParent(), true);
+
+ for (int x = 0; x < 32; x++) {
+ for (int z = 0; z < 32; z++) {
+ ChunkPos pos = new ChunkPos(x, z);
+ changed[0] = false;
+ if (region.hasChunk(pos) && !state.didFail) {
+ DataInputStream input = region.getChunkDataInputStream(pos);
+ CompoundTag root = NbtIo.read(input);
+ // if ((root.toString().contains("betternether:chest") || root.toString().contains("bclib:chest"))) {
+ // NbtIo.write(root, new File(file.toString() + "-" + x + "-" + z + ".nbt"));
+ // }
+ input.close();
+
+ //Checking TileEntities
+ ListTag tileEntities = root.getCompound("Level")
+ .getList("TileEntities", Tag.TAG_COMPOUND);
+ fixItemArrayWithID(tileEntities, changed, data, true);
+
+ //Checking Entities
+ ListTag entities = root.getList("Entities", Tag.TAG_COMPOUND);
+ fixItemArrayWithID(entities, changed, data, true);
+
+ //Checking Block Palette
+ ListTag sections = root.getCompound("Level")
+ .getList("Sections", Tag.TAG_COMPOUND);
+ sections.forEach((tag) -> {
+ ListTag palette = ((CompoundTag) tag).getList("Palette", Tag.TAG_COMPOUND);
+ palette.forEach((blockTag) -> {
+ CompoundTag blockTagCompound = ((CompoundTag) blockTag);
+ changed[0] |= data.replaceStringFromIDs(blockTagCompound, "Name");
+ });
+
+ try {
+ changed[0] |= data.patchBlockState(
+ palette,
+ ((CompoundTag) tag).getList(
+ "BlockStates",
+ Tag.TAG_LONG
+ )
+ );
+ } catch (PatchDidiFailException e) {
+ BCLib.LOGGER.error("Failed fixing BlockState in " + pos);
+ state.addError("Failed fixing BlockState in " + pos + " (" + e.getMessage() + ")");
+ state.didFail = true;
+ changed[0] = false;
+ e.printStackTrace();
+ }
+ });
+
+ if (changed[0]) {
+ LOGGER.warning("Writing '{}': {}/{}", file, x, z);
+ // NbtIo.write(root, new File(file.toString() + "-" + x + "-" + z + "-changed.nbt"));
+ DataOutputStream output = region.getChunkDataOutputStream(pos);
+ NbtIo.write(root, output);
+ output.close();
+ }
+ }
+ }
+ }
+ region.close();
+ } catch (Exception e) {
+ BCLib.LOGGER.error("Failed fixing Region.");
+ state.addError("Failed fixing Region in " + file.getName() + " (" + e.getMessage() + ")");
+ state.didFail = true;
+ e.printStackTrace();
+ }
+ }
+
+ static CompoundTag patchConfTag = null;
+
+ static CompoundTag getPatchData() {
+ if (patchConfTag == null) {
+ patchConfTag = WorldConfig.getCompoundTag(BCLib.MOD_ID, Configs.MAIN_PATCH_CATEGORY);
+ }
+ return patchConfTag;
+ }
+
+ static void fixItemArrayWithID(ListTag items, boolean[] changed, MigrationProfile data, boolean recursive) {
+ items.forEach(inTag -> {
+ fixID((CompoundTag) inTag, changed, data, recursive);
+ });
+ }
+
+
+ static void fixID(CompoundTag inTag, boolean[] changed, MigrationProfile data, boolean recursive) {
+ final CompoundTag tag = inTag;
+
+ changed[0] |= data.replaceStringFromIDs(tag, "id");
+ if (tag.contains("Item")) {
+ CompoundTag item = (CompoundTag) tag.get("Item");
+ fixID(item, changed, data, recursive);
+ }
+
+ if (recursive && tag.contains("Items")) {
+ fixItemArrayWithID(tag.getList("Items", Tag.TAG_COMPOUND), changed, data, true);
+ }
+ if (recursive && tag.contains("Inventory")) {
+ ListTag inventory = tag.getList("Inventory", Tag.TAG_COMPOUND);
+ fixItemArrayWithID(inventory, changed, data, true);
+ }
+ if (tag.contains("tag")) {
+ CompoundTag entityTag = (CompoundTag) tag.get("tag");
+ if (entityTag.contains("BlockEntityTag")) {
+ CompoundTag blockEntityTag = (CompoundTag) entityTag.get("BlockEntityTag");
+ fixID(blockEntityTag, changed, data, recursive);
+ /*ListTag items = blockEntityTag.getList("Items", Tag.TAG_COMPOUND);
+ fixItemArrayWithID(items, changed, data, recursive);*/
+ }
+ }
+ }
+
+ private static List getAllPlayers(File dir) {
+ List list = new ArrayList<>();
+ dir = new File(dir, "playerdata");
+ if (!dir.exists() || !dir.isDirectory()) {
+ return list;
+ }
+ for (File file : dir.listFiles()) {
+ if (file.isFile() && file.getName().endsWith(".dat")) {
+ list.add(file);
+ }
+ }
+ return list;
+ }
+
+ private static List getAllRegions(File dir, List list) {
+ if (list == null) {
+ list = new ArrayList<>();
+ }
+ for (File file : dir.listFiles()) {
+ if (file.isDirectory()) {
+ getAllRegions(file, list);
+ } else if (file.isFile() && file.getName().endsWith(".mca")) {
+ list.add(file);
+ }
+ }
+ return list;
+ }
+
+ /**
+ * register a new Patch
+ *
+ * @param patch A #Supplier that will instantiate the new Patch Object
+ */
+ public static void registerPatch(Supplier patch) {
+ Patch.getALL().add(patch.get());
+ }
+
+ private static CompoundTag readNbt(File file) throws IOException {
+ try {
+ return NbtIo.readCompressed(file);
+ } catch (ZipException | EOFException e) {
+ return NbtIo.read(file);
+ }
+ }
+
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/datafixer/ForcedLevelPatch.java b/src/main/java/org/betterx/bclib/api/v2/datafixer/ForcedLevelPatch.java
new file mode 100644
index 00000000..0d18a7e0
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/datafixer/ForcedLevelPatch.java
@@ -0,0 +1,57 @@
+package org.betterx.bclib.api.v2.datafixer;
+
+import org.betterx.bclib.interfaces.PatchBiFunction;
+import org.betterx.bclib.interfaces.PatchFunction;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.jetbrains.annotations.NotNull;
+
+
+/**
+ * A Patch for level.dat that is always executed no matter what Patchlevel is set in a world.
+ */
+public abstract class ForcedLevelPatch extends Patch {
+ protected ForcedLevelPatch(@NotNull String modID, String version) {
+ super(modID, version, true);
+ }
+
+ @Override
+ public final Map getIDReplacements() {
+ return new HashMap();
+ }
+
+ @Override
+ public final PatchFunction getWorldDataPatcher() {
+ return null;
+ }
+
+ @Override
+ public final PatchBiFunction getBlockStatePatcher() {
+ return null;
+ }
+
+ @Override
+ public final List getWorldDataIDPaths() {
+ return null;
+ }
+
+ @Override
+ public PatchFunction getLevelDatPatcher() {
+ return this::runLevelDatPatch;
+ }
+
+ /**
+ * Called with the contents of level.dat in {@code root}
+ *
+ * @param root The contents of level.dat
+ * @param profile The active migration profile
+ * @return true, if the run did change the contents of root
+ */
+ abstract protected Boolean runLevelDatPatch(CompoundTag root, MigrationProfile profile);
+}
+
diff --git a/src/main/java/org/betterx/bclib/api/v2/datafixer/MigrationProfile.java b/src/main/java/org/betterx/bclib/api/v2/datafixer/MigrationProfile.java
new file mode 100644
index 00000000..931773ca
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/datafixer/MigrationProfile.java
@@ -0,0 +1,374 @@
+package org.betterx.bclib.api.v2.datafixer;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.interfaces.PatchBiFunction;
+import org.betterx.bclib.interfaces.PatchFunction;
+import org.betterx.worlds.together.util.ModUtil;
+import org.betterx.worlds.together.world.WorldConfig;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtIo;
+import net.minecraft.nbt.Tag;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.*;
+import java.util.stream.Collectors;
+import org.jetbrains.annotations.NotNull;
+
+public class MigrationProfile {
+ final Set mods;
+ final Map idReplacements;
+ final List> levelPatchers;
+ final List> statePatchers;
+ final List worldDataPatchers;
+ final Map> worldDataIDPaths;
+
+ private final CompoundTag config;
+ private CompoundTag level;
+ private File levelBaseDir;
+ private boolean prePatchChangedLevelDat;
+ private boolean didRunPrePatch;
+ private Exception prePatchException;
+
+ MigrationProfile(CompoundTag config, boolean applyAll) {
+ this.config = config;
+
+ this.mods = Collections.unmodifiableSet(Patch.getALL()
+ .stream()
+ .map(p -> p.modID)
+ .collect(Collectors.toSet()));
+
+ HashMap replacements = new HashMap();
+ List> levelPatches = new LinkedList<>();
+ List worldDataPatches = new LinkedList<>();
+ List> statePatches = new LinkedList<>();
+ HashMap> worldDataIDPaths = new HashMap<>();
+ for (String modID : mods) {
+
+ Patch.getALL()
+ .stream()
+ .filter(p -> p.modID.equals(modID))
+ .forEach(patch -> {
+ List paths = patch.getWorldDataIDPaths();
+ if (paths != null) worldDataIDPaths.put(modID, paths);
+
+ if (applyAll || currentPatchLevel(modID) < patch.level || patch.alwaysApply) {
+ replacements.putAll(patch.getIDReplacements());
+ if (patch.getLevelDatPatcher() != null)
+ levelPatches.add(patch.getLevelDatPatcher());
+ if (patch.getWorldDataPatcher() != null)
+ worldDataPatches.add(patch);
+ if (patch.getBlockStatePatcher() != null)
+ statePatches.add(patch.getBlockStatePatcher());
+ DataFixerAPI.LOGGER.info("Applying " + patch);
+ } else {
+ DataFixerAPI.LOGGER.info("Ignoring " + patch);
+ }
+ });
+ }
+
+ this.worldDataIDPaths = Collections.unmodifiableMap(worldDataIDPaths);
+ this.idReplacements = Collections.unmodifiableMap(replacements);
+ this.levelPatchers = Collections.unmodifiableList(levelPatches);
+ this.worldDataPatchers = Collections.unmodifiableList(worldDataPatches);
+ this.statePatchers = Collections.unmodifiableList(statePatches);
+ }
+
+ /**
+ * This method is supposed to be used by developers to apply id-patches to custom nbt structures. It is only
+ * available in Developer-Mode
+ */
+ public static void fixCustomFolder(File dir) {
+ if (!BCLib.isDevEnvironment()) return;
+ MigrationProfile profile = Patch.createMigrationData();
+ List nbts = getAllNbts(dir, null);
+ nbts.parallelStream().forEach((file) -> {
+ DataFixerAPI.LOGGER.info("Loading NBT " + file);
+ try {
+ CompoundTag root = NbtIo.readCompressed(file);
+ boolean[] changed = {false};
+ int spawnerIdx = -1;
+ if (root.contains("palette")) {
+ ListTag items = root.getList("palette", Tag.TAG_COMPOUND);
+ for (int idx = 0; idx < items.size(); idx++) {
+ final CompoundTag tag = (CompoundTag) items.get(idx);
+ if (tag.contains("Name") && tag.getString("Name").equals("minecraft:spawner"))
+ spawnerIdx = idx;
+ if (tag.contains("Name") && (tag.getString("Name").equals("minecraft:") || tag.getString("Name")
+ .equals(""))) {
+ System.out.println("Empty Name");
+ }
+ if (tag.contains("id") && (tag.getString("id").equals("minecraft:") || tag.getString("id")
+ .equals(""))) {
+ System.out.println("Empty ID");
+ }
+ changed[0] |= profile.replaceStringFromIDs(tag, "Name");
+ }
+ }
+
+ if (spawnerIdx >= 0 && root.contains("blocks")) {
+ ListTag items = root.getList("blocks", Tag.TAG_COMPOUND);
+ for (int idx = 0; idx < items.size(); idx++) {
+ final CompoundTag blockTag = (CompoundTag) items.get(idx);
+ if (blockTag.contains("state") && blockTag.getInt("state") == spawnerIdx && blockTag.contains(
+ "nbt")) {
+ CompoundTag nbt = blockTag.getCompound("nbt");
+ if (nbt.contains("SpawnData")) {
+ final CompoundTag entity = nbt.getCompound("SpawnData");
+ if (!entity.contains("entity")) {
+ CompoundTag data = new CompoundTag();
+ data.put("entity", entity);
+ nbt.put("SpawnData", data);
+
+ changed[0] = true;
+ }
+ }
+ if (nbt.contains("SpawnPotentials")) {
+ ListTag pots = nbt.getList("SpawnPotentials", Tag.TAG_COMPOUND);
+ for (Tag potItemIn : pots) {
+ final CompoundTag potItem = (CompoundTag) potItemIn;
+ if (potItem.contains("Weight")) {
+ int weight = potItem.getInt("Weight");
+ potItem.putInt("weight", weight);
+ potItem.remove("Weight");
+
+ changed[0] = true;
+ }
+
+ if (potItem.contains("Entity")) {
+ CompoundTag entity = potItem.getCompound("Entity");
+ CompoundTag data = new CompoundTag();
+ data.put("entity", entity);
+
+ potItem.put("data", data);
+ potItem.remove("Entity");
+
+ changed[0] = true;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (changed[0]) {
+ DataFixerAPI.LOGGER.info("Writing NBT " + file);
+ NbtIo.writeCompressed(root, file);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ });
+ }
+
+ private static List getAllNbts(File dir, List list) {
+ if (list == null) {
+ list = new ArrayList<>();
+ }
+ for (File file : dir.listFiles()) {
+ if (file.isDirectory()) {
+ getAllNbts(file, list);
+ } else if (file.isFile() && file.getName().endsWith(".nbt")) {
+ list.add(file);
+ }
+ }
+ return list;
+ }
+
+ final public CompoundTag getLevelDat(File levelBaseDir) {
+ if (level == null || this.levelBaseDir == null || !this.levelBaseDir.equals(levelBaseDir)) {
+ runPrePatches(levelBaseDir);
+ }
+ return level;
+ }
+
+ final public boolean isLevelDatChanged() {
+ return prePatchChangedLevelDat;
+ }
+
+ final public File getLevelDatFile() {
+ return new File(levelBaseDir, "level.dat");
+ }
+
+ final public Exception getPrePatchException() {
+ return prePatchException;
+ }
+
+
+ final public void runPrePatches(File levelBaseDir) {
+ if (didRunPrePatch) {
+ BCLib.LOGGER.warning("Already did run PrePatches for " + this.levelBaseDir + ".");
+ }
+ BCLib.LOGGER.info("Running Pre Patchers on " + levelBaseDir);
+
+ this.levelBaseDir = levelBaseDir;
+ this.level = null;
+ this.prePatchException = null;
+ didRunPrePatch = true;
+
+ this.prePatchChangedLevelDat = runPreLevelPatches(getLevelDatFile());
+ }
+
+ private boolean runPreLevelPatches(File levelDat) {
+ try {
+ level = NbtIo.readCompressed(levelDat);
+
+ boolean changed = patchLevelDat(level);
+ return changed;
+ } catch (IOException | PatchDidiFailException e) {
+ prePatchException = e;
+ return false;
+ }
+ }
+
+ final public void markApplied() {
+ for (String modID : mods) {
+ DataFixerAPI.LOGGER.info(
+ "Updating Patch-Level for '{}' from {} to {}",
+ modID,
+ ModUtil.convertModVersion(currentPatchLevel(modID)),
+ ModUtil.convertModVersion(Patch.maxPatchLevel(modID))
+ );
+ if (config != null)
+ config.putString(modID, Patch.maxPatchVersion(modID));
+ }
+ }
+
+ public String currentPatchVersion(@NotNull String modID) {
+ if (config == null || !config.contains(modID)) return "0.0.0";
+ return config.getString(modID);
+ }
+
+ public int currentPatchLevel(@NotNull String modID) {
+ return ModUtil.convertModVersion(currentPatchVersion(modID));
+ }
+
+ public boolean hasAnyFixes() {
+ boolean hasLevelDatPatches;
+ if (didRunPrePatch != false) {
+ hasLevelDatPatches = prePatchChangedLevelDat;
+ } else {
+ hasLevelDatPatches = levelPatchers.size() > 0;
+ }
+
+ return idReplacements.size() > 0 || hasLevelDatPatches || worldDataPatchers.size() > 0;
+ }
+
+ public String replaceStringFromIDs(@NotNull String val) {
+ final String replace = idReplacements.get(val);
+ return replace;
+ }
+
+ public boolean replaceStringFromIDs(@NotNull CompoundTag tag, @NotNull String key) {
+ if (!tag.contains(key)) return false;
+
+ final String val = tag.getString(key);
+ final String replace = idReplacements.get(val);
+
+ if (replace != null) {
+ DataFixerAPI.LOGGER.warning("Replacing ID '{}' with '{}'.", val, replace);
+ tag.putString(key, replace);
+ return true;
+ }
+
+ return false;
+ }
+
+ private boolean replaceIDatPath(@NotNull ListTag list, @NotNull String[] parts, int level) {
+ boolean[] changed = {false};
+ if (level == parts.length - 1) {
+ DataFixerAPI.fixItemArrayWithID(list, changed, this, true);
+ } else {
+ list.forEach(inTag -> changed[0] |= replaceIDatPath((CompoundTag) inTag, parts, level + 1));
+ }
+ return changed[0];
+ }
+
+ private boolean replaceIDatPath(@NotNull CompoundTag tag, @NotNull String[] parts, int level) {
+ boolean changed = false;
+ for (int i = level; i < parts.length - 1; i++) {
+ final String part = parts[i];
+ if (tag.contains(part)) {
+ final byte type = tag.getTagType(part);
+ if (type == Tag.TAG_LIST) {
+ ListTag list = tag.getList(part, Tag.TAG_COMPOUND);
+ return replaceIDatPath(list, parts, i);
+ } else if (type == Tag.TAG_COMPOUND) {
+ tag = tag.getCompound(part);
+ }
+ } else {
+ return false;
+ }
+ }
+
+ if (tag != null && parts.length > 0) {
+ final String key = parts[parts.length - 1];
+ final byte type = tag.getTagType(key);
+ if (type == Tag.TAG_LIST) {
+ final ListTag list = tag.getList(key, Tag.TAG_COMPOUND);
+ final boolean[] _changed = {false};
+ if (list.size() == 0) {
+ _changed[0] = DataFixerAPI.fixStringIDList(tag, key, this);
+ } else {
+ DataFixerAPI.fixItemArrayWithID(list, _changed, this, true);
+ }
+ return _changed[0];
+ } else if (type == Tag.TAG_STRING) {
+ return replaceStringFromIDs(tag, key);
+ } else if (type == Tag.TAG_COMPOUND) {
+ final CompoundTag cTag = tag.getCompound(key);
+ boolean[] _changed = {false};
+ DataFixerAPI.fixID(cTag, _changed, this, true);
+ return _changed[0];
+ }
+ }
+
+
+ return false;
+ }
+
+ public boolean replaceIDatPath(@NotNull CompoundTag root, @NotNull String path) {
+ String[] parts = path.split("\\.");
+ return replaceIDatPath(root, parts, 0);
+ }
+
+ public boolean patchLevelDat(@NotNull CompoundTag level) throws PatchDidiFailException {
+ boolean changed = false;
+ for (PatchFunction f : levelPatchers) {
+ changed |= f.apply(level, this);
+ }
+ return changed;
+ }
+
+ public void patchWorldData() throws PatchDidiFailException {
+ for (Patch patch : worldDataPatchers) {
+ CompoundTag root = WorldConfig.getRootTag(patch.modID);
+ boolean changed = patch.getWorldDataPatcher().apply(root, this);
+ if (changed) {
+ WorldConfig.saveFile(patch.modID);
+ }
+ }
+
+ for (Map.Entry> entry : worldDataIDPaths.entrySet()) {
+ CompoundTag root = WorldConfig.getRootTag(entry.getKey());
+ boolean[] changed = {false};
+ entry.getValue().forEach(path -> {
+ changed[0] |= replaceIDatPath(root, path);
+ });
+
+ if (changed[0]) {
+ WorldConfig.saveFile(entry.getKey());
+ }
+ }
+ }
+
+ public boolean patchBlockState(ListTag palette, ListTag states) throws PatchDidiFailException {
+ boolean changed = false;
+ for (PatchBiFunction f : statePatchers) {
+ changed |= f.apply(palette, states, this);
+ }
+ return changed;
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/datafixer/Patch.java b/src/main/java/org/betterx/bclib/api/v2/datafixer/Patch.java
new file mode 100644
index 00000000..cadf2ec3
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/datafixer/Patch.java
@@ -0,0 +1,237 @@
+package org.betterx.bclib.api.v2.datafixer;
+
+import org.betterx.bclib.interfaces.PatchBiFunction;
+import org.betterx.bclib.interfaces.PatchFunction;
+import org.betterx.worlds.together.util.ModUtil;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.jetbrains.annotations.NotNull;
+
+public abstract class Patch {
+ private static final List ALL = new ArrayList<>(10);
+
+ /**
+ * The Patch-Level derived from {@link #version}
+ */
+ public final int level;
+
+ /**
+ * The Patch-Version string
+ */
+ public final String version;
+
+ /**
+ * The Mod-ID that registered this Patch
+ */
+
+ @NotNull
+ public final String modID;
+
+ /**
+ * This Mod is tested for each level start
+ */
+ public final boolean alwaysApply;
+
+ static List getALL() {
+ return ALL;
+ }
+
+ /**
+ * Returns the highest Patch-Version that is available for the given mod. If no patches were
+ * registerd for the mod, this will return 0.0.0
+ *
+ * @param modID The ID of the mod you want to query
+ * @return The highest Patch-Version that was found
+ */
+ public static String maxPatchVersion(@NotNull String modID) {
+ return ALL.stream().filter(p -> p.modID.equals(modID)).map(p -> p.version).reduce((p, c) -> c).orElse("0.0.0");
+ }
+
+ /**
+ * Returns the highest patch-level that is available for the given mod. If no patches were
+ * registerd for the mod, this will return 0
+ *
+ * @param modID The ID of the mod you want to query
+ * @return The highest Patch-Level that was found
+ */
+ public static int maxPatchLevel(@NotNull String modID) {
+ return ALL.stream().filter(p -> p.modID.equals(modID)).mapToInt(p -> p.level).max().orElse(0);
+ }
+
+ /**
+ * Called by inheriting classes.
+ *
+ * Performs some sanity checks on the values and might throw a #RuntimeException if any
+ * inconsistencies are found.
+ *
+ * @param modID The ID of the Mod you want to register a patch for. This should be your
+ * ModID only. The ModID can not be {@code null} or an empty String.
+ * @param version The mod-version that introduces the patch. This needs Semantic-Version String
+ * like x.x.x. Developers are responsible for registering their patches in the correct
+ * order (with increasing versions). You are not allowed to register a new
+ * Patch with a version lower or equal than
+ * {@link Patch#maxPatchVersion(String)}
+ */
+ protected Patch(@NotNull String modID, String version) {
+ this(modID, version, false);
+ }
+
+ /**
+ * Internal Constructor used to create patches that can allways run (no matter what patchlevel a level has)
+ *
+ * @param modID The ID of the Mod
+ * @param version The mod-version that introduces the patch. When {@Code runAllways} is set, this version will
+ * determine the patchlevel that is written to the level
+ * @param alwaysApply When true, this patch is always active, no matter the patchlevel of the world.
+ * This should be used sparingly and just for patches that apply to level.dat (as they only take
+ * effect when changes are detected). Use {@link ForcedLevelPatch} to instatiate.
+ */
+ Patch(@NotNull String modID, String version, boolean alwaysApply) {
+ //Patchlevels need to be unique and registered in ascending order
+ if (modID == null || modID.isEmpty()) {
+ throw new RuntimeException("[INTERNAL ERROR] Patches need a valid modID!");
+ }
+
+ if (version == null || version.isEmpty()) {
+ throw new RuntimeException("Invalid Mod-Version");
+ }
+
+ this.version = version;
+ this.alwaysApply = alwaysApply;
+ this.level = ModUtil.convertModVersion(version);
+ if (!ALL.stream().filter(p -> p.modID.equals(modID)).noneMatch(p -> p.level >= this.level) || this.level <= 0) {
+ throw new RuntimeException(
+ "[INTERNAL ERROR] Patch-levels need to be created in ascending order beginning with 1.");
+ }
+
+ this.modID = modID;
+ }
+
+ @Override
+ public String toString() {
+ return "Patch{" + modID + ':' + version + ':' + level + '}';
+ }
+
+
+ /**
+ * Return block data fixes. Fixes will be applied on world load if current patch-level for
+ * the linked mod is lower than the {@link #level}.
+ *
+ * The default implementation of this method returns an empty map.
+ *
+ * @return The returned Map should contain the replacements. All occurences of the
+ * {@code KeySet} are replaced with the associated value.
+ */
+ public Map getIDReplacements() {
+ return new HashMap();
+ }
+
+ /**
+ * Return a {@link PatchFunction} that is called with the content of level.dat.
+ *
+ * The function needs to return {@code true}, if changes were made to the data.
+ * If an error occurs, the method should throw a {@link PatchDidiFailException}
+ *
+ * The default implementation of this method returns null.
+ *
+ * @return {@code true} if changes were applied and we need to save the data
+ */
+ public PatchFunction getLevelDatPatcher() {
+ return null;
+ }
+
+ /**
+ * Return a {@link PatchFunction} that is called with the content from the
+ * {@link org.betterx.worlds.together.world.WorldConfig} for this Mod.
+ * The function needs to return {@code true}, if changes were made to the data.
+ * If an error occurs, the method should throw a {@link PatchDidiFailException}
+ *
+ * The default implementation of this method returns null.
+ *
+ * @return {@code true} if changes were applied and we need to save the data
+ */
+ public PatchFunction getWorldDataPatcher() {
+ return null;
+ }
+
+ /**
+ * Return a {@link PatchBiFunction} that is called with pallette and blockstate of
+ * each chunk in every region. This method is called AFTER all ID replacements
+ * from {@link #getIDReplacements()} were applied to the pallete.
+ *
+ * The first parameter is the palette and the second is the blockstate.
+ *
+ * The function needs to return {@code true}, if changes were made to the data.
+ * If an error occurs, the method should throw a {@link PatchDidiFailException}
+ *
+ * The default implementation of this method returns null.
+ *
+ * @return {@code true} if changes were applied and we need to save the data
+ */
+ public PatchBiFunction getBlockStatePatcher() {
+ return null;
+ }
+
+ /**
+ * Generates ready to use data for all currently registered patches. The list of
+ * patches is selected by the current patch-level of the world.
+ *
+ * A {@link #Patch} with a given {@link #level} is only included if the patch-level of the
+ * world is less
+ *
+ * @param config The current patch-level configuration*
+ * @return a new {@link MigrationProfile} Object.
+ */
+ static MigrationProfile createMigrationData(CompoundTag config) {
+ return new MigrationProfile(config, false);
+ }
+
+ /**
+ * This method is supposed to be used by developers to apply id-patches to custom nbt structures. It is only
+ * available in Developer-Mode
+ */
+ static MigrationProfile createMigrationData() {
+ return new MigrationProfile(null, true);
+ }
+
+ /**
+ * Returns a list of paths where your mod stores IDs in your {@link org.betterx.worlds.together.world.WorldConfig}-File.
+ *
+ * {@link DataFixerAPI} will use information from the latest patch that returns a non-null-result. This list is used
+ * to automatically fix changed IDs from all active patches (see {@link Patch#getIDReplacements()}
+ *
+ * The end of the path can either be a {@link net.minecraft.nbt.StringTag}, a {@link net.minecraft.nbt.ListTag} or
+ * a {@link CompoundTag}. If the Path contains a non-leaf {@link net.minecraft.nbt.ListTag}, all members of that
+ * list will be processed. For example:
+ *
+ * - global +
+ * | - key (String)
+ * | - items (List) +
+ * | - { id (String) }
+ * | - { id (String) }
+ *
+ * The path global.items.id will fix all id-entries in the items-list, while the path
+ * global.key will only fix the key-entry.
+ *
+ * if the leaf-entry (= the last part of the path, which would be items in global.items) is a
+ * {@link CompoundTag}, the system will fix any id entry. If the {@link CompoundTag} contains an item
+ * or tag.BlockEntityTag entry, the system will recursivley continue with those. If an items
+ * or inventory-{@link net.minecraft.nbt.ListTag} was found, the system will continue recursivley with
+ * every item of that list.
+ *
+ * if the leaf-entry is a {@link net.minecraft.nbt.ListTag}, it is handle the same as a child items entry
+ * of a {@link CompoundTag}.
+ *
+ * @return {@code null} if nothing changes or a list of Paths in your {@link org.betterx.worlds.together.world.WorldConfig}-File.
+ * Paths are dot-seperated (see {@link org.betterx.worlds.together.world.WorldConfig#getCompoundTag(String, String)}).
+ */
+ public List getWorldDataIDPaths() {
+ return null;
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/datafixer/PatchDidiFailException.java b/src/main/java/org/betterx/bclib/api/v2/datafixer/PatchDidiFailException.java
new file mode 100644
index 00000000..053d29fe
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/datafixer/PatchDidiFailException.java
@@ -0,0 +1,11 @@
+package org.betterx.bclib.api.v2.datafixer;
+
+public class PatchDidiFailException extends Exception {
+ public PatchDidiFailException() {
+ super();
+ }
+
+ public PatchDidiFailException(Exception e) {
+ super(e);
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/levelgen/LevelGenEvents.java b/src/main/java/org/betterx/bclib/api/v2/levelgen/LevelGenEvents.java
new file mode 100644
index 00000000..2b70401c
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/levelgen/LevelGenEvents.java
@@ -0,0 +1,97 @@
+package org.betterx.bclib.api.v2.levelgen;
+
+import org.betterx.bclib.BCLib;
+import org.betterx.bclib.api.v2.LifeCycleAPI;
+import org.betterx.bclib.api.v2.dataexchange.DataExchangeAPI;
+import org.betterx.bclib.api.v2.datafixer.DataFixerAPI;
+import org.betterx.bclib.api.v2.levelgen.biomes.InternalBiomeAPI;
+import org.betterx.bclib.api.v2.poi.PoiManager;
+import org.betterx.worlds.together.tag.v3.TagManager;
+import org.betterx.worlds.together.world.WorldConfig;
+import org.betterx.worlds.together.world.event.WorldEvents;
+
+import net.minecraft.core.Registry;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.tags.TagLoader;
+import net.minecraft.world.level.dimension.LevelStem;
+import net.minecraft.world.level.storage.LevelResource;
+import net.minecraft.world.level.storage.LevelStorageSource;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+public class LevelGenEvents {
+ public static void setupWorld() {
+ InternalBiomeAPI.prepareNewLevel();
+ DataExchangeAPI.prepareServerside();
+ }
+
+ public static void register() {
+ WorldEvents.BEFORE_WORLD_LOAD.on(LevelGenEvents::beforeWorldLoad);
+
+ WorldEvents.ON_WORLD_LOAD.on(LevelGenEvents::onWorldLoad);
+ WorldEvents.WORLD_REGISTRY_READY.on(LevelGenEvents::worldRegistryReady);
+ WorldEvents.ON_FINALIZE_LEVEL_STEM.on(LevelGenEvents::finalizeStem);
+ WorldEvents.ON_FINALIZED_WORLD_LOAD.on(LevelGenEvents::finalizedWorldLoad);
+
+ WorldEvents.PATCH_WORLD.on(LevelGenEvents::patchExistingWorld);
+
+ WorldEvents.BEFORE_ADDING_TAGS.on(LevelGenEvents::applyBiomeTags);
+ }
+
+
+ private static void applyBiomeTags(
+ String directory,
+ Map> tagsMap
+ ) {
+ if (directory.equals(TagManager.BIOMES.directory)) {
+ InternalBiomeAPI._runBiomeTagAdders();
+ }
+ }
+
+
+ private static boolean patchExistingWorld(
+ LevelStorageSource.LevelStorageAccess storageAccess,
+ Consumer allDone
+ ) {
+ final Path dataPath = storageAccess.getLevelPath(LevelResource.ROOT).resolve("data");
+ WorldConfig.setDataDir(dataPath.toFile());
+ return DataFixerAPI.fixData(storageAccess, allDone != null && BCLib.isClient(), allDone);
+ }
+
+ private static void worldRegistryReady(RegistryAccess a) {
+ InternalBiomeAPI.initRegistry(a);
+ }
+
+ private static void beforeWorldLoad(
+ LevelStorageSource.LevelStorageAccess storageAccess,
+ boolean isNewWorld,
+ boolean isServer
+ ) {
+ setupWorld();
+ if (isNewWorld) {
+ WorldConfig.saveFile(BCLib.MOD_ID);
+ DataFixerAPI.initializePatchData();
+ }
+ }
+
+ private static void onWorldLoad() {
+ LifeCycleAPI._runBeforeLevelLoad();
+ }
+
+ private static void finalizeStem(
+ Registry dimensionRegistry,
+ ResourceKey dimension,
+ LevelStem levelStem
+ ) {
+ InternalBiomeAPI.applyModifications(levelStem.generator().getBiomeSource(), dimension);
+ }
+
+ private static void finalizedWorldLoad(Registry dimensionRegistry) {
+ PoiManager.updateStates();
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/levelgen/biomes/BCLBiome.java b/src/main/java/org/betterx/bclib/api/v2/levelgen/biomes/BCLBiome.java
new file mode 100644
index 00000000..3c2ce01e
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/levelgen/biomes/BCLBiome.java
@@ -0,0 +1,506 @@
+package org.betterx.bclib.api.v2.levelgen.biomes;
+
+import org.betterx.bclib.util.WeightedList;
+import org.betterx.worlds.together.world.event.WorldBootstrap;
+
+import com.mojang.datafixers.Products;
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.core.Registry;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.util.KeyDispatchDataCodec;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraft.world.level.biome.Biomes;
+import net.minecraft.world.level.biome.Climate;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Random;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Nullable;
+
+
+/**
+ * Stores additional Data for a {@link Biome}. Instances of {@link BCLBiome} are linked to
+ * Biomes using a {@link ResourceKey}. The data is registerd and Stored in
+ * {@link BCLBiomeRegistry#BCL_BIOMES_REGISTRY} registries, allowing them to be overriden by Datapacks.
+ *
+ * As such, if you extend BCLBiome with custom types, especially if those new type have a custom state,
+ * you need to provide a Codec for your class using
+ * {@link BCLBiomeRegistry#registerBiomeCodec(ResourceLocation, KeyDispatchDataCodec)}.
+ *
+ * You may use {@link BCLBiome#codecWithSettings(RecordCodecBuilder.Instance)} to create a Codec that includes
+ * all default settings for {@link BCLBiome} as well as additional Data for your specific subclass.
+ */
+public class BCLBiome implements BiomeData {
+ public static final Codec CODEC = RecordCodecBuilder.create(instance -> codecWithSettings(instance).apply(
+ instance,
+ BCLBiome::new
+ ));
+ public static final KeyDispatchDataCodec extends BCLBiome> KEY_CODEC = KeyDispatchDataCodec.of(CODEC);
+
+ public KeyDispatchDataCodec extends BCLBiome> codec() {
+ return KEY_CODEC;
+ }
+
+ private static class CodecAttributes {
+ public RecordCodecBuilder t0 = Codec.FLOAT.fieldOf("terrainHeight")
+ .orElse(0.1f)
+ .forGetter((T o1) -> o1.settings.terrainHeight);
+
+ public RecordCodecBuilder t1 = Codec.FLOAT.fieldOf("fogDensity")
+ .orElse(1.0f)
+ .forGetter((T o1) -> o1.settings.fogDensity);
+ public RecordCodecBuilder t2 = Codec.FLOAT.fieldOf("genChance")
+ .orElse(1.0f)
+ .forGetter((T o1) -> o1.settings.genChance);
+ public RecordCodecBuilder t3 = Codec.INT.fieldOf("edgeSize")
+ .orElse(0)
+ .forGetter((T o1) -> o1.settings.edgeSize);
+ public RecordCodecBuilder t4 = Codec.BOOL.fieldOf("vertical")
+ .orElse(false)
+ .forGetter((T o1) -> o1.settings.vertical);
+ public RecordCodecBuilder> t5 =
+ ResourceLocation.CODEC
+ .optionalFieldOf("edge")
+ .orElse(Optional.empty())
+ .forGetter((T o1) -> ((BCLBiome) o1).edge == null
+ ? Optional.empty()
+ : Optional.of(((BCLBiome) o1).edge));
+ public RecordCodecBuilder t6 =
+ ResourceLocation.CODEC.fieldOf("biome")
+ .forGetter((T o) -> ((BCLBiome) o).biomeID);
+ public RecordCodecBuilder>> t7 =
+ Climate.ParameterPoint.CODEC.listOf()
+ .optionalFieldOf("parameter_points")
+ .orElse(Optional.of(List.of()))
+ .forGetter((T o) ->
+ o.parameterPoints == null || o.parameterPoints.isEmpty()
+ ? Optional.empty()
+ : Optional.of(o.parameterPoints));
+
+ public RecordCodecBuilder> t8 =
+ ResourceLocation.CODEC.optionalFieldOf("parent")
+ .orElse(Optional.empty())
+ .forGetter(
+ (T o1) ->
+ ((BCLBiome) o1).biomeParent == null
+ ? Optional.empty()
+ : Optional.of(
+ ((BCLBiome) o1).biomeParent));
+ public RecordCodecBuilder> t10 =
+ Codec.STRING.optionalFieldOf("intended_for")
+ .orElse(Optional.of(BiomeAPI.BiomeType.NONE.getName()))
+ .forGetter((T o) ->
+ ((BCLBiome) o).intendedType == null
+ ? Optional.empty()
+ : Optional.of(((BCLBiome) o).intendedType.getName()));
+ }
+
+ public static Products.P11, Float, Float, Float, Integer, Boolean, Optional, ResourceLocation, Optional>, Optional, Optional, P11> codecWithSettings(
+ RecordCodecBuilder.Instance instance,
+ final RecordCodecBuilder p11
+ ) {
+ CodecAttributes a = new CodecAttributes<>();
+ return instance.group(a.t0, a.t1, a.t2, a.t3, a.t4, a.t5, a.t6, a.t7, a.t8, a.t10, p11);
+ }
+
+ public static Products.P12, Float, Float, Float, Integer, Boolean, Optional, ResourceLocation, Optional>, Optional, Optional, P11, P12> codecWithSettings(
+ RecordCodecBuilder.Instance instance,
+ final RecordCodecBuilder p11,
+ final RecordCodecBuilder p12
+ ) {
+ CodecAttributes a = new CodecAttributes<>();
+ return instance.group(a.t0, a.t1, a.t2, a.t3, a.t4, a.t5, a.t6, a.t7, a.t8, a.t10, p11, p12);
+ }
+
+ public static Products.P14, Float, Float, Float, Integer, Boolean, Optional, ResourceLocation, Optional>, Optional, Optional, P11, P12, P13, P14> codecWithSettings(
+ RecordCodecBuilder.Instance instance,
+ final RecordCodecBuilder p11,
+ final RecordCodecBuilder p12,
+ final RecordCodecBuilder p13,
+ final RecordCodecBuilder p14
+ ) {
+ CodecAttributes a = new CodecAttributes<>();
+ return instance.group(a.t0, a.t1, a.t2, a.t3, a.t4, a.t5, a.t6, a.t7, a.t8, a.t10, p11, p12, p13, p14);
+ }
+
+ public static Products.P13, Float, Float, Float, Integer, Boolean, Optional, ResourceLocation, Optional>, Optional, Optional, P11, P12, P13> codecWithSettings(
+ RecordCodecBuilder.Instance instance,
+ final RecordCodecBuilder p11,
+ final RecordCodecBuilder p12,
+ final RecordCodecBuilder p13
+ ) {
+ CodecAttributes a = new CodecAttributes<>();
+ return instance.group(a.t0, a.t1, a.t2, a.t3, a.t4, a.t5, a.t6, a.t7, a.t8, a.t10, p11, p12, p13);
+ }
+
+ public static Products.P10, Float, Float, Float, Integer, Boolean, Optional, ResourceLocation, Optional>, Optional, Optional> codecWithSettings(
+ RecordCodecBuilder.Instance instance
+ ) {
+ CodecAttributes a = new CodecAttributes<>();
+ return instance.group(a.t0, a.t1, a.t2, a.t3, a.t4, a.t5, a.t6, a.t7, a.t8, a.t10);
+ }
+
+ public final BCLBiomeSettings settings;
+ private final Map customData = Maps.newHashMap();
+ private final ResourceLocation biomeID;
+ private final ResourceKey biomeKey;
+
+ protected final List parameterPoints = Lists.newArrayList();
+
+ private ResourceLocation biomeParent;
+ private ResourceLocation edge;
+
+ private BiomeAPI.BiomeType intendedType = BiomeAPI.BiomeType.NONE;
+
+ protected BCLBiome(
+ float terrainHeight,
+ float fogDensity,
+ float genChance,
+ int edgeSize,
+ boolean vertical,
+ Optional edge,
+ ResourceLocation biomeID,
+ Optional> parameterPoints,
+ Optional biomeParent,
+ Optional intendedType
+ ) {
+ this.settings = new BCLBiomeSettings(
+ terrainHeight,
+ fogDensity,
+ genChance,
+ edgeSize,
+ vertical
+ );
+ this.edge = edge.orElse(null);
+ this.biomeID = biomeID;
+ this.biomeKey = ResourceKey.create(Registries.BIOME, biomeID);
+ this.biomeParent = biomeParent.orElse(null);
+ if (parameterPoints.isPresent()) this.parameterPoints.addAll(parameterPoints.get());
+ this.setIntendedType(intendedType.map(t -> BiomeAPI.BiomeType.create(t)).orElse(BiomeAPI.BiomeType.NONE));
+ }
+
+ /**
+ * Create wrapper for existing biome using its {@link ResourceLocation} identifier.
+ *
+ * @param biomeKey {@link ResourceKey} for the {@link Biome}.
+ */
+ protected BCLBiome(ResourceKey biomeKey) {
+ this(biomeKey.location());
+ }
+
+ /**
+ * Create wrapper for existing biome using its {@link ResourceLocation} identifier.
+ *
+ * @param biomeID {@link ResourceLocation} biome ID.
+ */
+ protected BCLBiome(ResourceLocation biomeID) {
+ this(ResourceKey.create(Registries.BIOME, biomeID), null);
+ }
+
+ /**
+ * Create wrapper for existing biome.
+ *
+ * @param biomeID Teh ResoureLocation for this Biome
+ */
+ @ApiStatus.Internal
+ public BCLBiome(ResourceLocation biomeID, BiomeAPI.BiomeType type) {
+ this(ResourceKey.create(Registries.BIOME, biomeID), (BCLBiomeSettings) null);
+ setIntendedType(type);
+ }
+
+ /**
+ * Create a new Biome
+ *
+ * @param biomeKey {@link ResourceKey} of the wrapped Biome
+ * @param defaults The Settings for this Biome or null if you want to apply the defaults
+ */
+ protected BCLBiome(ResourceKey biomeKey, BCLBiomeSettings defaults) {
+ this.settings = defaults == null ? new BCLBiomeSettings() : defaults;
+ this.biomeID = biomeKey.location();
+ this.biomeKey = biomeKey;
+ }
+
+ /**
+ * Create a new Biome
+ *
+ * @param biomeID {@link ResourceLocation} of the wrapped Biome
+ * @param defaults The Settings for this Biome or null if you want to apply the defaults
+ */
+ protected BCLBiome(ResourceLocation biomeID, BCLBiomeSettings defaults) {
+ this.settings = defaults == null ? new BCLBiomeSettings() : defaults;
+ this.biomeID = biomeID;
+ this.biomeKey = ResourceKey.create(Registries.BIOME, biomeID);
+ }
+
+ /**
+ * Changes the intended Type for this Biome
+ *
+ * @param type the new type
+ * @return the same instance
+ */
+ protected BCLBiome setIntendedType(BiomeAPI.BiomeType type) {
+ return _setIntendedType(type);
+ }
+
+ BCLBiome _setIntendedType(BiomeAPI.BiomeType type) {
+ this.intendedType = type;
+ return this;
+ }
+
+ public BiomeAPI.BiomeType getIntendedType() {
+ return this.intendedType;
+ }
+
+ /**
+ * Get current biome edge.
+ *
+ * @return {@link BCLBiome} edge.
+ */
+ @Nullable
+ public BCLBiome getEdge() {
+ return BiomeAPI.getBiome(edge);
+ }
+
+ public boolean hasEdge() {
+ return !BCLBiomeRegistry.isEmptyBiome(edge);
+ }
+
+
+ BCLBiome _setEdge(BCLBiome edge) {
+ if (edge != null) {
+ this.edge = edge.biomeID;
+ edge.biomeParent = this.biomeID;
+ } else {
+ this.edge = null;
+ }
+ return this;
+ }
+
+ /**
+ * Set biome edge for this biome instance. If there is already an edge, the
+ * biome is added as subBiome to the current edge-biome
+ *
+ * @param newEdge The new edge
+ * @return same {@link BCLBiome}.
+ */
+ public BCLBiome addEdge(BCLBiome newEdge) {
+ if (this.edge != null) {
+ newEdge.biomeParent = this.edge;
+ } else {
+ this._setEdge(newEdge);
+ }
+ return this;
+ }
+
+ /**
+ * Adds sub-biome into this biome instance. Biome chance will be interpreted as a sub-biome generation chance.
+ * Biome itself has chance 1.0 compared to all its sub-biomes.
+ *
+ * @param biome {@link Random} to be added.
+ * @return same {@link BCLBiome}.
+ */
+ public BCLBiome addSubBiome(BCLBiome biome) {
+ biome.biomeParent = this.biomeID;
+ return this;
+ }
+
+ private WeightedList getSubBiomes() {
+ RegistryAccess acc = WorldBootstrap.getLastRegistryAccess();
+ WeightedList subbiomes = new WeightedList<>();
+ subbiomes.add(this, 1.0f);
+ if (acc == null) return subbiomes;
+
+ Registry reg = acc.registry(BCLBiomeRegistry.BCL_BIOMES_REGISTRY).orElse(null);
+ if (reg == null) reg = BCLBiomeRegistry.BUILTIN_BCL_BIOMES;
+
+ for (Map.Entry, BCLBiome> entry : reg.entrySet()) {
+ BCLBiome b = entry.getValue();
+ if (
+ this.biomeID.equals(entry.getValue().biomeParent)
+ && !entry.getValue().isEdgeBiome()
+ ) {
+ subbiomes.add(b, b.settings.genChance);
+ }
+ }
+
+ return subbiomes;
+ }
+
+ public void forEachSubBiome(BiConsumer consumer) {
+ final WeightedList subbiomes = getSubBiomes();
+ for (int i = 0; i < subbiomes.size(); i++)
+ consumer.accept(subbiomes.get(i), subbiomes.getWeight(i));
+ }
+
+ /**
+ * Getter for parent {@link BCLBiome} or null if there are no parent biome.
+ *
+ * @return {@link BCLBiome} or null.
+ */
+ @Nullable
+ public BCLBiome getParentBiome() {
+ return BiomeAPI.getBiome(this.biomeParent);
+ }
+
+ public boolean hasParentBiome() {
+ return !BCLBiomeRegistry.isEmptyBiome(biomeParent);
+ }
+
+ /**
+ * Compares biome instances (directly) and their parents. Used in custom world generator.
+ *
+ * @param biome {@link BCLBiome}
+ * @return true if biome or its parent is same.
+ */
+ public boolean isSame(BCLBiome biome) {
+ return biome == this || (biome.biomeParent != null && biome.biomeParent.equals(this.biomeID));
+ }
+
+ /**
+ * Getter for biome identifier.
+ *
+ * @return {@link ResourceLocation}
+ */
+ public ResourceLocation getID() {
+ return biomeID;
+ }
+
+
+ /**
+ * Getter for biomeKey
+ *
+ * @return {@link ResourceKey}.
+ */
+ public ResourceKey getBiomeKey() {
+ return biomeKey;
+ }
+
+ public ResourceKey getBCLBiomeKey() {
+ return (ResourceKey) (Object) ResourceKey.create(BCLBiomeRegistry.BCL_BIOMES_REGISTRY, biomeID);
+ }
+
+ /**
+ * For internal use from BiomeAPI only
+ */
+ void afterRegistration() {
+
+ }
+
+ public boolean is(ResourceKey key) {
+ return biomeID.equals(key.location());
+ }
+
+ public boolean is(ResourceLocation loc) {
+ return biomeID.equals(loc);
+ }
+
+ public boolean is(BCLBiome biome) {
+ if (biome == null) return false;
+ return biomeID.equals(biome.biomeID);
+ }
+
+ public boolean equals(ResourceKey key) {
+ return is(key);
+ }
+
+ public boolean equals(ResourceLocation loc) {
+ return is(loc);
+ }
+
+ public boolean equals(BCLBiome biome) {
+ return is(biome);
+ }
+
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (obj instanceof BCLBiome biome) {
+ return is(biome);
+ }
+ if (obj instanceof ResourceKey key) {
+ return is(key);
+ }
+ if (obj instanceof ResourceLocation loc) {
+ return is(loc);
+ }
+ return super.equals(obj);
+ }
+
+ @Override
+ public int hashCode() {
+ return biomeID.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return biomeID.toString();
+ }
+
+
+ /**
+ * Adds structures to this biome. For internal use only.
+ * Used inside {@link BCLBiomeBuilder}.
+ */
+ void addClimateParameters(List params) {
+ this.parameterPoints.addAll(params);
+ }
+
+ public void forEachClimateParameter(Consumer consumer) {
+ this.parameterPoints.forEach(consumer);
+ }
+
+ /**
+ * Returns the group used in the config Files for this biome
+ *
+ * Example: {@code Configs.BIOMES_CONFIG.getFloat(configGroup(), "generation_chance", 1.0);}
+ *
+ * @return The group name
+ */
+ public String configGroup() {
+ return biomeID.getNamespace() + "." + biomeID.getPath();
+ }
+
+ private final boolean didLoadConfig = false;
+
+ public boolean isEdgeBiome() {
+ final BCLBiome parent = getParentBiome();
+ if (parent == null) return false;
+ return this.biomeID.equals(parent.edge);
+ }
+
+ boolean allowFabricRegistration() {
+ return !isEdgeBiome();
+ }
+
+ @ApiStatus.Internal
+ private Biome biomeToRegister;
+
+ @ApiStatus.Internal
+ void _setBiomeToRegister(Biome b) {
+ this.biomeToRegister = b;
+ }
+
+ @ApiStatus.Internal
+ Biome _getBiomeToRegister() {
+ return this.biomeToRegister;
+ }
+
+ @ApiStatus.Internal
+ boolean _hasBiomeToRegister() {
+ return this.biomeToRegister != null;
+ }
+}
diff --git a/src/main/java/org/betterx/bclib/api/v2/levelgen/biomes/BCLBiomeBuilder.java b/src/main/java/org/betterx/bclib/api/v2/levelgen/biomes/BCLBiomeBuilder.java
new file mode 100644
index 00000000..bb3385f5
--- /dev/null
+++ b/src/main/java/org/betterx/bclib/api/v2/levelgen/biomes/BCLBiomeBuilder.java
@@ -0,0 +1,994 @@
+package org.betterx.bclib.api.v2.levelgen.biomes;
+
+import org.betterx.bclib.api.v2.levelgen.structures.BCLStructure;
+import org.betterx.bclib.api.v2.levelgen.surface.SurfaceRuleBuilder;
+import org.betterx.bclib.api.v3.levelgen.features.BCLFeature;
+import org.betterx.bclib.entity.BCLEntityWrapper;
+import org.betterx.bclib.mixin.common.BiomeGenerationSettingsAccessor;
+import org.betterx.bclib.util.CollectionsUtil;
+import org.betterx.bclib.util.Pair;
+import org.betterx.ui.ColorUtil;
+import org.betterx.worlds.together.tag.v3.TagManager;
+
+import net.minecraft.core.Holder;
+import net.minecraft.core.HolderSet;
+import net.minecraft.core.particles.ParticleOptions;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.data.worldgen.BiomeDefaultFeatures;
+import net.minecraft.data.worldgen.BootstapContext;
+import net.minecraft.data.worldgen.biome.OverworldBiomes;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.sounds.Music;
+import net.minecraft.sounds.SoundEvent;
+import net.minecraft.tags.TagKey;
+import net.minecraft.util.Mth;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.entity.Mob;
+import net.minecraft.world.level.biome.*;
+import net.minecraft.world.level.biome.Biome.BiomeBuilder;
+import net.minecraft.world.level.biome.Biome.Precipitation;
+import net.minecraft.world.level.biome.MobSpawnSettings.SpawnerData;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.levelgen.GenerationStep;
+import net.minecraft.world.level.levelgen.GenerationStep.Decoration;
+import net.minecraft.world.level.levelgen.Noises;
+import net.minecraft.world.level.levelgen.SurfaceRules;
+import net.minecraft.world.level.levelgen.carver.ConfiguredWorldCarver;
+import net.minecraft.world.level.levelgen.placement.PlacedFeature;
+
+import net.fabricmc.fabric.api.biome.v1.BiomeModifications;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+
+import java.util.*;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+public class BCLBiomeBuilder {
+ static final ConcurrentLinkedQueue> UNBOUND_BIOMES = new ConcurrentLinkedQueue<>();
+
+ public static int calculateSkyColor(float temperature) {
+ return OverworldBiomes.calculateSkyColor(temperature);
+ }
+
+ public static int DEFAULT_NETHER_WATER_COLOR = 0x3F76E4;
+ public static int DEFAULT_END_WATER_COLOR = DEFAULT_NETHER_WATER_COLOR;
+ public static int DEFAULT_NETHER_WATER_FOG_COLOR = 0x050533;
+ public static int DEFAULT_END_WATER_FOG_COLOR = DEFAULT_NETHER_WATER_FOG_COLOR;
+ public static int DEFAULT_END_FOG_COLOR = 0xA080A0;
+ public static int DEFAULT_END_SKY_COLOR = 0x000000;
+ public static float DEFAULT_NETHER_TEMPERATURE = 2.0f;
+ public static float DEFAULT_END_TEMPERATURE = 0.5f;
+ public static float DEFAULT_NETHER_WETNESS = 0.0f;
+ public static float DEFAULT_END_WETNESS = 0.5f;
+
+
+ @FunctionalInterface
+ public interface BiomeSupplier