From cf944c933fbe203daf45facb1b21da29580a5c6a Mon Sep 17 00:00:00 2001 From: Nero978 Date: Wed, 14 Oct 2026 06:41:25 +0800 Subject: [PATCH] Fail incomplete asset builds and support pinned renderer commits --- README.md | 13 ++++ tooling/pom.xml | 17 +++++ .../tooling/minecraft/ItemRenderer.java | 62 ++++++++++++++---- .../tooling/minecraft/MinecraftAssetTool.java | 28 +++++--- .../tooling/spritesheet/AtlasGenerator.java | 23 +++---- .../spritesheet/SpritesheetGenerator.java | 9 +-- .../src/main/resources/renderer/Program.cs | 2 + .../minecraft/RendererRevisionTest.java | 65 +++++++++++++++++++ .../tooling/minecraft/ToolingFailureTest.java | 46 +++++++++++++ .../spritesheet/AtlasWriteFailureTest.java | 26 ++++++++ 10 files changed, 250 insertions(+), 41 deletions(-) create mode 100644 tooling/src/test/java/net/aerh/imagegenerator/tooling/minecraft/RendererRevisionTest.java create mode 100644 tooling/src/test/java/net/aerh/imagegenerator/tooling/minecraft/ToolingFailureTest.java create mode 100644 tooling/src/test/java/net/aerh/imagegenerator/tooling/spritesheet/AtlasWriteFailureTest.java diff --git a/README.md b/README.md index 8460e83..193671e 100644 --- a/README.md +++ b/README.md @@ -667,3 +667,16 @@ The project includes a GitHub Actions workflow for generating and updating Minec - Rendering items at configurable sizes (requires .NET 10) - Generating sprite atlases with coordinate metadata - Generating item overlays for colored variants (armor, potions, etc.) + +### Reproducible renderer setup and build failures + +Pass `--renderer-commit=` to `MinecraftAssetTool` to fetch and check out +a specific MinecraftRenderer commit. Pinned checkouts are not automatically +updated, and changing their commit rebuilds the renderer. Git checkout does not +force away local modifications. `--no-update-renderer` reuses an existing checkout +without pulling when no commit is pinned; the existing unpinned default remains unchanged. + +Missing .NET, renderer setup/render failures, individual batch render failures, +empty atlases, invalid PNG input and output-write failures fail the CLI with a +nonzero exit status. Build integrations can therefore stop instead of packaging +partial or stale resources. Renderer setup builds only the net10.0 library project. diff --git a/tooling/pom.xml b/tooling/pom.xml index 213781d..fc3f1a1 100644 --- a/tooling/pom.xml +++ b/tooling/pom.xml @@ -15,6 +15,11 @@ jar + + org.junit.jupiter + junit-jupiter + test + com.github.SkyBlock-Nerds Marmalade @@ -37,6 +42,18 @@ + + + + org.apache.maven.plugins + maven-surefire-plugin + + ${surefire.excludedGroups} + + + + + generate-spritesheet diff --git a/tooling/src/main/java/net/aerh/imagegenerator/tooling/minecraft/ItemRenderer.java b/tooling/src/main/java/net/aerh/imagegenerator/tooling/minecraft/ItemRenderer.java index 433a153..6c6bed2 100644 --- a/tooling/src/main/java/net/aerh/imagegenerator/tooling/minecraft/ItemRenderer.java +++ b/tooling/src/main/java/net/aerh/imagegenerator/tooling/minecraft/ItemRenderer.java @@ -27,6 +27,19 @@ public class ItemRenderer { private final Path repoPath; private boolean debug; + private String revision; + private boolean updateExisting = true; + + public void setRevision(String revision) { + if (revision != null && !revision.matches("[0-9a-fA-F]{40}")) { + throw new IllegalArgumentException("Renderer commit must be a full 40-character SHA"); + } + this.revision = revision; + } + + public void setUpdateExisting(boolean updateExisting) { + this.updateExisting = updateExisting; + } public ItemRenderer() { this(ToolingConstants.RENDERER_CLONE); @@ -72,10 +85,11 @@ public void setup(boolean forceReclone, boolean forceRebuild) throws IOException cloneRepository(); } else { System.out.println("Repository already cloned at: " + repoPath); - pullLatest(); + if (revision == null && updateExisting) pullLatest(); } - if (forceRebuild || !isBuilt()) { + boolean revisionChanged = revision != null && checkoutRevision(); + if (forceRebuild || revisionChanged || !isBuilt()) { buildProject(); } else { System.out.println("Project already built."); @@ -85,7 +99,7 @@ public void setup(boolean forceReclone, boolean forceRebuild) throws IOException /** * Checks if required tools are available. */ - private void checkPrerequisites() throws IOException, InterruptedException { + protected void checkPrerequisites() throws IOException, InterruptedException { if (!isCommandAvailable("git", "--version")) { throw new IllegalStateException("Git is not installed or not in PATH. Please install Git."); } @@ -127,7 +141,7 @@ public boolean isCloned() { * Checks if the project has been built. */ public boolean isBuilt() { - return Files.exists(repoPath.resolve("CreateAtlases/bin")); + return Files.isRegularFile(repoPath.resolve("MinecraftRenderer/bin/Release/net10.0/MinecraftRenderer.dll")); } /** @@ -154,25 +168,49 @@ private void pullLatest() throws IOException, InterruptedException { ProcessBuilder pb = new ProcessBuilder("git", "pull", "--ff-only"); pb.directory(repoPath.toFile()); - try { - runProcess(pb, "Failed to pull latest changes"); - } catch (IOException e) { - System.out.println("Warning: Could not pull latest changes: " + e.getMessage()); - } + runProcess(pb, "Failed to pull latest changes"); } /** * Builds the .NET project. */ - private void buildProject() throws IOException, InterruptedException { + private boolean checkoutRevision() throws IOException, InterruptedException { + String previous = capture("rev-parse", "HEAD"); + ProcessBuilder exists = git("cat-file", "-e", revision + "^{commit}"); + exists.redirectErrorStream(true); + Process probe = exists.start(); + probe.getInputStream().readAllBytes(); + if (probe.waitFor() != 0) { + runProcess(git("fetch", "--depth=1", "origin", revision), "Failed to fetch renderer commit"); + } + // Do not force checkout: caller changes must not be silently overwritten. + runProcess(git("checkout", "--detach", revision), "Failed to checkout renderer commit"); + return !previous.equalsIgnoreCase(capture("rev-parse", "HEAD")); + } + + private ProcessBuilder git(String... args) { + java.util.List command = new java.util.ArrayList<>(); + command.add("git"); + command.addAll(java.util.List.of(args)); + return new ProcessBuilder(command).directory(repoPath.toFile()); + } + + private String capture(String... args) throws IOException, InterruptedException { + Process process = git(args).redirectErrorStream(true).start(); + String output = new String(process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).trim(); + if (process.waitFor() != 0) throw new IOException("Cannot read renderer revision: " + output); + return output; + } + + protected void buildProject() throws IOException, InterruptedException { System.out.println("Building MinecraftRenderer..."); - ProcessBuilder restorePb = new ProcessBuilder("dotnet", "restore"); + ProcessBuilder restorePb = new ProcessBuilder("dotnet", "restore", "MinecraftRenderer/MinecraftRenderer.csproj"); restorePb.directory(repoPath.toFile()); runProcess(restorePb, "Failed to restore .NET dependencies"); ProcessBuilder buildPb = new ProcessBuilder( - "dotnet", "build", + "dotnet", "build", "MinecraftRenderer/MinecraftRenderer.csproj", "--framework", "net10.0", "--configuration", "Release", "--no-restore" ); diff --git a/tooling/src/main/java/net/aerh/imagegenerator/tooling/minecraft/MinecraftAssetTool.java b/tooling/src/main/java/net/aerh/imagegenerator/tooling/minecraft/MinecraftAssetTool.java index 2b05223..6cf0691 100644 --- a/tooling/src/main/java/net/aerh/imagegenerator/tooling/minecraft/MinecraftAssetTool.java +++ b/tooling/src/main/java/net/aerh/imagegenerator/tooling/minecraft/MinecraftAssetTool.java @@ -49,6 +49,8 @@ public class MinecraftAssetTool { private String minecraftVersion = null; private int renderSize = 256; + private String rendererCommit; + private boolean updateRenderer = true; private boolean keepJar = true; private boolean forceReclone = false; private boolean forceRebuild = false; @@ -77,6 +79,7 @@ public static void main(String[] args) { } case "--force-clone" -> tool.forceReclone = true; case "--force-build" -> tool.forceRebuild = true; + case "--no-update-renderer" -> tool.updateRenderer = false; case "--no-clean" -> tool.cleanBuildDir = false; case "--help" -> { printHelp(); @@ -85,6 +88,8 @@ public static void main(String[] args) { default -> { if (arg.startsWith("--version=")) { tool.minecraftVersion = arg.substring("--version=".length()); + } else if (arg.startsWith("--renderer-commit=")) { + tool.rendererCommit = arg.substring("--renderer-commit=".length()); } else if (arg.startsWith("--size=")) { tool.renderSize = Integer.parseInt(arg.substring("--size=".length())); } @@ -129,6 +134,8 @@ private static void printHelp() { System.out.println(" --size= Render size in pixels (default: 256)"); System.out.println(" --skip-download Skip download step (use cached assets)"); System.out.println(" --skip-render Skip render step (use existing renders)"); + System.out.println(" --renderer-commit= Pin MinecraftRenderer to a full commit SHA"); + System.out.println(" --no-update-renderer Reuse the existing renderer checkout without pulling"); System.out.println(" --force-clone Force re-clone MinecraftRenderer repository"); System.out.println(" --force-build Force rebuild MinecraftRenderer"); System.out.println(" --no-clean Keep build directory after completion"); @@ -226,14 +233,12 @@ private void runDownloadStep() throws IOException, InterruptedException { System.out.println("[Download] Extracted " + count + " files"); } - private void runRenderStep() throws IOException, InterruptedException { + protected void runRenderStep() throws IOException, InterruptedException { System.out.println("\n[Render] Running MinecraftRenderer..."); System.out.println("-".repeat(40)); if (!isDotNetAvailable()) { - System.out.println("WARNING: .NET SDK not found. Skipping render step."); - System.out.println("Install .NET SDK 10.0+ from https://dotnet.microsoft.com/download"); - return; + throw new IOException(".NET SDK 10.0+ is required for rendering"); } if (Files.exists(ToolingConstants.RENDERED_ITEMS)) { @@ -241,17 +246,16 @@ private void runRenderStep() throws IOException, InterruptedException { ToolingConstants.deleteDirectory(ToolingConstants.RENDERED_ITEMS); } - ItemRenderer renderer = new ItemRenderer(); + ItemRenderer renderer = createRenderer(); + renderer.setRevision(rendererCommit); + renderer.setUpdateExisting(updateRenderer); renderer.setDebug(debug); try { renderer.setup(forceReclone, forceRebuild); renderer.renderAllItems(ToolingConstants.RENDERED_ITEMS, minecraftVersion, renderSize); } catch (Exception e) { - System.out.println("WARNING: Renderer failed: " + e.getMessage()); - if (debug) { - e.printStackTrace(); - } + throw new IOException("Renderer failed", e); } } @@ -280,7 +284,11 @@ private void cleanBuildDirectory() { } } - private boolean isDotNetAvailable() { + protected ItemRenderer createRenderer() { + return new ItemRenderer(); + } + + protected boolean isDotNetAvailable() { try { ProcessBuilder pb = new ProcessBuilder("dotnet", "--version"); pb.redirectErrorStream(true); diff --git a/tooling/src/main/java/net/aerh/imagegenerator/tooling/spritesheet/AtlasGenerator.java b/tooling/src/main/java/net/aerh/imagegenerator/tooling/spritesheet/AtlasGenerator.java index d427c0d..a9caa1c 100644 --- a/tooling/src/main/java/net/aerh/imagegenerator/tooling/spritesheet/AtlasGenerator.java +++ b/tooling/src/main/java/net/aerh/imagegenerator/tooling/spritesheet/AtlasGenerator.java @@ -47,8 +47,7 @@ public void generate() { System.out.println("Loaded " + entries.size() + " textures."); if (entries.isEmpty()) { - System.err.println("No textures to pack!"); - return; + throw new IllegalStateException("No textures to pack!"); } BufferedImage atlas = packAtlas(entries); @@ -90,28 +89,27 @@ protected BufferedImage packAtlas(List entries) { protected void saveAtlas(BufferedImage atlas) { File outputFile = new File(atlasOutputPath); - File parentDir = outputFile.getParentFile(); + File parentDir = outputFile.getAbsoluteFile().getParentFile(); if (!parentDir.exists() && !parentDir.mkdirs()) { - System.err.println("Failed to create directory: " + parentDir.getAbsolutePath()); - return; + throw new IllegalStateException("Failed to create directory: " + parentDir.getAbsolutePath()); } try { System.out.println("Saving atlas to: " + outputFile.getAbsolutePath()); - ImageIO.write(atlas, "png", outputFile); + if (!ImageIO.write(atlas, "png", outputFile)) { + throw new IOException("No PNG writer available"); + } System.out.println("Atlas saved successfully!"); } catch (IOException e) { - System.err.println("Failed to save atlas: " + e.getMessage()); - e.printStackTrace(); + throw new IllegalStateException("Failed to save atlas: " + outputFile, e); } } protected void saveCoordinatesJson(List entries) { File jsonFile = new File(jsonOutputPath); - File parentDir = jsonFile.getParentFile(); + File parentDir = jsonFile.getAbsoluteFile().getParentFile(); if (!parentDir.exists() && !parentDir.mkdirs()) { - System.err.println("Failed to create directory: " + parentDir.getAbsolutePath()); - return; + throw new IllegalStateException("Failed to create directory: " + parentDir.getAbsolutePath()); } JsonArray jsonArray = new JsonArray(); @@ -123,8 +121,7 @@ protected void saveCoordinatesJson(List entries) { GSON.toJson(jsonArray, writer); System.out.println("Coordinates JSON saved to: " + jsonFile.getAbsolutePath()); } catch (IOException e) { - System.err.println("Failed to save coordinates JSON: " + e.getMessage()); - e.printStackTrace(); + throw new IllegalStateException("Failed to save coordinates JSON: " + jsonFile, e); } } diff --git a/tooling/src/main/java/net/aerh/imagegenerator/tooling/spritesheet/SpritesheetGenerator.java b/tooling/src/main/java/net/aerh/imagegenerator/tooling/spritesheet/SpritesheetGenerator.java index ceb579a..5e0b64c 100644 --- a/tooling/src/main/java/net/aerh/imagegenerator/tooling/spritesheet/SpritesheetGenerator.java +++ b/tooling/src/main/java/net/aerh/imagegenerator/tooling/spritesheet/SpritesheetGenerator.java @@ -83,8 +83,7 @@ protected List loadTextures() { File[] files = folder.listFiles(f -> f.isFile() && f.getName().toLowerCase().endsWith(".png")); if (files == null) { - System.err.println("No textures found in: " + folder.getAbsolutePath()); - return textures; + throw new IllegalStateException("Texture input directory cannot be read: " + folder); } System.out.println("Loading textures from: " + folder.getAbsolutePath()); @@ -103,8 +102,7 @@ protected List loadTextures() { BufferedImage image = loadImage(file); if (image == null) { - System.err.println("Failed to load: " + file.getName()); - continue; + throw new IllegalStateException("Failed to load texture: " + file); } try { @@ -117,8 +115,7 @@ protected List loadTextures() { textures.add(new AtlasEntry(name, processed)); } catch (Exception e) { - System.err.println("Failed to process: " + file.getName()); - e.printStackTrace(); + throw new IllegalStateException("Failed to process texture: " + file, e); } } diff --git a/tooling/src/main/resources/renderer/Program.cs b/tooling/src/main/resources/renderer/Program.cs index b566165..1548331 100644 --- a/tooling/src/main/resources/renderer/Program.cs +++ b/tooling/src/main/resources/renderer/Program.cs @@ -87,3 +87,5 @@ void PrintProgress(int current, int total, string item) Console.WriteLine($"\n\n[{new string('█', barWidth)}] 100.0% | Complete!\n"); Console.WriteLine($"Rendered: {rendered} | Failed: {failed}"); + +if (failed > 0 || rendered == 0) Environment.Exit(1); diff --git a/tooling/src/test/java/net/aerh/imagegenerator/tooling/minecraft/RendererRevisionTest.java b/tooling/src/test/java/net/aerh/imagegenerator/tooling/minecraft/RendererRevisionTest.java new file mode 100644 index 0000000..b0c69b2 --- /dev/null +++ b/tooling/src/test/java/net/aerh/imagegenerator/tooling/minecraft/RendererRevisionTest.java @@ -0,0 +1,65 @@ +package net.aerh.imagegenerator.tooling.minecraft; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class RendererRevisionTest { + @TempDir Path directory; + + @Test void pinnedRevisionDoesNotPullAndRebuildsWhenHeadChanges() throws Exception { + Path repo = directory.resolve("renderer"); + Files.createDirectories(repo); + git(repo, "init"); + git(repo, "config", "user.name", "Test"); + git(repo, "config", "user.email", "test@example.invalid"); + Files.writeString(repo.resolve("model.txt"), "old"); + git(repo, "add", "model.txt"); + git(repo, "commit", "-m", "old"); + String old = git(repo, "rev-parse", "HEAD"); + Files.writeString(repo.resolve("model.txt"), "new"); + git(repo, "commit", "-am", "new"); + var renderer = new TestRenderer(repo); + renderer.setRevision(old); + renderer.setup(false, false); + assertEquals(old, git(repo, "rev-parse", "HEAD")); + assertEquals(1, renderer.builds); + renderer.setup(false, false); + assertEquals(1, renderer.builds); + } + + @Test void noUpdateCanReuseCheckoutWithoutAnyRemote() throws Exception { + Path repo = directory.resolve("renderer"); + Files.createDirectories(repo); + git(repo, "init"); + var renderer = new TestRenderer(repo); + renderer.setUpdateExisting(false); + renderer.setup(false, false); + assertEquals(0, renderer.builds); + } + + @Test void commitMustBeAFullSha() { + assertThrows(IllegalArgumentException.class, () -> new ItemRenderer(directory).setRevision("master")); + } + + private static String git(Path repo, String... args) throws Exception { + var command = new ArrayList(List.of("git")); + command.addAll(List.of(args)); + var process = new ProcessBuilder(command).directory(repo.toFile()).redirectErrorStream(true).start(); + var output = new String(process.getInputStream().readAllBytes()).trim(); + assertEquals(0, process.waitFor(), output); + return output; + } + + private static class TestRenderer extends ItemRenderer { + int builds; + TestRenderer(Path path) { super(path); } + protected void checkPrerequisites() {} + public boolean isBuilt() { return true; } + protected void buildProject() { builds++; } + } +} diff --git a/tooling/src/test/java/net/aerh/imagegenerator/tooling/minecraft/ToolingFailureTest.java b/tooling/src/test/java/net/aerh/imagegenerator/tooling/minecraft/ToolingFailureTest.java new file mode 100644 index 0000000..8705a0b --- /dev/null +++ b/tooling/src/test/java/net/aerh/imagegenerator/tooling/minecraft/ToolingFailureTest.java @@ -0,0 +1,46 @@ +package net.aerh.imagegenerator.tooling.minecraft; + +import net.aerh.imagegenerator.tooling.spritesheet.SpritesheetGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.*; + +class ToolingFailureTest { + @TempDir Path directory; + + @Test void missingDotNetIsAnError() { + var tool = new MinecraftAssetTool() { + protected boolean isDotNetAvailable() { return false; } + }; + assertThrows(IOException.class, tool::runRenderStep); + } + + @Test void rendererFailureIsPropagated() { + var tool = new MinecraftAssetTool() { + protected boolean isDotNetAvailable() { return true; } + protected ItemRenderer createRenderer() { + return new ItemRenderer(directory) { + public void setup(boolean clone, boolean rebuild) throws IOException { + throw new IOException("simulated renderer failure"); + } + }; + } + }; + var exception = assertThrows(IOException.class, tool::runRenderStep); + assertEquals("simulated renderer failure", exception.getCause().getMessage()); + } + + @Test void invalidPngAndEmptyDirectoryFailInsteadOfPublishingPartialAtlases() throws Exception { + assertThrows(IllegalStateException.class, () -> new SpritesheetGenerator(directory.toString()).generate()); + Files.writeString(directory.resolve("broken.png"), "not a PNG"); + assertThrows(IllegalStateException.class, () -> new SpritesheetGenerator(directory.toString()).generate()); + } + + @Test void anEmptyBuildDirectoryIsNotACompiledRenderer() throws Exception { + Files.createDirectories(directory.resolve("CreateAtlases/bin")); + assertFalse(new ItemRenderer(directory).isBuilt()); + } +} diff --git a/tooling/src/test/java/net/aerh/imagegenerator/tooling/spritesheet/AtlasWriteFailureTest.java b/tooling/src/test/java/net/aerh/imagegenerator/tooling/spritesheet/AtlasWriteFailureTest.java new file mode 100644 index 0000000..f84f0b0 --- /dev/null +++ b/tooling/src/test/java/net/aerh/imagegenerator/tooling/spritesheet/AtlasWriteFailureTest.java @@ -0,0 +1,26 @@ +package net.aerh.imagegenerator.tooling.spritesheet; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.awt.image.BufferedImage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class AtlasWriteFailureTest { + @TempDir Path directory; + @Test void outputParentMustBeWritableDirectory() throws Exception { + Path blocker = directory.resolve("file"); + Files.writeString(blocker, "not a directory"); + var generator = new AtlasGenerator(256, blocker.resolve("atlas.png").toString(), directory.resolve("coords.json").toString()) { + protected List loadTextures() { + return List.of(new AtlasEntry("item", new BufferedImage(256, 256, BufferedImage.TYPE_INT_ARGB))); + } + protected JsonObject entryToJson(AtlasEntry entry) { return new JsonObject(); } + }; + assertThrows(IllegalStateException.class, generator::generate); + assertFalse(Files.exists(directory.resolve("coords.json"))); + } +}