simplify the usability and hardening code: main-thread hop and async saved-data scan shared (MainThread.run, MapUsageScan.diskAsync), render settings passed as a record and applied by one applyConfig, rejections counted in tryReserve, pack folder scanned once per phase with the loader's zip order, admin subcommand table, shared number parsing and timing text, dead overloads removed, comments thinned
This commit is contained in:
1 parent
57a3a06cdf
commit
1797436519
19 files changed
+272
-314
No files matched your search
@@ -47,28 +47,16 @@ public final class Main extends JavaPlugin {
|
||||
public final NamespacedKey filmCountKey = new NamespacedKey(this, "filmcount");
|
||||
public final NamespacedKey filmMarker = new NamespacedKey(this, "film");
|
||||
|
||||
/** The {@code render.*} limits from config.yml, normalised. */
|
||||
public record RenderSettings(int threads, int maxConcurrent, int queueSize, int timeoutSeconds) {
|
||||
@Override
|
||||
public String toString() {
|
||||
return threads + " core(s), max " + maxConcurrent + " concurrent, queue " + queueSize + ", timeout "
|
||||
+ timeoutSeconds + "s";
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
instance = this;
|
||||
|
||||
saveDefaultConfig();
|
||||
addNewConfigOptions();
|
||||
DebugLog.configure(getLogger(), getConfig().getBoolean("debug", false));
|
||||
RenderManager.Settings settings = applyConfig();
|
||||
MapManager.init(getServer().getWorlds().getFirst().getUID());
|
||||
pendingPhotos = new PendingPhotos(getDataFolder());
|
||||
pendingPhotos.recoverAbandoned();
|
||||
RenderSettings settings = renderSettings();
|
||||
renderManager = new RenderManager(
|
||||
this, settings.threads(), settings.maxConcurrent(), settings.queueSize(), settings.timeoutSeconds());
|
||||
renderManager = new RenderManager(this, settings);
|
||||
getLogger().info("Render pool: " + settings + ".");
|
||||
|
||||
PluginCommand command = Objects.requireNonNull(getCommand("pixelpic"));
|
||||
@@ -88,7 +76,6 @@ public final class Main extends JavaPlugin {
|
||||
|
||||
// Initialise the map palette on the main thread (MapPalette) before any off-thread dithering.
|
||||
MapColorPalette.size();
|
||||
// Resource pack (download/update) and models are set up off the main thread; photos wait for it.
|
||||
setup = new SetupManager(this);
|
||||
setup.start(null);
|
||||
Bukkit.getScheduler().runTaskTimer(this, () -> setup.closeRetired(false), 100, 100);
|
||||
@@ -126,7 +113,10 @@ public final class Main extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private RenderSettings renderSettings() {
|
||||
/** Brings config.yml up to date, applies the debug switch and returns the render limits. */
|
||||
private RenderManager.Settings applyConfig() {
|
||||
addNewConfigOptions();
|
||||
DebugLog.configure(getLogger(), getConfig().getBoolean("debug", false));
|
||||
int cores = Runtime.getRuntime().availableProcessors();
|
||||
int threads = getConfig().getInt("render.threads", 0);
|
||||
if (threads <= 0) threads = Math.max(1, cores - 2);
|
||||
@@ -134,22 +124,15 @@ public final class Main extends JavaPlugin {
|
||||
int maxConcurrent = Math.max(1, getConfig().getInt("render.max-concurrent", 2));
|
||||
int queueSize = Math.max(0, getConfig().getInt("render.queue-size", 8));
|
||||
int timeoutSeconds = Math.max(1, getConfig().getInt("render.timeout-seconds", 30));
|
||||
return new RenderSettings(threads, maxConcurrent, queueSize, timeoutSeconds);
|
||||
return new RenderManager.Settings(threads, maxConcurrent, queueSize, timeoutSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code /pixelpic reload}: re-reads config.yml (debug logging; render limits applied to the running pool,
|
||||
* queued photos are kept) and sets up the renderer again from the resource pack folder and the models,
|
||||
* downloading or updating the pack as configured. False when a setup run is still going on.
|
||||
*/
|
||||
/** {@code /pixelpic reload}: config, render limits (live), resource pack and models. False while a setup runs. */
|
||||
public boolean reload(CommandSender requester) {
|
||||
if (setup.running()) return false;
|
||||
reloadConfig();
|
||||
addNewConfigOptions();
|
||||
DebugLog.configure(getLogger(), getConfig().getBoolean("debug", false));
|
||||
RenderSettings settings = renderSettings();
|
||||
renderManager.configure(
|
||||
settings.threads(), settings.maxConcurrent(), settings.queueSize(), settings.timeoutSeconds());
|
||||
RenderManager.Settings settings = applyConfig();
|
||||
renderManager.configure(settings);
|
||||
getLogger().info("Reloaded config.yml: render pool " + settings + ", debug " + DebugLog.enabled() + ".");
|
||||
return setup.start(requester);
|
||||
}
|
||||
@@ -158,7 +141,7 @@ public final class Main extends JavaPlugin {
|
||||
public void onDisable() {
|
||||
SurvivalRecipes.unregister();
|
||||
if (renderManager != null) {
|
||||
renderManager.shutdown(); // delivers or fails every open job synchronously (film refunds)
|
||||
renderManager.shutdown();
|
||||
}
|
||||
MapManager.detachAll();
|
||||
if (setup != null) {
|
||||
|
||||
@@ -32,6 +32,16 @@ public final class ResourcePackLoader {
|
||||
return root.relativize(path).toString().replace(File.separatorChar, '/');
|
||||
}
|
||||
|
||||
/** The {@code .zip} files below {@code root} in load order (see the class doc). */
|
||||
public static List<Path> zips(Path root) throws IOException {
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
return walk.filter(Files::isRegularFile)
|
||||
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
|
||||
.sorted(Comparator.comparing(p -> relative(root, p)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
public static Optional<ResourcePack> load(File resourcePackDir, Logger logger) {
|
||||
return load(resourcePackDir, logger, Set.of());
|
||||
}
|
||||
@@ -58,14 +68,9 @@ public final class ResourcePackLoader {
|
||||
}
|
||||
}
|
||||
|
||||
Path root = resourcePackDir.toPath();
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
List<Path> zips = walk.filter(Files::isRegularFile)
|
||||
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
|
||||
.filter(p -> !exclude.contains(p))
|
||||
.sorted(Comparator.comparing(p -> relative(root, p)))
|
||||
.toList();
|
||||
for (Path zip : zips) {
|
||||
try {
|
||||
for (Path zip : zips(resourcePackDir.toPath())) {
|
||||
if (exclude.contains(zip)) continue;
|
||||
try {
|
||||
ZipResourcePack pack = new ZipResourcePack(zip);
|
||||
// Zip entries are files only; the client jar has no pack.mcmeta, so probe two vanilla files.
|
||||
|
||||
@@ -7,10 +7,9 @@ import eu.mhsl.minecraft.pixelpics.setup.SetupManager;
|
||||
import eu.mhsl.minecraft.pixelpics.survival.CameraItems;
|
||||
import eu.mhsl.minecraft.pixelpics.survival.PhotoService;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.DebugLog;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MainThread;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapUsageScan;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -28,21 +27,31 @@ import org.bukkit.command.TabExecutor;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.plugin.IllegalPluginAccessException;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/**
|
||||
* {@code /pixelpic}: {@code help} (everyone with {@code pixelpic.use}: how camera, film and photos work) and the admin
|
||||
* subcommands ({@code pixelpic.admin}): {@code info}, {@code photo}, {@code give}, {@code reload}, {@code cleanup}.
|
||||
* Messages are German like the rest of the player-facing texts.
|
||||
*/
|
||||
/** {@code /pixelpic help} for everyone, the other subcommands need {@code pixelpic.admin}. Messages are German. */
|
||||
public class PixelPicsCommand implements TabExecutor {
|
||||
|
||||
private static final String ADMIN = "pixelpic.admin";
|
||||
private static final List<String> ADMIN_SUBCOMMANDS = List.of("info", "photo", "give", "reload", "cleanup");
|
||||
private static final int DEFAULT_CLEANUP_DAYS = 30;
|
||||
private static final int MAX_GIVE = 64;
|
||||
|
||||
/** An admin subcommand as listed by {@code help}. */
|
||||
private record Sub(String name, String params, String help) {}
|
||||
|
||||
private static final List<Sub> ADMIN_SUBS = List.of(
|
||||
new Sub("info", "", "Status, Versionen, Resource Pack, Warteschlange, Statistik"),
|
||||
new Sub("photo", " [Spieler]", "Testfoto ohne Kamera und Film, mit Zeitmessung"),
|
||||
new Sub("give", " <Spieler> <camera|film> [Anzahl]", "Kamera oder Filmrollen geben"),
|
||||
new Sub("reload", "", "config.yml, Resource Pack und Modelle neu laden"),
|
||||
new Sub(
|
||||
"cleanup",
|
||||
" [confirm] [Tage]",
|
||||
"ungenutzte Fotos löschen (ohne confirm nur Vorschau, Standard " + DEFAULT_CLEANUP_DAYS
|
||||
+ " Tage)"));
|
||||
private static final List<String> ADMIN_SUBCOMMANDS =
|
||||
ADMIN_SUBS.stream().map(Sub::name).toList();
|
||||
|
||||
private static boolean cleanupRunning = false;
|
||||
|
||||
@Override
|
||||
@@ -69,7 +78,8 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
case "photo" -> photo(sender, label, args);
|
||||
case "give" -> give(sender, label, args);
|
||||
case "reload" -> reload(sender);
|
||||
default -> cleanup(sender, label, args);
|
||||
case "cleanup" -> cleanup(sender, label, args);
|
||||
default -> throw new IllegalStateException(sub);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -136,28 +146,15 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
}
|
||||
if (!sender.hasPermission(ADMIN)) return;
|
||||
sender.sendMessage(Component.text("Admin-Befehle:", NamedTextColor.GOLD));
|
||||
adminLine(sender, label, "info", "", "Status, Versionen, Resource Pack, Warteschlange, Statistik");
|
||||
adminLine(sender, label, "photo", " [Spieler]", "Testfoto ohne Kamera und Film, mit Zeitmessung");
|
||||
adminLine(sender, label, "give", " <Spieler> <camera|film> [Anzahl]", "Kamera oder Filmrollen geben");
|
||||
adminLine(sender, label, "reload", "", "config.yml, Resource Pack und Modelle neu laden");
|
||||
adminLine(
|
||||
sender,
|
||||
label,
|
||||
"cleanup",
|
||||
" [confirm] [Tage]",
|
||||
"ungenutzte Fotos löschen (ohne confirm nur Vorschau, Standard " + DEFAULT_CLEANUP_DAYS + " Tage)");
|
||||
for (Sub sub : ADMIN_SUBS) {
|
||||
String command = "/" + label + " " + sub.name();
|
||||
sender.sendMessage(Component.text(command + sub.params(), NamedTextColor.YELLOW)
|
||||
.clickEvent(ClickEvent.suggestCommand(command + " "))
|
||||
.hoverEvent(HoverEvent.showText(Component.text("Klicken zum Einfügen")))
|
||||
.append(Component.text(" – " + sub.help(), NamedTextColor.GRAY)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void adminLine(CommandSender sender, String label, String sub, String params, String text) {
|
||||
String command = "/" + label + " " + sub;
|
||||
sender.sendMessage(Component.text(command + params, NamedTextColor.YELLOW)
|
||||
.clickEvent(ClickEvent.suggestCommand(command + " "))
|
||||
.hoverEvent(HoverEvent.showText(Component.text("Klicken zum Einfügen")))
|
||||
.append(Component.text(" – " + text, NamedTextColor.GRAY)));
|
||||
}
|
||||
|
||||
// ---- info ----
|
||||
|
||||
private static void info(CommandSender sender) {
|
||||
Main plugin = Main.getInstance();
|
||||
SetupManager setup = plugin.getSetup();
|
||||
@@ -227,9 +224,8 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
+ stats.rejectedCount() + " abgelehnt";
|
||||
if (stats.developedCount() > 0) {
|
||||
RenderStats.Timings avg = stats.average();
|
||||
photos += "; Ø " + seconds(avg.total()) + " (Warten " + avg.queued() + " ms, Snapshot " + avg.snapshot()
|
||||
+ " ms, Raytracing " + seconds(avg.trace()) + ", Dithern " + avg.dither() + " ms), max. "
|
||||
+ seconds(stats.maxTotal()) + ", Snapshot max. " + stats.maxSnapshot() + " ms";
|
||||
photos += "; Ø " + seconds(avg.total()) + " (" + phases(avg, "") + "), max. " + seconds(stats.maxTotal())
|
||||
+ ", Snapshot max. " + stats.maxSnapshot() + " ms";
|
||||
}
|
||||
line(sender, "Fotos seit Start", photos, null);
|
||||
line(sender, "Debug-Logging", DebugLog.enabled() ? "an" : "aus (config.yml: debug)", null);
|
||||
@@ -239,11 +235,7 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
String text = storage.photos() + (storage.photos() == 1 ? " Foto, " : " Fotos, ")
|
||||
+ String.format(Locale.GERMANY, "%.1f MB", storage.bytes() / 1048576.0) + " in "
|
||||
+ storage.folder();
|
||||
try {
|
||||
Bukkit.getScheduler().runTask(plugin, () -> line(sender, "Gespeichert", text, null));
|
||||
} catch (IllegalPluginAccessException ignored) {
|
||||
// disabled meanwhile
|
||||
}
|
||||
MainThread.run(plugin, () -> line(sender, "Gespeichert", text, null));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -260,12 +252,15 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
.append(Component.text(value, valueColor != null ? valueColor : NamedTextColor.WHITE)));
|
||||
}
|
||||
|
||||
private static String phases(RenderStats.Timings t, String snapshotNote) {
|
||||
return "Warten " + t.queued() + " ms, Snapshot " + t.snapshot() + " ms" + snapshotNote + ", Raytracing "
|
||||
+ seconds(t.trace()) + ", Dithern " + t.dither() + " ms";
|
||||
}
|
||||
|
||||
private static String seconds(long ms) {
|
||||
return String.format(Locale.GERMANY, "%.2f s", ms / 1000.0);
|
||||
}
|
||||
|
||||
// ---- photo ----
|
||||
|
||||
private static void photo(CommandSender sender, String label, String[] args) {
|
||||
Player target;
|
||||
if (args.length >= 2) {
|
||||
@@ -289,9 +284,8 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
return;
|
||||
}
|
||||
sender.sendMessage(Component.text(
|
||||
"Testfoto" + who + " entwickelt in " + seconds(timings.total()) + ": Warten " + timings.queued()
|
||||
+ " ms, Snapshot " + timings.snapshot() + " ms (Main-Thread), Raytracing "
|
||||
+ seconds(timings.trace()) + ", Dithern " + timings.dither() + " ms.",
|
||||
"Testfoto" + who + " entwickelt in " + seconds(timings.total()) + ": "
|
||||
+ phases(timings, " (Main-Thread)") + ".",
|
||||
NamedTextColor.GREEN));
|
||||
});
|
||||
if (refusal != null) {
|
||||
@@ -303,8 +297,6 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
// ---- give ----
|
||||
|
||||
private static void give(CommandSender sender, String label, String[] args) {
|
||||
if (args.length < 3) {
|
||||
error(sender, "Verwendung: /" + label + " give <Spieler> <camera|film> [Anzahl]");
|
||||
@@ -318,17 +310,10 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
error(sender, "Unbekanntes Item „" + args[2] + "“ — möglich sind camera und film.");
|
||||
return;
|
||||
}
|
||||
int amount = 1;
|
||||
if (args.length >= 4) {
|
||||
try {
|
||||
amount = Integer.parseInt(args[3]);
|
||||
} catch (NumberFormatException e) {
|
||||
amount = -1;
|
||||
}
|
||||
if (amount < 1 || amount > MAX_GIVE) {
|
||||
error(sender, "Die Anzahl muss eine Zahl von 1 bis " + MAX_GIVE + " sein.");
|
||||
return;
|
||||
}
|
||||
int amount = args.length >= 4 ? parse(args[3], 1, MAX_GIVE) : 1;
|
||||
if (amount < 0) {
|
||||
error(sender, "Die Anzahl muss eine Zahl von 1 bis " + MAX_GIVE + " sein.");
|
||||
return;
|
||||
}
|
||||
for (Player target : targets) {
|
||||
if (camera) {
|
||||
@@ -372,8 +357,6 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
return List.of(player);
|
||||
}
|
||||
|
||||
// ---- reload ----
|
||||
|
||||
private static void reload(CommandSender sender) {
|
||||
Main plugin = Main.getInstance();
|
||||
if (!plugin.reload(sender)) {
|
||||
@@ -389,12 +372,7 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
// ---- cleanup ----
|
||||
|
||||
/**
|
||||
* {@code /pixelpic cleanup [days]} dry-run; {@code /pixelpic cleanup confirm [days]} deletes photos that no saved
|
||||
* or loaded item references (see {@link MapUsageScan}) and that nobody has seen for {@code days} days.
|
||||
*/
|
||||
/** Without {@code confirm} a dry run; deletes photos no item references that nobody saw for {@code days} days. */
|
||||
private static void cleanup(CommandSender sender, String label, String[] args) {
|
||||
boolean confirm = false;
|
||||
int days = DEFAULT_CLEANUP_DAYS;
|
||||
@@ -403,11 +381,7 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
confirm = true;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
days = Integer.parseInt(args[i]);
|
||||
} catch (NumberFormatException e) {
|
||||
days = -1;
|
||||
}
|
||||
days = parse(args[i], 0, Integer.MAX_VALUE);
|
||||
if (days < 0) {
|
||||
error(
|
||||
sender,
|
||||
@@ -423,37 +397,20 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
|
||||
// Loaded state now, saved world and player data off the main thread, loaded state again before deciding.
|
||||
Set<Integer> liveBefore = MapUsageScan.live();
|
||||
List<Path> worlds = MapUsageScan.worldFolders();
|
||||
cleanupRunning = true;
|
||||
sender.sendMessage(Component.text("Durchsuche Welt- und Spielerdaten nach Fotos …", NamedTextColor.GRAY));
|
||||
Main plugin = Main.getInstance();
|
||||
boolean finalConfirm = confirm;
|
||||
int finalDays = days;
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
Set<Integer> onDisk = null;
|
||||
String error = null;
|
||||
try {
|
||||
onDisk = MapUsageScan.disk(worlds);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
error = e.getMessage();
|
||||
}
|
||||
Set<Integer> finalOnDisk = onDisk;
|
||||
String finalError = error;
|
||||
try {
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
cleanupRunning = false;
|
||||
if (finalOnDisk == null) {
|
||||
error(sender, "Konnte nicht alle Weltdaten lesen, es wurde nichts gelöscht: " + finalError);
|
||||
return;
|
||||
}
|
||||
Set<Integer> inUse = new HashSet<>(liveBefore);
|
||||
inUse.addAll(finalOnDisk);
|
||||
inUse.addAll(MapUsageScan.live());
|
||||
finishCleanup(sender, label, inUse, finalConfirm, finalDays);
|
||||
});
|
||||
} catch (IllegalPluginAccessException e) {
|
||||
cleanupRunning = false; // disabled meanwhile
|
||||
MapUsageScan.diskAsync(Main.getInstance(), (onDisk, failure) -> {
|
||||
cleanupRunning = false;
|
||||
if (onDisk == null) {
|
||||
error(sender, "Konnte nicht alle Weltdaten lesen, es wurde nichts gelöscht: " + failure.getMessage());
|
||||
return;
|
||||
}
|
||||
Set<Integer> inUse = new HashSet<>(liveBefore);
|
||||
inUse.addAll(onDisk);
|
||||
inUse.addAll(MapUsageScan.live());
|
||||
finishCleanup(sender, label, inUse, finalConfirm, finalDays);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -493,6 +450,16 @@ public class PixelPicsCommand implements TabExecutor {
|
||||
sender.sendMessage(Component.text(deleted + " Foto(s) gelöscht.", NamedTextColor.GREEN));
|
||||
}
|
||||
|
||||
/** {@code arg} as a number in {@code [min, max]}, else -1. */
|
||||
private static int parse(String arg, int min, int max) {
|
||||
try {
|
||||
int n = Integer.parseInt(arg);
|
||||
return n >= min && n <= max ? n : -1;
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void error(CommandSender sender, String message) {
|
||||
sender.sendMessage(Component.text(message, NamedTextColor.RED));
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder;
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.LongStream;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/**
|
||||
* Keeps the chunks of a queued photo loaded until its snapshot is taken. The snapshot is only captured when the job
|
||||
* starts; without a hold, a photographer who leaves meanwhile (teleport, other dimension, logout, flight) lets the
|
||||
* chunks unload after Paper's unload delay and the photo comes out (partly) empty. Plugin chunk tickets are per plugin
|
||||
* and not counted, hence the reference count per chunk. Only chunks loaded at the shot get a ticket, so a hold never
|
||||
* loads a chunk. Main thread only.
|
||||
* Keeps the chunks of a queued photo loaded until its snapshot is taken, so they do not unload when the photographer
|
||||
* leaves meanwhile. Plugin chunk tickets are not counted per plugin, hence the reference count. Only chunks loaded at
|
||||
* the shot get a ticket (a hold never loads one). Main thread only.
|
||||
*/
|
||||
public final class ChunkHolds {
|
||||
|
||||
@@ -27,35 +27,27 @@ public final class ChunkHolds {
|
||||
/** Holds the loaded chunks of {@code area} in {@code world} until {@link Hold#release}. */
|
||||
public Hold hold(World world, SnapshotBuilder.ChunkArea area) {
|
||||
Map<Long, Integer> held = counts.computeIfAbsent(world.getUID(), k -> new HashMap<>());
|
||||
int w = area.maxCX() - area.minCX() + 1, h = area.maxCZ() - area.minCZ() + 1;
|
||||
long[] keys = new long[Math.max(0, w) * Math.max(0, h)];
|
||||
int n = 0;
|
||||
LongStream.Builder keys = LongStream.builder();
|
||||
for (int cx = area.minCX(); cx <= area.maxCX(); cx++) {
|
||||
for (int cz = area.minCZ(); cz <= area.maxCZ(); cz++) {
|
||||
if (!world.isChunkLoaded(cx, cz)) continue;
|
||||
long key = key(cx, cz);
|
||||
long key = WorldSnapshot.chunkKey(cx, cz);
|
||||
if (held.merge(key, 1, Integer::sum) == 1) world.addPluginChunkTicket(cx, cz, plugin);
|
||||
keys[n++] = key;
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
return new Hold(world.getUID(), keys, n);
|
||||
}
|
||||
|
||||
private static long key(int cx, int cz) {
|
||||
return ((long) cx << 32) | (cz & 0xFFFFFFFFL);
|
||||
return new Hold(world.getUID(), keys.build().toArray());
|
||||
}
|
||||
|
||||
/** The chunks of one photo; {@link #release} is idempotent. */
|
||||
public final class Hold {
|
||||
private final UUID world;
|
||||
private final long[] keys;
|
||||
private final int size;
|
||||
private boolean released;
|
||||
|
||||
private Hold(UUID world, long[] keys, int size) {
|
||||
private Hold(UUID world, long[] keys) {
|
||||
this.world = world;
|
||||
this.keys = keys;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public void release() {
|
||||
@@ -64,8 +56,7 @@ public final class ChunkHolds {
|
||||
Map<Long, Integer> held = counts.get(world);
|
||||
if (held == null) return;
|
||||
World w = Bukkit.getWorld(world); // null once unloaded: its tickets are gone with it
|
||||
for (int i = 0; i < size; i++) {
|
||||
long key = keys[i];
|
||||
for (long key : keys) {
|
||||
Integer left = held.computeIfPresent(key, (k, c) -> c > 1 ? c - 1 : null);
|
||||
if (left == null && w != null) w.removePluginChunkTicket((int) (key >> 32), (int) key, plugin);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.utils.DebugLog;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MainThread;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -25,7 +26,6 @@ import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.plugin.IllegalPluginAccessException;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
@@ -53,6 +53,15 @@ public final class RenderManager {
|
||||
QUEUE_FULL
|
||||
}
|
||||
|
||||
/** The {@code render.*} limits from config.yml, already normalised. */
|
||||
public record Settings(int threads, int maxConcurrent, int queueSize, int timeoutSeconds) {
|
||||
@Override
|
||||
public String toString() {
|
||||
return threads + " core(s), max " + maxConcurrent + " concurrent, queue " + queueSize + ", timeout "
|
||||
+ timeoutSeconds + "s";
|
||||
}
|
||||
}
|
||||
|
||||
private static final int SHUTDOWN_WAIT_SECONDS = 5;
|
||||
|
||||
private final Plugin plugin;
|
||||
@@ -61,7 +70,6 @@ public final class RenderManager {
|
||||
private final ScheduledExecutorService watchdog;
|
||||
private final Set<UUID> activeUsers = ConcurrentHashMap.newKeySet();
|
||||
private final Set<Job<?>> jobs = ConcurrentHashMap.newKeySet();
|
||||
/** Main-thread tasks of the jobs ({@link #onMainThread}), run one per tick by {@link #runNextMainTask}. */
|
||||
private final ConcurrentLinkedQueue<MainTask> mainTasks = new ConcurrentLinkedQueue<>();
|
||||
|
||||
private final BukkitTask mainTaskPump;
|
||||
@@ -74,20 +82,18 @@ public final class RenderManager {
|
||||
private volatile int maxConcurrent;
|
||||
private volatile long timeoutMillis;
|
||||
|
||||
public RenderManager(Plugin plugin, int threads, int maxConcurrent, int queueSize, int timeoutSeconds) {
|
||||
public RenderManager(Plugin plugin, Settings settings) {
|
||||
this.plugin = plugin;
|
||||
this.capacity = maxConcurrent + queueSize;
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
this.timeoutMillis = Math.max(1L, timeoutSeconds) * 1000L;
|
||||
this.tracePool = new ForkJoinPool(threads, lowPriorityForkJoinFactory(), null, false);
|
||||
this.tracePool = new ForkJoinPool(settings.threads(), lowPriorityForkJoinFactory(), null, false);
|
||||
this.dispatcher = new ThreadPoolExecutor(
|
||||
maxConcurrent,
|
||||
maxConcurrent,
|
||||
settings.maxConcurrent(),
|
||||
settings.maxConcurrent(),
|
||||
30,
|
||||
TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(), // capacity is enforced by inFlight, not this queue
|
||||
lowPriorityThreadFactory());
|
||||
this.dispatcher.allowCoreThreadTimeOut(true);
|
||||
applyLimits(settings);
|
||||
this.watchdog = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "PixelPics-render-watchdog");
|
||||
thread.setDaemon(true);
|
||||
@@ -98,22 +104,27 @@ public final class RenderManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies new limits to the running manager ({@code /pixelpic reload}): trace threads, concurrent renders, queue
|
||||
* size and timeout. Running and queued jobs are kept; a smaller capacity only rejects new photos until enough
|
||||
* are done.
|
||||
* Applies new limits to the running manager ({@code /pixelpic reload}). Running and queued jobs are kept; a smaller
|
||||
* capacity only rejects new photos until enough are done.
|
||||
*/
|
||||
public void configure(int threads, int maxConcurrent, int queueSize, int timeoutSeconds) {
|
||||
tracePool.setParallelism(threads);
|
||||
if (maxConcurrent >= dispatcher.getMaximumPoolSize()) {
|
||||
dispatcher.setMaximumPoolSize(maxConcurrent);
|
||||
dispatcher.setCorePoolSize(maxConcurrent);
|
||||
public void configure(Settings settings) {
|
||||
tracePool.setParallelism(settings.threads());
|
||||
int max = settings.maxConcurrent();
|
||||
// ThreadPoolExecutor rejects core > max at every step, so the order depends on the direction.
|
||||
if (max >= dispatcher.getMaximumPoolSize()) {
|
||||
dispatcher.setMaximumPoolSize(max);
|
||||
dispatcher.setCorePoolSize(max);
|
||||
} else {
|
||||
dispatcher.setCorePoolSize(maxConcurrent);
|
||||
dispatcher.setMaximumPoolSize(maxConcurrent);
|
||||
dispatcher.setCorePoolSize(max);
|
||||
dispatcher.setMaximumPoolSize(max);
|
||||
}
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
this.capacity = maxConcurrent + queueSize;
|
||||
this.timeoutMillis = Math.max(1L, timeoutSeconds) * 1000L;
|
||||
applyLimits(settings);
|
||||
}
|
||||
|
||||
private void applyLimits(Settings settings) {
|
||||
maxConcurrent = settings.maxConcurrent();
|
||||
capacity = settings.maxConcurrent() + settings.queueSize();
|
||||
timeoutMillis = settings.timeoutSeconds() * 1000L;
|
||||
}
|
||||
|
||||
/** Photos rendering now (incl. waiting for their snapshot), photos waiting for a worker, and the limits. */
|
||||
@@ -131,7 +142,6 @@ public final class RenderManager {
|
||||
timeoutMillis / 1000);
|
||||
}
|
||||
|
||||
/** Counters and timings of the photos since the start. */
|
||||
public RenderStats stats() {
|
||||
return stats;
|
||||
}
|
||||
@@ -141,7 +151,6 @@ public final class RenderManager {
|
||||
return jobs.isEmpty();
|
||||
}
|
||||
|
||||
/** Keeps the chunks of queued photos loaded until their snapshot (main thread). */
|
||||
public ChunkHolds chunkHolds() {
|
||||
return chunkHolds;
|
||||
}
|
||||
@@ -153,10 +162,14 @@ public final class RenderManager {
|
||||
|
||||
/** Atomically reserves a slot for {@code user}. On {@code ACCEPTED}, you must later release it. */
|
||||
public Outcome tryReserve(UUID user) {
|
||||
if (!activeUsers.add(user)) return Outcome.USER_BUSY;
|
||||
if (!activeUsers.add(user)) {
|
||||
stats.rejected();
|
||||
return Outcome.USER_BUSY;
|
||||
}
|
||||
if (inFlight.incrementAndGet() > capacity) {
|
||||
inFlight.decrementAndGet();
|
||||
activeUsers.remove(user);
|
||||
stats.rejected();
|
||||
return Outcome.QUEUE_FULL;
|
||||
}
|
||||
return Outcome.ACCEPTED;
|
||||
@@ -209,21 +222,15 @@ public final class RenderManager {
|
||||
}
|
||||
release(user);
|
||||
}
|
||||
if (shuttingDown) return; // shutdown() delivers it
|
||||
try {
|
||||
Bukkit.getScheduler().runTask(plugin, job::finish);
|
||||
} catch (IllegalPluginAccessException e) {
|
||||
// disabled in between: shutdown() delivers it
|
||||
}
|
||||
// Not scheduled while shutting down or once disabled: shutdown() delivers it then.
|
||||
if (!shuttingDown) MainThread.run(plugin, job::finish);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* From a dispatched job's {@code work}: runs {@code task} on the main thread and waits for its result. Lets a job
|
||||
* capture its world snapshot only once it is its turn, so queued photos hold no snapshot. The tasks of all jobs run
|
||||
* one per tick in request order, so jobs starting together (a burst, or {@code max-concurrent} > 1) never stack
|
||||
* their snapshots into one long tick. Returns null without running {@code task} once {@code cancelled} is set
|
||||
* (timeout, shutdown), the plugin is disabled or the worker is interrupted.
|
||||
* From a dispatched job's {@code work}: runs {@code task} on the main thread and waits for its result. The tasks of
|
||||
* all jobs run one per tick in request order, so jobs starting together never stack their snapshots into one long
|
||||
* tick. Returns null without running {@code task} once {@code cancelled} is set or the worker is interrupted.
|
||||
*/
|
||||
public <T> T onMainThread(Supplier<T> task, AtomicBoolean cancelled) {
|
||||
if (shuttingDown) return null;
|
||||
@@ -254,7 +261,7 @@ public final class RenderManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every tick: runs the oldest main-thread task whose job is still alive (cancelled ones are dropped). */
|
||||
/** Every tick: the oldest main-thread task whose job is not cancelled. */
|
||||
private void runNextMainTask() {
|
||||
MainTask next;
|
||||
while ((next = mainTasks.poll()) != null) {
|
||||
|
||||
@@ -20,9 +20,8 @@ import org.bukkit.util.Vector;
|
||||
* Water vision ({@code LocalPlayer.getWaterVision}) is taken as fully adapted (1), which keeps the end distance and
|
||||
* brightens the colour to a maximum channel of 1. Lava: #991A00 from 0.25 to 1 block (no fire resistance).
|
||||
*
|
||||
* <p>Air outside the overworld: the Nether fogs like vanilla's environmental fog (dimension attributes
|
||||
* {@code fog_start_distance} 10, {@code fog_end_distance} 96) towards the sky context's fog colour (the camera biome's
|
||||
* {@code fog_color}); the End uses the atmospheric fog towards its fog colour (#181318) instead of the sky.
|
||||
* <p>Air outside the overworld: the Nether fogs like vanilla's environmental fog towards the camera biome's
|
||||
* {@code fog_color}; the End uses the overworld's atmospheric fog towards its fog colour instead of the sky.
|
||||
*
|
||||
* @param fogColor in air: the fixed colour distant surfaces fog towards, or -1 for the sky colour in their direction
|
||||
* @param fogMax the strongest fog (1 = opaque)
|
||||
@@ -30,8 +29,8 @@ import org.bukkit.util.Vector;
|
||||
public record CameraMedium(int fluid, int fogColor, double fogStart, double fogEnd, double fogMax) {
|
||||
|
||||
/** Overworld air: PixelPics' atmospheric fog towards the sky. */
|
||||
public static final CameraMedium AIR =
|
||||
new CameraMedium(FluidRenderer.NONE, -1, CameraMedium.AIR_FOG_START, CameraMedium.AIR_FOG_END, 0.75);
|
||||
public static final CameraMedium AIR = new CameraMedium(
|
||||
FluidRenderer.NONE, -1, CameraMedium.AIR_FOG_START, CameraMedium.AIR_FOG_END, CameraMedium.AIR_FOG_MAX);
|
||||
|
||||
private static final double[] KERNEL = {0, 1, 4, 6, 4, 1, 0};
|
||||
|
||||
|
||||
@@ -7,13 +7,10 @@ package eu.mhsl.minecraft.pixelpics.render.sky;
|
||||
*/
|
||||
public record SkyContext(long dayTime, int moonPhase, long fullTime, Skybox skybox, int fogColor) {
|
||||
|
||||
/** The dimension's sky like vanilla's {@code DimensionType.skybox}. */
|
||||
/** Like vanilla's {@code DimensionType.skybox}; {@code NONE} (the Nether) shows only the fog colour. */
|
||||
public enum Skybox {
|
||||
/** Overworld sky: gradient, sun, moon, stars and clouds. */
|
||||
OVERWORLD,
|
||||
/** No sky (the Nether): only the fog colour. */
|
||||
NONE,
|
||||
/** The End's textured sky box. */
|
||||
END
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +34,21 @@ public final class SkyRenderer {
|
||||
// vertex colour #282828.
|
||||
private static final double END_SKY_REPEAT = 16;
|
||||
private static final int END_SKY_TINT = 0x28;
|
||||
// Face poses of the end sky quads: identity, rotX(90°), rotX(-90°), rotX(180°), rotZ(90°), rotZ(-90°).
|
||||
// Face poses of the end sky quads: identity, rotX(90°), rotX(-90°), rotX(180°), rotZ(90°), rotZ(-90°), as
|
||||
// {axis (0 none, 1 x, 2 z), cos, sin}.
|
||||
private static final double[][] END_FACES = {
|
||||
{0, 0}, {1, Math.PI / 2}, {1, -Math.PI / 2}, {1, Math.PI}, {2, Math.PI / 2}, {2, -Math.PI / 2}
|
||||
endFace(0, 0),
|
||||
endFace(1, Math.PI / 2),
|
||||
endFace(1, -Math.PI / 2),
|
||||
endFace(1, Math.PI),
|
||||
endFace(2, Math.PI / 2),
|
||||
endFace(2, -Math.PI / 2)
|
||||
};
|
||||
|
||||
private static double[] endFace(int axis, double angle) {
|
||||
return new double[] {axis, Math.cos(angle), Math.sin(angle)};
|
||||
}
|
||||
|
||||
private final int[][] moonTexture;
|
||||
private final int[][] cloudTexture;
|
||||
private final int[][] endSkyTexture;
|
||||
@@ -62,7 +72,7 @@ public final class SkyRenderer {
|
||||
// The quad whose pose maps its plane y = -100 onto the ray: q = pose^T d with the most negative q.y.
|
||||
double bestY = 0, qx = 0, qz = 0;
|
||||
for (double[] face : END_FACES) {
|
||||
double c = Math.cos(face[1]), sn = Math.sin(face[1]);
|
||||
double c = face[1], sn = face[2];
|
||||
double x, y, z;
|
||||
if (face[0] == 1) { // rotX(a)^T
|
||||
x = dx;
|
||||
|
||||
+2
-1
@@ -53,7 +53,8 @@ public final class DecorationSnapshotBuilder {
|
||||
item = ItemSpecs.of(stack);
|
||||
if (stack.getType() == Material.FILLED_MAP) {
|
||||
int id = MapPixels.mapId(stack);
|
||||
if (id >= 0 && MapManager.isManaged(id)) {
|
||||
// the attached renderer first: spares the file checks for loaded photos
|
||||
if (id >= 0 && (MapManager.renderer(id) != null || MapManager.isManaged(id))) {
|
||||
map = MapPixels.photo(id); // photos have no markers
|
||||
} else {
|
||||
MapView view = MapPixels.view(stack);
|
||||
|
||||
@@ -46,9 +46,8 @@ final class MapPixels {
|
||||
}
|
||||
|
||||
/**
|
||||
* A PixelPics photo ({@link MapManager#isManaged}) without loading the server's map data (a wall of photos not
|
||||
* loaded yet cost ~4 ms per map on the main thread): its renderer's indices, else the stored index cache, else
|
||||
* blank film like its map shows meanwhile.
|
||||
* A PixelPics photo without loading the server's map data (expensive on the main thread for a wall of photos): its
|
||||
* renderer's indices, else the stored index cache, else blank film like its map shows meanwhile.
|
||||
*/
|
||||
static int[][] photo(int mapId) {
|
||||
ImageMapRenderer renderer = MapManager.renderer(mapId);
|
||||
|
||||
@@ -2,18 +2,15 @@ package eu.mhsl.minecraft.pixelpics.setup;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.File;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.ResourcePackLoader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
@@ -38,26 +35,20 @@ public final class PackVersions {
|
||||
return "vanilla-" + version + ".zip";
|
||||
}
|
||||
|
||||
/** All {@code .zip} files below {@code dir} with their version, sorted by relative path (load order). */
|
||||
/** All {@code .zip} files below {@code dir} with their version, in load order. */
|
||||
public static List<VersionedPack> scan(Path dir) {
|
||||
List<VersionedPack> out = new ArrayList<>();
|
||||
if (!Files.isDirectory(dir)) return out;
|
||||
try (Stream<Path> walk = Files.walk(dir)) {
|
||||
for (Path zip : walk.filter(Files::isRegularFile)
|
||||
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
|
||||
.sorted(Comparator.comparing(
|
||||
p -> dir.relativize(p).toString().replace(File.separatorChar, '/')))
|
||||
.toList()) {
|
||||
out.add(new VersionedPack(zip, version(zip)));
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// an unreadable folder: the loader reports it
|
||||
if (!Files.isDirectory(dir)) return List.of();
|
||||
try {
|
||||
return ResourcePackLoader.zips(dir).stream()
|
||||
.map(zip -> new VersionedPack(zip, version(zip)))
|
||||
.toList();
|
||||
} catch (IOException e) {
|
||||
return List.of(); // an unreadable folder: the loader reports it
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The pack's version, or null when it has no stamp and no {@code vanilla-<version>.zip} name. */
|
||||
public static String version(Path zip) {
|
||||
private static String version(Path zip) {
|
||||
try (ZipFile file = new ZipFile(zip.toFile())) {
|
||||
ZipEntry stamp = file.getEntry(STAMP);
|
||||
if (stamp != null) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import eu.mhsl.minecraft.pixelpics.render.entity.cem.DatapackVariants;
|
||||
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.DebugLog;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MainThread;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
@@ -48,16 +49,11 @@ import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.plugin.IllegalPluginAccessException;
|
||||
|
||||
/**
|
||||
* Sets up the renderer off the main thread, at start and on {@code /pixelpic reload}: checks the vanilla packs in
|
||||
* {@code resourcepack/} against the server's Minecraft version, downloads the matching pack when none is present
|
||||
* ({@code resource-pack.auto-download}) or when the known vanilla packs are for another version
|
||||
* ({@code resource-pack.auto-update}; the old ones go to {@code backup/}), picks the entity model dump, builds the
|
||||
* renderer and swaps it in on the main thread. Photos are refused with a clear message until the first renderer is
|
||||
* ready; a pack update keeps rendering with the old pack until the new one is in place. Replaced packs are closed
|
||||
* once no photo renders with them any more.
|
||||
* Builds the renderer off the main thread (start and {@code /pixelpic reload}), downloading or updating the vanilla
|
||||
* pack as configured, and swaps it in on the main thread. During an update the old pack keeps rendering; replaced
|
||||
* packs are closed once no photo renders with them any more.
|
||||
*/
|
||||
public final class SetupManager {
|
||||
|
||||
@@ -66,15 +62,14 @@ public final class SetupManager {
|
||||
private static final Pattern DUMP_VERSION = Pattern.compile("\"version\"\\s*:\\s*\"([^\"]+)\"");
|
||||
private static final int SAMPLE = 8;
|
||||
|
||||
/** A pack zip with its version (null = unknown) and whether it is loaded. */
|
||||
/** {@code version} null = unknown. */
|
||||
public record PackInfo(String name, String version, boolean loaded) {}
|
||||
|
||||
/** How many of the server's blocks/items the pack has a block state/item definition for; up to 8 missing. */
|
||||
/** How many of the server's blocks/items the pack has a block state/item definition for. */
|
||||
public record Coverage(int total, int missing, List<String> sample) {}
|
||||
|
||||
/** What the current renderer was built from, for {@code /pixelpic info}. */
|
||||
public record Report(
|
||||
String serverVersion,
|
||||
List<PackInfo> packs,
|
||||
Coverage blocks,
|
||||
Coverage items,
|
||||
@@ -103,13 +98,14 @@ public final class SetupManager {
|
||||
private final Main plugin;
|
||||
private final Logger log;
|
||||
private final File packDir;
|
||||
private final File downloadDir;
|
||||
private final ExecutorService worker;
|
||||
|
||||
private volatile DefaultScreenRenderer renderer;
|
||||
private volatile Report report;
|
||||
/** The current background step (shown in messages and info), or null. */
|
||||
/** The current background step (German, shown in messages and info), or null. */
|
||||
private volatile String task;
|
||||
/** The last setup problem an admin should fix, or null. */
|
||||
/** The last setup problem an admin should fix (German), or null. */
|
||||
private volatile String problem;
|
||||
|
||||
private volatile boolean running;
|
||||
@@ -121,6 +117,7 @@ public final class SetupManager {
|
||||
this.plugin = plugin;
|
||||
this.log = plugin.getLogger();
|
||||
this.packDir = new File(plugin.getDataFolder(), "resourcepack");
|
||||
this.downloadDir = new File(plugin.getDataFolder(), "download");
|
||||
ThreadPoolExecutor executor =
|
||||
new ThreadPoolExecutor(1, 1, 30, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), r -> {
|
||||
Thread thread = new Thread(r, "PixelPics-setup");
|
||||
@@ -224,9 +221,9 @@ public final class SetupManager {
|
||||
log.warning("Could not create the resource pack folder " + packDir);
|
||||
}
|
||||
cleanPartFiles();
|
||||
List<PackVersions.VersionedPack> versioned = PackVersions.scan(packDir.toPath()).stream()
|
||||
.filter(v -> v.version() != null)
|
||||
.toList();
|
||||
List<PackVersions.VersionedPack> packs = PackVersions.scan(packDir.toPath());
|
||||
List<PackVersions.VersionedPack> versioned =
|
||||
packs.stream().filter(v -> v.version() != null).toList();
|
||||
boolean matching = versioned.stream().anyMatch(v -> v.version().equals(server));
|
||||
boolean download = false;
|
||||
if (!versioned.isEmpty() && !matching) {
|
||||
@@ -249,7 +246,7 @@ public final class SetupManager {
|
||||
}
|
||||
}
|
||||
|
||||
Built built = build(in, outdated(server, matching));
|
||||
Built built = build(in, packs, matching ? outdated(packs, server) : Set.of());
|
||||
if (built != null) {
|
||||
swap(built, requester, download ? "Alter Pack aktiv, lade den neuen herunter …" : null);
|
||||
} else if (in.autoDownload()) {
|
||||
@@ -289,11 +286,7 @@ public final class SetupManager {
|
||||
Path fresh;
|
||||
try {
|
||||
fresh = downloader.download(
|
||||
server,
|
||||
in.manifestUrl(),
|
||||
in.maxBytes(),
|
||||
new File(plugin.getDataFolder(), "download").toPath(),
|
||||
packDir.toPath());
|
||||
server, in.manifestUrl(), in.maxBytes(), downloadDir.toPath(), packDir.toPath());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
if (closed) return;
|
||||
problem = "Download des Resource Packs " + server + " fehlgeschlagen: " + describe(e);
|
||||
@@ -308,13 +301,15 @@ public final class SetupManager {
|
||||
"Download fehlgeschlagen: " + describe(e) + (hasOld ? " — der alte Pack bleibt aktiv." : ""));
|
||||
return;
|
||||
} finally {
|
||||
new File(plugin.getDataFolder(), "download").delete();
|
||||
downloadDir.delete();
|
||||
}
|
||||
if (closed) return;
|
||||
log.info("Downloaded " + fresh.getFileName() + " in " + (System.nanoTime() - t0) / 1_000_000 + " ms.");
|
||||
task = "Resource Pack wird geladen";
|
||||
backupOutdated(server); // still open by the old renderer: fine on Linux, else they stay but are left out
|
||||
Built built = build(in, outdated(server, true));
|
||||
// still open by the old renderer: fine on Linux, else they stay but are left out
|
||||
backupOutdated(outdated(PackVersions.scan(packDir.toPath()), server));
|
||||
List<PackVersions.VersionedPack> packs = PackVersions.scan(packDir.toPath());
|
||||
Built built = build(in, packs, outdated(packs, server));
|
||||
if (built == null) {
|
||||
problem = "Der heruntergeladene Pack " + fresh.getFileName() + " ließ sich nicht laden.";
|
||||
log.severe("The downloaded pack " + fresh + " could not be loaded.");
|
||||
@@ -333,19 +328,18 @@ public final class SetupManager {
|
||||
};
|
||||
}
|
||||
|
||||
/** The versioned vanilla packs of other versions, left out while one for {@code server} exists. */
|
||||
private Set<Path> outdated(String server, boolean matching) {
|
||||
/** The versioned vanilla packs of other versions (left out while one for {@code server} exists). */
|
||||
private static Set<Path> outdated(List<PackVersions.VersionedPack> packs, String server) {
|
||||
Set<Path> out = new HashSet<>();
|
||||
if (!matching) return out;
|
||||
for (PackVersions.VersionedPack v : PackVersions.scan(packDir.toPath())) {
|
||||
for (PackVersions.VersionedPack v : packs) {
|
||||
if (v.version() != null && !v.version().equals(server)) out.add(v.path());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void backupOutdated(String server) {
|
||||
private void backupOutdated(Set<Path> outdated) {
|
||||
File backup = new File(plugin.getDataFolder(), "backup");
|
||||
for (Path old : outdated(server, true)) {
|
||||
for (Path old : outdated) {
|
||||
try {
|
||||
Files.createDirectories(backup.toPath());
|
||||
Path target = backup.toPath().resolve(old.getFileName());
|
||||
@@ -359,14 +353,14 @@ public final class SetupManager {
|
||||
}
|
||||
|
||||
private void cleanPartFiles() {
|
||||
for (File dir : new File[] {packDir, new File(plugin.getDataFolder(), "download")}) {
|
||||
for (File dir : new File[] {packDir, downloadDir}) {
|
||||
File[] parts = dir.listFiles((d, name) -> name.endsWith(".part"));
|
||||
if (parts != null) for (File f : parts) f.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads the packs (without {@code exclude}) and builds a renderer; null when there is no pack. */
|
||||
private Built build(Inputs in, Set<Path> exclude) {
|
||||
private Built build(Inputs in, List<PackVersions.VersionedPack> scanned, Set<Path> exclude) {
|
||||
long t0 = System.nanoTime();
|
||||
Optional<ResourcePack> loaded = ResourcePackLoader.load(packDir, log, exclude);
|
||||
if (loaded.isEmpty()) return null;
|
||||
@@ -398,12 +392,11 @@ public final class SetupManager {
|
||||
|
||||
Coverage blocks = coverage(resourcePack, in.blocks(), "assets/minecraft/blockstates/");
|
||||
Coverage items = coverage(resourcePack, in.items(), "assets/minecraft/items/");
|
||||
List<PackInfo> packs = PackVersions.scan(packDir.toPath()).stream()
|
||||
List<PackInfo> packs = scanned.stream()
|
||||
.map(v -> new PackInfo(
|
||||
packDir.toPath().relativize(v.path()).toString(), v.version(), !exclude.contains(v.path())))
|
||||
.toList();
|
||||
Report r = new Report(
|
||||
in.serverVersion(),
|
||||
packs,
|
||||
blocks,
|
||||
items,
|
||||
@@ -413,7 +406,7 @@ public final class SetupManager {
|
||||
datapackVariants,
|
||||
!font.isEmpty(),
|
||||
(System.nanoTime() - t0) / 1_000_000);
|
||||
logReport(r);
|
||||
logReport(r, in.serverVersion());
|
||||
return new Built(resourcePack, screenRenderer, r);
|
||||
} catch (RuntimeException | Error e) {
|
||||
resourcePack.close();
|
||||
@@ -422,9 +415,8 @@ public final class SetupManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the vanilla entity model dump: {@code vanilla_entity_models.json} in the data folder overrides the copy
|
||||
* bundled with the plugin unless only the bundled one matches the server version. Warns when the dump in use
|
||||
* is for another version.
|
||||
* Loads the vanilla entity model dump: {@code vanilla_entity_models.json} in the data folder overrides the bundled
|
||||
* one unless only the bundled one matches the server version.
|
||||
*/
|
||||
private Dump loadDump(CemModelLoader cemLoader, String server) {
|
||||
File override = new File(plugin.getDataFolder(), DUMP_FILE);
|
||||
@@ -461,7 +453,7 @@ public final class SetupManager {
|
||||
InputStream open() throws IOException;
|
||||
}
|
||||
|
||||
/** The {@code version} at the start of a dump (written first), or null. */
|
||||
/** The dump's {@code version} (its first key, so only the head is read), or null. */
|
||||
private static String peekVersion(StreamSource source) {
|
||||
try (InputStream in = source.open()) {
|
||||
if (in == null) return null;
|
||||
@@ -481,7 +473,7 @@ public final class SetupManager {
|
||||
keys.size(), missing.size(), missing.stream().limit(SAMPLE).toList());
|
||||
}
|
||||
|
||||
private void logReport(Report r) {
|
||||
private void logReport(Report r, String server) {
|
||||
String loaded = String.join(
|
||||
", ",
|
||||
r.packs().stream()
|
||||
@@ -499,7 +491,7 @@ public final class SetupManager {
|
||||
+ " and " + r.items().missing() + " item models "
|
||||
+ r.items().sample() + " of this server: they"
|
||||
+ " render as missing textures. The pack is probably for an older Minecraft version than "
|
||||
+ r.serverVersion() + "; a pack named " + PackVersions.fileName(r.serverVersion())
|
||||
+ server + "; a pack named " + PackVersions.fileName(server)
|
||||
+ " or with a " + PackVersions.STAMP + " is updated automatically.");
|
||||
}
|
||||
}
|
||||
@@ -536,12 +528,6 @@ public final class SetupManager {
|
||||
}
|
||||
|
||||
private boolean onMain(Runnable task) {
|
||||
if (closed) return false;
|
||||
try {
|
||||
Bukkit.getScheduler().runTask(plugin, task);
|
||||
return true;
|
||||
} catch (IllegalPluginAccessException e) {
|
||||
return false;
|
||||
}
|
||||
return !closed && MainThread.run(plugin, task);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package eu.mhsl.minecraft.pixelpics.setup;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -34,8 +35,8 @@ import java.util.zip.ZipOutputStream;
|
||||
*/
|
||||
public final class VanillaPackDownloader {
|
||||
|
||||
/** The asset folders the renderer reads (blockstates, models, textures, item definitions, atlases, fonts). */
|
||||
static final List<String> ASSET_DIRS = List.of(
|
||||
/** The asset folders the renderer reads. */
|
||||
private static final List<String> ASSET_DIRS = List.of(
|
||||
"assets/minecraft/blockstates/",
|
||||
"assets/minecraft/models/",
|
||||
"assets/minecraft/textures/",
|
||||
@@ -111,7 +112,7 @@ public final class VanillaPackDownloader {
|
||||
private int extract(Path jar, Path zip, String version, String sha1) throws IOException {
|
||||
int files = 0;
|
||||
try (ZipFile in = new ZipFile(jar.toFile());
|
||||
ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(zip))) {
|
||||
ZipOutputStream out = new ZipOutputStream(new BufferedOutputStream(Files.newOutputStream(zip)))) {
|
||||
for (ZipEntry e : Collections.list(in.entries())) {
|
||||
String name = e.getName();
|
||||
if (e.isDirectory()
|
||||
|
||||
@@ -5,9 +5,7 @@ import eu.mhsl.minecraft.pixelpics.utils.MapManager;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapUsageScan;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -15,7 +13,6 @@ import org.bukkit.Bukkit;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.IllegalPluginAccessException;
|
||||
|
||||
/**
|
||||
* Persists photos that are still rendering ({@code pending.yml}), so a crash cannot swallow film: a job still listed
|
||||
@@ -37,11 +34,9 @@ public final class PendingPhotos {
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns jobs left over from a crash into film refunds; their maps keep the blank placeholder. A crash also rolls
|
||||
* the world back to its last save: if that save predates the shot, the photo item is in no saved data and the
|
||||
* camera still holds the film, so a refund would duplicate it. The saved worlds and player files are therefore
|
||||
* scanned (off the main thread) and only photos found there are refunded; if the scan fails, all are refunded. The
|
||||
* jobs stay in the file until decided, so another crash meanwhile loses nothing.
|
||||
* Turns jobs left over from a crash into film refunds; their maps keep the blank placeholder. Only photos found in
|
||||
* the saved data are refunded (all if the scan fails): a crash rolls back to the last save, and a shot after it
|
||||
* left the film in the camera, so a refund would duplicate it. Jobs stay in the file until decided.
|
||||
*/
|
||||
public void recoverAbandoned() {
|
||||
ConfigurationSection jobs = yml.getConfigurationSection(JOBS);
|
||||
@@ -71,23 +66,14 @@ public final class PendingPhotos {
|
||||
return;
|
||||
}
|
||||
Main plugin = Main.getInstance();
|
||||
List<Path> worlds = MapUsageScan.worldFolders();
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
Set<Integer> saved;
|
||||
try {
|
||||
saved = MapUsageScan.disk(worlds);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
// dropped when disabled meanwhile: the jobs are still in the file, decided at the next start
|
||||
MapUsageScan.diskAsync(plugin, (saved, error) -> {
|
||||
if (error != null) {
|
||||
plugin.getLogger()
|
||||
.warning("Could not scan the saved data for photos lost in the crash (" + e.getMessage()
|
||||
.warning("Could not scan the saved data for photos lost in the crash (" + error.getMessage()
|
||||
+ "); refunding all of their film.");
|
||||
saved = null;
|
||||
}
|
||||
Set<Integer> found = saved;
|
||||
try {
|
||||
Bukkit.getScheduler().runTask(plugin, () -> refundAbandoned(owed, found));
|
||||
} catch (IllegalPluginAccessException ignored) {
|
||||
// disabled meanwhile: the jobs are still in the file, decided at the next start
|
||||
}
|
||||
refundAbandoned(owed, saved);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,7 +82,8 @@ public final class PendingPhotos {
|
||||
int refunded = 0;
|
||||
for (Map.Entry<Integer, UUID> e : owed.entrySet()) {
|
||||
if (saved == null || saved.contains(e.getKey())) {
|
||||
addRefund(e.getValue(), false);
|
||||
String key = REFUNDS + "." + e.getValue();
|
||||
yml.set(key, yml.getInt(key) + 1);
|
||||
refunded++;
|
||||
}
|
||||
yml.set(JOBS + "." + e.getKey(), null);
|
||||
@@ -122,13 +109,9 @@ public final class PendingPhotos {
|
||||
}
|
||||
|
||||
public void addRefund(UUID player) {
|
||||
addRefund(player, true);
|
||||
}
|
||||
|
||||
private void addRefund(UUID player, boolean save) {
|
||||
String key = REFUNDS + "." + player;
|
||||
yml.set(key, yml.getInt(key) + 1);
|
||||
if (save) save();
|
||||
save();
|
||||
}
|
||||
|
||||
/** The films owed to {@code player}, now cleared. */
|
||||
|
||||
@@ -38,11 +38,6 @@ public final class PhotoService {
|
||||
|
||||
private PhotoService() {}
|
||||
|
||||
/** A debug photo that uses no film. */
|
||||
public static boolean takePhoto(Player player) {
|
||||
return takePhoto(player, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* False (with action-bar feedback) when no photo was taken: no renderer, or rejected by the queue. With
|
||||
* {@code usesFilm} the caller takes the film on true; it is given back if the photo then fails to develop.
|
||||
@@ -84,7 +79,6 @@ public final class PhotoService {
|
||||
UUID user = player.getUniqueId();
|
||||
RenderManager.Outcome outcome = manager.tryReserve(user);
|
||||
if (outcome != RenderManager.Outcome.ACCEPTED) {
|
||||
manager.stats().rejected();
|
||||
return Component.text(
|
||||
outcome == RenderManager.Outcome.USER_BUSY
|
||||
? "Deine letzte Aufnahme wird noch entwickelt …"
|
||||
@@ -95,9 +89,8 @@ public final class PhotoService {
|
||||
boolean dispatched = false;
|
||||
ChunkHolds.Hold hold = null;
|
||||
try {
|
||||
// The view is fixed now; the world snapshot is taken (on the main thread) only when the render starts, so
|
||||
// photos waiting in the queue hold no snapshot. Its chunks stay loaded until then, also if the
|
||||
// photographer leaves.
|
||||
// The view is fixed now, the snapshot is taken only when the render starts (queued photos hold none);
|
||||
// its chunks stay loaded until then, also if the photographer leaves.
|
||||
Location eye = player.getEyeLocation();
|
||||
Resolution resolution = new Resolution(MapImageDither.SIZE, MapImageDither.SIZE);
|
||||
hold = manager.chunkHolds().hold(eye.getWorld(), renderer.chunkArea(eye, resolution));
|
||||
@@ -150,7 +143,7 @@ public final class PhotoService {
|
||||
return new RenderOutput(image, indices, marks);
|
||||
},
|
||||
out -> {
|
||||
chunks.release(); // already released by prepare
|
||||
chunks.release(); // idempotent; normally released after prepare
|
||||
if (!MapManager.saveImage(out.image(), id)) {
|
||||
// not stored: it would be lost (or a vanilla map) after a restart
|
||||
failed(id, user, usesFilm, onDone);
|
||||
|
||||
@@ -19,7 +19,6 @@ public final class DebugLog {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** Logs {@code message} at INFO with a {@code [debug]} prefix when debug logging is on. */
|
||||
public static void log(String message) {
|
||||
Logger log = logger;
|
||||
if (enabled && log != null) log.info("[debug] " + message);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package eu.mhsl.minecraft.pixelpics.utils;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.plugin.IllegalPluginAccessException;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
public final class MainThread {
|
||||
|
||||
private MainThread() {}
|
||||
|
||||
/**
|
||||
* Schedules {@code task} for the next tick from any thread; false when the plugin was disabled meanwhile (the
|
||||
* scheduler then refuses tasks and the task never runs).
|
||||
*/
|
||||
public static boolean run(Plugin plugin, Runnable task) {
|
||||
try {
|
||||
Bukkit.getScheduler().runTask(plugin, task);
|
||||
return true;
|
||||
} catch (IllegalPluginAccessException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,7 @@ public class MapManager {
|
||||
|
||||
/** Deletes both files of a map id; true if anything was deleted. */
|
||||
public static boolean delete(int id) {
|
||||
renderers.remove(id);
|
||||
boolean deleted = false;
|
||||
File png = getImagePath(id);
|
||||
File idx = getIndexPath(id);
|
||||
|
||||
@@ -15,6 +15,7 @@ import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
@@ -35,6 +36,7 @@ import org.bukkit.inventory.EntityEquipment;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/**
|
||||
* Finds the map ids still referenced anywhere, so cleanup never deletes a photo that exists: {@link #live} looks at
|
||||
@@ -100,6 +102,26 @@ public final class MapUsageScan {
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs {@link #disk} over {@link #worldFolders} off the main thread and hands the ids (or the error) to
|
||||
* {@code onMain} on the main thread; dropped once the plugin is disabled. Main thread.
|
||||
*/
|
||||
public static void diskAsync(Plugin plugin, BiConsumer<Set<Integer>, Exception> onMain) {
|
||||
List<Path> worlds = worldFolders();
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
Set<Integer> ids = null;
|
||||
Exception error = null;
|
||||
try {
|
||||
ids = disk(worlds);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
error = e;
|
||||
}
|
||||
Set<Integer> found = ids;
|
||||
Exception failure = error;
|
||||
MainThread.run(plugin, () -> onMain.accept(found, failure));
|
||||
});
|
||||
}
|
||||
|
||||
/** Map ids in the saved region and player files below {@code roots}; throws if any of them is unreadable. */
|
||||
public static Set<Integer> disk(List<Path> roots) throws IOException {
|
||||
Set<Integer> ids = new HashSet<>();
|
||||
|
||||
Reference in new issue
Block a user