fix support for litematic v4
This commit is contained in:
9 files changed
+614
-14
No files matched your search
@@ -47,9 +47,20 @@ dependencies {
|
|||||||
exclude group: 'com.google.guava', module: 'listenablefuture'
|
exclude group: 'com.google.guava', module: 'listenablefuture'
|
||||||
exclude group: 'com.google.guava', module: 'failureaccess'
|
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)
|
configurations.implementation.extendsFrom(configurations.shadow)
|
||||||
|
|
||||||
|
tasks.named('test', Test) {
|
||||||
|
useJUnitPlatform()
|
||||||
|
testLogging {
|
||||||
|
events "passed", "skipped", "failed"
|
||||||
|
exceptionFormat "full"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
shadowJar {
|
shadowJar {
|
||||||
configurations = [project.configurations.shadow]
|
configurations = [project.configurations.shadow]
|
||||||
exclude("META-INF")
|
exclude("META-INF")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import io.ib67.sfcraft.module.SignatureService;
|
|||||||
import io.ib67.sfcraft.module.supervisor.WebHandler;
|
import io.ib67.sfcraft.module.supervisor.WebHandler;
|
||||||
import io.ib67.sfcraft.util.Helper;
|
import io.ib67.sfcraft.util.Helper;
|
||||||
import io.ib67.sfcraft.util.SFConsts;
|
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.config.JavalinConfig;
|
||||||
import io.javalin.http.Context;
|
import io.javalin.http.Context;
|
||||||
import io.javalin.http.UploadedFile;
|
import io.javalin.http.UploadedFile;
|
||||||
@@ -145,7 +145,7 @@ public class SchematicUploader extends WebHandler {
|
|||||||
log.info("Saved " + fileName + " as a schematic.");
|
log.info("Saved " + fileName + " as a schematic.");
|
||||||
sendSuccess(player, baseFileName);
|
sendSuccess(player, baseFileName);
|
||||||
} else if (fileName.endsWith(".litematic")) {
|
} 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) -> {
|
converter.read((name, nbt) -> {
|
||||||
name = baseFileName + "-" + Helper.cleanFileName(name) + ".schematic";
|
name = baseFileName + "-" + Helper.cleanFileName(name) + ".schematic";
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
package io.ib67.sfcraft.util.litematic;
|
package io.ib67.sfcraft.util.litematic;
|
||||||
|
|
||||||
|
import com.google.common.collect.Lists;
|
||||||
import io.ib67.sfcraft.util.TypedNbtList;
|
import io.ib67.sfcraft.util.TypedNbtList;
|
||||||
import io.netty.buffer.ByteBuf;
|
|
||||||
import io.netty.buffer.Unpooled;
|
import io.netty.buffer.Unpooled;
|
||||||
import lombok.SneakyThrows;
|
import lombok.SneakyThrows;
|
||||||
import net.minecraft.nbt.*;
|
import net.minecraft.nbt.*;
|
||||||
import net.minecraft.network.FriendlyByteBuf;
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
import org.apache.commons.compress.utils.Lists;
|
|
||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
|
||||||
import java.util.function.BiConsumer;
|
import java.util.function.BiConsumer;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A cleaned up version of https://github.com/GoldenDelicios/Lite2Edit
|
* A cleaned up version of https://github.com/GoldenDelicios/Lite2Edit
|
||||||
@@ -21,31 +18,63 @@ import java.util.stream.Collectors;
|
|||||||
public class LitematicConverter implements AutoCloseable {
|
public class LitematicConverter implements AutoCloseable {
|
||||||
protected final InputStream input;
|
protected final InputStream input;
|
||||||
protected final NbtAccounter sizeTracker;
|
protected final NbtAccounter sizeTracker;
|
||||||
|
private final CompoundTag preParsedRoot;
|
||||||
|
|
||||||
public LitematicConverter(InputStream input, NbtAccounter sizeTracker) {
|
public LitematicConverter(InputStream input, NbtAccounter sizeTracker) {
|
||||||
this.input = input;
|
this.input = input;
|
||||||
this.sizeTracker = sizeTracker;
|
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
|
@SneakyThrows
|
||||||
public void read(
|
public void read(
|
||||||
BiConsumer<String, CompoundTag> schematicOutput
|
BiConsumer<String, CompoundTag> schematicOutput
|
||||||
) {
|
) {
|
||||||
var root = NbtIo.readCompressed(input, sizeTracker);
|
var root = preParsedRoot != null ? preParsedRoot : NbtIo.readCompressed(input, sizeTracker);
|
||||||
var dataVersion = root.getInt("MinecraftDataVersion")
|
var dataVersion = root.getInt("MinecraftDataVersion")
|
||||||
.orElseThrow(() -> new IllegalStateException("MinecraftDataVersion not found"));
|
.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")
|
var regionsNbt = root.getCompound("Regions")
|
||||||
.orElseThrow(() -> new IllegalStateException("Regions not found"));
|
.orElseThrow(() -> new IllegalStateException("Regions not found"));
|
||||||
var i = 0;
|
|
||||||
for (String regionName : regionsNbt.keySet()) {
|
for (String regionName : regionsNbt.keySet()) {
|
||||||
var compound = regionsNbt.getCompound(regionName)
|
var compound = regionsNbt.getCompound(regionName)
|
||||||
.orElseThrow(() -> new IllegalStateException("Region " + regionName + " not found"));
|
.orElseThrow(() -> new IllegalStateException("Region " + regionName + " not found"));
|
||||||
var j = i++;
|
var schematic = convertRegionToSchematic(readRegionDataVersion(compound, dataVersion), compound);
|
||||||
var schematic = convertRegionToSchematic(dataVersion, compound);
|
|
||||||
schematicOutput.accept(regionName, schematic);
|
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) {
|
protected CompoundTag convertRegionToSchematic(int dataVersion, CompoundTag region) {
|
||||||
var worldEditTag = new CompoundTag();
|
var worldEditTag = new CompoundTag();
|
||||||
|
|
||||||
@@ -123,9 +152,12 @@ public class LitematicConverter implements AutoCloseable {
|
|||||||
if (_properties.isPresent()) {
|
if (_properties.isPresent()) {
|
||||||
var properties = _properties.get();
|
var properties = _properties.get();
|
||||||
name.append("[");
|
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>();
|
var props = new ArrayList<String>();
|
||||||
for (String key : properties.keySet()) {
|
for (String key : properties.keySet().stream().sorted().toList()) {
|
||||||
props.add(key + "=" + properties.getString(key));
|
props.add(key + "=" + properties.getString(key)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Property " + key + " not found")));
|
||||||
}
|
}
|
||||||
name.append(String.join(",", props));
|
name.append(String.join(",", props));
|
||||||
name.append("]");
|
name.append("]");
|
||||||
@@ -172,12 +204,20 @@ public class LitematicConverter implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
buffer.writeVarInt(value);
|
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
|
@Override
|
||||||
public void close() throws Exception {
|
public void close() throws Exception {
|
||||||
input.close();
|
if (input != null) {
|
||||||
|
input.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected record SizeTuple(
|
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);
|
super(input, sizeTracker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** See {@link LitematicConverter#LitematicConverter(CompoundTag, NbtAccounter)}. */
|
||||||
|
protected LitematicConverterV3(CompoundTag preParsedRoot, NbtAccounter sizeTracker) {
|
||||||
|
super(preParsedRoot, sizeTracker);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected CompoundTag convertRegionToSchematic(int dataVersion, CompoundTag region) {
|
protected CompoundTag convertRegionToSchematic(int dataVersion, CompoundTag region) {
|
||||||
var schematicsTag = new CompoundTag();
|
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.
Reference in new issue
Block a user