diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java index 0ab0624..0da2d71 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java @@ -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) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePackLoader.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePackLoader.java index 8515e7c..506e8fe 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePackLoader.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePackLoader.java @@ -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 zips(Path root) throws IOException { + try (Stream 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 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 walk = Files.walk(root)) { - List 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. diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java b/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java index 351d033..5af5dff 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java @@ -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 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 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", " [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 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", " [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 [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 liveBefore = MapUsageScan.live(); - List 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 onDisk = null; - String error = null; - try { - onDisk = MapUsageScan.disk(worlds); - } catch (IOException | RuntimeException e) { - error = e.getMessage(); - } - Set 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 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 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)); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/ChunkHolds.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/ChunkHolds.java index 6143895..a1c3285 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/ChunkHolds.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/ChunkHolds.java @@ -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 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 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); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java index b7801e4..2bf1bef 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java @@ -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 activeUsers = ConcurrentHashMap.newKeySet(); private final Set> jobs = ConcurrentHashMap.newKeySet(); - /** Main-thread tasks of the jobs ({@link #onMainThread}), run one per tick by {@link #runNextMainTask}. */ private final ConcurrentLinkedQueue 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 onMainThread(Supplier 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) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java index eea8377..101198c 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java @@ -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). * - *

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. + *

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}; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyContext.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyContext.java index ea4826c..6376ce0 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyContext.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyContext.java @@ -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 } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyRenderer.java index 508eeda..c91c2e4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/sky/SkyRenderer.java @@ -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; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java index 988e9d2..cc3b562 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java @@ -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); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java index 218fa0e..67fe385 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java @@ -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); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/setup/PackVersions.java b/src/main/java/eu/mhsl/minecraft/pixelpics/setup/PackVersions.java index ced13bd..2f7c2bd 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/setup/PackVersions.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/setup/PackVersions.java @@ -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 scan(Path dir) { - List out = new ArrayList<>(); - if (!Files.isDirectory(dir)) return out; - try (Stream 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-.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) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/setup/SetupManager.java b/src/main/java/eu/mhsl/minecraft/pixelpics/setup/SetupManager.java index 794be85..50a8dd0 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/setup/SetupManager.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/setup/SetupManager.java @@ -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 sample) {} /** What the current renderer was built from, for {@code /pixelpic info}. */ public record Report( - String serverVersion, List 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 versioned = PackVersions.scan(packDir.toPath()).stream() - .filter(v -> v.version() != null) - .toList(); + List packs = PackVersions.scan(packDir.toPath()); + List 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 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 outdated(String server, boolean matching) { + /** The versioned vanilla packs of other versions (left out while one for {@code server} exists). */ + private static Set outdated(List packs, String server) { Set 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 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 exclude) { + private Built build(Inputs in, List scanned, Set exclude) { long t0 = System.nanoTime(); Optional 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 packs = PackVersions.scan(packDir.toPath()).stream() + List 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); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/setup/VanillaPackDownloader.java b/src/main/java/eu/mhsl/minecraft/pixelpics/setup/VanillaPackDownloader.java index 1f4b65e..acd9c39 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/setup/VanillaPackDownloader.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/setup/VanillaPackDownloader.java @@ -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 ASSET_DIRS = List.of( + /** The asset folders the renderer reads. */ + private static final List 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() diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PendingPhotos.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PendingPhotos.java index 5d1ba2b..8aa10e7 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PendingPhotos.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PendingPhotos.java @@ -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 worlds = MapUsageScan.worldFolders(); - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - Set 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 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 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. */ diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java index 02532b5..6b794bf 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java @@ -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); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/DebugLog.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/DebugLog.java index bc38791..e7f54c5 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/DebugLog.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/DebugLog.java @@ -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); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MainThread.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MainThread.java new file mode 100644 index 0000000..fbc1c15 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MainThread.java @@ -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; + } + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java index 4d8096c..4c34dc2 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java @@ -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); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapUsageScan.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapUsageScan.java index c7414ff..0bd2b09 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapUsageScan.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapUsageScan.java @@ -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, Exception> onMain) { + List worlds = worldFolders(); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + Set ids = null; + Exception error = null; + try { + ids = disk(worlds); + } catch (IOException | RuntimeException e) { + error = e; + } + Set 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 disk(List roots) throws IOException { Set ids = new HashSet<>();