Expand material support and checkpoint the completed building toolkit

Expose the runtime block/item registry through compact material search and
single-material descriptions. Preserve private block-entity data through
checked edits and durable undo without sending payloads to model context.

Include the completed terrain tools, isolated world/map plugin, station lift,
ACP streaming and guidance fixes, local camera auto-connect, construction
scripts, and their public documentation, references and verification records.
Active station decoration and private runtime data remain outside this commit.

Validation: 145 Maven tests, 47 Bridge tests, successful camera Gradle build,
and isolated Paper verification of all 1,196 block defaults plus 5,392
independent property cases for placement, same-material edits and restoration.
This commit is contained in:
Emil
2026-09-13 16:59:13 +03:00
parent 0290559abf
commit 89c53d7058
179 changed files with 37719 additions and 162 deletions
@@ -0,0 +1,38 @@
package io.github.minecraftbuilder.paper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
final class BlockSnapshotsTest {
@Test void payloadSurvivesExactlyButPublicViewOnlyContainsStateAndDigest() {
String state = "minecraft:chest[facing=north,type=single,waterlogged=false]";
String nbt = "{Items:[{id:\"minecraft:written_book\",components:{text:\"private secret\"}}],id:\"minecraft:chest\"}";
String encoded = BlockSnapshots.encode(state, nbt);
assertEquals(state, BlockSnapshots.state(encoded));
assertEquals(nbt, BlockSnapshots.decode(encoded).nbt());
var publicValue = BlockSnapshots.publicView(encoded);
assertEquals(2, publicValue.size());
assertEquals(state, publicValue.get("state"));
assertTrue(publicValue.get("snapshot_id").matches("[0-9a-f]{64}"));
assertFalse(publicValue.toString().contains("private secret"));
assertNotEquals(BlockSnapshots.snapshotId(encoded), BlockSnapshots.snapshotId(BlockSnapshots.encode(state, nbt + " ")));
assertEquals(publicValue, BlockSnapshots.publicView(encoded));
}
@Test void oldPlainJournalStatesStayUnchanged() {
String plain = "minecraft:stone";
assertEquals(plain, BlockSnapshots.decode(plain).state());
assertNull(BlockSnapshots.decode(plain).nbt());
assertNull(BlockSnapshots.snapshotId(plain));
assertEquals(java.util.Map.of("state", plain), BlockSnapshots.publicView(plain));
}
@Test void entityBudgetCountsUtf8BytesAndErrorsNeverIncludePrivateText() {
String huge = "секрет".repeat(BlockSnapshots.MAX_SNAPSHOT_BYTES / 7);
var error = assertThrows(IllegalArgumentException.class, () -> BlockSnapshots.encode("minecraft:chest", huge));
assertTrue(error.getMessage().contains("snapshot_budget_exceeded"));
assertFalse(error.getMessage().contains("секрет"));
}
@Test void malformedKnownSnapshotAndAmbiguousStateAreRejected() {
assertThrows(IllegalArgumentException.class, () -> BlockSnapshots.decode("\u0000mcb-block-v1:minecraft:chest"));
assertThrows(IllegalArgumentException.class, () -> BlockSnapshots.encode("minecraft:chest\n{}", "{}"));
}
}
@@ -0,0 +1,20 @@
package io.github.minecraftbuilder.paper;
import io.github.minecraftbuilder.core.*;
import org.junit.jupiter.api.Test;
import java.util.*;
import java.io.ByteArrayInputStream;
import javax.imageio.ImageIO;
import static org.junit.jupiter.api.Assertions.*;
class BrushPreviewTest {
@Test void nativePreviewShowsActualBeforeAfterWithoutWorldWriteClaim() throws Exception {
var r=new TerrainBrush.Result(Map.of(new BlockPos(0,2,0),"minecraft:stone"),Set.of(new BlockPos(0,1,0)),
new Region("world",new BlockPos(0,0,0),new BlockPos(1,3,1)),new int[][]{{1,1},{1,1}},new int[][]{{2,1},{0,1}},2,1,1);
var result=BrushPreview.render(r);assertEquals("live_snapshot",result.get("source"));assertEquals(false,result.get("world_edited"));
var image=ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode((String)result.get("imageBase64"))));
assertEquals(780,image.getWidth());assertEquals(300,image.getHeight());
assertNotEquals(image.getRGB(10,40),image.getRGB(270,40));
assertEquals(0x89CC70,image.getRGB(530,40)&0xFFFFFF);assertEquals(0xE5A35B,image.getRGB(530,160)&0xFFFFFF);
}
}
@@ -0,0 +1,46 @@
package io.github.minecraftbuilder.paper;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Waterlogged;
import org.bukkit.block.data.type.Leaves;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Proxy;
import static org.junit.jupiter.api.Assertions.*;
/** Parsed BlockData stubs test policy; the live registry integration test validates item rejection. */
final class BuildingWorldPolicyTest {
private static BlockData state(Material material, Class<? extends BlockData> type,
boolean waterlogged, boolean persistent) {
return (BlockData) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type},
(proxy, method, arguments) -> switch (method.getName()) {
case "getMaterial" -> material;
case "isWaterlogged" -> waterlogged;
case "isPersistent" -> persistent;
default -> throw new UnsupportedOperationException(method.getName());
});
}
@Test void leafAndWaterloggedStatesAreNoLongerArtificiallyRestricted() {
for (Material material : new Material[]{Material.SPRUCE_LEAVES, Material.OAK_LEAVES})
for (boolean wet : new boolean[]{false,true})
for (boolean persistent : new boolean[]{false,true})
assertTrue(BuildingWorld.supportsData(state(material, Leaves.class, wet, persistent)));
for (Material material : new Material[]{Material.SPRUCE_FENCE, Material.CUT_SANDSTONE_SLAB,
Material.BARRIER, Material.CHEST})
assertTrue(BuildingWorld.supportsData(state(material, Waterlogged.class, true, true)));
}
@Test void fluidsGrowthAdministrativeAndBlockEntityTypesAreAccepted() {
for (Material material : new Material[]{Material.WATER, Material.LAVA, Material.OAK_SAPLING,
Material.PEONY, Material.CUT_COPPER, Material.STRUCTURE_VOID, Material.LIGHT,
Material.STRUCTURE_BLOCK, Material.JIGSAW, Material.COMMAND_BLOCK,
Material.CHAIN_COMMAND_BLOCK, Material.REPEATING_COMMAND_BLOCK, Material.TNT,
Material.CHEST, Material.OAK_SIGN, Material.DECORATED_POT, Material.BEEHIVE,
Material.TRIAL_SPAWNER, Material.VAULT})
assertTrue(BuildingWorld.supportsData(state(material, BlockData.class, false, true)), material.name());
}
@Test void missingAndLegacyDataDoNotBypassTheParserPolicy() {
assertFalse(BuildingWorld.supportsData(null));
assertFalse(BuildingWorld.supportsData(state(null, BlockData.class, false, true)));
assertFalse(BuildingWorld.supportsData(state(Material.LEGACY_STONE, BlockData.class, false, true)));
}
}
@@ -0,0 +1,54 @@
package io.github.minecraftbuilder.paper;
import com.google.gson.*;
import io.github.minecraftbuilder.core.BlockPos;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class ExpectedBlocksTest {
private final BlockPos a = new BlockPos(0,64,0), b = new BlockPos(1,64,0);
private JsonArray entries(String... states) {
JsonArray result=new JsonArray();
for(int x=0;x<states.length;x++) {
JsonObject e=new JsonObject(),p=new JsonObject();
p.addProperty("x",x);p.addProperty("y",64);p.addProperty("z",0);
e.add("pos",p);e.addProperty("state",states[x]);result.add(e);
}
return result;
}
@Test void rejectsManualChangeBetweenCallerReadAndPreparation() {
var snapshot=entries("minecraft:grass_block","minecraft:air");
var live=new HashMap<>(Map.of(a,"minecraft:grass_block",b,"minecraft:air"));
ExpectedBlocks.check(snapshot,Set.of(a,b),s->s,live::get);
live.put(b,"minecraft:gold_block");
var error=assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a,b),s->s,live::get));
assertTrue(error.getMessage().contains("Caller snapshot changed"));
assertEquals("minecraft:gold_block",live.get(b));
}
@Test void rejectsIncompleteDuplicateAndOutsideSnapshots() {
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(entries("air"),Set.of(a,b),s->s,p->"air"));
var duplicate=entries("air","air");duplicate.set(1,duplicate.get(0));
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(duplicate,Set.of(a,b),s->s,p->"air"));
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(entries("air","air"),Set.of(a,new BlockPos(2,64,0)),s->s,p->"air"));
}
@Test void canonicalizesExpectedStatesAndRejectsFractionalCoordinates() {
ExpectedBlocks.check(entries("grass_block"),Set.of(a),s->"minecraft:"+s,p->"minecraft:grass_block");
var fractional=entries("air");fractional.get(0).getAsJsonObject().getAsJsonObject("pos").addProperty("x",.5);
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(fractional,Set.of(a),s->s,p->"air"));
}
@Test void detectsContentsChangeEvenWhenBlockStateIsUnchanged() {
var snapshot=entries("minecraft:chest");
snapshot.get(0).getAsJsonObject().addProperty("snapshot_id","a".repeat(64));
ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->"a".repeat(64));
var changed=assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->"b".repeat(64)));
assertTrue(changed.getMessage().contains("block-entity snapshot changed"));
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->null));
}
@Test void requiresEntityDigestAndRejectsMalformedDigest() {
var snapshot=entries("minecraft:chest");
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->"a".repeat(64)));
snapshot.get(0).getAsJsonObject().addProperty("snapshot_id","not-a-hash");
assertThrows(RpcServer.Fault.class,()->ExpectedBlocks.check(snapshot,Set.of(a),s->s,p->"minecraft:chest",p->"a".repeat(64)));
}
}
@@ -0,0 +1,179 @@
package io.github.minecraftbuilder.paper;
import com.google.gson.Gson;
import org.junit.jupiter.api.Test;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.*;
class MaterialCatalogTest {
private static List<MaterialCatalog.Entry> fixtures() {
List<MaterialCatalog.Entry> entries = new ArrayList<>();
IntStream.range(0, 70).forEach(i -> entries.add(new MaterialCatalog.Entry("minecraft:block_%02d".formatted(i), true, true)));
entries.add(new MaterialCatalog.Entry("minecraft:water", true, false));
entries.add(new MaterialCatalog.Entry("minecraft:iron_pickaxe", false, true));
entries.add(new MaterialCatalog.Entry("minecraft:waxed_oxidized_copper_stairs", true, true));
entries.add(new MaterialCatalog.Entry("minecraft:oxidized_copper_bulb", true, true));
entries.add(new MaterialCatalog.Entry("minecraft:cut_copper_stairs", true, true));
return entries;
}
private MaterialCatalog catalog() {
return new MaterialCatalog("26.2-test", fixtures(), id ->
new MaterialCatalog.StateDescription(id + "[waterlogged=false]", Map.of("waterlogged", List.of("true", "false")), List.of("waterloggable")));
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> results(Map<String, Object> page) {
return (List<Map<String, Object>>) page.get("results");
}
@Test void summaryIsTinyAndContainsCountsRatherThanIds() {
var summary = catalog().summary();
assertEquals(75, summary.get("materials"));
assertEquals(74L, summary.get("blocks"));
assertEquals(74L, summary.get("items"));
assertEquals(16, summary.get("search_default_limit"));
assertEquals(32, summary.get("search_max_limit"));
String json = new Gson().toJson(summary);
assertTrue(json.length() < 200);
assertFalse(json.contains("minecraft:"));
}
@Test void searchDefaultsToBlocksAndSixteenCompactResults() {
var page = catalog().search(null, null, null, null);
assertEquals("block", page.get("kind"));
assertEquals(74, page.get("total"));
assertEquals(16, results(page).size());
assertTrue(results(page).stream().allMatch(entry -> entry.keySet().equals(Set.of("id", "block", "item"))));
assertTrue(((String) page.get("next_cursor")).length() < 100);
}
@Test void allPagesAreStableBoundedAndDoNotDuplicateOrLoseEntries() {
var catalog = catalog();
String cursor = null;
List<String> found = new ArrayList<>();
int pages = 0;
do {
var page = catalog.search("", "all", 32, cursor);
assertTrue(results(page).size() <= 32);
for (var result : results(page)) found.add((String) result.get("id"));
cursor = (String) page.get("next_cursor");
pages++;
} while (cursor != null);
assertEquals(3, pages);
assertEquals(75, found.size());
assertEquals(75, new HashSet<>(found).size());
assertEquals(found.stream().sorted().toList(), found);
}
@Test void tokenSearchRequiresEveryTokenAndIgnoresCase() {
var page = catalog().search(" COPPER stair ", "block", 32, null);
assertEquals("copper stair", page.get("query"));
assertEquals(2, results(page).size());
assertTrue(results(page).stream().allMatch(entry -> ((String) entry.get("id")).contains("copper_stairs")));
assertEquals(0, catalog().search("copper unknown", "all", 32, null).get("total"));
}
@Test void itemKindIncludesInventoryBlocksButExcludesFluids() {
var catalog = catalog();
assertEquals(0, catalog.search("water", "item", 16, null).get("total"));
assertEquals(1, catalog.search("water", "block", 16, null).get("total"));
assertEquals(1, catalog.search("pickaxe", "item", 16, null).get("total"));
assertEquals(0, catalog.search("pickaxe", "block", 16, null).get("total"));
}
@Test void rejectsCursorWhenCatalogOrSearchChanges() {
var catalog = catalog();
String cursor = (String) catalog.search("block", "all", 16, null).get("next_cursor");
assertEquals("invalid_cursor", assertThrows(RpcServer.Fault.class,
() -> catalog.search("water", "all", 16, cursor)).code);
assertEquals("invalid_cursor", assertThrows(RpcServer.Fault.class,
() -> catalog.search("block", "item", 16, cursor)).code);
var changed = new MaterialCatalog("26.3-test", fixtures(), id -> null);
assertEquals("stale_cursor", assertThrows(RpcServer.Fault.class,
() -> changed.search("block", "all", 16, cursor)).code);
var reordered = new ArrayList<>(fixtures());
Collections.reverse(reordered);
assertEquals(catalog.summary().get("version"), new MaterialCatalog("26.2-test", reordered, id -> null).summary().get("version"));
}
@Test void rejectsMalformedAndOversizedArguments() {
var catalog = catalog();
for (int limit : List.of(-1, 0, 33, Integer.MAX_VALUE))
assertEquals("invalid_material_query", assertThrows(RpcServer.Fault.class, () -> catalog.search("", "all", limit, null)).code);
assertThrows(RpcServer.Fault.class, () -> catalog.search("", "nonsense", null, null));
assertThrows(RpcServer.Fault.class, () -> catalog.search("x".repeat(97), "all", null, null));
assertEquals(0, catalog.search("x".repeat(96), "all", null, null).get("total"));
assertThrows(RpcServer.Fault.class, () -> catalog.search("x\ny", "all", null, null));
for (String cursor : List.of("bad", "x".repeat(101), "::", "0".repeat(24) + ":" + "0".repeat(24) + ":2147483647"))
assertEquals("invalid_cursor", assertThrows(RpcServer.Fault.class, () -> catalog.search("", "all", null, cursor)).code);
String valid = (String) catalog.search("", "all", 16, null).get("next_cursor");
String beyond = valid.substring(0, valid.lastIndexOf(':') + 1) + "999999999";
assertEquals("invalid_cursor", assertThrows(RpcServer.Fault.class, () -> catalog.search("", "all", 16, beyond)).code);
}
@Test void describeLoadsOnlyTheExactBlockRequested() {
AtomicInteger calls = new AtomicInteger();
var catalog = new MaterialCatalog("test", fixtures(), id -> {
calls.incrementAndGet();
return new MaterialCatalog.StateDescription(id, Map.of(), List.of());
});
catalog.summary();
catalog.search("", "all", 16, null);
assertEquals(0, calls.get());
var description = catalog.describe("water");
assertEquals("minecraft:water", description.get("default_state"));
assertEquals(true, description.get("placeable"));
assertEquals(1, calls.get());
var item = catalog.describe("minecraft:iron_pickaxe");
assertEquals(false, item.get("placeable"));
assertEquals(true, item.get("item"));
assertFalse(item.containsKey("default_state"));
assertFalse(item.containsKey("properties"));
assertEquals(1, calls.get());
}
@Test void rejectsUnknownLegacyAndNonExactIds() {
for (String id : List.of("minecraft:missing", "minecraft:legacy_stone", "stone[axis=y]", "stone{}", "STONE", "mod:stone", "", "x".repeat(129)))
assertEquals("invalid_material", assertThrows(RpcServer.Fault.class, () -> catalog().describe(id)).code);
assertEquals("invalid_material", assertThrows(RpcServer.Fault.class, () -> catalog().describe(null)).code);
}
@Test void describesBooleanIntegerAndEnumDomainsWithoutAStateProduct() throws Exception {
var state = new FakeState(List.of(
new FakeProperty("waterlogged", List.of(false, true)),
new FakeProperty("age", List.of(0, 1, 2, 3)),
new FakeProperty("facing", List.of(Facing.N, Facing.S))));
var properties = MaterialCatalog.readProperties(state);
assertEquals(List.of("false", "true"), properties.get("waterlogged"));
assertEquals(List.of("0", "1", "2", "3"), properties.get("age"));
assertEquals(List.of("north", "south"), properties.get("facing"));
assertEquals(List.of("age", "facing", "waterlogged"), new ArrayList<>(properties.keySet()));
assertEquals(8, properties.values().stream().mapToInt(List::size).sum());
assertEquals(0, MaterialCatalog.readProperties(new FakeState(List.of())).size());
}
@Test void enormousOrDuplicatePropertyDomainsFailRatherThanTruncate() {
var enormous = new FakeState(List.of(new FakeProperty("age", IntStream.range(0, 257).boxed().toList())));
assertEquals("material_properties_unavailable", assertThrows(RpcServer.Fault.class,
() -> MaterialCatalog.readProperties(enormous)).code);
var duplicate = new FakeState(List.of(new FakeProperty("age", List.of(0)), new FakeProperty("age", List.of(0))));
assertThrows(RpcServer.Fault.class, () -> MaterialCatalog.readProperties(duplicate));
}
enum Facing { N, S }
public record FakeState(Collection<FakeProperty> properties) {
public Collection<FakeProperty> getProperties() { return properties; }
}
public record FakeProperty(String name, Collection<?> values) {
public String getName() { return name; }
public Collection<?> getPossibleValues() { return values; }
public String getName(Comparable<?> value) {
return value instanceof Facing facing ? (facing == Facing.N ? "north" : "south") : value.toString();
}
}
}
@@ -76,10 +76,10 @@ class SchematicAssetsTest {
assertEquals(expected, assets.read(exported.assetId(), ZERO, 0));
}
@Test void rejectsSparseSnapshotUnsupportedBlocksAndUnrepresentableOrigin() throws Exception {
@Test void rejectsSparseSnapshotMalformedBlocksAndUnrepresentableOrigin() throws Exception {
SchematicAssets assets = new SchematicAssets(root);
assertThrows(IOException.class, () -> assets.exportSnapshot("Gap", Map.of(ZERO, STONE, new BlockPos(2, 0, 0), STONE), ZERO, 5000));
assertThrows(IOException.class, () -> assets.exportSnapshot("Chest", Map.of(ZERO, "minecraft:chest"), ZERO, 5000));
assertThrows(IOException.class, () -> assets.exportSnapshot("Chest", Map.of(ZERO, "minecraft:chest{Items:[]}"), ZERO, 5000));
assertThrows(IOException.class, () -> assets.exportSnapshot("Offset", Map.of(new BlockPos(Integer.MIN_VALUE, 0, 0), STONE), new BlockPos(Integer.MAX_VALUE, 0, 0), 5000));
assertEquals(0, assets.list().size());
}
@@ -156,10 +156,22 @@ class SchematicAssetsTest {
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_stairs", 90));
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_log", 90));
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_log[axis=x,axis=z]", 0));
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_stairs[facing=up]", 0));
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_slab[type=top,waterlogged=true]", 0));
assertEquals("minecraft:oak_slab[type=top,waterlogged=true]", SchematicAssets.rotateState("minecraft:oak_slab[type=top,waterlogged=true]", 0));
assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:stone[rotation=4]", 90));
}
@Test void delegatesNewMaterialValidationAndRotationToRuntimeCodec() throws Exception {
List<Integer> rotations=new ArrayList<>();
SchematicAssets dynamic=new SchematicAssets(root.resolve("dynamic"),(state,degrees)->{
rotations.add(degrees);
if(!state.startsWith("minecraft:cherry_trapdoor"))throw new IOException("Unknown runtime block");
return degrees==90?state.replace("facing=north","facing=east"):state;
});
String trapdoor="minecraft:cherry_trapdoor[facing=north,half=bottom,open=true,powered=false,waterlogged=true]";
var asset=dynamic.exportSnapshot("New palette",Map.of(ZERO,trapdoor),ZERO,5000);
assertEquals(trapdoor.replace("facing=north","facing=east"),dynamic.read(asset.assetId(),ZERO,90).get(ZERO));
assertTrue(rotations.contains(90));
assertThrows(IOException.class,()->dynamic.exportSnapshot("Invalid",Map.of(ZERO,"minecraft:unknown_block"),ZERO,5000));
}
@Test void metadataAndPlacementRejectFutureDataVersionOnTheActualRead() throws Exception {
SchematicAssets assets = new SchematicAssets(root);
@@ -0,0 +1,24 @@
package io.github.minecraftbuilder.paper;
import com.google.gson.JsonParser;
import io.github.minecraftbuilder.core.TerrainRecipe;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.util.Base64;
import javax.imageio.ImageIO;
import static org.junit.jupiter.api.Assertions.*;
class TerrainPreviewTest {
@Test void previewIsBoundedNativePngAndReportsClippingWithoutClaimingWorldVerification() throws Exception {
var recipe=new TerrainRecipe(JsonParser.parseString("""
{"version":1,"min":{"x":0,"y":0,"z":0},"max":{"x":7,"y":7,"z":7},
"base_height":20,"seed":1,"mode":"sculpt","noise":{"amplitude":0,"scale":8},
"palette":{"rock":"minecraft:stone","soil":"minecraft:dirt","surface":"minecraft:grass_block","soil_depth":2},"features":[],"preserve":[]}
""").getAsJsonObject());
var result=TerrainPreview.render(recipe,32,4096);
assertEquals(false,result.get("world_verified"));assertEquals(64,result.get("clipped_samples"));assertEquals(1,result.get("tile_count"));
var image=ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode((String)result.get("imageBase64"))));
assertEquals(16,image.getWidth());assertEquals(16,image.getHeight());
assertThrows(IllegalArgumentException.class,()->TerrainPreview.render(recipe,1024,4096));
}
}