automatic setup off the main thread: the vanilla resource pack is downloaded from mojang when none is present (client jar of the server version, sha1 and size from the manifest, https only, timeouts, size limit) and replaced after a minecraft update (version stamp or vanilla-<version>.zip, old pack keeps rendering until then and goes to backup/), photos refused with a clear message until ready, packs of other versions left out, coverage check of block states and item definitions, entity dump version checked against the server (outdated override ignored), new config options added to existing configs, pack script stamps and verifies
This commit is contained in:
1 parent
be94c33ea6
commit
492bcb3a39
8 files changed
+1025
-123
No files matched your search
@@ -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<String> 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<String> 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<ResourcePack> 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<File> 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() {
|
||||
|
||||
@@ -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<ResourcePack> 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<ResourcePack> load(File resourcePackDir, Logger logger, Set<Path> exclude) {
|
||||
if (!resourcePackDir.isDirectory()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
@@ -56,6 +62,7 @@ public final class ResourcePackLoader {
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
List<Path> zips = walk.filter(Files::isRegularFile)
|
||||
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
|
||||
.filter(p -> !exclude.contains(p))
|
||||
.sorted(Comparator.comparing(p -> relative(root, p)))
|
||||
.toList();
|
||||
for (Path zip : zips) {
|
||||
|
||||
@@ -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-<version>.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<VersionedPack> scan(Path dir) {
|
||||
List<VersionedPack> out = new ArrayList<>();
|
||||
if (!Files.isDirectory(dir)) return out;
|
||||
try (Stream<Path> walk = Files.walk(dir)) {
|
||||
for (Path zip : walk.filter(Files::isRegularFile)
|
||||
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
|
||||
.sorted(Comparator.comparing(
|
||||
p -> dir.relativize(p).toString().replace(File.separatorChar, '/')))
|
||||
.toList()) {
|
||||
out.add(new VersionedPack(zip, version(zip)));
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// an unreadable folder: the loader reports it
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The pack's version, or null when it has no stamp and no {@code vanilla-<version>.zip} name. */
|
||||
public static String version(Path zip) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> sample) {}
|
||||
|
||||
/** What the current renderer was built from, for {@code /pixelpic info}. */
|
||||
public record Report(
|
||||
String serverVersion,
|
||||
List<PackInfo> packs,
|
||||
Coverage blocks,
|
||||
Coverage items,
|
||||
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<File> worldFolders,
|
||||
List<String> blocks,
|
||||
List<String> 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<ResourcePack> 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<String> blocks = new ArrayList<>();
|
||||
List<String> 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<PackVersions.VersionedPack> 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<Path> outdated(String server, boolean matching) {
|
||||
Set<Path> 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<Path> exclude) {
|
||||
long t0 = System.nanoTime();
|
||||
Optional<ResourcePack> 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<PackInfo> 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<String> keys, String folder) {
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> progress;
|
||||
|
||||
/** {@code progress} receives short status lines (e.g. the downloaded share) from the download thread. */
|
||||
public VanillaPackDownloader(String userAgent, Consumer<String> progress) {
|
||||
this.userAgent = userAgent;
|
||||
this.progress = progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the client jar of {@code version} into {@code tempDir} and writes {@code packDir/vanilla-<version>.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;
|
||||
}
|
||||
}
|
||||
@@ -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<RenderStats.Timings> onDone) {
|
||||
return start(player, false, onDone);
|
||||
}
|
||||
|
||||
private static Component start(Player player, boolean usesFilm, Consumer<RenderStats.Timings> 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<RenderStats.Timings> 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) {
|
||||
|
||||
@@ -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-<version>.zip (~6 MB).
|
||||
auto-download: true
|
||||
|
||||
# The vanilla pack (vanilla-<version>.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.
|
||||
|
||||
@@ -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 <output-dir> # -> <output-dir>/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)"
|
||||
Reference in new issue
Block a user