This commit is contained in:
iceBear67 committed 2025-09-21 12:57:07 +08:00
1 parent 32b61dedf9
commit bc9fab8d22
15 files changed
+332 -97

No files matched your search

+3
View File
@@ -1,6 +1,8 @@
plugins {
id 'fabric-loom' version "${loom_version}"
id 'maven-publish'
id("io.freefair.lombok") version "9.0.0-rc2"
}
version = project.mod_version
@@ -38,6 +40,7 @@ dependencies {
// Fabric API. This is technically optional, but you probably want it anyway.
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
implementation("io.minio:minio:8.5.17")
}
@@ -1,10 +0,0 @@
package com.example;
import net.fabricmc.api.ClientModInitializer;
public class ExampleModClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
// This entrypoint is suitable for setting up client-specific logic, such as rendering.
}
}
@@ -1,15 +0,0 @@
package com.example.mixin.client;
import net.minecraft.client.MinecraftClient;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(MinecraftClient.class)
public class ExampleClientMixin {
@Inject(at = @At("HEAD"), method = "run")
private void init(CallbackInfo info) {
// This code is injected into the start of MinecraftClient.run()V
}
}
@@ -1,11 +0,0 @@
{
"required": true,
"package": "com.example.mixin.client",
"compatibilityLevel": "JAVA_21",
"client": [
"ExampleClientMixin"
],
"injectors": {
"defaultRequire": 1
}
}
-24
View File
@@ -1,24 +0,0 @@
package com.example;
import net.fabricmc.api.ModInitializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ExampleMod implements ModInitializer {
public static final String MOD_ID = "modid";
// This logger is used to write text to the console and the log file.
// It is considered best practice to use your mod id as the logger's name.
// That way, it's clear which mod wrote info, warnings, and errors.
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
@Override
public void onInitialize() {
// This code runs as soon as Minecraft is in a mod-load-ready state.
// However, some things (like resources) may still be uninitialized.
// Proceed with mild caution.
LOGGER.info("Hello Fabric world!");
}
}
@@ -1,15 +0,0 @@
package com.example.mixin;
import net.minecraft.server.MinecraftServer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(MinecraftServer.class)
public class ExampleMixin {
@Inject(at = @At("HEAD"), method = "loadWorld")
private void init(CallbackInfo info) {
// This code is injected into the start of MinecraftServer.loadWorld()V
}
}
@@ -0,0 +1,134 @@
package io.ib67.sfcraft;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.ib67.sfcraft.config.JustBackupConfig;
import io.ib67.sfcraft.config.StorageOption;
import io.ib67.sfcraft.config.serializer.StorageOptionSerializer;
import io.ib67.sfcraft.mixin.LevelStorageAccessor;
import io.ib67.sfcraft.mixin.MixinMinecraftServer;
import lombok.SneakyThrows;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.world.ServerWorld;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.FileInputStream;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public class JustBackupMod implements ModInitializer {
public static final String MOD_ID = "justbackup";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public static final AtomicBoolean PERFORMING_BACKUP = new AtomicBoolean(false);
private static final Gson GSON = new GsonBuilder().setPrettyPrinting()
.registerTypeAdapter(StorageOption.class, new StorageOptionSerializer(
Map.of("s3", StorageOption.S3.class,
"local", StorageOption.Local.class)
))
.create();
protected ScheduledExecutorService scheduledBackupExecutor;
protected JustBackupConfig config;
protected volatile MinecraftServer server;
@Override
@SneakyThrows
public void onInitialize() {
LOGGER.info("Loading backup configuration");
var configPath = Path.of("config").resolve("just_backup.json");
if (Files.notExists(configPath.getParent())) {
Files.createDirectories(configPath.getParent());
}
if (Files.notExists(configPath)) {
saveConfig(configPath);
}
config = GSON.fromJson(Files.readString(configPath), JustBackupConfig.class);
LOGGER.info("Configuration has been loaded. Using storage option: {}", config.option().type());
scheduledBackupExecutor = Executors.newSingleThreadScheduledExecutor();
ServerLifecycleEvents.SERVER_STARTED.register(server -> {
LOGGER.info("Launching background threads...");
this.server = server;
scheduledBackupExecutor.scheduleAtFixedRate(this::performBackup,
config.backupIntervalMinutes(), config.backupIntervalMinutes(), TimeUnit.MINUTES);
});
}
@SneakyThrows
private void performBackup() {
if (server == null) {
LOGGER.warn("MinecraftServer is not initialized yet, not doing backup");
return;
}
int i = 0;
while (!PERFORMING_BACKUP.compareAndSet(false, true)) {
if (i++ % 10 == 0) LOGGER.info("Server is saving world while performing backup! waiting...");
Thread.sleep(2000);
}
// performing backup is now true
try {
if (i == 0) {
// not saved yet.
server.saveAll(true, true, false);
}
var session = ((LevelStorageAccessor) server).getSession();
var path = session.getDirectory().getRootPath();
// !compress
if (!config.compress()) {
performBackupUncompressed(new WorldDir(Path.of(path)));
}
} finally {
PERFORMING_BACKUP.set(false);
}
}
@SneakyThrows
private void performBackupUncompressed(WorldDir worldDir) {
var everything = worldDir.everything();
var opt = (StorageOption.Local) config.option();
var dst = Path.of(opt.saveDir()).resolve("Backup_" + DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now()));
Files.createDirectories(dst);
try(var f = Files.walk(worldDir.region())){
bundleUncompressed(dst.resolve("regions.gz"), f.toList());
}
try(var f = Files.walk(worldDir.poi())) {
bundleUncompressed(dst.resolve("poi.gz"), f.toList());
}
var other = dst.resolve("other.zip", )
}
@SneakyThrows
private static void bundleUncompressed(Path dst, Collection<Path> toBundle){
try(var fileOut = new RandomAccessFile(dst.toFile(), "rw");){
var fileOutDst = fileOut.getChannel();
for (Path path : toBundle) {
var file = path.toFile();
try(var in = new FileInputStream(path.toFile())){
in.getChannel().transferTo(0, file.length(), fileOutDst);
}
}
}
}
@SneakyThrows
private void saveConfig(Path configPath) {
LOGGER.info("Saving default configuration");
var cfg = new JustBackupConfig(
60,
5,
true,
new StorageOption.Local("backups")
);
Files.writeString(configPath, GSON.toJson(cfg));
}
}
@@ -0,0 +1,67 @@
package io.ib67.sfcraft;
import lombok.SneakyThrows;
import java.nio.file.FileVisitOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public record WorldDir(
Path root
) {
public Path region() {
return root.resolve("region");
}
public Path poi() {
return root.resolve("poi");
}
public Path entities() {
return root.resolve("entities");
}
public Path playerdata() {
return root.resolve("playerdata");
}
@SneakyThrows
public Map<String, WorldDir> otherDimensions() {
var begin = root.resolve("dimensions").toAbsolutePath();
try (var w = Files.walk(begin, 3)) {
var _r = w.filter(Files::isDirectory)
.filter(it -> Files.exists(it.resolve("region")) && Files.exists(it.resolve("poi")))
.collect(Collectors.groupingBy(it -> it.relativize(root).toString(),
Collectors.mapping(WorldDir::new, Collectors.toList())));
var r = new HashMap<String, WorldDir>();
_r.forEach((k, v) -> {
assert v.size() <= 1;
r.put(k, v.getFirst());
});
return r;
}
}
/**
* @return Files expect poi, region, entities, playerdata and things in dimension folder.
*/
@SneakyThrows
public List<Path> otherFiles() {
var exclude = List.of(playerdata(), entities(), region(), poi(), root.resolve("dimensions"));
try (var f = Files.walk(root)) {
return f.filter(it -> exclude.stream().noneMatch(it::startsWith)).toList();
}
}
@SneakyThrows
public List<Path> everything() {
try (var f = Files.walk(root)) {
return f.toList();
}
}
}
@@ -0,0 +1,18 @@
package io.ib67.sfcraft.config;
import org.apache.commons.lang3.Validate;
import java.util.List;
public record JustBackupConfig(
long backupIntervalMinutes,
int keepBackups,
boolean compress,
StorageOption option
) {
public JustBackupConfig {
Validate.isTrue(backupIntervalMinutes > 0, "backup interval must be positive");
Validate.isTrue(keepBackups > 0, "keep backups must be positive");
Validate.notNull(option, "option cannot be null");
}
}
@@ -0,0 +1,27 @@
package io.ib67.sfcraft.config;
public sealed interface StorageOption {
String type();
record S3(
String region,
String accessKey,
String secretKey,
String endpoint,
String bucket
) implements StorageOption {
@Override
public String type() {
return "s3";
}
}
record Local(
String saveDir
) implements StorageOption {
@Override
public String type() {
return "local";
}
}
}
@@ -0,0 +1,29 @@
package io.ib67.sfcraft.config.serializer;
import com.google.gson.*;
import io.ib67.sfcraft.config.StorageOption;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.lang.reflect.Type;
import java.util.Map;
@RequiredArgsConstructor
@Getter
public class StorageOptionSerializer implements JsonDeserializer<StorageOption>, JsonSerializer<StorageOption> {
protected final Map<String, Class<? extends StorageOption>> subTypes;
@Override
public StorageOption deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
if (!(json instanceof JsonObject jo)) throw new JsonParseException("Storage option must be a json object");
var type = jo.get("type").getAsString().toLowerCase();
return context.deserialize(json, subTypes.get(type));
}
@Override
public JsonElement serialize(StorageOption src, Type typeOfSrc, JsonSerializationContext context) {
var jo = context.serialize(src).getAsJsonObject();
jo.addProperty("type", src.type());
return jo;
}
}
@@ -0,0 +1,12 @@
package io.ib67.sfcraft.mixin;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.storage.LevelStorage;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(MinecraftServer.class)
public interface LevelStorageAccessor {
@Accessor
LevelStorage.Session getSession();
}
@@ -0,0 +1,33 @@
package io.ib67.sfcraft.mixin;
import io.ib67.sfcraft.JustBackupMod;
import lombok.Getter;
import net.minecraft.server.MinecraftServer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import javax.security.auth.callback.Callback;
import java.util.concurrent.atomic.AtomicBoolean;
@Mixin(MinecraftServer.class)
public abstract class MixinMinecraftServer {
@Shadow private int ticksUntilAutosave;
@Shadow protected abstract int getAutosaveInterval();
@Inject(method = "runAutosave", at = @At("HEAD"), cancellable = true)
private void backup$checkSaving(CallbackInfo ci) {
// is already backing up
this.ticksUntilAutosave = getAutosaveInterval();
if (!JustBackupMod.PERFORMING_BACKUP.compareAndSet(false, true)) ci.cancel();
}
@Inject(method="runAutosave", at=@At("RETURN"))
private void backup$disableProtect(CallbackInfo ci){
JustBackupMod.PERFORMING_BACKUP.set(false);
}
}
+6 -20
View File
@@ -1,41 +1,27 @@
{
"schemaVersion": 1,
"id": "modid",
"id": "justbackup",
"version": "${version}",
"name": "Example mod",
"description": "This is an example description! Tell everyone what your mod is about!",
"name": "JustBackup",
"description": "Just backup",
"authors": [
"Me!"
"iceBear67"
],
"contact": {
"homepage": "https://fabricmc.net/",
"sources": "https://github.com/FabricMC/fabric-example-mod"
},
"license": "CC0-1.0",
"icon": "assets/modid/icon.png",
"environment": "*",
"entrypoints": {
"main": [
"com.example.ExampleMod"
],
"client": [
"com.example.ExampleModClient"
"io.ib67.sfcraft.JustBackupMod"
]
},
"mixins": [
"modid.mixins.json",
{
"config": "modid.client.mixins.json",
"environment": "client"
}
"justbackup.mixins.json"
],
"depends": {
"fabricloader": ">=0.16.14",
"minecraft": "~1.21.8",
"java": ">=21",
"fabric-api": "*"
},
"suggests": {
"another-mod": "*"
}
}
@@ -1,9 +1,10 @@
{
"required": true,
"package": "com.example.mixin",
"package": "io.ib67.sfcraft.mixin",
"compatibilityLevel": "JAVA_21",
"mixins": [
"ExampleMixin"
"LevelStorageAccessor",
"MixinMinecraftServer"
],
"injectors": {
"defaultRequire": 1