diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java index c1b94dc..9861ade 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java @@ -1,39 +1,33 @@ package eu.mhsl.minecraft.pixelpics; -import eu.mhsl.minecraft.pixelpics.assets.AssetReader; -import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry; -import eu.mhsl.minecraft.pixelpics.assets.ResourcePack; -import eu.mhsl.minecraft.pixelpics.assets.ResourcePackLoader; -import eu.mhsl.minecraft.pixelpics.assets.SkinCache; -import eu.mhsl.minecraft.pixelpics.assets.TextureCache; -import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont; -import eu.mhsl.minecraft.pixelpics.assets.font.FontLoader; import eu.mhsl.minecraft.pixelpics.commands.PixelPicsCommand; import eu.mhsl.minecraft.pixelpics.listeners.OnMapInitialize; import eu.mhsl.minecraft.pixelpics.render.RenderManager; -import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker; -import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker; -import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader; -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.setup.SetupManager; import eu.mhsl.minecraft.pixelpics.survival.CameraListener; import eu.mhsl.minecraft.pixelpics.survival.CraftingListener; import eu.mhsl.minecraft.pixelpics.survival.ItemGuardListener; import eu.mhsl.minecraft.pixelpics.survival.JoinListener; import eu.mhsl.minecraft.pixelpics.survival.PendingPhotos; import eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes; +import eu.mhsl.minecraft.pixelpics.utils.DebugLog; import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette; import eu.mhsl.minecraft.pixelpics.utils.MapManager; import java.io.File; -import java.io.FileInputStream; +import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Objects; -import java.util.Optional; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; -import org.bukkit.World; +import org.bukkit.configuration.Configuration; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.event.Listener; import org.bukkit.plugin.java.JavaPlugin; @@ -41,8 +35,7 @@ public final class Main extends JavaPlugin { private static Main instance; - private ResourcePack resourcePack; - private DefaultScreenRenderer screenRenderer; + private SetupManager setup; private RenderManager renderManager; private PendingPhotos pendingPhotos; @@ -52,15 +45,29 @@ 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)); MapManager.init(getServer().getWorlds().getFirst().getUID()); pendingPhotos = new PendingPhotos(getDataFolder()); pendingPhotos.recoverAbandoned(); - initRenderManager(); + RenderSettings settings = renderSettings(); + renderManager = new RenderManager( + this, settings.threads(), settings.maxConcurrent(), settings.queueSize(), settings.timeoutSeconds()); + getLogger().info("Render pool: " + settings + "."); Objects.requireNonNull(getCommand("pixelPic")).setExecutor(new PixelPicsCommand()); for (Listener listener : List.of( @@ -74,10 +81,47 @@ public final class Main extends JavaPlugin { SurvivalRecipes.register(); MapManager.reattachAfterReload(OnMapInitialize::attach); - initRenderer(); + // 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); } - private void initRenderManager() { + /** + * Brings an existing config.yml up to date when this version added options: the file is rewritten from the + * bundled config.yml (layout and comments) with all of the admin's values (and their own comments) kept. + */ + private void addNewConfigOptions() { + FileConfiguration config = getConfig(); + Configuration defaults = config.getDefaults(); + if (defaults == null) return; + List added = defaults.getKeys(true).stream() + .filter(k -> !defaults.isConfigurationSection(k) && !config.isSet(k)) + .toList(); + if (added.isEmpty()) return; + YamlConfiguration merged = new YamlConfiguration(); + try (InputStream in = Objects.requireNonNull(getResource("config.yml")); + Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { + merged.load(reader); + for (String key : config.getKeys(true)) { + if (config.isConfigurationSection(key)) continue; + merged.set(key, config.get(key)); + List comments = config.getComments(key); + if (!comments.isEmpty()) merged.setComments(key, comments); + } + merged.save(new File(getDataFolder(), "config.yml")); + reloadConfig(); + getLogger().info("Added new options to config.yml (values kept): " + String.join(", ", added) + "."); + } catch (IOException | InvalidConfigurationException e) { + getLogger() + .warning("Could not add the new options to config.yml (" + e.getMessage() + + "); their defaults apply: " + String.join(", ", added) + "."); + } + } + + private RenderSettings renderSettings() { int cores = Runtime.getRuntime().availableProcessors(); int threads = getConfig().getInt("render.threads", 0); if (threads <= 0) threads = Math.max(1, cores - 2); @@ -85,77 +129,7 @@ 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)); - - this.renderManager = new RenderManager(this, threads, maxConcurrent, queueSize, timeoutSeconds); - getLogger() - .info("Render pool: " + threads + " core(s), max " + maxConcurrent + " concurrent, queue " + queueSize - + ", timeout " + timeoutSeconds + "s."); - } - - private void initRenderer() { - File resourcePackDir = new File(getDataFolder(), "resourcepack"); - if (!resourcePackDir.exists() && !resourcePackDir.mkdirs()) { - getLogger().warning("Could not create resource pack directory: " + resourcePackDir); - } - - Optional pack = ResourcePackLoader.load(resourcePackDir, getLogger()); - if (pack.isEmpty()) { - getLogger() - .severe("No resource pack found in " + resourcePackDir.getPath() - + " — place a vanilla resource pack (directory with assets/minecraft/... or a .zip) there. " - + "/pixelPic is disabled until a pack is available."); - return; - } - - this.resourcePack = pack.get(); - AssetReader reader = new AssetReader(resourcePack); - TextureCache textures = new TextureCache(resourcePack); - BlockModelRegistry registry = new BlockModelRegistry(reader, textures); - BiomeTintProvider tintProvider = new BiomeTintProvider(textures); - - CemModelLoader cemLoader = new CemModelLoader(); - try (InputStream in = getResource("cem/cem_template_models.json")) { - int n = in == null ? 0 : cemLoader.load(in, getLogger()); - getLogger().info("Loaded " + n + " CEM entity models."); - } catch (Exception e) { - getLogger().severe("Failed to load CEM entity models: " + e.getMessage()); - } - // Vanilla models dumped from the client jar (tools/VanillaModelDump.java) override the CEM set: an - // optional newer dump in the data folder (no recompile on MC updates), else the bundled copy. - File dump = new File(getDataFolder(), "vanilla_entity_models.json"); - try (InputStream in = - dump.isFile() ? new FileInputStream(dump) : getResource("cem/vanilla_entity_models.json")) { - if (in != null) { - long t0 = System.nanoTime(); - int n = cemLoader.load(in, getLogger()); - getLogger() - .info("Loaded vanilla entity models" + (dump.isFile() ? " from " + dump.getName() : "") + " (" - + n + " models total, " + (System.nanoTime() - t0) / 1_000_000 + " ms)."); - } - } catch (Exception e) { - getLogger().severe("Failed to load vanilla entity models: " + e.getMessage()); - } - List worldFolders = - getServer().getWorlds().stream().map(World::getWorldFolder).toList(); - int datapackVariants = DatapackVariants.load(cemLoader, worldFolders, getLogger()); - if (datapackVariants > 0) getLogger().info("Loaded " + datapackVariants + " datapack mob variants."); - SkinCache skinCache = new SkinCache(); - BitmapFont font = FontLoader.load(resourcePack, textures, getLogger()); - getLogger().info("Loaded sign font (" + (font.isEmpty() ? "no glyphs — text disabled" : "ok") + ")."); - CemBaker entityBaker = new CemBaker(cemLoader, textures, skinCache); - BlockEntityBaker blockEntityBaker = new BlockEntityBaker(cemLoader, textures, skinCache, font); - - this.screenRenderer = new DefaultScreenRenderer( - registry, - tintProvider, - textures, - entityBaker, - blockEntityBaker, - getLogger(), - renderManager.tracePool()); - // Initialise the map palette on the main thread (MapPalette) before any off-thread dithering. - MapColorPalette.size(); - getLogger().info("PixelPics renderer initialized with resource pack assets."); + return new RenderSettings(threads, maxConcurrent, queueSize, timeoutSeconds); } @Override @@ -163,18 +137,22 @@ public final class Main extends JavaPlugin { SurvivalRecipes.unregister(); if (renderManager != null) { renderManager.shutdown(); // delivers or fails every open job synchronously (film refunds) - renderManager = null; } MapManager.detachAll(); - if (resourcePack != null) { - resourcePack.close(); - resourcePack = null; + if (setup != null) { + setup.close(); + setup = null; } + renderManager = null; } - /** The renderer, or {@code null} when no resource pack is available (degraded mode). */ + /** The renderer, or {@code null} while it is set up or when no resource pack is available. */ public DefaultScreenRenderer getScreenRenderer() { - return this.screenRenderer; + return setup == null ? null : setup.renderer(); + } + + public SetupManager getSetup() { + return setup; } public PendingPhotos getPendingPhotos() { 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 7669016..8515e7c 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePackLoader.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePackLoader.java @@ -9,6 +9,7 @@ import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.logging.Logger; import java.util.stream.Stream; @@ -32,6 +33,11 @@ public final class ResourcePackLoader { } public static Optional load(File resourcePackDir, Logger logger) { + return load(resourcePackDir, logger, Set.of()); + } + + /** Like {@link #load(File, Logger)}, skipping the zips in {@code exclude}. */ + public static Optional load(File resourcePackDir, Logger logger, Set exclude) { if (!resourcePackDir.isDirectory()) { return Optional.empty(); } @@ -56,6 +62,7 @@ public final class ResourcePackLoader { 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) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/setup/PackVersions.java b/src/main/java/eu/mhsl/minecraft/pixelpics/setup/PackVersions.java new file mode 100644 index 0000000..ced13bd --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/setup/PackVersions.java @@ -0,0 +1,76 @@ +package eu.mhsl.minecraft.pixelpics.setup; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import java.io.File; +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; + +/** + * The Minecraft version of the vanilla packs in {@code resourcepack/}: from the {@value #STAMP} written by the + * auto-download and {@code tools/build-vanilla-pack.sh}, else from a file name {@code vanilla-.zip} (packs + * built by older versions of the script). Packs without either have no known version and are never replaced. + */ +public final class PackVersions { + + /** Version stamp in the pack root: {@code {"version": "26.3", ...}}. */ + public static final String STAMP = "pixelpics-pack.json"; + + private static final Pattern FILE_NAME = Pattern.compile("vanilla-(.+)\\.zip"); + + /** A zip in the pack folder; {@code version} is null when unknown. */ + public record VersionedPack(Path path, String version) {} + + private PackVersions() {} + + public static String fileName(String version) { + return "vanilla-" + version + ".zip"; + } + + /** All {@code .zip} files below {@code dir} with their version, sorted by relative path (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 + } + 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) { + try (ZipFile file = new ZipFile(zip.toFile())) { + ZipEntry stamp = file.getEntry(STAMP); + if (stamp != null) { + try (InputStream in = file.getInputStream(stamp)) { + JsonElement root = JsonParser.parseString(new String(in.readAllBytes(), StandardCharsets.UTF_8)); + String v = root.getAsJsonObject().get("version").getAsString(); + if (!v.isBlank()) return v; + } + } + } catch (IOException | RuntimeException ignored) { + // not a zip or no readable stamp: fall back to the file name + } + Matcher m = FILE_NAME.matcher(zip.getFileName().toString()); + return m.matches() ? m.group(1) : 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 new file mode 100644 index 0000000..794be85 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/setup/SetupManager.java @@ -0,0 +1,547 @@ +package eu.mhsl.minecraft.pixelpics.setup; + +import eu.mhsl.minecraft.pixelpics.Main; +import eu.mhsl.minecraft.pixelpics.assets.AssetReader; +import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry; +import eu.mhsl.minecraft.pixelpics.assets.ResourcePack; +import eu.mhsl.minecraft.pixelpics.assets.ResourcePackLoader; +import eu.mhsl.minecraft.pixelpics.assets.SkinCache; +import eu.mhsl.minecraft.pixelpics.assets.TextureCache; +import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont; +import eu.mhsl.minecraft.pixelpics.assets.font.FontLoader; +import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker; +import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker; +import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader; +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 java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +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. + */ +public final class SetupManager { + + static final String DUMP_FILE = "vanilla_entity_models.json"; + private static final String BUNDLED_DUMP = "cem/vanilla_entity_models.json"; + 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. */ + 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. */ + 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, + String dumpSource, + String dumpVersion, + int entityModels, + int datapackVariants, + boolean fontOk, + long millis) {} + + private record Built(ResourcePack pack, DefaultScreenRenderer renderer, Report report) {} + + private record Inputs( + String serverVersion, + boolean autoDownload, + boolean autoUpdate, + String manifestUrl, + long maxBytes, + List worldFolders, + List blocks, + List items, + ForkJoinPool tracePool) {} + + private record Dump(String version, boolean override) {} + + private final Main plugin; + private final Logger log; + private final File packDir; + private final ExecutorService worker; + + private volatile DefaultScreenRenderer renderer; + private volatile Report report; + /** The current background step (shown in messages and info), or null. */ + private volatile String task; + /** The last setup problem an admin should fix, or null. */ + private volatile String problem; + + private volatile boolean running; + private volatile boolean closed; + private ResourcePack pack; + private final List retired = new ArrayList<>(); + + public SetupManager(Main plugin) { + this.plugin = plugin; + this.log = plugin.getLogger(); + this.packDir = new File(plugin.getDataFolder(), "resourcepack"); + ThreadPoolExecutor executor = + new ThreadPoolExecutor(1, 1, 30, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), r -> { + Thread thread = new Thread(r, "PixelPics-setup"); + thread.setDaemon(true); + return thread; + }); + executor.allowCoreThreadTimeOut(true); // no idle thread once set up + this.worker = executor; + } + + public DefaultScreenRenderer renderer() { + return renderer; + } + + public Report report() { + return report; + } + + public String task() { + return task; + } + + public String problem() { + return problem; + } + + public boolean running() { + return running; + } + + /** Why photos are refused while there is no renderer (German, for players). */ + public String unavailableMessage() { + String step = task; + if (step != null) return "PixelPics wird gerade eingerichtet (" + step + ") — versuch es gleich nochmal."; + if (running) return "PixelPics startet noch — versuch es gleich nochmal."; + return "PixelPics ist nicht einsatzbereit (kein Resource Pack) — bitte einem Admin melden."; + } + + /** + * Starts a setup run on the setup thread (main thread only). {@code requester} (may be null) is told about + * progress and the result. False when a run is already going on. + */ + public boolean start(CommandSender requester) { + if (running || closed) return false; + running = true; + problem = null; + Inputs in = inputs(); + worker.execute(() -> run(in, requester)); + return true; + } + + /** Closes packs replaced by a newer renderer once no photo renders any more (main thread). */ + public void closeRetired(boolean force) { + if (retired.isEmpty()) return; + if (!force + && (plugin.getRenderManager() == null + || !plugin.getRenderManager().isIdle())) return; + retired.forEach(ResourcePack::close); + retired.clear(); + } + + /** On disable: stops a running download/build and closes all packs. */ + public void close() { + closed = true; + worker.shutdownNow(); + renderer = null; + if (pack != null) pack.close(); + pack = null; + closeRetired(true); + } + + private Inputs inputs() { + FileConfiguration config = plugin.getConfig(); + List blocks = new ArrayList<>(); + List items = new ArrayList<>(); + for (Material m : Material.values()) { + if (m.isLegacy()) continue; + if (m.isBlock()) blocks.add(m.getKey().getKey()); + if (m.isItem()) items.add(m.getKey().getKey()); + } + return new Inputs( + Bukkit.getMinecraftVersion(), + config.getBoolean("resource-pack.auto-download", true), + config.getBoolean("resource-pack.auto-update", true), + config.getString( + "resource-pack.manifest-url", + "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"), + Math.max(1, config.getLong("resource-pack.max-download-mb", 256)) << 20, + plugin.getServer().getWorlds().stream() + .map(World::getWorldFolder) + .toList(), + blocks, + items, + plugin.getRenderManager().tracePool()); + } + + private void run(Inputs in, CommandSender requester) { + try { + String server = in.serverVersion(); + if (!packDir.isDirectory() && !packDir.mkdirs()) { + log.warning("Could not create the resource pack folder " + packDir); + } + cleanPartFiles(); + List versioned = PackVersions.scan(packDir.toPath()).stream() + .filter(v -> v.version() != null) + .toList(); + boolean matching = versioned.stream().anyMatch(v -> v.version().equals(server)); + boolean download = false; + if (!versioned.isEmpty() && !matching) { + String names = String.join( + ", ", + versioned.stream() + .map(v -> v.path().getFileName() + " (" + v.version() + ")") + .toList()); + if (in.autoUpdate()) { + log.warning("The vanilla resource pack " + names + " is not for Minecraft " + server + + ", the version this server runs: downloading the " + server + " pack (the old one keeps" + + " rendering until then)."); + download = true; + } else { + problem = "Resource Pack " + names + " passt nicht zu Minecraft " + server + " (auto-update aus)."; + log.warning("The vanilla resource pack " + names + " is not for Minecraft " + server + + ", the version this server runs. New blocks and items render as missing textures." + + " resource-pack.auto-update is off: replace it with tools/build-vanilla-pack.sh " + server + + " or turn auto-update on and run /pixelpic reload."); + } + } + + Built built = build(in, outdated(server, matching)); + if (built != null) { + swap(built, requester, download ? "Alter Pack aktiv, lade den neuen herunter …" : null); + } else if (in.autoDownload()) { + log.info("No resource pack in " + packDir + ": downloading the vanilla assets of Minecraft " + server + + " from Mojang (resource-pack.auto-download). Photos are refused until it is ready."); + tell(requester, "Kein Resource Pack vorhanden — lade Minecraft " + server + " herunter …"); + download = true; + } else { + problem = "Kein Resource Pack (auto-download aus)."; + log.severe("No resource pack in " + packDir + " and resource-pack.auto-download is off: photos are" + + " disabled. Put a vanilla pack there (tools/build-vanilla-pack.sh " + server + + "), or turn auto-download on, then run /pixelpic reload."); + tell(requester, "Kein Resource Pack gefunden und auto-download ist aus — Fotos bleiben deaktiviert."); + } + + if (download) download(in, requester, built != null); + } catch (Throwable t) { + problem = "Einrichtung fehlgeschlagen: " + t; + log.log(Level.SEVERE, "PixelPics setup failed", t); + tell(requester, "Einrichtung fehlgeschlagen: " + t.getMessage() + " (Details im Server-Log)"); + } finally { + task = null; + onMain(() -> running = false); + if (closed) running = false; + } + } + + private void download(Inputs in, CommandSender requester, boolean hasOld) { + String server = in.serverVersion(); + long t0 = System.nanoTime(); + task = "Resource Pack " + server + " wird heruntergeladen"; + VanillaPackDownloader downloader = + new VanillaPackDownloader("PixelPics/" + plugin.getPluginMeta().getVersion(), step -> { + task = step; + DebugLog.log("Pack download: " + step); + }); + Path fresh; + try { + fresh = downloader.download( + server, + in.manifestUrl(), + in.maxBytes(), + new File(plugin.getDataFolder(), "download").toPath(), + packDir.toPath()); + } catch (IOException | RuntimeException e) { + if (closed) return; + problem = "Download des Resource Packs " + server + " fehlgeschlagen: " + describe(e); + log.warning("Could not download the vanilla resource pack for Minecraft " + server + ": " + e + + (hasOld + ? ". Keeping the old pack." + : ". Photos stay disabled. Without internet access build the pack elsewhere with" + + " tools/build-vanilla-pack.sh " + server + " and put the zip into " + packDir + + ", then run /pixelpic reload (also retries the download).")); + tell( + requester, + "Download fehlgeschlagen: " + describe(e) + (hasOld ? " — der alte Pack bleibt aktiv." : "")); + return; + } finally { + new File(plugin.getDataFolder(), "download").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)); + if (built == null) { + problem = "Der heruntergeladene Pack " + fresh.getFileName() + " ließ sich nicht laden."; + log.severe("The downloaded pack " + fresh + " could not be loaded."); + return; + } + swap(built, requester, null); + } + + /** A short German reason for a failed download. */ + private static String describe(Exception e) { + return switch (e) { + case UnknownHostException u -> "Adresse " + u.getMessage() + " nicht auflösbar (kein Internet?)"; + case SocketTimeoutException t -> "Zeitüberschreitung (" + t.getMessage() + ")"; + case ConnectException c -> "keine Verbindung (" + c.getMessage() + ")"; + default -> String.valueOf(e.getMessage()); + }; + } + + /** The versioned vanilla packs of other versions, left out while one for {@code server} exists. */ + private Set outdated(String server, boolean matching) { + Set out = new HashSet<>(); + if (!matching) return out; + for (PackVersions.VersionedPack v : PackVersions.scan(packDir.toPath())) { + if (v.version() != null && !v.version().equals(server)) out.add(v.path()); + } + return out; + } + + private void backupOutdated(String server) { + File backup = new File(plugin.getDataFolder(), "backup"); + for (Path old : outdated(server, true)) { + try { + Files.createDirectories(backup.toPath()); + Path target = backup.toPath().resolve(old.getFileName()); + Files.move(old, target, StandardCopyOption.REPLACE_EXISTING); + log.info("Moved the outdated pack " + old.getFileName() + " to " + target + "."); + } catch (IOException e) { + log.warning("Could not move the outdated pack " + old + " to " + backup + " (" + e + + "); it stays unused while the current pack exists."); + } + } + } + + private void cleanPartFiles() { + for (File dir : new File[] {packDir, new File(plugin.getDataFolder(), "download")}) { + 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) { + long t0 = System.nanoTime(); + Optional loaded = ResourcePackLoader.load(packDir, log, exclude); + if (loaded.isEmpty()) return null; + ResourcePack resourcePack = loaded.get(); + try { + AssetReader reader = new AssetReader(resourcePack); + TextureCache textures = new TextureCache(resourcePack); + BlockModelRegistry registry = new BlockModelRegistry(reader, textures); + BiomeTintProvider tintProvider = new BiomeTintProvider(textures); + + CemModelLoader cemLoader = new CemModelLoader(); + try (InputStream template = plugin.getResource("cem/cem_template_models.json")) { + int n = template == null ? 0 : cemLoader.load(template, log); + DebugLog.log("Loaded " + n + " CEM template entity models."); + } catch (Exception e) { + log.severe("Failed to load CEM entity models: " + e.getMessage()); + } + Dump dump = loadDump(cemLoader, in.serverVersion()); + int datapackVariants = DatapackVariants.load(cemLoader, in.worldFolders(), log); + if (datapackVariants > 0) log.info("Loaded " + datapackVariants + " datapack mob variants."); + SkinCache skinCache = new SkinCache(); + BitmapFont font = FontLoader.load(resourcePack, textures, log); + if (font.isEmpty()) + log.warning("The resource pack has no usable font: signs and text displays stay blank."); + CemBaker entityBaker = new CemBaker(cemLoader, textures, skinCache); + BlockEntityBaker blockEntityBaker = new BlockEntityBaker(cemLoader, textures, skinCache, font); + DefaultScreenRenderer screenRenderer = new DefaultScreenRenderer( + registry, tintProvider, textures, entityBaker, blockEntityBaker, log, in.tracePool()); + + 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() + .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, + dump.override() ? DUMP_FILE : "gebündelt", + dump.version(), + cemLoader.vanillaNames().size(), + datapackVariants, + !font.isEmpty(), + (System.nanoTime() - t0) / 1_000_000); + logReport(r); + return new Built(resourcePack, screenRenderer, r); + } catch (RuntimeException | Error e) { + resourcePack.close(); + throw e; + } + } + + /** + * 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. + */ + private Dump loadDump(CemModelLoader cemLoader, String server) { + File override = new File(plugin.getDataFolder(), DUMP_FILE); + String bundledVersion = peekVersion(() -> plugin.getResource(BUNDLED_DUMP)); + String overrideVersion = override.isFile() ? peekVersion(() -> new FileInputStream(override)) : null; + boolean useOverride = override.isFile() && (server.equals(overrideVersion) || !server.equals(bundledVersion)); + if (override.isFile() && !useOverride) { + log.warning("Ignoring " + override + " (dump of Minecraft " + overrideVersion + + "): the bundled dump matches this server (" + server + "). Delete the file."); + } + String version = useOverride ? overrideVersion : bundledVersion; + try (InputStream in = useOverride ? new FileInputStream(override) : plugin.getResource(BUNDLED_DUMP)) { + if (in == null) { + log.warning("No vanilla entity model dump found: mobs use the CEM template models."); + return new Dump(null, false); + } + int n = cemLoader.load(in, log); + DebugLog.log("Loaded the " + (useOverride ? DUMP_FILE : "bundled") + " entity model dump " + version + " (" + + n + " models total)."); + } catch (Exception e) { + log.severe("Failed to load the vanilla entity model dump: " + e.getMessage()); + } + if (!server.equals(version)) { + log.warning("The entity model dump is for Minecraft " + version + " but the server runs " + server + + ": new or changed mobs may render with old or missing models. A matching dump needs the" + + " Minecraft client classes and a JDK, so PixelPics cannot create it at runtime: update PixelPics," + + " or run tools/update-vanilla-models.sh " + server + " plugins/PixelPics/ on a machine with the " + + server + " client installed and restart."); + } + return new Dump(version, useOverride); + } + + private interface StreamSource { + InputStream open() throws IOException; + } + + /** The {@code version} at the start of a dump (written first), or null. */ + private static String peekVersion(StreamSource source) { + try (InputStream in = source.open()) { + if (in == null) return null; + Matcher m = DUMP_VERSION.matcher(new String(in.readNBytes(512), StandardCharsets.UTF_8)); + return m.find() ? m.group(1) : null; + } catch (IOException e) { + return null; + } + } + + private static Coverage coverage(ResourcePack pack, List keys, String folder) { + List missing = keys.stream() + .filter(k -> !pack.exists(folder + k + ".json")) + .sorted() + .toList(); + return new Coverage( + keys.size(), missing.size(), missing.stream().limit(SAMPLE).toList()); + } + + private void logReport(Report r) { + String loaded = String.join( + ", ", + r.packs().stream() + .filter(PackInfo::loaded) + .map(p -> p.name() + (p.version() != null ? " (" + p.version() + ")" : "")) + .toList()); + log.info("Resource pack: " + (loaded.isEmpty() ? "folder pack" : loaded) + "; block states " + + (r.blocks().total() - r.blocks().missing()) + "/" + r.blocks().total() + ", item models " + + (r.items().total() - r.items().missing()) + "/" + r.items().total() + "; entity model dump " + + r.dumpVersion() + " (" + r.dumpSource() + ", " + r.entityModels() + " models); built in " + + r.millis() + " ms."); + if (r.blocks().missing() > 0 || r.items().missing() > 0) { + log.warning("The resource pack lacks " + r.blocks().missing() + " block states " + + r.blocks().sample() + + " 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()) + + " or with a " + PackVersions.STAMP + " is updated automatically."); + } + } + + /** Swaps the renderer in on the main thread; the old pack closes once no photo uses it. */ + private void swap(Built built, CommandSender requester, String note) { + boolean scheduled = onMain(() -> { + if (closed) { + built.pack().close(); + return; + } + if (pack != null) retired.add(pack); + pack = built.pack(); + report = built.report(); + renderer = built.renderer(); + closeRetired(false); + if (requester != null) { + Report r = built.report(); + requester.sendMessage(Component.text( + "PixelPics neu geladen in " + r.millis() + " ms" + + (r.blocks().missing() + r.items().missing() > 0 + ? " — Pack unvollständig, siehe /pixelpic info." + : ".") + + (note != null ? " " + note : ""), + NamedTextColor.GREEN)); + } + }); + if (!scheduled) built.pack().close(); + } + + private void tell(CommandSender requester, String message) { + if (requester == null) return; + onMain(() -> requester.sendMessage(Component.text(message, NamedTextColor.YELLOW))); + } + + private boolean onMain(Runnable task) { + if (closed) return false; + try { + Bukkit.getScheduler().runTask(plugin, task); + return true; + } catch (IllegalPluginAccessException e) { + return false; + } + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/setup/VanillaPackDownloader.java b/src/main/java/eu/mhsl/minecraft/pixelpics/setup/VanillaPackDownloader.java new file mode 100644 index 0000000..1f4b65e --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/setup/VanillaPackDownloader.java @@ -0,0 +1,214 @@ +package eu.mhsl.minecraft.pixelpics.setup; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Collections; +import java.util.HexFormat; +import java.util.List; +import java.util.function.Consumer; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +/** + * Builds the vanilla resource pack PixelPics renders from, like {@code tools/build-vanilla-pack.sh}: looks the + * version up in Mojang's version manifest, downloads that version's client jar (https only, size limit, sha1 from the + * manifest verified, connect/read timeouts) and keeps only the assets the renderer reads, plus a + * {@value PackVersions#STAMP} with the version. + */ +public final class VanillaPackDownloader { + + /** The asset folders the renderer reads (blockstates, models, textures, item definitions, atlases, fonts). */ + static final List ASSET_DIRS = List.of( + "assets/minecraft/blockstates/", + "assets/minecraft/models/", + "assets/minecraft/textures/", + "assets/minecraft/items/", + "assets/minecraft/atlases/", + "assets/minecraft/font/"); + + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 30_000; + private static final long MAX_JSON_BYTES = 16L << 20; + + private final String userAgent; + private final Consumer progress; + + /** {@code progress} receives short status lines (e.g. the downloaded share) from the download thread. */ + public VanillaPackDownloader(String userAgent, Consumer progress) { + this.userAgent = userAgent; + this.progress = progress; + } + + /** + * Downloads the client jar of {@code version} into {@code tempDir} and writes {@code packDir/vanilla-.zip} + * (atomically). Returns the pack. Interruptible between reads. + */ + public Path download(String version, String manifestUrl, long maxBytes, Path tempDir, Path packDir) + throws IOException { + JsonObject manifest = json(manifestUrl, null); + JsonObject entry = null; + for (JsonElement e : manifest.getAsJsonArray("versions")) { + JsonObject o = e.getAsJsonObject(); + if (version.equals(o.get("id").getAsString())) entry = o; + } + if (entry == null) throw new IOException("Minecraft " + version + " is not listed in the version manifest"); + JsonObject versionJson = json( + entry.get("url").getAsString(), + entry.has("sha1") ? entry.get("sha1").getAsString() : null); + JsonObject client = versionJson.getAsJsonObject("downloads").getAsJsonObject("client"); + String url = client.get("url").getAsString(); + String sha1 = client.get("sha1").getAsString(); + long size = client.get("size").getAsLong(); + if (size > maxBytes) { + throw new IOException("the client jar is " + mb(size) + " MB, more than resource-pack.max-download-mb (" + + mb(maxBytes) + " MB)"); + } + + Files.createDirectories(tempDir); + Files.createDirectories(packDir); + // unique names (an old download of a disabled instance may still run); created with the default umask + String unique = Long.toHexString(System.nanoTime()); + Path jar = tempDir.resolve("client-" + version + "-" + unique + ".jar.part"); + Path zip = packDir.resolve("vanilla-" + version + "-" + unique + ".zip.part"); + try { + progress.accept("Lade Minecraft-" + version + "-Client herunter (" + mb(size) + " MB)"); + try (OutputStream out = Files.newOutputStream(jar)) { + String got = fetch(url, out, size, true); + if (!got.equalsIgnoreCase(sha1)) { + throw new IOException("sha1 mismatch for the client jar (expected " + sha1 + ", got " + got + ")"); + } + } + if (Files.size(jar) != size) throw new IOException("client jar size differs from the manifest"); + progress.accept("Extrahiere Assets aus dem Client"); + int files = extract(jar, zip, version, sha1); + if (files < 1000) throw new IOException("the client jar holds only " + files + " asset files"); + Path target = packDir.resolve(PackVersions.fileName(version)); + Files.move(zip, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + return target; + } finally { + Files.deleteIfExists(jar); + Files.deleteIfExists(zip); + } + } + + 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))) { + for (ZipEntry e : Collections.list(in.entries())) { + String name = e.getName(); + if (e.isDirectory() + || name.contains("..") + || ASSET_DIRS.stream().noneMatch(name::startsWith)) { + continue; + } + if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException("interrupted"); + out.putNextEntry(new ZipEntry(name)); + try (InputStream data = in.getInputStream(e)) { + data.transferTo(out); + } + out.closeEntry(); + files++; + } + JsonObject stamp = new JsonObject(); + stamp.addProperty("version", version); + stamp.addProperty("client_sha1", sha1); + stamp.addProperty("created", Instant.now().toString()); + stamp.addProperty("source", "PixelPics auto-download"); + out.putNextEntry(new ZipEntry(PackVersions.STAMP)); + out.write(stamp.toString().getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + return files; + } + + private JsonObject json(String url, String sha1) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + String got = fetch(url, buffer, MAX_JSON_BYTES, false); + if (sha1 != null && !got.equalsIgnoreCase(sha1)) { + throw new IOException("sha1 mismatch for " + url); + } + try { + return JsonParser.parseString(buffer.toString(StandardCharsets.UTF_8)) + .getAsJsonObject(); + } catch (RuntimeException e) { + throw new IOException("unreadable json from " + url + ": " + e.getMessage()); + } + } + + /** Streams {@code url} into {@code out} (at most {@code limit} bytes) and returns the sha1 of the body. */ + private String fetch(String url, OutputStream out, long limit, boolean report) throws IOException { + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + throw new IOException("invalid url " + url); + } + if (!"https".equalsIgnoreCase(uri.getScheme())) throw new IOException("only https urls are allowed: " + url); + HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setInstanceFollowRedirects(true); // same protocol only: never https -> http + connection.setRequestProperty("User-Agent", userAgent); + try { + int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) throw new IOException("HTTP " + status + " for " + url); + if (!"https".equalsIgnoreCase(connection.getURL().getProtocol())) { + throw new IOException("redirected away from https: " + connection.getURL()); + } + long announced = connection.getContentLengthLong(); + if (announced > limit) throw new IOException(url + " is larger than " + mb(limit) + " MB"); + MessageDigest digest = sha1(); + long total = 0; + int lastShare = -1; + byte[] buf = new byte[64 * 1024]; + try (InputStream in = connection.getInputStream()) { + int n; + while ((n = in.read(buf)) > 0) { + if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException("interrupted"); + total += n; + if (total > limit) throw new IOException(url + " is larger than " + mb(limit) + " MB"); + digest.update(buf, 0, n); + out.write(buf, 0, n); + int share = (int) (total * 4 / limit); // limit is the expected size for the jar + if (report && share != lastShare) { + lastShare = share; + progress.accept("Download " + mb(total) + " / " + mb(limit) + " MB"); + } + } + } + return HexFormat.of().formatHex(digest.digest()); + } finally { + connection.disconnect(); + } + } + + private static MessageDigest sha1() { + try { + return MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + private static long mb(long bytes) { + return (bytes + (1 << 20) - 1) >> 20; + } +} 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 27a4311..02532b5 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java @@ -3,14 +3,17 @@ package eu.mhsl.minecraft.pixelpics.survival; import eu.mhsl.minecraft.pixelpics.Main; import eu.mhsl.minecraft.pixelpics.render.ChunkHolds; import eu.mhsl.minecraft.pixelpics.render.RenderManager; +import eu.mhsl.minecraft.pixelpics.render.RenderStats; import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer; import eu.mhsl.minecraft.pixelpics.render.render.RenderJob; import eu.mhsl.minecraft.pixelpics.render.render.Resolution; +import eu.mhsl.minecraft.pixelpics.utils.DebugLog; import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer; import eu.mhsl.minecraft.pixelpics.utils.MapImageDither; import eu.mhsl.minecraft.pixelpics.utils.MapManager; import java.awt.image.BufferedImage; import java.util.UUID; +import java.util.function.Consumer; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; @@ -26,16 +29,16 @@ import org.bukkit.persistence.PersistentDataType; /** * Photographs a player's view into a {@code FILLED_MAP} that develops once the render (throttled by - * {@link RenderManager}) is done. Used by the camera and {@code /pixelPic}; the caller takes the film, + * {@link RenderManager}) is done. Used by the camera and {@code /pixelpic photo}; the caller takes the film, * a photo that fails to develop gives it back. */ public final class PhotoService { - private record RenderOutput(BufferedImage image, byte[] indices) {} + private record RenderOutput(BufferedImage image, byte[] indices, long[] marks) {} private PhotoService() {} - /** A debug photo that uses no film ({@code /pixelPic}). */ + /** A debug photo that uses no film. */ public static boolean takePhoto(Player player) { return takePhoto(player, false); } @@ -45,31 +48,48 @@ public final class PhotoService { * {@code usesFilm} the caller takes the film on true; it is given back if the photo then fails to develop. */ public static boolean takePhoto(Player player, boolean usesFilm) { + Component refusal = start(player, usesFilm, null); + if (refusal == null) return true; + player.sendActionBar(refusal); + return false; + } + + /** + * A photo without film for {@code /pixelpic photo}: null when it was taken (the map goes to {@code player}), else + * why not. {@code onDone} gets the photo's timings once it developed, or null when it failed. + */ + public static Component takeDebugPhoto(Player player, Consumer onDone) { + return start(player, false, onDone); + } + + private static Component start(Player player, boolean usesFilm, Consumer onDone) { + long shot = System.nanoTime(); Main plugin = Main.getInstance(); DefaultScreenRenderer renderer = plugin.getScreenRenderer(); if (renderer == null) { - player.sendActionBar( - Component.text("PixelPics ist nicht einsatzbereit (kein Resource-Pack).", NamedTextColor.RED)); - return false; + return Component.text( + plugin.getSetup() == null + ? "PixelPics ist deaktiviert." + : plugin.getSetup().unavailableMessage(), + NamedTextColor.RED); } if (!MapManager.canStore()) { - player.sendActionBar(Component.text( - "Fotos können gerade nicht gespeichert werden — bitte einem Admin melden.", NamedTextColor.RED)); plugin.getLogger().warning("Photo of " + player.getName() + " refused: the image folder is not writable."); - return false; + return Component.text( + "Fotos können gerade nicht gespeichert werden — bitte einem Admin melden.", NamedTextColor.RED); } RenderManager manager = plugin.getRenderManager(); UUID user = player.getUniqueId(); RenderManager.Outcome outcome = manager.tryReserve(user); if (outcome != RenderManager.Outcome.ACCEPTED) { - player.sendActionBar(Component.text( + manager.stats().rejected(); + return Component.text( outcome == RenderManager.Outcome.USER_BUSY ? "Deine letzte Aufnahme wird noch entwickelt …" : "Zu viele Aufnahmen gerade — versuch es gleich erneut.", - NamedTextColor.RED)); - return false; + NamedTextColor.RED); } boolean dispatched = false; @@ -109,11 +129,14 @@ public final class PhotoService { manager.dispatch( user, cancelled -> { + long[] marks = new long[4]; // snapshot start/end, traced, dithered RenderJob job = manager.onMainThread( () -> { + marks[0] = System.nanoTime(); try { return renderer.prepare(eye, resolution, user); } finally { + marks[1] = System.nanoTime(); chunks.release(); } }, @@ -121,19 +144,28 @@ public final class PhotoService { if (job == null) return null; BufferedImage image = renderer.execute(job, cancelled); if (cancelled.get()) return null; - return new RenderOutput(image, MapImageDither.dither(image)); + marks[2] = System.nanoTime(); + byte[] indices = MapImageDither.dither(image); + marks[3] = System.nanoTime(); + return new RenderOutput(image, indices, marks); }, out -> { chunks.release(); // already released by prepare if (!MapManager.saveImage(out.image(), id)) { - failed( - id, user, - usesFilm); // not stored: it would be lost (or a vanilla map) after a restart + // not stored: it would be lost (or a vanilla map) after a restart + failed(id, user, usesFilm, onDone); return; } MapManager.saveIndices(out.indices(), id); plugin.getPendingPhotos().remove(id); mapRenderer.develop(out.indices()); + RenderStats.Timings timings = timings(shot, out.marks()); + manager.stats().developed(timings); + DebugLog.log("Photo " + id + " of " + player.getName() + " developed in " + timings.total() + + " ms: queue " + timings.queued() + ", snapshot " + timings.snapshot() + ", trace " + + timings.trace() + ", dither " + timings.dither() + " ms" + + (usesFilm ? "" : " (debug)")); + if (onDone != null) onDone.accept(timings); Player online = Bukkit.getPlayer(user); if (online != null) { online.playSound(online.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f); @@ -141,7 +173,7 @@ public final class PhotoService { }, () -> { chunks.release(); // the job may have ended before its snapshot - failed(id, user, usesFilm); + failed(id, user, usesFilm, onDone); }); dispatched = true; } finally { @@ -150,8 +182,21 @@ public final class PhotoService { if (hold != null) hold.release(); } } + return null; + } - return true; + private static RenderStats.Timings timings(long shot, long[] marks) { + long now = System.nanoTime(); + return new RenderStats.Timings( + ms(marks[0] - shot), + ms(marks[1] - marks[0]), + ms(marks[2] - marks[1]), + ms(marks[3] - marks[2]), + ms(now - shot)); + } + + private static long ms(long nanos) { + return nanos / 1_000_000; } /** @@ -159,9 +204,13 @@ public final class PhotoService { * taken back from the photographer's inventory if it is still there, and the film is given back — right away, * or on their next join when they are offline. */ - private static void failed(int mapId, UUID user, boolean usesFilm) { + private static void failed(int mapId, UUID user, boolean usesFilm, Consumer onDone) { Main plugin = Main.getInstance(); plugin.getPendingPhotos().remove(mapId); + RenderManager manager = plugin.getRenderManager(); + if (manager != null) manager.stats().failed(); + DebugLog.log("Photo " + mapId + " of " + user + " failed" + (usesFilm ? " (film refunded)" : " (debug)")); + if (onDone != null) onDone.accept(null); Player player = Bukkit.getPlayer(user); if (!usesFilm) { if (player != null) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d2677da..18df868 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,4 +1,28 @@ -# PixelPics rendering performance settings. +# PixelPics configuration. Apply changes with /pixelpic reload (no restart needed). + +# Detailed log output: timings of every photo (queue, snapshot, ray tracing, dithering), setup steps and +# stack traces of failed renders. Leave off in normal operation. +debug: false + +# PixelPics renders with the vanilla block/item models, textures and fonts of a resource pack in +# plugins/PixelPics/resourcepack/ (a folder with assets/minecraft/... or .zip files; see /pixelpic info). +resource-pack: + # No pack there: download the Minecraft client jar of the server's version from Mojang (sha1-verified) + # in the background and keep the needed assets as resourcepack/vanilla-.zip (~6 MB). + auto-download: true + + # The vanilla pack (vanilla-.zip or a pack with a pixelpics-pack.json) is for another Minecraft + # version than the server, e.g. after a server update: download the matching one and move the old one to + # plugins/PixelPics/backup/. Other packs (e.g. your own textures) are never touched. + auto-update: true + + # Mojang's version manifest. Change only for a mirror (https only). + manifest-url: "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" + + # Downloads larger than this are refused (the 26.3 client jar is about 40 MB). + max-download-mb: 256 + +# Rendering performance settings. render: # CPU cores the ray tracer may use. 0 = auto (available processors minus 2, at least 1). # Lower this to leave more CPU headroom for the server tick. diff --git a/tools/build-vanilla-pack.sh b/tools/build-vanilla-pack.sh index 8cd06c9..fd20750 100755 --- a/tools/build-vanilla-pack.sh +++ b/tools/build-vanilla-pack.sh @@ -1,11 +1,13 @@ #!/usr/bin/env bash # Builds the runtime resource pack PixelPics renders from (blockstates, models, textures, items, # atlases, font) straight from Mojang's CLIENT jar — no launcher install needed. The plugin ships no -# assets itself; drop the resulting zip into plugins/PixelPics/resourcepack/. Run it once per -# Minecraft update. No pack.mcmeta needed (the client jar has none; the loader detects a zip by its -# vanilla blockstate files). +# assets itself. The plugin does the same on its own (resource-pack.auto-download/auto-update in +# config.yml); use this script for servers without internet access, then drop the zip into +# plugins/PixelPics/resourcepack/. No pack.mcmeta needed (the client jar has none; the loader detects +# a zip by its vanilla blockstate files). The zip carries a pixelpics-pack.json with the version, so +# the plugin's auto-update recognises and replaces it after a Minecraft update. # -# Needs: curl, jq, unzip, zip. +# Needs: curl, jq, unzip, zip, sha1sum. # # Usage: tools/build-vanilla-pack.sh 26.3 # -> ./vanilla-26.3.zip # tools/build-vanilla-pack.sh 26.3 # -> /vanilla-26.3.zip @@ -22,9 +24,14 @@ META="$(curl -fsS https://piston-meta.mojang.com/mc/game/version_manifest_v2.jso [[ -n "$META" ]] || { echo "Minecraft version $VER not found in Mojang's version manifest"; exit 1; } echo "Downloading client jar for $VER ..." -curl -fsS -o "$WORK/client.jar" "$(curl -fsS "$META" | jq -r .downloads.client.url)" +curl -fsS -o "$WORK/version.json" "$META" +SHA1="$(jq -r .downloads.client.sha1 "$WORK/version.json")" +curl -fsS -o "$WORK/client.jar" "$(jq -r .downloads.client.url "$WORK/version.json")" +echo "$SHA1 $WORK/client.jar" | sha1sum -c --quiet - || { echo "sha1 mismatch for the client jar"; exit 1; } unzip -q "$WORK/client.jar" 'assets/minecraft/'{blockstates,models,textures,items,atlases,font}'/*' -d "$WORK/pack" +jq -n --arg v "$VER" --arg s "$SHA1" --arg t "$(date -u +%FT%TZ)" \ + '{version: $v, client_sha1: $s, created: $t, source: "tools/build-vanilla-pack.sh"}' > "$WORK/pack/pixelpics-pack.json" rm -f "$OUT" (cd "$WORK/pack" && zip -qr "$OUT" .) echo "Wrote $OUT ($(unzip -Z1 "$OUT" | grep -vc '/$') files)"