fix support for litematic v4

This commit is contained in:
iceBear67 committed 2026-08-08 15:20:57 +08:00
1 parent 48efb192e6
commit b3375a32c0
9 files changed
+614 -14

No files matched your search

@@ -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 {
@@ -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<String, CompoundTag> 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<String>();
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(
@@ -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.
*
* <p>Version → converter mapping (extend the dispatch as new formats appear):
* <ul>
* <li><b>1</b> (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.</li>
* <li><b>2–3</b> (flat TileEntities, single data version):
* {@link LitematicConverterV3}.</li>
* <li><b>4+</b> (per-region {@code DataVersion}, current litematica; the
* sample {@code iron.litematic} is version 7): {@link LitematicConverterV4}.</li>
* </ul>
* A missing {@code Version} tag is treated as modern (>= 2).
*
* <p>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);
}
}
@@ -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();
@@ -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).
*
* <p>Output format is identical to {@link LitematicConverterV3}; this class only
* adds the input-side version handling via the template hooks in
* {@link LitematicConverter}:
* <ul>
* <li>{@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.</li>
* <li>{@link #readRegionDataVersion}: honors the per-region
* {@code DataVersion} tag (written by litematica since format v4), falling
* back to the root {@code MinecraftDataVersion}.</li>
* </ul>
*/
@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);
}
}
@@ -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());
}
}
@@ -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<Integer, String>();
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<Integer> 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);
}
}
}
Binary file not shown.