From b3375a32c0c5db8d0fa09e343468855dbc3912ac Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Sat, 8 Aug 2026 15:17:06 +0800 Subject: [PATCH] fix support for litematic v4 --- build.gradle | 11 + .../supervisor/web/SchematicUploader.java | 4 +- .../util/litematic/LitematicConverter.java | 64 +++- .../litematic/LitematicConverterFactory.java | 52 +++ .../util/litematic/LitematicConverterV3.java | 5 + .../util/litematic/LitematicConverterV4.java | 60 ++++ .../LitematicConverterFactoryTest.java | 124 +++++++ .../litematic/LitematicConverterV4Test.java | 308 ++++++++++++++++++ src/test/resources/iron.litematic | Bin 0 -> 2307 bytes 9 files changed, 614 insertions(+), 14 deletions(-) create mode 100644 src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverterFactory.java create mode 100644 src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverterV4.java create mode 100644 src/test/java/io/ib67/sfcraft/util/litematic/LitematicConverterFactoryTest.java create mode 100644 src/test/java/io/ib67/sfcraft/util/litematic/LitematicConverterV4Test.java create mode 100644 src/test/resources/iron.litematic diff --git a/build.gradle b/build.gradle index f792d6c..9ed98e2 100644 --- a/build.gradle +++ b/build.gradle @@ -47,9 +47,20 @@ dependencies { exclude group: 'com.google.guava', module: 'listenablefuture' exclude group: 'com.google.guava', module: 'failureaccess' } + testImplementation platform("org.junit:junit-bom:5.10.2") + testImplementation "org.junit.jupiter:junit-jupiter" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" } configurations.implementation.extendsFrom(configurations.shadow) +tasks.named('test', Test) { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + } +} + shadowJar { configurations = [project.configurations.shadow] exclude("META-INF") diff --git a/src/main/java/io/ib67/sfcraft/module/supervisor/web/SchematicUploader.java b/src/main/java/io/ib67/sfcraft/module/supervisor/web/SchematicUploader.java index 4278864..1f28644 100644 --- a/src/main/java/io/ib67/sfcraft/module/supervisor/web/SchematicUploader.java +++ b/src/main/java/io/ib67/sfcraft/module/supervisor/web/SchematicUploader.java @@ -10,7 +10,7 @@ import io.ib67.sfcraft.module.SignatureService; import io.ib67.sfcraft.module.supervisor.WebHandler; import io.ib67.sfcraft.util.Helper; import io.ib67.sfcraft.util.SFConsts; -import io.ib67.sfcraft.util.litematic.LitematicConverterV3; +import io.ib67.sfcraft.util.litematic.LitematicConverterFactory; import io.javalin.config.JavalinConfig; import io.javalin.http.Context; import io.javalin.http.UploadedFile; @@ -145,7 +145,7 @@ public class SchematicUploader extends WebHandler { log.info("Saved " + fileName + " as a schematic."); sendSuccess(player, baseFileName); } else if (fileName.endsWith(".litematic")) { - try (var converter = new LitematicConverterV3(file.content(), new NbtAccounter(config.maxSchematicSize, 64))) { + try (var converter = LitematicConverterFactory.forFile(file.content(), new NbtAccounter(config.maxSchematicSize, 64))) { converter.read((name, nbt) -> { name = baseFileName + "-" + Helper.cleanFileName(name) + ".schematic"; try { diff --git a/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverter.java b/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverter.java index 28a7eab..262fa0e 100644 --- a/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverter.java +++ b/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverter.java @@ -1,18 +1,15 @@ package io.ib67.sfcraft.util.litematic; +import com.google.common.collect.Lists; import io.ib67.sfcraft.util.TypedNbtList; -import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import lombok.SneakyThrows; import net.minecraft.nbt.*; import net.minecraft.network.FriendlyByteBuf; -import org.apache.commons.compress.utils.Lists; import java.io.InputStream; import java.util.ArrayList; -import java.util.List; import java.util.function.BiConsumer; -import java.util.stream.Collectors; /** * A cleaned up version of https://github.com/GoldenDelicios/Lite2Edit @@ -21,31 +18,63 @@ import java.util.stream.Collectors; public class LitematicConverter implements AutoCloseable { protected final InputStream input; protected final NbtAccounter sizeTracker; + private final CompoundTag preParsedRoot; public LitematicConverter(InputStream input, NbtAccounter sizeTracker) { this.input = input; this.sizeTracker = sizeTracker; + this.preParsedRoot = null; + } + + /** + * For converters whose root tag was already parsed and validated (see + * {@link LitematicConverterFactory}): {@link #read} uses this root instead of + * re-parsing the stream, and {@link #close} leaves the caller's input alone. + */ + protected LitematicConverter(CompoundTag preParsedRoot, NbtAccounter sizeTracker) { + this.input = null; + this.sizeTracker = sizeTracker; + this.preParsedRoot = preParsedRoot; } @SneakyThrows public void read( BiConsumer schematicOutput ) { - var root = NbtIo.readCompressed(input, sizeTracker); + var root = preParsedRoot != null ? preParsedRoot : NbtIo.readCompressed(input, sizeTracker); var dataVersion = root.getInt("MinecraftDataVersion") .orElseThrow(() -> new IllegalStateException("MinecraftDataVersion not found")); + // Template hooks: subclasses may adapt to the input format version + // (see LitematicConverterV4) without re-implementing the whole pipeline. + readInputVersion(root); var regionsNbt = root.getCompound("Regions") .orElseThrow(() -> new IllegalStateException("Regions not found")); - var i = 0; for (String regionName : regionsNbt.keySet()) { var compound = regionsNbt.getCompound(regionName) .orElseThrow(() -> new IllegalStateException("Region " + regionName + " not found")); - var j = i++; - var schematic = convertRegionToSchematic(dataVersion, compound); + var schematic = convertRegionToSchematic(readRegionDataVersion(compound, dataVersion), compound); schematicOutput.accept(regionName, schematic); } } + /** + * Template hook: the litematic input format version, read from the root tag. + * Subclasses may log, validate or reject unsupported versions here. + */ + protected int readInputVersion(CompoundTag root) { + return root.getInt("Version").orElse(0); + } + + /** + * Template hook: the data version to bake into each region's schematic output. + * The base implementation always uses the root {@code MinecraftDataVersion}; + * newer litematic formats store a per-region {@code DataVersion} that takes + * precedence (litematica: {@code regionTag.getIntOrDefault("DataVersion", mainDataVersion)}). + */ + protected int readRegionDataVersion(CompoundTag region, int fallback) { + return fallback; + } + protected CompoundTag convertRegionToSchematic(int dataVersion, CompoundTag region) { var worldEditTag = new CompoundTag(); @@ -123,9 +152,12 @@ public class LitematicConverter implements AutoCloseable { if (_properties.isPresent()) { var properties = _properties.get(); name.append("["); + // Sort keys so the produced palette string is deterministic + // (NbtCompound keySet order is unspecified in modern MC versions) var props = new ArrayList(); - for (String key : properties.keySet()) { - props.add(key + "=" + properties.getString(key)); + for (String key : properties.keySet().stream().sorted().toList()) { + props.add(key + "=" + properties.getString(key) + .orElseThrow(() -> new IllegalStateException("Property " + key + " not found"))); } name.append(String.join(",", props)); name.append("]"); @@ -172,12 +204,20 @@ public class LitematicConverter implements AutoCloseable { } buffer.writeVarInt(value); } - return buffer.array(); + // Only return the bytes actually written: the backing array of an + // Unpooled.buffer() is a power-of-two slab that may be larger than the + // written data, and trailing garbage would be read back as extra blocks + // (as air) by WorldEdit's VarIntIterator. + var result = new byte[buffer.writerIndex()]; + buffer.getBytes(0, result); + return result; } @Override public void close() throws Exception { - input.close(); + if (input != null) { + input.close(); + } } protected record SizeTuple( diff --git a/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverterFactory.java b/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverterFactory.java new file mode 100644 index 0000000..cc3d0c3 --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverterFactory.java @@ -0,0 +1,52 @@ +package io.ib67.sfcraft.util.litematic; + +import lombok.SneakyThrows; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; + +import java.io.InputStream; + +/** + * Selects the appropriate {@link LitematicConverter} for a litematic file based + * on its format version (root {@code Version} tag), so callers never hardcode a + * converter class. + * + *

Version → converter mapping (extend the dispatch as new formats appear): + *

    + *
  • 1 (legacy, ~MC 1.12): rejected — TileEntities use the + * {@code {TileNBT, x, y, z}} wrapper format, which the flat-field conversion + * in the converters does not support.
  • + *
  • 2–3 (flat TileEntities, single data version): + * {@link LitematicConverterV3}.
  • + *
  • 4+ (per-region {@code DataVersion}, current litematica; the + * sample {@code iron.litematic} is version 7): {@link LitematicConverterV4}.
  • + *
+ * A missing {@code Version} tag is treated as modern (>= 2). + * + *

The format version lives inside the gzip'd root tag, so the file is parsed + * once here; the parsed root is handed to the chosen converter (via its + * pre-parsed-root constructor) instead of being read a second time. The caller's + * stream is consumed and closed by this method. + */ +public final class LitematicConverterFactory { + private LitematicConverterFactory() { + } + + @SneakyThrows + public static LitematicConverter forFile(InputStream input, NbtAccounter sizeTracker) { + var root = NbtIo.readCompressed(input, sizeTracker); + input.close(); + int version = root.getInt("Version").orElse(0); + if (version == 1) { + throw new IllegalStateException( + "Unsupported litematic input version 1: version 1 TileEntities use the legacy " + + "{TileNBT, x, y, z} wrapper format, which the converters do not support"); + } + if (version >= 2 && version < 4) { + return new LitematicConverterV3(root, sizeTracker); + } + // version 0 (missing tag) and 4+ (including the current v7 sample) + return new LitematicConverterV4(root, sizeTracker); + } +} diff --git a/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverterV3.java b/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverterV3.java index 404b9c9..8de232c 100644 --- a/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverterV3.java +++ b/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverterV3.java @@ -17,6 +17,11 @@ public class LitematicConverterV3 extends LitematicConverter { super(input, sizeTracker); } + /** See {@link LitematicConverter#LitematicConverter(CompoundTag, NbtAccounter)}. */ + protected LitematicConverterV3(CompoundTag preParsedRoot, NbtAccounter sizeTracker) { + super(preParsedRoot, sizeTracker); + } + @Override protected CompoundTag convertRegionToSchematic(int dataVersion, CompoundTag region) { var schematicsTag = new CompoundTag(); diff --git a/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverterV4.java b/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverterV4.java new file mode 100644 index 0000000..e447a24 --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/util/litematic/LitematicConverterV4.java @@ -0,0 +1,60 @@ +package io.ib67.sfcraft.util.litematic; + +import lombok.extern.log4j.Log4j2; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; + +import java.io.InputStream; + +/** + * Version-aware litematic → Sponge v3 converter, targeting the current + * litematica schematic format (version 4+; the sample {@code iron.litematic} + * is version 7). + * + *

Output format is identical to {@link LitematicConverterV3}; this class only + * adds the input-side version handling via the template hooks in + * {@link LitematicConverter}: + *

    + *
  • {@link #readInputVersion}: logs the detected format version and + * rejects version 1, whose TileEntities use the legacy + * {@code {TileNBT, x, y, z}} wrapper instead of flat fields.
  • + *
  • {@link #readRegionDataVersion}: honors the per-region + * {@code DataVersion} tag (written by litematica since format v4), falling + * back to the root {@code MinecraftDataVersion}.
  • + *
+ */ +@Log4j2 +public class LitematicConverterV4 extends LitematicConverterV3 { + public LitematicConverterV4(InputStream input, NbtAccounter sizeTracker) { + super(input, sizeTracker); + } + + /** See {@link LitematicConverter#LitematicConverter(CompoundTag, NbtAccounter)}. */ + protected LitematicConverterV4(CompoundTag preParsedRoot, NbtAccounter sizeTracker) { + super(preParsedRoot, sizeTracker); + } + + @Override + protected int readInputVersion(CompoundTag root) { + var version = super.readInputVersion(root); + if (version < 2) { + if (version == 0) { + log.warn("Litematic input has no 'Version' tag; assuming modern format (>= 2)"); + } else { + throw new IllegalStateException( + "Unsupported litematic input version " + version + + ": version 1 TileEntities use the legacy {TileNBT, x, y, z} wrapper format, " + + "which this converter does not support"); + } + } + log.info("Detected litematic input format version {}", version); + return version; + } + + @Override + protected int readRegionDataVersion(CompoundTag region, int fallback) { + // litematica format >= 4 stores a per-region DataVersion (see + // LitematicaSchematic.readRegions: getIntOrDefault("DataVersion", mainDataVersion)) + return region.getInt("DataVersion").orElse(fallback); + } +} diff --git a/src/test/java/io/ib67/sfcraft/util/litematic/LitematicConverterFactoryTest.java b/src/test/java/io/ib67/sfcraft/util/litematic/LitematicConverterFactoryTest.java new file mode 100644 index 0000000..70d20de --- /dev/null +++ b/src/test/java/io/ib67/sfcraft/util/litematic/LitematicConverterFactoryTest.java @@ -0,0 +1,124 @@ +package io.ib67.sfcraft.util.litematic; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Exercises {@link LitematicConverterFactory}: the format version (root + * {@code Version} tag) must select the right converter class, reject the legacy + * v1 format, and resolve the sample {@code iron.litematic} (a v7 file) to + * {@link LitematicConverterV4}. + */ +class LitematicConverterFactoryTest { + + private static final NbtAccounter ACCOUNTER = new NbtAccounter(10_240_000, 64); + + @Test + void selectsV3ForVersion2And3() throws Exception { + for (int version : new int[]{2, 3}) { + try (var converter = forVersion(version)) { + assertEquals(LitematicConverterV3.class, converter.getClass(), + "litematic version " + version + " must map to LitematicConverterV3"); + } + } + } + + @Test + void selectsV4ForVersion4And7() throws Exception { + for (int version : new int[]{4, 7}) { + try (var converter = forVersion(version)) { + assertInstanceOf(LitematicConverterV4.class, converter, + "litematic version " + version + " must map to LitematicConverterV4"); + } + } + } + + @Test + void treatsMissingVersionAsModern() throws Exception { + var root = new CompoundTag(); + root.putInt("MinecraftDataVersion", 4903); + root.put("Regions", new CompoundTag()); + try (var converter = LitematicConverterFactory.forFile(toInputStream(root), ACCOUNTER)) { + assertInstanceOf(LitematicConverterV4.class, converter, "missing Version tag must map to V4"); + } + } + + @Test + void rejectsLegacyVersion1() throws Exception { + var root = new CompoundTag(); + root.putInt("MinecraftDataVersion", 4903); + root.putInt("Version", 1); + root.put("Regions", new CompoundTag()); + var ex = assertThrows(IllegalStateException.class, + () -> LitematicConverterFactory.forFile(toInputStream(root), ACCOUNTER)); + assertTrue(ex.getMessage().contains("version 1"), "error must mention version 1: " + ex.getMessage()); + } + + /** The sample is litematic version 7 → V4, and the produced schematic must be valid Sponge v3. */ + @Test + void sampleResolvesToV4AndConverts() throws Exception { + try (InputStream in = getClass().getResourceAsStream("/iron.litematic")) { + assertNotNull(in, "test resource /iron.litematic missing"); + CompoundTag[] out = new CompoundTag[1]; + try (var converter = LitematicConverterFactory.forFile(in, ACCOUNTER)) { + assertInstanceOf(LitematicConverterV4.class, converter, "sample is v7, must select V4"); + converter.read((n, tag) -> out[0] = tag); + } + assertNotNull(out[0], "converter produced no output"); + var schematic = out[0].getCompound("Schematic").orElseThrow(); + assertEquals(3, schematic.getInt("Version").orElseThrow(), "output must be Sponge v3"); + // sample region has no per-region DataVersion -> root MinecraftDataVersion (4903) + assertEquals(4903, schematic.getInt("DataVersion").orElseThrow(), "DataVersion falls back to root"); + } + } + + // --- helpers ----------------------------------------------------------- + + private static LitematicConverter forVersion(int version) throws Exception { + var root = new CompoundTag(); + root.putInt("MinecraftDataVersion", 4903); + root.putInt("Version", version); + var regions = new CompoundTag(); + regions.put("r", minimalRegion()); + root.put("Regions", regions); + return LitematicConverterFactory.forFile(toInputStream(root), ACCOUNTER); + } + + /** A minimal but structurally valid 1x1x1 region, all air. */ + private static CompoundTag minimalRegion() { + var region = new CompoundTag(); + var size = new CompoundTag(); + size.putInt("x", 1); + size.putInt("y", 1); + size.putInt("z", 1); + region.put("Size", size); + var pos = new CompoundTag(); + pos.putInt("x", 10); + pos.putInt("y", 20); + pos.putInt("z", 30); + region.put("Position", pos); + var palette = new ListTag(); + var air = new CompoundTag(); + air.putString("Name", "minecraft:air"); + palette.add(air); + region.put("BlockStatePalette", palette); + region.putLongArray("BlockStates", new long[]{0}); + region.put("TileEntities", new ListTag()); + return region; + } + + private static InputStream toInputStream(CompoundTag root) throws Exception { + var out = new ByteArrayOutputStream(); + NbtIo.writeCompressed(root, out); + return new ByteArrayInputStream(out.toByteArray()); + } +} diff --git a/src/test/java/io/ib67/sfcraft/util/litematic/LitematicConverterV4Test.java b/src/test/java/io/ib67/sfcraft/util/litematic/LitematicConverterV4Test.java new file mode 100644 index 0000000..5d79d04 --- /dev/null +++ b/src/test/java/io/ib67/sfcraft/util/litematic/LitematicConverterV4Test.java @@ -0,0 +1,308 @@ +package io.ib67.sfcraft.util.litematic; + +import io.netty.buffer.Unpooled; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.network.FriendlyByteBuf; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Exercises {@link LitematicConverterV4} (the converter selected by + * {@link LitematicConverterFactory} for the sample, a v7 litematic) against + * {@code iron.litematic} and validates the output the same way WorldEdit's + * {@code SpongeSchematicV3Reader} would consume it. {@link #runConverter} goes + * through the factory, so this suite doubles as a regression test for the + * version-based selection path. + */ +class LitematicConverterV4Test { + + private static final int EXPECTED_VOLUME = 3078; // |19 * -9 * -18| + private static final int EXPECTED_TOTAL_BLOCKS = 409; // litematic Metadata.TotalBlocks + + @Test + void convertsSampleWithoutError() throws Exception { + var result = runConverter(); + assertNotNull(result.schematic(), "converter produced no output"); + assertNotNull(result.name(), "converter produced no region name"); + System.out.println("[test] region name = " + result.name()); + System.out.println("[test] schematic keys = " + result.schematic().keySet()); + } + + @Test + void producesValidSpongeV3Structure() throws Exception { + var result = runConverter(); + CompoundTag schematic = result.schematic(); + CompoundTag schematicTag = requireCompound(schematic, "Schematic"); + + assertEquals(3, schematicTag.getInt("Version").orElseThrow(), "Version must be 3 (Sponge v3)"); + assertEquals(19, (int) schematicTag.getShort("Width").orElseThrow(), "Width (|x|)"); + assertEquals(9, (int) schematicTag.getShort("Height").orElseThrow(), "Height (|y|)"); + assertEquals(18, (int) schematicTag.getShort("Length").orElseThrow(), "Length (|z|)"); + assertEquals(3, schematicTag.getIntArray("Offset").orElseThrow().length, "Offset must be int[3]"); + + // Metadata.WorldEdit.Origin must be the minimum corner: (0, 0, 0) for this file + var metadata = requireCompound(schematicTag, "Metadata"); + var worldEdit = requireCompound(metadata, "WorldEdit"); + assertArrayEquals(new int[]{0, 0, 0}, worldEdit.getIntArray("Origin").orElseThrow(), "Origin should be min corner"); + + // Blocks container + var blocks = requireCompound(schematicTag, "Blocks"); + assertTrue(blocks.contains("Palette"), "Blocks.Palette missing"); + assertTrue(blocks.contains("Data"), "Blocks.Data missing"); + assertTrue(blocks.contains("BlockEntities"), "Blocks.BlockEntities missing"); + } + + @Test + void paletteMatchesSource() throws Exception { + var result = runConverter(); + var blocks = requireCompound(requireCompound(result.schematic(), "Schematic"), "Blocks"); + var palette = requireCompound(blocks, "Palette"); + + // 22 palette entries in the source litematic + assertEquals(22, palette.size(), "palette entry count"); + + // Regression: property values must not be rendered as Optional[...] (getString returns Optional) + for (String key : palette.keySet()) { + assertFalse(key.contains("Optional["), + "palette key must not contain Optional[...]: " + key); + assertFalse(key.contains("null"), + "palette key must not contain 'null': " + key); + } + + // air must be at id 0 (litematica always puts air first; WorldEdit needs a sane default) + assertEquals("minecraft:air", palette.keySet().stream() + .filter(k -> palette.getInt(k).orElseThrow() == 0) + .findFirst().orElseThrow(() -> new AssertionError("no palette entry with id 0")), + "id 0 must be minecraft:air"); + + System.out.println("[test] palette:"); + var sorted = new TreeMap(); + palette.keySet().forEach(k -> sorted.put(palette.getInt(k).orElseThrow(), k)); + sorted.forEach((id, name) -> System.out.printf("[test] %2d -> %s%n", id, name)); + } + + @Test + void blockDataDecodesToValidPaletteIds() throws Exception { + var result = runConverter(); + var blocks = requireCompound(requireCompound(result.schematic(), "Schematic"), "Blocks"); + var palette = requireCompound(blocks, "Palette"); + byte[] data = blocks.getByteArray("Data").orElseThrow(); + + var buf = new FriendlyByteBuf(Unpooled.wrappedBuffer(data)); + List ids = new ArrayList<>(); + while (buf.isReadable()) { + ids.add(buf.readVarInt()); + } + + assertEquals(EXPECTED_VOLUME, ids.size(), "block count must equal |size| volume"); + + int paletteMax = palette.size(); + long nonAir = 0; + for (int i = 0; i < ids.size(); i++) { + int id = ids.get(i); + assertTrue(id >= 0 && id < paletteMax, + "block #" + i + " has palette id " + id + " but palette only has " + paletteMax + " entries"); + if (id != 0) nonAir++; + } + + // The source litematic metadata says 409 non-air blocks total + assertEquals(EXPECTED_TOTAL_BLOCKS, nonAir, "non-air block count"); + + // Sanity: the ground floor (y=0) at z=0 is a dirt_path strip in the source data + // WorldEdit index = (y * length + z) * width + x, so data index 0 = (0,0,0) + int first = ids.get(0); + String firstName = palette.keySet().stream() + .filter(k -> palette.getInt(k).orElseThrow() == first) + .findFirst().orElseThrow(); + System.out.println("[test] block(0,0,0) = id " + first + " " + firstName); + assertEquals("minecraft:dirt_path", firstName, "bottom-front-left block should be dirt_path (source ground)"); + } + + @Test + void worldEditRoundTripLayout() throws Exception { + // Re-implement WorldEdit's SpongeSchematicV3Reader decoding and check the + // produced layout matches the source litematic layout (min-corner ordered). + var result = runConverter(); + CompoundTag schematicTag = requireCompound(result.schematic(), "Schematic"); + int width = schematicTag.getShort("Width").orElseThrow(); + int height = schematicTag.getShort("Height").orElseThrow(); + int length = schematicTag.getShort("Length").orElseThrow(); + + var blocks = requireCompound(schematicTag, "Blocks"); + var palette = requireCompound(blocks, "Palette"); + byte[] data = blocks.getByteArray("Data").orElseThrow(); + + var buf = new FriendlyByteBuf(Unpooled.wrappedBuffer(data)); + int[][] grid = new int[height][width * length]; + int index = 0; + while (buf.isReadable()) { + int id = buf.readVarInt(); + int y = index / (width * length); + int z = (index % (width * length)) / width; + int x = index % width; + grid[y][z * width + x] = id; + index++; + } + + // Ground floor y=0 must be a solid path/grass layer (matches source analysis) + int groundNonAir = 0; + for (int i = 0; i < width * length; i++) { + if (grid[0][i] != 0) groundNonAir++; + } + System.out.println("[test] y=0 non-air = " + groundNonAir + " of " + (width * length)); + assertTrue(groundNonAir > 300, "ground floor should be mostly solid (source has 333 non-air at y=0)"); + + // Bed must sit at y=5 like the source (bed foot id=16, head id=17) + int bedAt = -1; + for (int y = 0; y < height; y++) { + for (int i = 0; i < width * length; i++) { + if (grid[y][i] == 16 || grid[y][i] == 17) bedAt = y; + } + } + System.out.println("[test] bed layer y = " + bedAt); + assertEquals(5, bedAt, "bed must be on the same layer as in the source (y=5)"); + } + + // --- V4-specific behavior ------------------------------------------------ + + /** Missing 'Version' tag is tolerated (assumed modern format); must not throw. */ + @Test + void toleratesMissingVersionTag() throws Exception { + var root = new CompoundTag(); + root.putInt("MinecraftDataVersion", 4903); + root.put("Regions", new CompoundTag()); + try (var converter = new LitematicConverterV4(toInputStream(root), new NbtAccounter(10_240_000, 64))) { + converter.read((n, t) -> fail("empty Regions must yield no regions")); + } + } + + /** Format version 1 uses the legacy {TileNBT, x, y, z} TileEntities wrapper; reject it. */ + @Test + void rejectsLegacyVersion1() throws Exception { + var root = new CompoundTag(); + root.putInt("MinecraftDataVersion", 4903); + root.putInt("Version", 1); + root.put("Regions", new CompoundTag()); + var ex = assertThrows(IllegalStateException.class, () -> { + try (var converter = new LitematicConverterV4(toInputStream(root), new NbtAccounter(10_240_000, 64))) { + converter.read((n, t) -> { + }); + } + }); + assertTrue(ex.getMessage().contains("version 1"), "error must mention version 1: " + ex.getMessage()); + } + + /** + * Litematica format >= 4 writes a per-region DataVersion. V4 must honor it + * (falling back to the root MinecraftDataVersion), while V3 always uses the + * root value. Run the same bytes through both to prove the difference. + */ + @Test + void honorsPerRegionDataVersion() throws Exception { + var root = new CompoundTag(); + root.putInt("MinecraftDataVersion", 4903); + root.putInt("Version", 7); + var regions = new CompoundTag(); + regions.put("r", minimalRegion(4902)); + root.put("Regions", regions); + byte[] bytes = toByteArray(root); + + CompoundTag[] v4Out = new CompoundTag[1]; + try (var converter = new LitematicConverterV4(new ByteArrayInputStream(bytes), new NbtAccounter(10_240_000, 64))) { + converter.read((n, t) -> v4Out[0] = t); + } + assertEquals(4902, requireCompound(v4Out[0], "Schematic").getInt("DataVersion").orElseThrow(), + "V4 must prefer the per-region DataVersion"); + + CompoundTag[] v3Out = new CompoundTag[1]; + try (var converter = new LitematicConverterV3(new ByteArrayInputStream(bytes), new NbtAccounter(10_240_000, 64))) { + converter.read((n, t) -> v3Out[0] = t); + } + assertEquals(4903, requireCompound(v3Out[0], "Schematic").getInt("DataVersion").orElseThrow(), + "V3 falls back to the root MinecraftDataVersion"); + } + + // --- helpers ----------------------------------------------------------- + + private record ConversionResult(String name, CompoundTag schematic) { + } + + private ConversionResult runConverter() throws Exception { + try (InputStream in = getClass().getResourceAsStream("/iron.litematic")) { + assertNotNull(in, "test resource /iron.litematic missing"); + CompoundTag[] out = new CompoundTag[1]; + String[] name = new String[1]; + try (var converter = LitematicConverterFactory.forFile(in, new NbtAccounter(10_240_000, 64))) { + converter.read((n, tag) -> { + name[0] = n; + out[0] = tag; + }); + } + return new ConversionResult(name[0], out[0]); + } + } + + /** A minimal but structurally valid 1x1x1 region, all air, with the given per-region DataVersion. */ + private static CompoundTag minimalRegion(int dataVersion) { + var region = new CompoundTag(); + var size = new CompoundTag(); + size.putInt("x", 1); + size.putInt("y", 1); + size.putInt("z", 1); + region.put("Size", size); + var pos = new CompoundTag(); + pos.putInt("x", 10); + pos.putInt("y", 20); + pos.putInt("z", 30); + region.put("Position", pos); + var palette = new ListTag(); + var air = new CompoundTag(); + air.putString("Name", "minecraft:air"); + palette.add(air); + region.put("BlockStatePalette", palette); + region.putLongArray("BlockStates", new long[]{0}); + region.put("TileEntities", new ListTag()); + region.putInt("DataVersion", dataVersion); + return region; + } + + private static InputStream toInputStream(CompoundTag root) throws Exception { + return new ByteArrayInputStream(toByteArray(root)); + } + + private static byte[] toByteArray(CompoundTag root) throws Exception { + var out = new ByteArrayOutputStream(); + NbtIo.writeCompressed(root, out); + return out.toByteArray(); + } + + private static CompoundTag requireCompound(CompoundTag parent, String key) { + return parent.getCompound(key).orElseThrow(() -> new AssertionError("missing compound: " + key)); + } + + /** compile-time sanity: NbtIo.writeCompressed should accept the produced tag (used by SchematicUploader) */ + @Test + void outputCanBeSerialized() throws Exception { + var result = runConverter(); + var tmp = java.nio.file.Files.createTempFile("schematic", ".schematic"); + try { + NbtIo.writeCompressed(result.schematic(), tmp); + assertTrue(java.nio.file.Files.size(tmp) > 0); + System.out.println("[test] wrote " + java.nio.file.Files.size(tmp) + " bytes to " + tmp); + } finally { + java.nio.file.Files.deleteIfExists(tmp); + } + } +} diff --git a/src/test/resources/iron.litematic b/src/test/resources/iron.litematic new file mode 100644 index 0000000000000000000000000000000000000000..51f88929a2856be17e7eb8c02d72dab226e18e5d GIT binary patch literal 2307 zcmV+e3HMgG%_-6329^dY;1^Z z;@UuHX*uS0=k6Bo?JT=aw^i3k ziYg)S_Nn`*2c&&tV`G2E-@O1(i@zCsz79bMY|y#NRf>ZEyb=T+)H_X<))lD&JgurJ zt*!#^)~%b`MfdaXJa*q*oh|yav5yVEd-2orBnWTP2hy5OO%l^6it~3fjwbk#+XIud z092?7%ulI0l}!@o>lx{f#u(vfO)uj_2=F0a#r=n_*aJKe?jjbqEzQ`Lc!Ch>A(ln; z1T}5b(cEA=B|N!BO;LO-I)eA1E*j?>iLOuud{EOGvzTcJHLWS+;6jH4{!Lbd#4<46 zsSx-gCQ2Gs(PH6vh9mCg&QN!b^N7sea$aQ+^7|9q|zE!4Nav=R4BX& zhgw>PTn;TN4v8~OV}nr~nGlXt$BCvzmAq%U=7FkB`a^_MQzR1>%2=6N_{Z74lN*Lx zf3pAL$)CS|;Np)xu&~`^gF02(m_C4Bq8r8G-VW8Id3$P#_UvP+m`Y3QW<>qLBbWa6 z_&pv=+_&B~4^35soC0UAwavW9QF_~r9*l=G<)!f07CaWd|7Z6dlwjdD zRnrKfc01vuL)D3f?8HhB1&T%=DA+? z135+GQ19W2pY}b`{c`g+FI#oj)ZNWrz0jC#Z#wQqj}Je7XzwZGO1ARRMQ<(W9Lg?x ztoPN|w+~H;deV%eo)_kda692_r?P`QU>Wnv_Zox_UdH*3h*Fv*Gw3 zX9cx-KW@F4dhO_)g<7jzsP%_S=lq+mZu-)(UBS197wH@b&4T*5Nm& ziCS;{H&9DjGbL(8&hM#lA~p-CHTOfGQ7*h)@js4Q&F2F3?-WtXz3sdE^opH3-raEZ zdrRh}J;TdaPed)>?T=a`Cr|(Lmz(Rgkqd3w%g@C=G17I{-WNXb%#Mhm)x$X<)3WtIWM9iX!K!$&>o^u&Z$V*MHuzX1Qk~oV?|UG z@?=-ZDyqq&1|naf)0I|M3f)dd5vp`r=wDpEACZN`LJ;aPMQw;PxBS$5I5PrJH7Jf8 zG?qvRq{M`@>=}KJGW{{eIZNF@?9!sx!KBJj9>>(fB26F`S_QU+E~GNCBN`Jy9jDi4 z>1;4FLB*WXP!{N};&)t$nV3kn0RmP^M3?yB<)%2@@+OHEFS_ZE6ImpV&thqw6xJjO zh9O{ad^04H9A;XvmttbhhTcIsD|&r8mfMtpp6g03A$EMeH~=O!-t>qyx4=0kO=lyG zZlw8!ajhL+v@vngU!pL;WI1i9Zfyp^;`7E4JH~r?Ya}NrQ6`r>&6!gwIg+1-iZzD;1cxoJwr9wciHRCJGE8J zwgEHOhGyG6;p0qt2lEUchENcHM;5c2LNGtHcYxvE0Z|^2M1c* zG%wh=Czxy8bjj{)c>i_`l|B|QHUt6Oh^u15F23DB%DmjS8z8>ju+7D-Z4cEITx@k{ zX;s;Ri%;i#Aqe3usz&o4cI0SzX8Vm_WEKKLed*XX=RelMIaJ