- ) tag;
+ }
+
/**
* Adds {@link Block} to NETHER_GROUND and GEN_TERRAIN tags to process it properly in terrain generators and block logic.
+ *
* @param block - {@link Block}.
*/
public static void addNetherGround(Block block) {
- TagHelper.addTag(NETHER_GROUND, block);
- TagHelper.addTag(GEN_TERRAIN, block);
+ addTag(BLOCK_NETHER_GROUND, block);
+ addTag(BLOCK_GEN_TERRAIN, block);
}
/**
* Adds {@link Block} to END_GROUND and GEN_TERRAIN tags to process it properly in terrain generators and block logic.
+ *
* @param block - {@link Block}.
*/
public static void addEndGround(Block block) {
- TagHelper.addTag(GEN_TERRAIN, block);
- TagHelper.addTag(END_GROUND, block);
+ addTag(BLOCK_GEN_TERRAIN, block);
+ addTag(BLOCK_END_GROUND, block);
}
/**
* Initializes basic tags. Should be called only in BCLib main class.
*/
public static void init() {
- TagHelper.addTag(BOOKSHELVES, Blocks.BOOKSHELF);
- TagHelper.addTag(GEN_TERRAIN, Blocks.END_STONE, Blocks.NETHERRACK, Blocks.SOUL_SAND, Blocks.SOUL_SOIL);
- TagHelper.addTag(NETHER_GROUND, Blocks.NETHERRACK, Blocks.SOUL_SAND, Blocks.SOUL_SOIL);
- TagHelper.addTag(END_GROUND, Blocks.END_STONE);
- TagHelper.addTag(BLOCK_CHEST, Blocks.CHEST);
- TagHelper.addTag(ITEM_CHEST, Items.CHEST);
- TagHelper.addTag(IRON_INGOTS, Items.IRON_INGOT);
- TagHelper.addTag(FURNACES, Blocks.FURNACE);
+ addTag(BLOCK_BOOKSHELVES, Blocks.BOOKSHELF);
+ addTag(BLOCK_GEN_TERRAIN, Blocks.END_STONE, Blocks.NETHERRACK, Blocks.SOUL_SAND, Blocks.SOUL_SOIL);
+ addTag(BLOCK_NETHER_GROUND, Blocks.NETHERRACK, Blocks.SOUL_SAND, Blocks.SOUL_SOIL);
+ addTag(BLOCK_END_GROUND, Blocks.END_STONE);
+ addTag(BLOCK_CHEST, Blocks.CHEST);
+ addTag(ITEM_CHEST, Items.CHEST);
+ addTag(ITEM_IRON_INGOTS, Items.IRON_INGOT);
+ addTag(ITEM_FURNACES, Blocks.FURNACE);
+ }
+
+ /**
+ * Adds one Tag to multiple Blocks.
+ *
+ * Example:
+ *
{@code Tag.Named DIMENSION_STONE = makeBlockTag("mymod", "dim_stone");
+ * addTag(DIMENSION_STONE, Blocks.END_STONE, Blocks.NETHERRACK);}
+ *
+ * The call will reserve the Tag. The Tag is added to the blocks once
+ * {@link #apply(String, Map)} was executed.
+ *
+ * @param tag The new Tag
+ * @param blocks One or more blocks that should receive the Tag.
+ */
+ public static void addTag(Tag.Named tag, Block... blocks) {
+ ResourceLocation tagID = tag.getName();
+ Set set = TAGS_BLOCK.computeIfAbsent(tagID, k -> Sets.newHashSet());
+ for (Block block : blocks) {
+ ResourceLocation id = Registry.BLOCK.getKey(block);
+ if (id != Registry.BLOCK.getDefaultKey()) {
+ set.add(id);
+ }
+ }
+ }
+
+ /**
+ * Adds one Tag to multiple Items.
+ *
+ * Example:
+ *
{@code Tag.Named- METALS = makeBlockTag("mymod", "metals");
+ * addTag(METALS, Items.IRON_INGOT, Items.GOLD_INGOT, Items.COPPER_INGOT);}
+ *
+ * The call will reserve the Tag. The Tag is added to the items once
+ * {@link #apply(String, Map)} was executed.
+ *
+ * @param tag The new Tag
+ * @param items One or more item that should receive the Tag.
+ */
+ public static void addTag(Tag.Named- tag, ItemLike... items) {
+ ResourceLocation tagID = tag.getName();
+ Set set = TAGS_ITEM.computeIfAbsent(tagID, k -> Sets.newHashSet());
+ for (ItemLike item : items) {
+ ResourceLocation id = Registry.ITEM.getKey(item.asItem());
+ if (id != Registry.ITEM.getDefaultKey()) {
+ set.add(id);
+ }
+ }
+ }
+
+ /**
+ * Adds multiple Tags to one Item.
+ *
+ * The call will reserve the Tags. The Tags are added to the Item once
+ * * {@link #apply(String, Map)} was executed.
+ *
+ * @param item The Item that will receive all Tags
+ * @param tags One or more Tags
+ */
+ @SafeVarargs
+ public static void addTags(ItemLike item, Tag.Named- ... tags) {
+ for (Tag.Named
- tag : tags) {
+ addTag(tag, item);
+ }
+ }
+
+ /**
+ * Adds multiple Tags to one Block.
+ *
+ * The call will reserve the Tags. The Tags are added to the Block once
+ * * {@link #apply(String, Map)} was executed.
+ *
+ * @param block The Block that will receive all Tags
+ * @param tags One or more Tags
+ */
+ @SafeVarargs
+ public static void addTags(Block block, Tag.Named... tags) {
+ for (Tag.Named tag : tags) {
+ addTag(tag, block);
+ }
+ }
+
+ /**
+ * Adds all {@code ids} to the {@code builder}.
+ *
+ * @param builder
+ * @param ids
+ * @return The Builder passed as {@code builder}.
+ */
+ public static Tag.Builder apply(Tag.Builder builder, Set ids) {
+ ids.forEach(value -> builder.addElement(value, "Better End Code"));
+ return builder;
+ }
+
+ /**
+ * Automatically called in {@link net.minecraft.tags.TagLoader#loadAndBuild(ResourceManager)}.
+ *
+ * In most cases there is no need to call this Method manually.
+ *
+ * @param directory The name of the Tag-directory. Should be either "tags/blocks" or
+ * "tags/items".
+ * @param tagsMap The map that will hold the registered Tags
+ * @return The {@code tagsMap} Parameter.
+ */
+ public static Map apply(String directory, Map tagsMap) {
+ Map> endTags = null;
+ if ("tags/blocks".equals(directory)) {
+ endTags = TAGS_BLOCK;
+ }
+ else if ("tags/items".equals(directory)) {
+ endTags = TAGS_ITEM;
+ }
+ if (endTags != null) {
+ endTags.forEach((id, ids) -> apply(tagsMap.computeIfAbsent(id, key -> Tag.Builder.tag()), ids));
+ }
+ return tagsMap;
}
}
diff --git a/src/main/java/ru/bclib/api/WorldDataAPI.java b/src/main/java/ru/bclib/api/WorldDataAPI.java
index 6927c9ed..b1794df7 100644
--- a/src/main/java/ru/bclib/api/WorldDataAPI.java
+++ b/src/main/java/ru/bclib/api/WorldDataAPI.java
@@ -1,20 +1,30 @@
package ru.bclib.api;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import net.fabricmc.loader.api.FabricLoader;
+import net.fabricmc.loader.api.ModContainer;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.NbtIo;
+import net.minecraft.world.level.storage.LevelStorageSource.LevelStorageAccess;
+import ru.bclib.BCLib;
+import ru.bclib.api.datafixer.DataFixerAPI;
+import ru.bclib.util.ModUtil;
+
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.function.Consumer;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-
-import net.fabricmc.loader.api.FabricLoader;
-import net.fabricmc.loader.api.ModContainer;
-import net.minecraft.nbt.CompoundTag;
-import net.minecraft.nbt.NbtIo;
-import ru.bclib.BCLib;
-
+/**
+ * Mod-specifix data-storage for a world.
+ *
+ * This class provides the ability for mod to store persistent data inside a world. The Storage for the world is
+ * currently initialized as part of the {@link DataFixerAPI} in {@link DataFixerAPI#fixData(LevelStorageAccess, boolean, Consumer)}
+ * or {@link DataFixerAPI#initializeWorldData(File, boolean)}
+ */
public class WorldDataAPI {
private static final Map TAGS = Maps.newHashMap();
private static final List MODS = Lists.newArrayList();
@@ -22,36 +32,43 @@ public class WorldDataAPI {
public static void load(File dataDir) {
WorldDataAPI.dataDir = dataDir;
- MODS.stream().parallel().forEach(modID -> {
- File file = new File(dataDir, modID + ".nbt");
- CompoundTag root = new CompoundTag();
- TAGS.put(modID, root);
- if (file.exists()) {
- try {
- root = NbtIo.readCompressed(file);
- }
- catch (IOException e) {
- BCLib.LOGGER.error("World data loading failed", e);
- }
- }
- else {
- Optional optional = FabricLoader.getInstance().getModContainer(modID);
- if (optional.isPresent()) {
- ModContainer modContainer = optional.get();
- if (BCLib.isDevEnvironment()) {
- root.putString("version", "63.63.63");
+ MODS.stream()
+ .parallel()
+ .forEach(modID -> {
+ File file = new File(dataDir, modID + ".nbt");
+ CompoundTag root = new CompoundTag();
+ if (file.exists()) {
+ try {
+ root = NbtIo.readCompressed(file);
}
- else {
- root.putString("version", modContainer.getMetadata().getVersion().toString());
+ catch (IOException e) {
+ BCLib.LOGGER.error("World data loading failed", e);
}
- saveFile(modID);
}
- }
- });
+ else {
+ Optional optional = FabricLoader.getInstance()
+ .getModContainer(modID);
+ if (optional.isPresent()) {
+ ModContainer modContainer = optional.get();
+ if (BCLib.isDevEnvironment()) {
+ root.putString("version", "255.255.9999");
+ }
+ else {
+ root.putString("version", modContainer.getMetadata()
+ .getVersion()
+ .toString());
+ }
+ saveFile(modID);
+ }
+ }
+
+ TAGS.put(modID, root);
+ });
}
/**
* Register mod cache, world cache is located in world data folder.
+ *
* @param modID - {@link String} modID.
*/
public static void registerModCache(String modID) {
@@ -60,6 +77,7 @@ public class WorldDataAPI {
/**
* Get root {@link CompoundTag} for mod cache in world data folder.
+ *
* @param modID - {@link String} modID.
* @return {@link CompoundTag}
*/
@@ -74,13 +92,14 @@ public class WorldDataAPI {
/**
* Get {@link CompoundTag} with specified path from mod cache in world data folder.
+ *
* @param modID - {@link String} path to tag, dot-separated.
* @return {@link CompoundTag}
*/
public static CompoundTag getCompoundTag(String modID, String path) {
String[] parts = path.split("\\.");
CompoundTag tag = getRootTag(modID);
- for (String part: parts) {
+ for (String part : parts) {
if (tag.contains(part)) {
tag = tag.getCompound(part);
}
@@ -95,10 +114,14 @@ public class WorldDataAPI {
/**
* Forces mod cache file to be saved.
+ *
* @param modID {@link String} mod ID.
*/
public static void saveFile(String modID) {
try {
+ if (!dataDir.exists()) {
+ dataDir.mkdirs();
+ }
NbtIo.writeCompressed(getRootTag(modID), new File(dataDir, modID + ".nbt"));
}
catch (IOException e) {
@@ -108,6 +131,7 @@ public class WorldDataAPI {
/**
* Get stored mod version (only for mods with registered cache).
+ *
* @return {@link String} mod version.
*/
public static String getModVersion(String modID) {
@@ -116,9 +140,10 @@ public class WorldDataAPI {
/**
* Get stored mod version as integer (only for mods with registered cache).
+ *
* @return {@code int} mod version.
*/
public static int getIntModVersion(String modID) {
- return DataFixerAPI.getModVersion(getModVersion(modID));
+ return ModUtil.convertModVersion(getModVersion(modID));
}
}
diff --git a/src/main/java/ru/bclib/api/dataexchange/BaseDataHandler.java b/src/main/java/ru/bclib/api/dataexchange/BaseDataHandler.java
new file mode 100644
index 00000000..47ca87af
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/BaseDataHandler.java
@@ -0,0 +1,99 @@
+package ru.bclib.api.dataexchange;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+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 org.jetbrains.annotations.NotNull;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+
+public abstract class BaseDataHandler {
+ private final boolean originatesOnServer;
+ @NotNull
+ private final ResourceLocation identifier;
+
+ protected BaseDataHandler(ResourceLocation identifier, boolean originatesOnServer) {
+ this.originatesOnServer = originatesOnServer;
+ this.identifier = identifier;
+ }
+
+ final public boolean getOriginatesOnServer() {
+ return originatesOnServer;
+ }
+
+ final public ResourceLocation getIdentifier() {
+ return identifier;
+ }
+
+ @Environment(EnvType.CLIENT)
+ abstract void receiveFromServer(Minecraft client, ClientPacketListener handler, FriendlyByteBuf buf, PacketSender responseSender);
+
+ private ServerPlayer lastMessageSender;
+
+ void receiveFromClient(MinecraftServer server, ServerPlayer player, ServerGamePacketListenerImpl handler, FriendlyByteBuf buf, PacketSender responseSender) {
+ lastMessageSender = player;
+ }
+
+ final protected boolean reply(BaseDataHandler message, MinecraftServer server) {
+ if (lastMessageSender == null) return false;
+ message.sendToClient(server, lastMessageSender);
+ return true;
+ }
+
+ abstract void sendToClient(MinecraftServer server);
+
+ abstract void sendToClient(MinecraftServer server, ServerPlayer player);
+
+ @Environment(EnvType.CLIENT)
+ abstract void sendToServer(Minecraft client);
+
+ protected boolean isBlocking() { return false; }
+
+ @Override
+ public String toString() {
+ return "BasDataHandler{" + "originatesOnServer=" + originatesOnServer + ", identifier=" + identifier + '}';
+ }
+
+ /**
+ * Write a String to a buffer (Convenience Method)
+ *
+ * @param buf The buffer to write to
+ * @param s The String you want to write
+ */
+ public static void writeString(FriendlyByteBuf buf, String s) {
+ buf.writeByteArray(s.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Read a string from a buffer (Convenience Method)
+ *
+ * @param buf Thea buffer to read from
+ * @return The received String
+ */
+ public static String readString(FriendlyByteBuf buf) {
+ byte[] data = buf.readByteArray();
+ return new String(data, StandardCharsets.UTF_8);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof BaseDataHandler)) return false;
+ BaseDataHandler that = (BaseDataHandler) o;
+ return originatesOnServer == that.originatesOnServer && identifier.equals(that.identifier);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(originatesOnServer, identifier);
+ }
+}
+
diff --git a/src/main/java/ru/bclib/api/dataexchange/Connector.java b/src/main/java/ru/bclib/api/dataexchange/Connector.java
new file mode 100644
index 00000000..6f933489
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/Connector.java
@@ -0,0 +1,18 @@
+package ru.bclib.api.dataexchange;
+
+import ru.bclib.api.dataexchange.handler.DataExchange;
+
+import java.util.Set;
+
+abstract class Connector {
+ protected final DataExchange api;
+
+ Connector(DataExchange api) {
+ this.api = api;
+ }
+ public abstract boolean onClient();
+
+ protected Set getDescriptors(){
+ return api.getDescriptors();
+ }
+}
diff --git a/src/main/java/ru/bclib/api/dataexchange/ConnectorClientside.java b/src/main/java/ru/bclib/api/dataexchange/ConnectorClientside.java
new file mode 100644
index 00000000..6e192b91
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/ConnectorClientside.java
@@ -0,0 +1,70 @@
+package ru.bclib.api.dataexchange;
+
+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.PacketSender;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.multiplayer.ClientPacketListener;
+import net.minecraft.network.FriendlyByteBuf;
+import ru.bclib.BCLib;
+import ru.bclib.api.dataexchange.handler.DataExchange;
+
+/**
+ * This is an internal class that handles a Clienetside players Connection to a Server
+ */
+@Environment(EnvType.CLIENT)
+public class ConnectorClientside extends Connector {
+ private Minecraft client;
+ ConnectorClientside(DataExchange api) {
+ super(api);
+ this.client = null;
+ }
+
+
+ @Override
+ public boolean onClient() {
+ return true;
+ }
+
+ public void onPlayInit(ClientPacketListener handler, Minecraft client){
+ if (this.client!=null && this.client != client){
+ BCLib.LOGGER.warning("Client changed!");
+ }
+ this.client = client;
+ for(DataHandlerDescriptor desc : getDescriptors()){
+ ClientPlayNetworking.registerReceiver(desc.IDENTIFIER, (_client, _handler, _buf, _responseSender)->{
+ receiveFromServer(desc, _client, _handler, _buf, _responseSender);
+ });
+ }
+ }
+
+ public void onPlayReady(ClientPacketListener handler, PacketSender sender, Minecraft client){
+ for(DataHandlerDescriptor desc : getDescriptors()){
+ if (desc.sendOnJoin){
+ BaseDataHandler h = desc.JOIN_INSTANCE.get();
+ if (!h.getOriginatesOnServer()) {
+ h.sendToServer(client);
+ }
+ }
+ }
+ }
+
+ public void onPlayDisconnect(ClientPacketListener handler, Minecraft client){
+ for(DataHandlerDescriptor desc : getDescriptors()) {
+ ClientPlayNetworking.unregisterReceiver(desc.IDENTIFIER);
+ }
+ }
+
+ void receiveFromServer(DataHandlerDescriptor desc, Minecraft client, ClientPacketListener handler, FriendlyByteBuf buf, PacketSender responseSender){
+ BaseDataHandler h = desc.INSTANCE.get();
+ h.receiveFromServer(client, handler, buf, responseSender);
+ }
+
+ public void sendToServer(BaseDataHandler h){
+ if (client==null){
+ throw new RuntimeException("[internal error] Client not initialized yet!");
+ }
+ h.sendToServer(this.client);
+ }
+}
diff --git a/src/main/java/ru/bclib/api/dataexchange/ConnectorServerside.java b/src/main/java/ru/bclib/api/dataexchange/ConnectorServerside.java
new file mode 100644
index 00000000..f8debc99
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/ConnectorServerside.java
@@ -0,0 +1,67 @@
+package ru.bclib.api.dataexchange;
+
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.network.ServerGamePacketListenerImpl;
+import ru.bclib.BCLib;
+import ru.bclib.api.dataexchange.handler.DataExchange;
+
+/**
+ * This is an internal class that handles a Serverside Connection to a Client-Player
+ */
+public class ConnectorServerside extends Connector {
+ private MinecraftServer server;
+ ConnectorServerside(DataExchange api) {
+ super(api);
+ server = null;
+ }
+
+ @Override
+ public boolean onClient() {
+ return false;
+ }
+
+ public void onPlayInit(ServerGamePacketListenerImpl handler, MinecraftServer server){
+ if (this.server!=null && this.server != server){
+ BCLib.LOGGER.warning("Server changed!");
+ }
+ this.server = server;
+ for(DataHandlerDescriptor desc : getDescriptors()){
+ ServerPlayNetworking.registerReceiver(handler, desc.IDENTIFIER, (_server, _player, _handler, _buf, _responseSender) -> {
+ receiveFromClient(desc, _server, _player, _handler, _buf, _responseSender);
+ });
+ }
+ }
+
+ public void onPlayReady(ServerGamePacketListenerImpl handler, PacketSender sender, MinecraftServer server){
+ for(DataHandlerDescriptor desc : getDescriptors()){
+ if (desc.sendOnJoin){
+ BaseDataHandler h = desc.JOIN_INSTANCE.get();
+ if (h.getOriginatesOnServer()) {
+ h.sendToClient(server, handler.player);
+ }
+ }
+ }
+ }
+
+ public void onPlayDisconnect(ServerGamePacketListenerImpl handler, MinecraftServer server){
+ for(DataHandlerDescriptor desc : getDescriptors()){
+ ServerPlayNetworking.unregisterReceiver(handler, desc.IDENTIFIER);
+ }
+ }
+
+ void receiveFromClient(DataHandlerDescriptor desc, MinecraftServer server, ServerPlayer player, ServerGamePacketListenerImpl handler, FriendlyByteBuf buf, PacketSender responseSender){
+ BaseDataHandler h = desc.INSTANCE.get();
+ h.receiveFromClient(server, player, handler, buf, responseSender);
+ }
+
+ public void sendToClient(BaseDataHandler h){
+ if (server==null){
+ throw new RuntimeException("[internal error] Server not initialized yet!");
+ }
+ h.sendToClient(this.server);
+ }
+}
diff --git a/src/main/java/ru/bclib/api/dataexchange/DataExchangeAPI.java b/src/main/java/ru/bclib/api/dataexchange/DataExchangeAPI.java
new file mode 100644
index 00000000..4a22c6db
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/DataExchangeAPI.java
@@ -0,0 +1,211 @@
+package ru.bclib.api.dataexchange;
+
+import com.google.common.collect.Lists;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.minecraft.network.FriendlyByteBuf;
+import ru.bclib.BCLib;
+import ru.bclib.api.dataexchange.handler.DataExchange;
+import ru.bclib.api.dataexchange.handler.autosync.AutoSync;
+import ru.bclib.api.dataexchange.handler.autosync.AutoSync.NeedTransferPredicate;
+import ru.bclib.api.dataexchange.handler.autosync.AutoSyncID;
+import ru.bclib.config.Config;
+import ru.bclib.util.ModUtil;
+
+import java.io.File;
+import java.util.List;
+import java.util.function.BiConsumer;
+
+public class DataExchangeAPI extends DataExchange {
+ private final static List MODS = Lists.newArrayList();
+
+ /**
+ * You should never need to create a custom instance of this Object.
+ */
+ public DataExchangeAPI() {
+ super();
+ }
+
+ @Environment(EnvType.CLIENT)
+ protected ConnectorClientside clientSupplier(DataExchange api) {
+ return new ConnectorClientside(api);
+ }
+
+ protected ConnectorServerside serverSupplier(DataExchange api) {
+ return new ConnectorServerside(api);
+ }
+
+ /**
+ * Register a mod to participate in the DataExchange.
+ *
+ * @param modID - {@link String} modID.
+ */
+ public static void registerMod(String modID) {
+ if (!MODS.contains(modID)) MODS.add(modID);
+ }
+
+ /**
+ * Register a mod dependency to participate in the DataExchange.
+ *
+ * @param modID - {@link String} modID.
+ */
+ public static void registerModDependency(String modID) {
+ if (ModUtil.getModInfo(modID, false) != null && !"0.0.0".equals(ModUtil.getModVersion(modID))) {
+ registerMod(modID);
+ } else {
+ BCLib.LOGGER.info("Mod Dependency '" + modID + "' not found. This is probably OK.");
+ }
+ }
+
+ /**
+ * Returns the IDs of all registered Mods.
+ *
+ * @return List of modIDs
+ */
+ public static List registeredMods() {
+ return MODS;
+ }
+
+ /**
+ * Add a new Descriptor for a {@link DataHandler}.
+ *
+ * @param desc The Descriptor you want to add.
+ */
+ public static void registerDescriptor(DataHandlerDescriptor desc) {
+ DataExchange api = DataExchange.getInstance();
+ api.getDescriptors()
+ .add(desc);
+ }
+
+ /**
+ * Bulk-Add a Descriptors for your {@link DataHandler}-Objects.
+ *
+ * @param desc The Descriptors you want to add.
+ */
+ public static void registerDescriptors(List 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, 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, 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/ru/bclib/api/dataexchange/DataHandler.java b/src/main/java/ru/bclib/api/dataexchange/DataHandler.java
new file mode 100644
index 00000000..b1043fc3
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/DataHandler.java
@@ -0,0 +1,274 @@
+package ru.bclib.api.dataexchange;
+
+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 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 ru.bclib.BCLib;
+import ru.bclib.api.dataexchange.handler.autosync.Chunker;
+import ru.bclib.api.dataexchange.handler.autosync.Chunker.PacketChunkSender;
+
+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 PacketChunkSender sender = new 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, 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);
+
+ abstract protected void deserializeIncomingDataOnServer(FriendlyByteBuf buf, PacketSender responseSender);
+
+ abstract protected void runOnServerGameThread(MinecraftServer server);
+
+
+ @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, responseSender);
+ final Runnable runner = () -> runOnServerGameThread(server);
+
+ 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/ru/bclib/api/dataexchange/DataHandlerDescriptor.java b/src/main/java/ru/bclib/api/dataexchange/DataHandlerDescriptor.java
new file mode 100644
index 00000000..2f9f80e5
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/DataHandlerDescriptor.java
@@ -0,0 +1,49 @@
+package ru.bclib.api.dataexchange;
+
+import net.minecraft.resources.ResourceLocation;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Objects;
+import java.util.function.Supplier;
+
+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/ru/bclib/api/dataexchange/FileHash.java b/src/main/java/ru/bclib/api/dataexchange/FileHash.java
new file mode 100644
index 00000000..de87f686
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/FileHash.java
@@ -0,0 +1,161 @@
+package ru.bclib.api.dataexchange;
+
+import net.minecraft.network.FriendlyByteBuf;
+import org.jetbrains.annotations.NotNull;
+import ru.bclib.BCLib;
+
+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;
+
+public class FileHash {
+ private static int ERR_DOES_NOT_EXIST = -10;
+ private static 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/ru/bclib/api/dataexchange/SyncFileHash.java b/src/main/java/ru/bclib/api/dataexchange/SyncFileHash.java
new file mode 100644
index 00000000..81a3c853
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/SyncFileHash.java
@@ -0,0 +1,108 @@
+package ru.bclib.api.dataexchange;
+
+import net.minecraft.network.FriendlyByteBuf;
+import ru.bclib.api.dataexchange.handler.autosync.AutoSync.NeedTransferPredicate;
+import ru.bclib.api.dataexchange.handler.autosync.AutoSyncID;
+
+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 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/ru/bclib/api/dataexchange/handler/DataExchange.java b/src/main/java/ru/bclib/api/dataexchange/handler/DataExchange.java
new file mode 100644
index 00000000..a11ddb6d
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/handler/DataExchange.java
@@ -0,0 +1,113 @@
+package ru.bclib.api.dataexchange.handler;
+
+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 net.minecraft.resources.ResourceLocation;
+import ru.bclib.api.dataexchange.BaseDataHandler;
+import ru.bclib.api.dataexchange.ConnectorClientside;
+import ru.bclib.api.dataexchange.ConnectorServerside;
+import ru.bclib.api.dataexchange.DataExchangeAPI;
+import ru.bclib.api.dataexchange.DataHandler;
+import ru.bclib.api.dataexchange.DataHandlerDescriptor;
+
+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 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/ru/bclib/api/dataexchange/handler/autosync/AutoFileSyncEntry.java b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/AutoFileSyncEntry.java
new file mode 100644
index 00000000..83e4ff0b
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/AutoFileSyncEntry.java
@@ -0,0 +1,237 @@
+package ru.bclib.api.dataexchange.handler.autosync;
+
+import net.minecraft.network.FriendlyByteBuf;
+import ru.bclib.BCLib;
+import ru.bclib.api.dataexchange.DataHandler;
+import ru.bclib.api.dataexchange.SyncFileHash;
+import ru.bclib.api.dataexchange.handler.autosync.AutoSync.NeedTransferPredicate;
+import ru.bclib.api.dataexchange.handler.autosync.SyncFolderDescriptor.SubFile;
+import ru.bclib.util.ModUtil;
+import ru.bclib.util.ModUtil.ModInfo;
+import ru.bclib.util.Pair;
+import ru.bclib.util.PathUtil;
+import ru.bclib.util.Triple;
+
+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 NeedTransferPredicate needTransfer;
+ public final File fileName;
+ public final boolean requestContent;
+ private SyncFileHash hash;
+
+ AutoFileSyncEntry(String modID, File fileName, boolean requestContent, NeedTransferPredicate needTransfer) {
+ this(modID, fileName.getName(), fileName, requestContent, needTransfer);
+ }
+
+ AutoFileSyncEntry(String modID, String uniqueID, File fileName, boolean requestContent, 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 (!PathUtil.isChildOf(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) {
+ 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/ru/bclib/api/dataexchange/handler/autosync/AutoSync.java b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/AutoSync.java
new file mode 100644
index 00000000..89a33463
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/AutoSync.java
@@ -0,0 +1,187 @@
+package ru.bclib.api.dataexchange.handler.autosync;
+
+import net.fabricmc.loader.api.FabricLoader;
+import ru.bclib.BCLib;
+import ru.bclib.api.dataexchange.DataExchangeAPI;
+import ru.bclib.api.dataexchange.SyncFileHash;
+import ru.bclib.config.Configs;
+import ru.bclib.config.ServerConfig;
+import ru.bclib.util.PathUtil;
+
+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 {
+ public 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/ru/bclib/api/dataexchange/handler/autosync/AutoSyncID.java b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/AutoSyncID.java
new file mode 100644
index 00000000..9ffbc64f
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/AutoSyncID.java
@@ -0,0 +1,142 @@
+package ru.bclib.api.dataexchange.handler.autosync;
+
+import net.minecraft.network.FriendlyByteBuf;
+import org.jetbrains.annotations.NotNull;
+import ru.bclib.api.dataexchange.DataHandler;
+import ru.bclib.config.Config;
+import ru.bclib.util.ModUtil;
+
+import java.io.File;
+import java.util.Objects;
+
+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/ru/bclib/api/dataexchange/handler/autosync/Chunker.java b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/Chunker.java
new file mode 100644
index 00000000..33a16719
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/Chunker.java
@@ -0,0 +1,270 @@
+package ru.bclib.api.dataexchange.handler.autosync;
+
+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 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 org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import ru.bclib.BCLib;
+import ru.bclib.api.dataexchange.BaseDataHandler;
+import ru.bclib.api.dataexchange.DataHandler;
+import ru.bclib.api.dataexchange.DataHandlerDescriptor;
+import ru.bclib.api.dataexchange.handler.DataExchange;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * 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 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
+ * 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 DataHandlerDescriptor DESCRIPTOR = new DataHandlerDescriptor(new ResourceLocation(BCLib.MOD_ID, "hello_client"), HelloClient::new, false, false);
+
+ public HelloClient() {
+ super(DESCRIPTOR.IDENTIFIER);
+ }
+
+ static String getBCLibVersion() {
+ return ModUtil.getModVersion(BCLib.MOD_ID);
+ }
+
+ @Override
+ protected boolean prepareDataOnServer() {
+ if (!Configs.SERVER_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the server.");
+ return false;
+ }
+
+ AutoSync.loadSyncFolder();
+ return true;
+ }
+
+ @Override
+ protected void serializeDataOnServer(FriendlyByteBuf buf) {
+ final String vbclib = getBCLibVersion();
+ BCLib.LOGGER.info("Sending Hello to Client. (server=" + vbclib + ")");
+
+ //write BCLibVersion (=protocol version)
+ buf.writeInt(ModUtil.convertModVersion(vbclib));
+
+ if (Configs.SERVER_CONFIG.isOfferingMods() || Configs.SERVER_CONFIG.isOfferingInfosForMods()) {
+ List mods = DataExchangeAPI.registeredMods();
+ final List inmods = mods;
+ if (Configs.SERVER_CONFIG.isOfferingAllMods() || Configs.SERVER_CONFIG.isOfferingInfosForMods()){
+ mods = new ArrayList<>(inmods.size());
+ mods.addAll(inmods);
+ mods.addAll(ModUtil
+ .getMods()
+ .entrySet()
+ .stream()
+ .filter(entry -> entry.getValue().metadata.getEnvironment()!= ModEnvironment.SERVER && !inmods.contains(entry.getKey()))
+ .map(entry -> entry.getKey())
+ .collect(Collectors.toList())
+ );
+ }
+
+ mods = mods
+ .stream()
+ .filter(entry -> !Configs.SERVER_CONFIG.get(ServerConfig.EXCLUDED_MODS).contains(entry))
+ .collect(Collectors.toList());
+
+ //write Plugin Versions
+ buf.writeInt(mods.size());
+ for (String modID : mods) {
+ final String ver = ModUtil.getModVersion(modID);
+ int size = 0;
+
+ final ModInfo mi = ModUtil.getModInfo(modID);
+ if (mi != null) {
+ try {
+ size = (int) Files.size(mi.jarPath);
+ } catch (IOException e) {
+ BCLib.LOGGER.error("Unable to get File Size: " + e.getMessage());
+ }
+ }
+
+
+ writeString(buf, modID);
+ buf.writeInt(ModUtil.convertModVersion(ver));
+ buf.writeInt(size);
+ final boolean canDownload = size>0 && Configs.SERVER_CONFIG.isOfferingMods() && (Configs.SERVER_CONFIG.isOfferingAllMods() || inmods.contains(modID));
+ buf.writeBoolean(canDownload);
+
+ BCLib.LOGGER.info(" - Listing Mod " + modID + " v" + ver + " (size: " + PathUtil.humanReadableFileSize(size) + ", download="+canDownload+")");
+ }
+ }
+ else {
+ BCLib.LOGGER.info("Server will not list Mods.");
+ buf.writeInt(0);
+ }
+
+ if (Configs.SERVER_CONFIG.isOfferingFiles() || Configs.SERVER_CONFIG.isOfferingConfigs()) {
+ //do only include files that exist on the server
+ final List existingAutoSyncFiles = AutoSync.getAutoSyncFiles()
+ .stream()
+ .filter(e -> e.fileName.exists())
+ .filter(e -> (e.isConfigFile() && Configs.SERVER_CONFIG.isOfferingConfigs()) || (e instanceof AutoFileSyncEntry.ForDirectFileRequest && Configs.SERVER_CONFIG.isOfferingFiles()))
+ .collect(Collectors.toList());
+
+ //send config Data
+ buf.writeInt(existingAutoSyncFiles.size());
+ for (AutoFileSyncEntry entry : existingAutoSyncFiles) {
+ entry.serialize(buf);
+ BCLib.LOGGER.info(" - Offering " + (entry.isConfigFile() ? "Config " : "File ") + entry);
+ }
+ }
+ else {
+ BCLib.LOGGER.info("Server will neither offer Files nor Configs.");
+ buf.writeInt(0);
+ }
+
+ if (Configs.SERVER_CONFIG.isOfferingFiles()) {
+ buf.writeInt(AutoSync.syncFolderDescriptions.size());
+ AutoSync.syncFolderDescriptions.forEach(desc -> {
+ BCLib.LOGGER.info(" - Offering Folder " + desc.localFolder + " (allowDelete=" + desc.removeAdditionalFiles + ")");
+ desc.serialize(buf);
+ });
+ }
+ else {
+ BCLib.LOGGER.info("Server will not offer Sync Folders.");
+ buf.writeInt(0);
+ }
+
+ buf.writeBoolean(Configs.SERVER_CONFIG.isOfferingInfosForMods());
+ }
+
+ String bclibVersion = "0.0.0";
+
+
+
+ IServerModMap modVersion = new ServerModMap();
+ List autoSyncedFiles = null;
+ List autoSynFolders = null;
+ boolean serverPublishedModInfo = false;
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void deserializeIncomingDataOnClient(FriendlyByteBuf buf, PacketSender responseSender) {
+ //read BCLibVersion (=protocol version)
+ bclibVersion = ModUtil.convertModVersion(buf.readInt());
+ final boolean protocolVersion_0_4_1 = ModUtil.isLargerOrEqualVersion(bclibVersion, "0.4.1");
+
+
+ //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
+ if (protocolVersion_0_4_1) {
+ size = buf.readInt();
+ canDownload = buf.readBoolean();
+ }
+ else {
+ size = 0;
+ canDownload = true;
+ }
+ 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
+ if (protocolVersion_0_4_1) {
+ final int folderCount = buf.readInt();
+ for (int i = 0; i < folderCount; i++) {
+ SyncFolderDescriptor desc = SyncFolderDescriptor.deserialize(buf);
+ autoSynFolders.add(desc);
+ }
+
+ serverPublishedModInfo = buf.readBoolean();
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ private void processAutoSyncFolder(final List filesToRequest, final List filesToRemove) {
+ if (!Configs.CLIENT_CONFIG.isAcceptingFiles()) {
+ return;
+ }
+
+ if (autoSynFolders.size() > 0) {
+ BCLib.LOGGER.info("Folders offered by Server:");
+ }
+
+ autoSynFolders.forEach(desc -> {
+ //desc contains the fileCache sent from the server, load the local version to get hold of the actual file cache on the client
+ SyncFolderDescriptor localDescriptor = AutoSync.getSyncFolderDescriptor(desc.folderID);
+ if (localDescriptor != null) {
+ BCLib.LOGGER.info(" - " + desc.folderID + " (" + desc.localFolder + ", allowRemove=" + desc.removeAdditionalFiles + ")");
+ localDescriptor.invalidateCache();
+
+ desc.relativeFilesStream()
+ .filter(desc::discardChildElements)
+ .forEach(subFile -> {
+ BCLib.LOGGER.warning(" * " + subFile.relPath + " (REJECTED)");
+ });
+
+
+ if (desc.removeAdditionalFiles) {
+ List additionalFiles = localDescriptor.relativeFilesStream()
+ .filter(subFile -> !desc.hasRelativeFile(subFile))
+ .map(desc::mapAbsolute)
+ .filter(desc::acceptChildElements)
+ .map(absPath -> new AutoSyncID.ForDirectFileRequest(desc.folderID, absPath.toFile()))
+ .collect(Collectors.toList());
+
+ additionalFiles.forEach(aid -> BCLib.LOGGER.info(" * " + desc.localFolder.relativize(aid.relFile.toPath()) + " (missing on server)"));
+ filesToRemove.addAll(additionalFiles);
+ }
+
+ desc.relativeFilesStream()
+ .filter(desc::acceptChildElements)
+ .forEach(subFile -> {
+ SubFile localSubFile = localDescriptor.getLocalSubFile(subFile.relPath);
+ if (localSubFile != null) {
+ //the file exists locally, check if the hashes match
+ if (!localSubFile.hash.equals(subFile.hash)) {
+ BCLib.LOGGER.info(" * " + subFile.relPath + " (changed)");
+ filesToRequest.add(new AutoSyncID.ForDirectFileRequest(desc.folderID, new File(subFile.relPath)));
+ }
+ else {
+ BCLib.LOGGER.info(" * " + subFile.relPath);
+ }
+ }
+ else {
+ //the file is missing locally
+ BCLib.LOGGER.info(" * " + subFile.relPath + " (missing on client)");
+ filesToRequest.add(new AutoSyncID.ForDirectFileRequest(desc.folderID, new File(subFile.relPath)));
+ }
+ });
+
+ //free some memory
+ localDescriptor.invalidateCache();
+ }
+ else {
+ BCLib.LOGGER.info(" - " + desc.folderID + " (Failed to find)");
+ }
+ });
+ }
+
+ @Environment(EnvType.CLIENT)
+ private void processSingleFileSync(final List filesToRequest) {
+ final boolean debugHashes = Configs.CLIENT_CONFIG.shouldPrintDebugHashes();
+
+ if (autoSyncedFiles.size() > 0) {
+ BCLib.LOGGER.info("Files offered by Server:");
+ }
+
+ //Handle single sync files
+ //Single files need to be registered for sync on both client and server
+ //There are no restrictions to the target folder, but the client decides the final
+ //location.
+ for (AutoSync.AutoSyncTriple e : autoSyncedFiles) {
+ String actionString = "";
+ FileContentWrapper contentWrapper = new FileContentWrapper(e.serverContent);
+ if (e.localMatch == null) {
+ actionString = "(unknown source -> omitting)";
+ //filesToRequest.add(new AutoSyncID(e.serverHash.modID, e.serverHash.uniqueID));
+ }
+ else if (e.localMatch.needTransfer.test(e.localMatch.getFileHash(), e.serverHash, contentWrapper)) {
+ actionString = "(prepare update)";
+ //we did not yet receive the new content
+ if (contentWrapper.getRawContent() == null) {
+ filesToRequest.add(new AutoSyncID(e.serverHash.modID, e.serverHash.uniqueID));
+ }
+ else {
+ filesToRequest.add(new AutoSyncID.WithContentOverride(e.serverHash.modID, e.serverHash.uniqueID, contentWrapper, e.localMatch.fileName));
+ }
+ }
+
+ BCLib.LOGGER.info(" - " + e + ": " + actionString);
+ if (debugHashes) {
+ BCLib.LOGGER.info(" * " + e.serverHash + " (Server)");
+ BCLib.LOGGER.info(" * " + e.localMatch.getFileHash() + " (Client)");
+ BCLib.LOGGER.info(" * local Content " + (contentWrapper.getRawContent() == null));
+ }
+ }
+ }
+
+
+ @Environment(EnvType.CLIENT)
+ private void processModFileSync(final List filesToRequest, final Set mismatchingMods) {
+ for (Entry e : modVersion.entrySet()) {
+ final String localVersion = ModUtil.getModVersion(e.getKey());
+ final OfferedModInfo serverInfo = e.getValue();
+ final boolean requestMod = !serverInfo.version.equals(localVersion) && serverInfo.size > 0 && serverInfo.canDownload;
+
+ BCLib.LOGGER.info(" - " + e.getKey() + " (client=" + localVersion + ", server=" + serverInfo.version + ", size=" + PathUtil.humanReadableFileSize(serverInfo.size) + (requestMod ? ", requesting" : "") + (serverInfo.canDownload ? "" :", not offered")+ ")");
+ 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, new TranslatableComponent("title.bclib.modmissmatch"), new TranslatableComponent("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 WithContentOverride) {
+ final WithContentOverride aidc = (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, new TranslatableComponent("title.bclib.filesync.progress"), new TranslatableComponent("message.bclib.filesync.progress"));
+ progress.progressStart(new TranslatableComponent("message.bclib.filesync.progress.stage.empty"));
+ ChunkerProgress.setProgressScreen(progress);
+
+ DataExchangeAPI.send(new RequestFiles(files));
+ }
+}
diff --git a/src/main/java/ru/bclib/api/dataexchange/handler/autosync/HelloServer.java b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/HelloServer.java
new file mode 100644
index 00000000..a3abd668
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/HelloServer.java
@@ -0,0 +1,108 @@
+package ru.bclib.api.dataexchange.handler.autosync;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.MinecraftServer;
+import ru.bclib.BCLib;
+import ru.bclib.api.dataexchange.DataExchangeAPI;
+import ru.bclib.api.dataexchange.DataHandler;
+import ru.bclib.api.dataexchange.DataHandlerDescriptor;
+import ru.bclib.config.Configs;
+import ru.bclib.util.ModUtil;
+
+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 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, PacketSender responseSender) {
+ bclibVersion = ModUtil.convertModVersion(buf.readInt());
+ }
+
+ @Override
+ protected void runOnServerGameThread(MinecraftServer server) {
+ 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/ru/bclib/api/dataexchange/handler/autosync/RequestFiles.java b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/RequestFiles.java
new file mode 100644
index 00000000..45579906
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/RequestFiles.java
@@ -0,0 +1,98 @@
+package ru.bclib.api.dataexchange.handler.autosync;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.MinecraftServer;
+import ru.bclib.BCLib;
+import ru.bclib.api.dataexchange.DataHandler;
+import ru.bclib.api.dataexchange.DataHandlerDescriptor;
+import ru.bclib.config.Configs;
+
+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 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, PacketSender responseSender) {
+ receivedToken = readString(buf);
+ int size = buf.readInt();
+ files = new ArrayList<>(size);
+
+ BCLib.LOGGER.info("Client requested " + size + " Files:");
+ for (int i = 0; i < size; i++) {
+ AutoSyncID asid = AutoSyncID.deserializeData(buf);
+ files.add(asid);
+ BCLib.LOGGER.info(" - " + asid);
+ }
+
+
+ }
+
+ @Override
+ protected void runOnServerGameThread(MinecraftServer server) {
+ 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/ru/bclib/api/dataexchange/handler/autosync/SendFiles.java b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/SendFiles.java
new file mode 100644
index 00000000..000be5c0
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/SendFiles.java
@@ -0,0 +1,216 @@
+package ru.bclib.api.dataexchange.handler.autosync;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+import net.minecraft.client.Minecraft;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.resources.ResourceLocation;
+import ru.bclib.BCLib;
+import ru.bclib.api.dataexchange.DataHandler;
+import ru.bclib.api.dataexchange.DataHandlerDescriptor;
+import ru.bclib.config.Configs;
+import ru.bclib.gui.screens.ConfirmRestartScreen;
+import ru.bclib.util.Pair;
+import ru.bclib.util.PathUtil;
+import ru.bclib.util.Triple;
+
+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 DataHandlerDescriptor DESCRIPTOR = new DataHandlerDescriptor(new ResourceLocation(BCLib.MOD_ID, "send_files"), SendFiles::new, false, false);
+
+ protected List files;
+ private String token;
+
+ public SendFiles() {
+ this(null, "");
+ }
+
+ public SendFiles(List files, String token) {
+ super(DESCRIPTOR.IDENTIFIER);
+ this.files = files;
+ this.token = token;
+ }
+
+ @Override
+ protected boolean prepareDataOnServer() {
+ if (!Configs.SERVER_CONFIG.isAllowingAutoSync()) {
+ BCLib.LOGGER.info("Auto-Sync was disabled on the server.");
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ protected void serializeDataOnServer(FriendlyByteBuf buf) {
+ List existingFiles = files.stream()
+ .filter(e -> e!=null &&e.fileName!=null && e.fileName.exists())
+ .collect(Collectors.toList());
+ /*
+ //this will try to send a file that was not registered or requested by the client
+ existingFiles.add(new AutoFileSyncEntry("none", new File("D:\\MinecraftPlugins\\BetterNether\\run\\server.properties"),true,(a, b, content) -> {
+ System.out.println("Got Content:" + content.length);
+ return true;
+ }));*/
+
+ /*//this will try to send a folder-file that was not registered or requested by the client
+ existingFiles.add(new AutoFileSyncEntry.ForDirectFileRequest(DataExchange.SYNC_FOLDER.folderID, new File("test.json"), DataExchange.SYNC_FOLDER.mapAbsolute("test.json").toFile()));*/
+
+ /*//this will try to send a folder-file that was not registered or requested by the client and is outside the base-folder
+ existingFiles.add(new AutoFileSyncEntry.ForDirectFileRequest(DataExchange.SYNC_FOLDER.folderID, new File("../breakout.json"), DataExchange.SYNC_FOLDER.mapAbsolute("../breakout.json").toFile()));*/
+
+
+ writeString(buf, token);
+ buf.writeInt(existingFiles.size());
+
+ BCLib.LOGGER.info("Sending " + existingFiles.size() + " Files to Client:");
+ for (AutoFileSyncEntry entry : existingFiles) {
+ int length = entry.serializeContent(buf);
+ BCLib.LOGGER.info(" - " + entry + " (" + PathUtil.humanReadableFileSize(length) + ")");
+ }
+ }
+
+ private List> receivedFiles;
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void deserializeIncomingDataOnClient(FriendlyByteBuf buf, PacketSender responseSender) {
+ if ( Configs.CLIENT_CONFIG.isAcceptingConfigs() || Configs.CLIENT_CONFIG.isAcceptingFiles() || Configs.CLIENT_CONFIG.isAcceptingMods()) {
+ token = readString(buf);
+ if (!token.equals(RequestFiles.currentToken)) {
+ RequestFiles.newToken();
+ BCLib.LOGGER.error("Unrequested File Transfer!");
+ receivedFiles = new ArrayList<>(0);
+ return;
+ }
+ RequestFiles.newToken();
+
+ int size = buf.readInt();
+ receivedFiles = new ArrayList<>(size);
+ BCLib.LOGGER.info("Server sent " + size + " Files:");
+ for (int i = 0; i < size; i++) {
+ Triple p = AutoFileSyncEntry.deserializeContent(buf);
+ if (p.first != null) {
+ final String type;
+ if (p.first.isConfigFile() && Configs.CLIENT_CONFIG.isAcceptingConfigs()) {
+ receivedFiles.add(p);
+ type = "Accepted Config ";
+ } else if (p.first instanceof AutoFileSyncEntry.ForModFileRequest && Configs.CLIENT_CONFIG.isAcceptingMods()){
+ receivedFiles.add(p);
+ type = "Accepted Mod ";
+ } else if ( Configs.CLIENT_CONFIG.isAcceptingFiles()){
+ receivedFiles.add(p);
+ type = "Accepted File ";
+ } else {
+ type = "Ignoring ";
+ }
+ BCLib.LOGGER.info(" - " + type + p.first + " (" + PathUtil.humanReadableFileSize(p.second.length) + ")");
+ }
+ else {
+ BCLib.LOGGER.error(" - Failed to receive File " + p.third + ", possibly sent from a Mod that is not installed on the client.");
+ }
+ }
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ protected void runOnClientGameThread(Minecraft client) {
+ if ( Configs.CLIENT_CONFIG.isAcceptingConfigs() || Configs.CLIENT_CONFIG.isAcceptingFiles() || Configs.CLIENT_CONFIG.isAcceptingMods()) {
+ BCLib.LOGGER.info("Writing Files:");
+
+ //TODO: Reject files that were not in the last RequestFiles.
+ 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/ru/bclib/api/dataexchange/handler/autosync/SyncFolderDescriptor.java b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/SyncFolderDescriptor.java
new file mode 100644
index 00000000..bba41d5e
--- /dev/null
+++ b/src/main/java/ru/bclib/api/dataexchange/handler/autosync/SyncFolderDescriptor.java
@@ -0,0 +1,206 @@
+package ru.bclib.api.dataexchange.handler.autosync;
+
+import net.minecraft.network.FriendlyByteBuf;
+import org.jetbrains.annotations.NotNull;
+import ru.bclib.BCLib;
+import ru.bclib.api.dataexchange.DataHandler;
+import ru.bclib.api.dataexchange.FileHash;
+import ru.bclib.api.dataexchange.handler.autosync.AutoSyncID.ForDirectFileRequest;
+import ru.bclib.config.Configs;
+import ru.bclib.util.PathUtil;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+public class SyncFolderDescriptor {
+ static class SubFile {
+ public final String relPath;
+ public final FileHash hash;
+
+
+ SubFile(String relPath, FileHash hash) {
+ this.relPath = relPath;
+ this.hash = hash;
+ }
+
+ @Override
+ public String toString() {
+ return relPath;
+ }
+
+ public void serialize(FriendlyByteBuf buf) {
+ DataHandler.writeString(buf, relPath);
+ hash.serialize(buf);
+ }
+
+ public static SubFile deserialize(FriendlyByteBuf buf) {
+ final String relPath = DataHandler.readString(buf);
+ FileHash hash = FileHash.deserialize(buf);
+ return new SubFile(relPath, hash);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o instanceof String) return relPath.equals(o);
+ if (!(o instanceof SubFile)) return false;
+ SubFile subFile = (SubFile) o;
+ return relPath.equals(subFile.relPath);
+ }
+
+ @Override
+ public int hashCode() {
+ return relPath.hashCode();
+ }
+ }
+
+ @NotNull
+ public final String folderID;
+ public final boolean removeAdditionalFiles;
+ @NotNull
+ public final Path localFolder;
+
+ private List fileCache;
+
+ public SyncFolderDescriptor(String folderID, Path localFolder, boolean removeAdditionalFiles) {
+ this.removeAdditionalFiles = removeAdditionalFiles;
+ this.folderID = folderID;
+ this.localFolder = localFolder;
+ fileCache = null;
+ }
+
+ @Override
+ public String toString() {
+ return "SyncFolderDescriptor{" + "folderID='" + folderID + '\'' + ", removeAdditionalFiles=" + removeAdditionalFiles + ", localFolder=" + localFolder + ", files=" + (fileCache == null ? "?" : fileCache.size()) + "}";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o instanceof String) {
+ return folderID.equals(o);
+ }
+ if (o instanceof ForDirectFileRequest) {
+ return folderID.equals(((ForDirectFileRequest) o).uniqueID);
+ }
+ if (!(o instanceof SyncFolderDescriptor)) return false;
+ SyncFolderDescriptor that = (SyncFolderDescriptor) o;
+ return folderID.equals(that.folderID);
+ }
+
+ @Override
+ public int hashCode() {
+ return folderID.hashCode();
+ }
+
+ public int fileCount() {
+ return fileCache == null ? 0 : fileCache.size();
+ }
+
+ public void invalidateCache() {
+ fileCache = null;
+ }
+
+ public void loadCache() {
+ if (fileCache == null) {
+ fileCache = new ArrayList<>(8);
+ PathUtil.fileWalker(localFolder.toFile(), p -> fileCache.add(new SubFile(localFolder.relativize(p)
+ .toString(), FileHash.create(p.toFile()))));
+
+ /*//this tests if we can trick the system to load files that are not beneath the base-folder
+ if (!BCLib.isClient()) {
+ fileCache.add(new SubFile("../breakout.json", FileHash.create(mapAbsolute("../breakout.json").toFile())));
+ }*/
+ }
+ }
+
+ public void serialize(FriendlyByteBuf buf) {
+ final boolean debugHashes = Configs.CLIENT_CONFIG.getBoolean(AutoSync.SYNC_CATEGORY, "debugHashes", false);
+ loadCache();
+
+ DataHandler.writeString(buf, folderID);
+ buf.writeBoolean(removeAdditionalFiles);
+ buf.writeInt(fileCache.size());
+ fileCache.forEach(fl -> {
+ BCLib.LOGGER.info(" - " + fl.relPath);
+ if (debugHashes) {
+ BCLib.LOGGER.info(" " + fl.hash);
+ }
+ fl.serialize(buf);
+ });
+ }
+
+ public static SyncFolderDescriptor deserialize(FriendlyByteBuf buf) {
+ final String folderID = DataHandler.readString(buf);
+ final boolean remAddFiles = buf.readBoolean();
+ final int count = buf.readInt();
+ SyncFolderDescriptor localDescriptor = AutoSync.getSyncFolderDescriptor(folderID);
+
+ final SyncFolderDescriptor desc;
+ if (localDescriptor != null) {
+ desc = new SyncFolderDescriptor(folderID, localDescriptor.localFolder, localDescriptor.removeAdditionalFiles && remAddFiles);
+ desc.fileCache = new ArrayList<>(count);
+ }
+ else {
+ BCLib.LOGGER.warning(BCLib.isClient() ? "Client" : "Server" + " does not know Sync-Folder ID '" + folderID + "'");
+ desc = null;
+ }
+
+ for (int i = 0; i < count; i++) {
+ SubFile relPath = SubFile.deserialize(buf);
+ if (desc != null) desc.fileCache.add(relPath);
+ }
+
+ return desc;
+ }
+
+ //Note: make sure loadCache was called before using this
+ boolean hasRelativeFile(String relFile) {
+ return fileCache.stream()
+ .filter(sf -> sf.equals(relFile))
+ .findFirst()
+ .isPresent();
+ }
+
+ //Note: make sure loadCache was called before using this
+ boolean hasRelativeFile(SubFile subFile) {
+ return hasRelativeFile(subFile.relPath);
+ }
+
+ //Note: make sure loadCache was called before using this
+ SubFile getLocalSubFile(String relPath) {
+ return fileCache.stream()
+ .filter(sf -> sf.relPath.equals(relPath))
+ .findFirst()
+ .orElse(null);
+ }
+
+ Stream relativeFilesStream() {
+ loadCache();
+ return fileCache.stream();
+ }
+
+ public Path mapAbsolute(String relPath) {
+ return this.localFolder.resolve(relPath)
+ .normalize();
+ }
+
+ public Path mapAbsolute(SubFile subFile) {
+ return this.localFolder.resolve(subFile.relPath)
+ .normalize();
+ }
+
+ public boolean acceptChildElements(Path absPath) {
+ return PathUtil.isChildOf(this.localFolder, absPath);
+ }
+
+ public boolean acceptChildElements(SubFile subFile) {
+ return acceptChildElements(mapAbsolute(subFile));
+ }
+
+ public boolean discardChildElements(SubFile subFile) {
+ return !acceptChildElements(subFile);
+ }
+}
diff --git a/src/main/java/ru/bclib/api/datafixer/DataFixerAPI.java b/src/main/java/ru/bclib/api/datafixer/DataFixerAPI.java
new file mode 100644
index 00000000..d104cbb3
--- /dev/null
+++ b/src/main/java/ru/bclib/api/datafixer/DataFixerAPI.java
@@ -0,0 +1,615 @@
+package ru.bclib.api.datafixer;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.minecraft.Util;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.components.toasts.SystemToast;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.gui.screens.worldselection.EditWorldScreen;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtIo;
+import net.minecraft.nbt.StringTag;
+import net.minecraft.nbt.Tag;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.TranslatableComponent;
+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 org.jetbrains.annotations.NotNull;
+import ru.bclib.BCLib;
+import ru.bclib.api.WorldDataAPI;
+import ru.bclib.config.Configs;
+import ru.bclib.gui.screens.AtomicProgressListener;
+import ru.bclib.gui.screens.ConfirmFixScreen;
+import ru.bclib.gui.screens.LevelFixErrorScreen;
+import ru.bclib.gui.screens.LevelFixErrorScreen.Listener;
+import ru.bclib.gui.screens.ProgressScreen;
+import ru.bclib.util.Logger;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.IOException;
+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;
+
+/**
+ * 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 static interface Callback {
+ public 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();
+ File levelDat = levelStorageAccess.getLevelPath(LevelResource.LEVEL_DATA_FILE).toFile();
+ boolean newWorld = false;
+ if (!levelDat.exists()) {
+ BCLib.LOGGER.info("Creating a new World, no fixes needed");
+ newWorld = true;
+ }
+
+ initializeWorldData(levelPath, newWorld);
+ if (newWorld) return false;
+
+ return fixData(levelPath, levelStorageAccess.getLevelId(), showUI, onResume);
+ }
+ /**
+ * Initializes the DataStorage for this world. If the world is new, the patch registry is initialized to the
+ * current versions of the plugins.
+ *
+ * This implementation will create a new {@link LevelStorageAccess} and call {@link #initializeWorldData(File, boolean)}
+ * using the provided root path.
+ * @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 newWorld {@code true} if this is a fresh world
+ */
+ public static void initializeWorldData(LevelStorageSource levelSource, String levelID, boolean newWorld) {
+ wrapCall(levelSource, levelID, (levelStorageAccess) -> {
+ initializeWorldData(levelStorageAccess.getLevelPath(LevelResource.ROOT).toFile(), newWorld);
+ return true;
+ });
+ }
+
+ /**
+ * Initializes the DataStorage for this world. If the world is new, the patch registry is initialized to the
+ * current versions of the plugins.
+ * @param levelBaseDir Folder of the world
+ * @param newWorld {@code true} if this is a fresh world
+ *
+ */
+ public static void initializeWorldData(File levelBaseDir, boolean newWorld){
+ WorldDataAPI.load(new File(levelBaseDir, "data"));
+
+ if (newWorld){
+ getMigrationProfile().markApplied();
+ WorldDataAPI.saveFile(BCLib.MOD_ID);
+ }
+ }
+
+ @Environment(EnvType.CLIENT)
+ private static AtomicProgressListener showProgressScreen(){
+ ProgressScreen ps = new ProgressScreen(Minecraft.getInstance().screen, new TranslatableComponent("title.bclib.datafixer.progress"), new TranslatableComponent("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((String) "Patching... {}%", percentage);
+ }
+ }
+
+ @Override
+ public void resetAtomic() {
+ counter = new AtomicInteger(0);
+ }
+
+ public void stop() {
+ }
+
+ public void progressStage(Component component) {
+ BCLib.LOGGER.info((String) "Patcher Stage... {}%", component.getString());
+ }
+ };
+ }
+ } else {
+ progress = null;
+ }
+
+ Supplier runner = () -> {
+ if (createBackup) {
+ progress.progressStage(new TranslatableComponent("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.getBoolean(Configs.MAIN_PATCH_CATEGORY, "applyPatches", true)) {
+ 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 = WorldDataAPI.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((Screen) null, whenFinished::accept));
+ }
+
+ private static State runDataFixes(File dir, MigrationProfile profile, AtomicProgressListener progress) {
+ State state = new State();
+ progress.resetAtomic();
+
+ progress.progressStage(new TranslatableComponent("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(new TranslatableComponent("message.bclib.datafixer.progress.players"));
+ players.parallelStream().forEach((file) -> {
+ fixPlayer(profile, state, file);
+ progress.incAtomic(maxProgress);
+ });
+
+ progress.progressStage(new TranslatableComponent("message.bclib.datafixer.progress.level"));
+ fixLevel(profile, state, dir);
+ progress.incAtomic(maxProgress);
+
+ progress.progressStage(new TranslatableComponent("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(new TranslatableComponent("message.bclib.datafixer.progress.regions"));
+ regions.parallelStream().forEach((file) -> {
+ fixRegion(profile, state, file);
+ progress.incAtomic(maxProgress);
+ });
+
+ if (!state.didFail) {
+ progress.progressStage(new TranslatableComponent("message.bclib.datafixer.progress.saving"));
+ profile.markApplied();
+ WorldDataAPI.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 {
+ LOGGER.info("Inspecting " + file);
+ boolean[] changed = new boolean[1];
+ RegionFile region = new RegionFile(file, file.getParentFile(), 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 = WorldDataAPI.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 e){
+ return NbtIo.read(file);
+ }
+ }
+
+}
diff --git a/src/main/java/ru/bclib/api/datafixer/ForcedLevelPatch.java b/src/main/java/ru/bclib/api/datafixer/ForcedLevelPatch.java
new file mode 100644
index 00000000..34951454
--- /dev/null
+++ b/src/main/java/ru/bclib/api/datafixer/ForcedLevelPatch.java
@@ -0,0 +1,47 @@
+package ru.bclib.api.datafixer;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+
+/**
+ * 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/ru/bclib/api/datafixer/MigrationProfile.java b/src/main/java/ru/bclib/api/datafixer/MigrationProfile.java
new file mode 100644
index 00000000..26065093
--- /dev/null
+++ b/src/main/java/ru/bclib/api/datafixer/MigrationProfile.java
@@ -0,0 +1,320 @@
+package ru.bclib.api.datafixer;
+
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.NbtIo;
+import net.minecraft.nbt.Tag;
+import org.jetbrains.annotations.NotNull;
+import ru.bclib.BCLib;
+import ru.bclib.api.WorldDataAPI;
+import ru.bclib.util.ModUtil;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+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};
+ if (root.contains("palette")){
+ ListTag items = root.getList("palette", Tag.TAG_COMPOUND);
+ items.forEach(inTag -> {
+ CompoundTag tag = (CompoundTag)inTag;
+ changed[0] |= profile.replaceStringFromIDs(tag, "Name");
+ });
+ }
+
+ if (changed[0]){
+ DataFixerAPI.LOGGER.info("Writing NBT " + file);
+ NbtIo.writeCompressed(root, file);
+ }
+ }
+ catch (IOException e) {
+ e.printStackTrace();
+ }
+ });
+ }
+
+ private static List