diff --git a/src/main/java/net/querz/mca/BlockStateIterator.java b/src/main/java/net/querz/mca/BlockStateIterator.java new file mode 100644 index 00000000..d287199f --- /dev/null +++ b/src/main/java/net/querz/mca/BlockStateIterator.java @@ -0,0 +1,36 @@ +package net.querz.mca; + +import net.querz.nbt.tag.CompoundTag; + +import java.util.Iterator; + +/** + * Enhanced iterable/iterator for iterating over {@link Section} block data. + * See {@link Section#blocksStates()} + */ +public interface BlockStateIterator extends Iterable, Iterator { + /** + * Sets the block state for the current block. + * Be careful to remember that the block state tag returned by this iterator is a reference + * that will affect all blocks using that tag. If your intention is to modify "just this one block" + * then copy the tag before modification - then call this function. + * @param state State to set. Must not be null. + */ + void setBlockStateAtCurrent(CompoundTag state); + + /** + * Performs palette and block state cleanup if, and only if, changes were made via this iterator. + */ + void cleanupPaletteAndBlockStatesIfDirty(); + + /** current block index (in range 0-4095) */ + int currentIndex(); + /** current block x within section (in range 0-15) */ + int currentX(); + /** current block z within section (in range 0-15) */ + int currentZ(); + /** current block y within section (in range 0-15) */ + int currentY(); + /** current block world level y */ + int currentBlockY(); +} diff --git a/src/main/java/net/querz/mca/Chunk.java b/src/main/java/net/querz/mca/Chunk.java index 7c3c620f..2a06f66b 100644 --- a/src/main/java/net/querz/mca/Chunk.java +++ b/src/main/java/net/querz/mca/Chunk.java @@ -2,53 +2,44 @@ import net.querz.nbt.tag.CompoundTag; import net.querz.nbt.tag.ListTag; -import net.querz.nbt.io.NamedTag; -import net.querz.nbt.io.NBTDeserializer; -import net.querz.nbt.io.NBTSerializer; -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.ByteArrayOutputStream; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.RandomAccessFile; + import java.util.Arrays; -import java.util.Iterator; import java.util.Map; -import java.util.TreeMap; +import static net.querz.mca.DataVersion.JAVA_1_15_19W36A; import static net.querz.mca.LoadFlags.*; -public class Chunk implements Iterable
{ - - public static final int DEFAULT_DATA_VERSION = 2567; - - private boolean partial; - private boolean raw; - - private int lastMCAUpdate; +/** + * Represents a REGION data mca chunk. Region chunks are composed of a set of {@link Section} where any empty/null + * section is filled with air blocks by the game. + */ +public class Chunk extends SectionedChunkBase
{ - private CompoundTag data; - - private int dataVersion; - private long lastUpdate; - private long inhabitedTime; - private int[] biomes; - private CompoundTag heightMaps; - private CompoundTag carvingMasks; - private Map sections = new TreeMap<>(); - private ListTag entities; - private ListTag tileEntities; - private ListTag tileTicks; - private ListTag liquidTicks; - private ListTag> lights; - private ListTag> liquidsToBeTicked; - private ListTag> toBeTicked; - private ListTag> postProcessing; - private String status; - private CompoundTag structures; + /** + * The default chunk data version used when no custom version is supplied. + * @deprecated Use {@code DataVersion.latest().id()} instead. + */ + @Deprecated + public static final int DEFAULT_DATA_VERSION = DataVersion.latest().id(); + + protected long lastUpdate; + protected long inhabitedTime; + protected int[] biomes; + protected CompoundTag heightMaps; + protected CompoundTag carvingMasks; + protected ListTag entities; // never populated for versions >= 1.17 + protected ListTag tileEntities; + protected ListTag tileTicks; + protected ListTag liquidTicks; + protected ListTag> lights; + protected ListTag> liquidsToBeTicked; + protected ListTag> toBeTicked; + protected ListTag> postProcessing; + protected String status; + protected CompoundTag structures; Chunk(int lastMCAUpdate) { - this.lastMCAUpdate = lastMCAUpdate; + super(lastMCAUpdate); } /** @@ -56,29 +47,31 @@ public class Chunk implements Iterable
{ * @param data The raw base data to be used. */ public Chunk(CompoundTag data) { - this.data = data; - initReferences(ALL_DATA); + super(data); } - private void initReferences(long loadFlags) { - if (data == null) { - throw new NullPointerException("data cannot be null"); - } - - if ((loadFlags != ALL_DATA) && (loadFlags & RAW) != 0) { - raw = true; - return; - } + /** + * {@inheritDoc} + */ + @Override + public Section createSection(int sectionY) throws IllegalArgumentException { + if (containsSection(sectionY)) throw new IllegalArgumentException("section already exists at section-y " + sectionY); + Section section = createSection(); + putSection(sectionY, section); + return section; + } - CompoundTag level; - if ((level = data.getCompoundTag("Level")) == null) { + @Override + protected void initReferences(final long loadFlags) { + CompoundTag level = data.getCompoundTag("Level"); + if (level == null) { throw new IllegalArgumentException("data does not contain \"Level\" tag"); } - dataVersion = data.getInt("DataVersion"); inhabitedTime = level.getLong("InhabitedTime"); lastUpdate = level.getLong("LastUpdate"); if ((loadFlags & BIOMES) != 0) { biomes = level.getIntArray("Biomes"); + if (biomes.length == 0) biomes = null; } if ((loadFlags & HEIGHTMAPS) != 0) { heightMaps = level.getCompoundTag("Heightmaps"); @@ -118,84 +111,25 @@ private void initReferences(long loadFlags) { for (CompoundTag section : level.getListTag("Sections").asCompoundTagList()) { int sectionIndex = section.getNumber("Y").byteValue(); Section newSection = new Section(section, dataVersion, loadFlags); - sections.put(sectionIndex, newSection); + putSection(sectionIndex, newSection, false); } } - - // If we haven't requested the full set of data we can drop the underlying raw data to let the GC handle it. - if (loadFlags != ALL_DATA) { - data = null; - partial = true; - } - } - - /** - * Serializes this chunk to a RandomAccessFile. - * @param raf The RandomAccessFile to be written to. - * @param xPos The x-coordinate of the chunk. - * @param zPos The z-coodrinate of the chunk. - * @return The amount of bytes written to the RandomAccessFile. - * @throws UnsupportedOperationException When something went wrong during writing. - * @throws IOException When something went wrong during writing. - */ - public int serialize(RandomAccessFile raf, int xPos, int zPos) throws IOException { - if (partial) { - throw new UnsupportedOperationException("Partially loaded chunks cannot be serialized"); - } - ByteArrayOutputStream baos = new ByteArrayOutputStream(4096); - try (BufferedOutputStream nbtOut = new BufferedOutputStream(CompressionType.ZLIB.compress(baos))) { - new NBTSerializer(false).toStream(new NamedTag(null, updateHandle(xPos, zPos)), nbtOut); - } - byte[] rawData = baos.toByteArray(); - raf.writeInt(rawData.length + 1); // including the byte to store the compression type - raf.writeByte(CompressionType.ZLIB.getID()); - raf.write(rawData); - return rawData.length + 5; } /** - * Reads chunk data from a RandomAccessFile. The RandomAccessFile must already be at the correct position. - * @param raf The RandomAccessFile to read the chunk data from. - * @throws IOException When something went wrong during reading. - */ - public void deserialize(RandomAccessFile raf) throws IOException { - deserialize(raf, ALL_DATA); - } - - /** - * Reads chunk data from a RandomAccessFile. The RandomAccessFile must already be at the correct position. - * @param raf The RandomAccessFile to read the chunk data from. - * @param loadFlags A logical or of {@link LoadFlags} constants indicating what data should be loaded - * @throws IOException When something went wrong during reading. - */ - public void deserialize(RandomAccessFile raf, long loadFlags) throws IOException { - byte compressionTypeByte = raf.readByte(); - CompressionType compressionType = CompressionType.getFromID(compressionTypeByte); - if (compressionType == null) { - throw new IOException("invalid compression type " + compressionTypeByte); - } - BufferedInputStream dis = new BufferedInputStream(compressionType.decompress(new FileInputStream(raf.getFD()))); - NamedTag tag = new NBTDeserializer(false).fromStream(dis); - if (tag != null && tag.getTag() instanceof CompoundTag) { - data = (CompoundTag) tag.getTag(); - initReferences(loadFlags); - } else { - throw new IOException("invalid data tag: " + (tag == null ? "null" : tag.getClass().getName())); - } - } - - /** - * @deprecated Use {@link #getBiomeAt(int, int, int)} instead + * May only be used for data versions LT 2203 which includes all of 1.14 + * and up until 19w36a (a 1.15 weekly snapshot). + * @deprecated Use {@link #getBiomeAt(int, int, int)} instead for 1.15 and beyond */ @Deprecated public int getBiomeAt(int blockX, int blockZ) { - if (dataVersion < 2202) { + if (dataVersion < JAVA_1_15_19W36A.id()) { if (biomes == null || biomes.length != 256) { return -1; } return biomes[getBlockIndex(blockX, blockZ)]; } else { - throw new IllegalStateException("cannot get biome using Chunk#getBiomeAt(int,int) from biome data with DataVersion of 2202 or higher, use Chunk#getBiomeAt(int,int,int) instead"); + throw new IllegalStateException("cannot get biome using Chunk#getBiomeAt(int,int) from biome data with DataVersion of 2203 or higher (1.15+), use Chunk#getBiomeAt(int,int,int) instead"); } } @@ -208,12 +142,7 @@ public int getBiomeAt(int blockX, int blockZ) { * @return The biome id or -1 if the biomes are not correctly initialized. */ public int getBiomeAt(int blockX, int blockY, int blockZ) { - if (dataVersion < 2202) { - if (biomes == null || biomes.length != 256) { - return -1; - } - return biomes[getBlockIndex(blockX, blockZ)]; - } else { + if (dataVersion >= JAVA_1_15_19W36A.id()) { if (biomes == null || biomes.length != 1024) { return -1; } @@ -222,13 +151,20 @@ public int getBiomeAt(int blockX, int blockY, int blockZ) { int biomeZ = (blockZ & 0xF) >> 2; return biomes[getBiomeIndex(biomeX, biomeY, biomeZ)]; + } else { + return getBiomeAt(blockX, blockZ); } } + /** + * Should only be used for data versions LT 2203 which includes all of 1.14 + * and up until 19w36a (a 1.15 weekly snapshot). + * @deprecated Use {@link #setBiomeAt(int, int, int, int)} instead for 1.15 and beyond + */ @Deprecated public void setBiomeAt(int blockX, int blockZ, int biomeID) { checkRaw(); - if (dataVersion < 2202) { + if (dataVersion < JAVA_1_15_19W36A.id()) { if (biomes == null || biomes.length != 256) { biomes = new int[256]; Arrays.fill(biomes, -1); @@ -259,13 +195,7 @@ public void setBiomeAt(int blockX, int blockZ, int biomeID) { */ public void setBiomeAt(int blockX, int blockY, int blockZ, int biomeID) { checkRaw(); - if (dataVersion < 2202) { - if (biomes == null || biomes.length != 256) { - biomes = new int[256]; - Arrays.fill(biomes, -1); - } - biomes[getBlockIndex(blockX, blockZ)] = biomeID; - } else { + if (dataVersion >= JAVA_1_15_19W36A.id()) { if (biomes == null || biomes.length != 1024) { biomes = new int[1024]; Arrays.fill(biomes, -1); @@ -275,6 +205,12 @@ public void setBiomeAt(int blockX, int blockY, int blockZ, int biomeID) { int biomeZ = (blockZ & 0xF) >> 2; biomes[getBiomeIndex(biomeX, blockY, biomeZ)] = biomeID; + } else { + if (biomes == null || biomes.length != 256) { + biomes = new int[256]; + Arrays.fill(biomes, -1); + } + biomes[getBlockIndex(blockX, blockZ)] = biomeID; } } @@ -283,7 +219,7 @@ int getBiomeIndex(int biomeX, int biomeY, int biomeZ) { } public CompoundTag getBlockStateAt(int blockX, int blockY, int blockZ) { - Section section = sections.get(MCAUtil.blockToChunk(blockY)); + Section section = getSection(MCAUtil.blockToChunk(blockY)); if (section == null) { return null; } @@ -304,51 +240,34 @@ public CompoundTag getBlockStateAt(int blockX, int blockY, int blockZ) { public void setBlockStateAt(int blockX, int blockY, int blockZ, CompoundTag state, boolean cleanup) { checkRaw(); int sectionIndex = MCAUtil.blockToChunk(blockY); - Section section = sections.get(sectionIndex); + Section section = getSection(sectionIndex); if (section == null) { - sections.put(sectionIndex, section = Section.newSection()); + putSection(sectionIndex, section = createSection(), false); + section.setDataVersion(dataVersion); } section.setBlockStateAt(blockX, blockY, blockZ, state, cleanup); } /** - * @return The DataVersion of this chunk. + * Creates a new section appropriately initialized for use inside this chunk. */ - public int getDataVersion() { - return dataVersion; + public Section createSection() { + return new Section(dataVersion); } /** - * Sets the DataVersion of this chunk. This does not check if the data of this chunk conforms - * to that DataVersion, that is the responsibility of the developer. - * @param dataVersion The DataVersion to be set. + * {@inheritDoc} */ + @Override public void setDataVersion(int dataVersion) { - checkRaw(); - this.dataVersion = dataVersion; - for (Section section : sections.values()) { + super.setDataVersion(dataVersion); + for (Section section : this) { if (section != null) { - section.dataVersion = dataVersion; + section.setDataVersion(dataVersion); } } } - /** - * @return The timestamp when this region file was last updated in seconds since 1970-01-01. - */ - public int getLastMCAUpdate() { - return lastMCAUpdate; - } - - /** - * Sets the timestamp when this region file was last updated in seconds since 1970-01-01. - * @param lastMCAUpdate The time in seconds since 1970-01-01. - */ - public void setLastMCAUpdate(int lastMCAUpdate) { - checkRaw(); - this.lastMCAUpdate = lastMCAUpdate; - } - /** * @return The generation station of this chunk. */ @@ -365,25 +284,6 @@ public void setStatus(String status) { this.status = status; } - /** - * Fetches the section at the given y-coordinate. - * @param sectionY The y-coordinate of the section in this chunk ranging from 0 to 15. - * @return The Section. - */ - public Section getSection(int sectionY) { - return sections.get(sectionY); - } - - /** - * Sets a section at a givesn y-coordinate - * @param sectionY The y-coordinate of the section in this chunk ranging from 0 to 15. - * @param section The section to be set. - */ - public void setSection(int sectionY, Section section) { - checkRaw(); - sections.put(sectionY, section); - } - /** * @return The timestamp when this chunk was last updated as a UNIX timestamp. */ @@ -425,15 +325,16 @@ public int[] getBiomes() { /** * Sets the biome IDs for this chunk. - * @param biomes The biome ID matrix of this chunk. Must have a length of 256. - * @throws IllegalArgumentException When the biome matrix does not have a length of 256 - * or is null + * @param biomes The biome ID matrix of this chunk. Must have a length of {@code 1024} for 1.15+ or {@code 256} + * for prior versions. + * @throws IllegalArgumentException When the biome matrix is {@code null} or does not have a version appropriate length. */ public void setBiomes(int[] biomes) { checkRaw(); if (biomes != null) { - if (dataVersion < 2202 && biomes.length != 256 || dataVersion >= 2202 && biomes.length != 1024) { - throw new IllegalArgumentException("biomes array must have a length of " + (dataVersion < 2202 ? "256" : "1024")); + final int requiredSize = dataVersion <= 0 || dataVersion >= JAVA_1_15_19W36A.id() ? 1024 : 256; + if (biomes.length != requiredSize) { + throw new IllegalArgumentException("biomes array must have a length of " + requiredSize); } } this.biomes = biomes; @@ -621,21 +522,20 @@ int getBlockIndex(int blockX, int blockZ) { public void cleanupPalettesAndBlockStates() { checkRaw(); - for (Section section : sections.values()) { + for (Section section : this) { if (section != null) { section.cleanupPaletteAndBlockStates(); } } } - private void checkRaw() { - if (raw) { - throw new UnsupportedOperationException("cannot update field when working with raw data"); - } - } - + /** + * @deprecated Dangerous - assumes latest full release data version defined by {@link DataVersion} + * prefer using {@link MCAFileBase#createChunk()} or {@link MCAFileBase#createChunkIfMissing(int, int)}. + */ + @Deprecated public static Chunk newChunk() { - return newChunk(DEFAULT_DATA_VERSION); + return Chunk.newChunk(DataVersion.latest().id()); } public static Chunk newChunk(int dataVersion) { @@ -647,80 +547,45 @@ public static Chunk newChunk(int dataVersion) { return c; } - /** - * Provides a reference to the full chunk data. - * @return The full chunk data or null if there is none, e.g. when this chunk has only been loaded partially. - */ - public CompoundTag getHandle() { - return data; - } - + @Override public CompoundTag updateHandle(int xPos, int zPos) { if (raw) { return data; } - - data.putInt("DataVersion", dataVersion); + super.updateHandle(xPos, zPos); CompoundTag level = data.getCompoundTag("Level"); level.putInt("xPos", xPos); level.putInt("zPos", zPos); level.putLong("LastUpdate", lastUpdate); level.putLong("InhabitedTime", inhabitedTime); - if (dataVersion < 2202) { - if (biomes != null && biomes.length == 256) { - level.putIntArray("Biomes", biomes); - } - } else { - if (biomes != null && biomes.length == 1024) { - level.putIntArray("Biomes", biomes); - } - } - if (heightMaps != null) { - level.put("Heightmaps", heightMaps); - } - if (carvingMasks != null) { - level.put("CarvingMasks", carvingMasks); - } - if (entities != null) { - level.put("Entities", entities); - } - if (tileEntities != null) { - level.put("TileEntities", tileEntities); - } - if (tileTicks != null) { - level.put("TileTicks", tileTicks); - } - if (liquidTicks != null) { - level.put("LiquidTicks", liquidTicks); - } - if (lights != null) { - level.put("Lights", lights); - } - if (liquidsToBeTicked != null) { - level.put("LiquidsToBeTicked", liquidsToBeTicked); - } - if (toBeTicked != null) { - level.put("ToBeTicked", toBeTicked); - } - if (postProcessing != null) { - level.put("PostProcessing", postProcessing); - } + if (biomes != null) { + final int requiredSize = dataVersion <= 0 || dataVersion >= JAVA_1_15_19W36A.id() ? 1024 : 256; + if (biomes.length != requiredSize) + throw new IllegalStateException( + String.format("Biomes array must be %d bytes for version %d, array size is %d", + requiredSize, dataVersion, biomes.length)); + level.putIntArray("Biomes", biomes); + } + level.putIfNotNull("Heightmaps", heightMaps); + level.putIfNotNull("CarvingMasks", carvingMasks); + level.putIfNotNull("Entities", entities); + level.putIfNotNull("TileEntities", tileEntities); + level.putIfNotNull("TileTicks", tileTicks); + level.putIfNotNull("LiquidTicks", liquidTicks); + level.putIfNotNull("Lights", lights); + level.putIfNotNull("LiquidsToBeTicked", liquidsToBeTicked); + level.putIfNotNull("ToBeTicked", toBeTicked); + level.putIfNotNull("PostProcessing", postProcessing); level.putString("Status", status); - if (structures != null) { - level.put("Structures", structures); - } + level.putIfNotNull("Structures", structures); + ListTag sections = new ListTag<>(CompoundTag.class); - for (Section section : this.sections.values()) { + for (Section section : this) { if (section != null) { - sections.add(section.updateHandle()); + sections.add(section.updateHandle(section.getHeight() /* contract of iterator assures correctness */)); } } level.put("Sections", sections); return data; } - - @Override - public Iterator
iterator() { - return sections.values().iterator(); - } } diff --git a/src/main/java/net/querz/mca/ChunkBase.java b/src/main/java/net/querz/mca/ChunkBase.java new file mode 100644 index 00000000..18a12741 --- /dev/null +++ b/src/main/java/net/querz/mca/ChunkBase.java @@ -0,0 +1,177 @@ +package net.querz.mca; + +import net.querz.nbt.io.NBTDeserializer; +import net.querz.nbt.io.NBTSerializer; +import net.querz.nbt.io.NamedTag; +import net.querz.nbt.tag.CompoundTag; + +import java.io.*; +import java.util.Objects; + +import static net.querz.mca.LoadFlags.ALL_DATA; +import static net.querz.mca.LoadFlags.RAW; + +/** + * Abstraction for the base of all chunk types which represent chunks composed of sub-chunks {@link SectionBase}. + */ +public abstract class ChunkBase implements VersionedDataContainer, TagWrapper { + protected int dataVersion; + protected boolean partial; + protected boolean raw; + protected int lastMCAUpdate; + protected CompoundTag data; + + ChunkBase(int lastMCAUpdate) { + this.lastMCAUpdate = lastMCAUpdate; + } + + /** + * Create a new chunk based on raw base data from a region file. + * @param data The raw base data to be used. + */ + public ChunkBase(CompoundTag data) { + this.data = data; + initReferences0(ALL_DATA); + } + + private void initReferences0(long loadFlags) { + Objects.requireNonNull(data, "data cannot be null"); + dataVersion = data.getInt("DataVersion"); + + if ((loadFlags != ALL_DATA) && (loadFlags & RAW) != 0) { + raw = true; + return; + } + + if (dataVersion == 0) { + throw new IllegalArgumentException("data does not contain \"DataVersion\" tag"); + } + + initReferences(loadFlags); + + // If we haven't requested the full set of data we can drop the underlying raw data to let the GC handle it. + if (loadFlags != ALL_DATA) { + data = null; + partial = true; + } + } + + /** + * Child classes should not call this method directly. + * Raw and partial data handling is taken care of, this method will not be called if {@code loadFlags} is + * {@link LoadFlags#RAW}. + */ + protected abstract void initReferences(final long loadFlags); + + /** + * {@inheritDoc} + */ + public int getDataVersion() { + return dataVersion; + } + + /** + * {@inheritDoc} + */ + public void setDataVersion(int dataVersion) { + this.dataVersion = Math.max(0, dataVersion); + } + + /** + * Serializes this chunk to a RandomAccessFile. + * @param raf The RandomAccessFile to be written to. + * @param xPos The x-coordinate of the chunk. + * @param zPos The z-coodrinate of the chunk. + * @return The amount of bytes written to the RandomAccessFile. + * @throws UnsupportedOperationException When something went wrong during writing. + * @throws IOException When something went wrong during writing. + */ + public int serialize(RandomAccessFile raf, int xPos, int zPos) throws IOException { + if (partial) { + throw new UnsupportedOperationException("Partially loaded chunks cannot be serialized"); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(4096); + try (BufferedOutputStream nbtOut = new BufferedOutputStream(CompressionType.ZLIB.compress(baos))) { + new NBTSerializer(false).toStream(new NamedTag(null, updateHandle(xPos, zPos)), nbtOut); + } + byte[] rawData = baos.toByteArray(); + raf.writeInt(rawData.length + 1); // including the byte to store the compression type + raf.writeByte(CompressionType.ZLIB.getID()); + raf.write(rawData); + return rawData.length + 5; + } + + /** + * Reads chunk data from a RandomAccessFile. The RandomAccessFile must already be at the correct position. + * @param raf The RandomAccessFile to read the chunk data from. + * @throws IOException When something went wrong during reading. + */ + public void deserialize(RandomAccessFile raf) throws IOException { + deserialize(raf, ALL_DATA); + } + + /** + * Reads chunk data from a RandomAccessFile. The RandomAccessFile must already be at the correct position. + * @param raf The RandomAccessFile to read the chunk data from. + * @param loadFlags A logical or of {@link LoadFlags} constants indicating what data should be loaded + * @throws IOException When something went wrong during reading. + */ + public void deserialize(RandomAccessFile raf, long loadFlags) throws IOException { + byte compressionTypeByte = raf.readByte(); + CompressionType compressionType = CompressionType.getFromID(compressionTypeByte); + if (compressionType == null) { + throw new IOException("invalid compression type " + compressionTypeByte); + } + BufferedInputStream dis = new BufferedInputStream(compressionType.decompress(new FileInputStream(raf.getFD()))); + NamedTag tag = new NBTDeserializer(false).fromStream(dis); + if (tag != null && tag.getTag() instanceof CompoundTag) { + data = (CompoundTag) tag.getTag(); + initReferences0(loadFlags); + } else { + throw new IOException("invalid data tag: " + (tag == null ? "null" : tag.getClass().getName())); + } + } + + /** + * @return The timestamp when this region file was last updated in seconds since 1970-01-01. + */ + public int getLastMCAUpdate() { + return lastMCAUpdate; + } + + /** + * Sets the timestamp when this region file was last updated in seconds since 1970-01-01. + * @param lastMCAUpdate The time in seconds since 1970-01-01. + */ + public void setLastMCAUpdate(int lastMCAUpdate) { + checkRaw(); + this.lastMCAUpdate = lastMCAUpdate; + } + + protected void checkRaw() { + if (raw) { + throw new UnsupportedOperationException("cannot update field when working with raw data"); + } + } + + /** + * Provides a reference to the full chunk data. + * @return The full chunk data or null if there is none, e.g. when this chunk has only been loaded partially. + */ + public CompoundTag getHandle() { + return data; + } + + public CompoundTag updateHandle() { + if (!raw) { + data.putInt("DataVersion", dataVersion); + } + return data; + } + + // Note: Not all chunk formats store xz in their NBT, but {@link MCAFileBase} will call this update method + // to give them the chance to record them. + public CompoundTag updateHandle(int xPos, int zPos) { + return updateHandle(); + } +} diff --git a/src/main/java/net/querz/mca/ChunkIterator.java b/src/main/java/net/querz/mca/ChunkIterator.java new file mode 100644 index 00000000..41e4e93b --- /dev/null +++ b/src/main/java/net/querz/mca/ChunkIterator.java @@ -0,0 +1,33 @@ +package net.querz.mca; + +import java.util.Iterator; + +/** + * Enhanced iterator for iterating over {@link ChunkBase} data. + * All 1024 chunks will be returned by successive calls to {@link #next()}, even + * those which are {@code null}. + * See {@link MCAFileBase#iterator()} + */ +public interface ChunkIterator extends Iterator { + /** + * Replaces the current chunk with the one given by calling {@link MCAFileBase#setChunk(int, ChunkBase)} + * with the {@link #currentIndex()}. Take care as the given chunk is NOT copied by this call. + * @param chunk Chunk to set, may be null. + */ + void set(I chunk); + + /** + * @return Current chunk index (in range 0-1023) + */ + int currentIndex(); + + /** + * @return Current chunk x within region (in range 0-31) + */ + int currentX(); + + /** + * @return Current chunk z within region (in range 0-31) + */ + int currentZ(); +} diff --git a/src/main/java/net/querz/mca/DataVersion.java b/src/main/java/net/querz/mca/DataVersion.java new file mode 100644 index 00000000..08c8c80f --- /dev/null +++ b/src/main/java/net/querz/mca/DataVersion.java @@ -0,0 +1,233 @@ +package net.querz.mca; + +import java.util.Arrays; +import java.util.Comparator; + +// source: version.json file, found in the root directory of the client and server jars +// table of versions can also be found on https://minecraft.fandom.com/wiki/Data_version#List_of_data_versions + +/** + * List of significant MC versions and MCA data versions. + * Non-full release versions are intended for use in data handling logic. + * The set of non-full release versions is not, and does not need to be, the complete set of all versions - only those + * which introduce changes to the MCA data structure are useful. + */ +public enum DataVersion { + // Kept in ASC order + UNKNOWN(0, 0, 0), + JAVA_1_9_0(169, 9, 0), + JAVA_1_9_1(175, 9, 1), + JAVA_1_9_2(176, 9, 2), + JAVA_1_9_3(183, 9, 3), + JAVA_1_9_4(184, 9, 4), + + JAVA_1_10_0(510, 10, 0), + JAVA_1_10_1(511, 10, 1), + JAVA_1_10_2(512, 10, 2), + + JAVA_1_11_0(819, 11, 0), + JAVA_1_11_1(921, 11, 1), + JAVA_1_11_2(922, 11, 2), + + JAVA_1_12_0(1139, 12, 0), + JAVA_1_12_1(1241, 12, 1), + JAVA_1_12_2(1343, 12, 2), + + JAVA_1_13_0(1519, 13, 0), + JAVA_1_13_1(1628, 13, 1), + JAVA_1_13_2(1631, 13, 2), + + // poi/r.X.Z.mca files introduced + JAVA_1_14_0(1952, 14, 0), + JAVA_1_14_1(1957, 14, 1), + JAVA_1_14_2(1963, 14, 2), + JAVA_1_14_3(1968, 14, 3), + JAVA_1_14_4(1976, 14, 4), + + // 3D Biomes added. Biomes array in the Level tag for each chunk changed + // to contain 1024 integers instead of 256 see {@link Chunk} + JAVA_1_15_19W36A(2203, 10, -1, "19w36a"), + JAVA_1_15_0(2225, 15, 0), + JAVA_1_15_1(2227, 15, 1), + JAVA_1_15_2(2230, 15, 2), + + // block pallet packing changed in this version - see {@link Section} + JAVA_1_16_20W17A(2529, 16, -1, "20w17a"), + JAVA_1_16_0(2566, 16, 0), + JAVA_1_16_1(2567, 16, 1), + JAVA_1_16_2(2578, 16, 2), + JAVA_1_16_3(2580, 16, 3), + JAVA_1_16_4(2584, 16, 4), + JAVA_1_16_5(2586, 16, 5), + + // entities/r.X.Z.mca files introduced + // entities no longer inside region/r.X.Z.mca - except in un-migrated chunks of course + JAVA_1_17_0(2724, 17, 0), + JAVA_1_17_1(2730, 17, 1), + + // fist experimental 1.18 build + JAVA_1_18_XS1(2825, 18, -1, "XS1"), + + // https://www.minecraft.net/en-us/article/minecraft-snapshot-21w39a + // Level.Sections[].BlockStates & Level.Sections[].Palette have moved to a container structure in Level.Sections[].block_states + // Level.Biomes are now paletted and live in a similar container structure in Level.Sections[].biomes + // Level.CarvingMasks[] is now long[] instead of byte[] + JAVA_1_18_21W39A(2836, 18, -1, "21w39a"), + + // https://www.minecraft.net/en-us/article/minecraft-snapshot-21w43a + // Removed chunk’s Level and moved everything it contained up + // Chunk’s Level.Entities has moved to entities + // Chunk’s Level.TileEntities has moved to block_entities + // Chunk’s Level.TileTicks and Level.ToBeTicked have moved to block_ticks + // Chunk’s Level.LiquidTicks and Level.LiquidsToBeTicked have moved to fluid_ticks + // Chunk’s Level.Sections has moved to sections + // Chunk’s Level.Structures has moved to structures + // Chunk’s Level.Structures.Starts has moved to structures.starts + // Chunk’s Level.Sections[].BlockStates and Level.Sections[].Palette have moved to a container structure in sections[].block_states + // Chunk’s Level.Biomes are now paletted and live in a similar container structure in sections[].biomes + // Added yPos the minimum section y position in the chunk + // Added below_zero_retrogen containing data to support below zero generation + // Added blending_data containing data to support blending new world generation with existing chunks + JAVA_1_18_21W43A(2844, 18, -1, "21w43a"); + + + private static final int[] ids; + private static final DataVersion latestFullReleaseVersion; + private final int id; + private final int minor; + private final int patch; + private final boolean isFullRelease; + private final String buildDescription; + private final String str; + + static { + ids = Arrays.stream(values()).sorted().mapToInt(DataVersion::id).toArray(); + latestFullReleaseVersion = Arrays.stream(values()) + .sorted(Comparator.reverseOrder()) + .filter(DataVersion::isFullRelease) + .findFirst().get(); + } + + DataVersion(int id, int minor, int patch) { + this(id, minor, patch, null); + } + + /** + * @param id data version + * @param minor minor version + * @param patch patch number, LT0 to indicate this data version is not a full release version + * @param buildDescription Suggested convention:
    + *
  • NULL for full release
  • + *
  • CT# for combat tests (e.g. CT6, CT6b)
  • + *
  • XS# for experimental snapshots(e.g. XS1, XS2)
  • + *
  • YYwWWz for weekly builds (e.g. 21w37a, 21w37b)
  • + *
  • PR# for pre-releases (e.g. PR1, PR2)
  • + *
  • RC# for release candidates (e.g. RC1, RC2)
+ */ + DataVersion(int id, int minor, int patch, String buildDescription) { + this.isFullRelease = patch >= 0; + if (!isFullRelease && (buildDescription == null || buildDescription.isEmpty())) + throw new IllegalArgumentException("buildDescription required for non-full releases"); + if (isFullRelease && (buildDescription != null && !buildDescription.isEmpty())) + throw new IllegalArgumentException("buildDescription not allowed for full releases"); + this.id = id; + this.minor = minor; + this.patch = isFullRelease ? patch : -1; + this.buildDescription = isFullRelease ? "FINAL" : buildDescription; + if (minor > 0) { + StringBuilder sb = new StringBuilder(); + sb.append(id).append(" (1.").append(minor); + if (patch > 0) sb.append('.').append(patch); + if (!isFullRelease) sb.append(' ').append(buildDescription); + this.str = sb.append(')').toString(); + } else { + this.str = name(); + } + } + + public int id() { + return id; + } + + /** + * Version format: major.minor.patch + */ + public int major() { + return 1; + } + + /** + * Version format: major.minor.patch + */ + public int minor() { + return minor; + } + + /** + * Version format: major.minor.patch + *

This value will be < 0 if this is not a full release version.

+ */ + public int patch() { + return patch; + } + + /** + * True for full release. + * False for all other builds (e.g. experimental, pre-releases, and release-candidates). + */ + public boolean isFullRelease() { + return isFullRelease; + } + + /** + * Description of the minecraft build which this {@link DataVersion} refers to. + * You'll find {@link #toString()} to be more useful in general. + *

Convention used:

    + *
  • "FULL" for full release
  • + *
  • YYwWWz for weekly builds (e.g. 21w37a, 21w37b)
  • + *
  • CT# for combat tests (e.g. CT6, CT6b)
  • + *
  • XS# for experimental snapshots(e.g. XS1, XS2)
  • + *
  • PR# for pre-releases (e.g. PR1, PR2)
  • + *
  • RC# for release candidates (e.g. RC1, RC2)
+ */ + public String getBuildDescription() { + return buildDescription; + } + + /** + * TRUE as of 1.14 + * Indicates if point of interest .mca files exist. E.g. 'poi/r.0.0.mca' + */ + public boolean hasPoiMca() { + return minor >= 14; + } + + /** + * TRUE as of 1.17 + * Entities were pulled out of region .mca files into their own .mca files. E.g. 'entities/r.0.0.mca' + */ + public boolean hasEntitiesMca() { + return minor >= 17; + } + + public static DataVersion bestFor(int dataVersion) { + int found = Arrays.binarySearch(ids, dataVersion); + if (found < 0) { + found = (found + 2) * -1; + if (found < 0) return UNKNOWN; + } + return values()[found]; + } + + /** + * @return The latest full release version defined. + */ + public static DataVersion latest() { + return latestFullReleaseVersion; + } + + @Override + public String toString() { + return str; + } +} diff --git a/src/main/java/net/querz/mca/MCAFile.java b/src/main/java/net/querz/mca/MCAFile.java index 0a6e7122..354f2c5d 100644 --- a/src/main/java/net/querz/mca/MCAFile.java +++ b/src/main/java/net/querz/mca/MCAFile.java @@ -8,211 +8,64 @@ import java.util.Arrays; import java.util.Iterator; import java.util.Map; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; -public class MCAFile implements Iterable { - +/** + * Represents a REGION data mca file. + */ +public class MCAFile extends MCAFileBase implements Iterable { /** * The default chunk data version used when no custom version is supplied. - * */ - public static final int DEFAULT_DATA_VERSION = 1628; - - private int regionX, regionZ; - private Chunk[] chunks; + *

Deprecated: use {@code DataVersion.latest().id()} instead. + */ + @Deprecated + public static final int DEFAULT_DATA_VERSION = DataVersion.latest().id(); /** - * MCAFile represents a world save file used by Minecraft to store world - * data on the hard drive. - * This constructor needs the x- and z-coordinates of the stored region, - * which can usually be taken from the file name {@code r.x.z.mca} - * @param regionX The x-coordinate of this region. - * @param regionZ The z-coordinate of this region. - * */ + * {@inheritDoc} + */ public MCAFile(int regionX, int regionZ) { - this.regionX = regionX; - this.regionZ = regionZ; - } - - /** - * Reads an .mca file from a {@code RandomAccessFile} into this object. - * This method does not perform any cleanups on the data. - * @param raf The {@code RandomAccessFile} to read from. - * @throws IOException If something went wrong during deserialization. - * */ - public void deserialize(RandomAccessFile raf) throws IOException { - deserialize(raf, LoadFlags.ALL_DATA); + super(regionX, regionZ); } /** - * Reads an .mca file from a {@code RandomAccessFile} into this object. - * This method does not perform any cleanups on the data. - * @param raf The {@code RandomAccessFile} to read from. - * @param loadFlags A logical or of {@link LoadFlags} constants indicating what data should be loaded - * @throws IOException If something went wrong during deserialization. - * */ - public void deserialize(RandomAccessFile raf, long loadFlags) throws IOException { - chunks = new Chunk[1024]; - for (int i = 0; i < 1024; i++) { - raf.seek(i * 4); - int offset = raf.read() << 16; - offset |= (raf.read() & 0xFF) << 8; - offset |= raf.read() & 0xFF; - if (raf.readByte() == 0) { - continue; - } - raf.seek(4096 + i * 4); - int timestamp = raf.readInt(); - Chunk chunk = new Chunk(timestamp); - raf.seek(4096 * offset + 4); //+4: skip data size - chunk.deserialize(raf, loadFlags); - chunks[i] = chunk; - } - } - - /** - * Calls {@link MCAFile#serialize(RandomAccessFile, boolean)} without updating any timestamps. - * @see MCAFile#serialize(RandomAccessFile, boolean) - * @param raf The {@code RandomAccessFile} to write to. - * @return The amount of chunks written to the file. - * @throws IOException If something went wrong during serialization. - * */ - public int serialize(RandomAccessFile raf) throws IOException { - return serialize(raf, false); - } - - /** - * Serializes this object to an .mca file. - * This method does not perform any cleanups on the data. - * @param raf The {@code RandomAccessFile} to write to. - * @param changeLastUpdate Whether it should update all timestamps that show - * when this file was last updated. - * @return The amount of chunks written to the file. - * @throws IOException If something went wrong during serialization. - * */ - public int serialize(RandomAccessFile raf, boolean changeLastUpdate) throws IOException { - int globalOffset = 2; - int lastWritten = 0; - int timestamp = (int) (System.currentTimeMillis() / 1000L); - int chunksWritten = 0; - int chunkXOffset = MCAUtil.regionToChunk(regionX); - int chunkZOffset = MCAUtil.regionToChunk(regionZ); - - if (chunks == null) { - return 0; - } - - for (int cx = 0; cx < 32; cx++) { - for (int cz = 0; cz < 32; cz++) { - int index = getChunkIndex(cx, cz); - Chunk chunk = chunks[index]; - if (chunk == null) { - continue; - } - raf.seek(4096 * globalOffset); - lastWritten = chunk.serialize(raf, chunkXOffset + cx, chunkZOffset + cz); - - if (lastWritten == 0) { - continue; - } - - chunksWritten++; - - int sectors = (lastWritten >> 12) + (lastWritten % 4096 == 0 ? 0 : 1); - - raf.seek(index * 4); - raf.writeByte(globalOffset >>> 16); - raf.writeByte(globalOffset >> 8 & 0xFF); - raf.writeByte(globalOffset & 0xFF); - raf.writeByte(sectors); - - // write timestamp - raf.seek(index * 4 + 4096); - raf.writeInt(changeLastUpdate ? timestamp : chunk.getLastMCAUpdate()); - - globalOffset += sectors; - } - } - - // padding - if (lastWritten % 4096 != 0) { - raf.seek(globalOffset * 4096 - 1); - raf.write(0); - } - return chunksWritten; - } - - /** - * Set a specific Chunk at a specific index. The index must be in range of 0 - 1023. - * @param index The index of the Chunk. - * @param chunk The Chunk to be set. - * @throws IndexOutOfBoundsException If index is not in the range. + * {@inheritDoc} */ - public void setChunk(int index, Chunk chunk) { - checkIndex(index); - if (chunks == null) { - chunks = new Chunk[1024]; - } - chunks[index] = chunk; + public MCAFile(int regionX, int regionZ, int defaultDataVersion) { + super(regionX, regionZ, defaultDataVersion); } /** - * Set a specific Chunk at a specific chunk location. - * The x- and z-value can be absolute chunk coordinates or they can be relative to the region origin. - * @param chunkX The x-coordinate of the Chunk. - * @param chunkZ The z-coordinate of the Chunk. - * @param chunk The chunk to be set. + * {@inheritDoc} */ - public void setChunk(int chunkX, int chunkZ, Chunk chunk) { - setChunk(getChunkIndex(chunkX, chunkZ), chunk); + public MCAFile(int regionX, int regionZ, DataVersion defaultDataVersion) { + super(regionX, regionZ, defaultDataVersion); } /** - * Returns the chunk data of a chunk at a specific index in this file. - * @param index The index of the chunk in this file. - * @return The chunk data. - * */ - public Chunk getChunk(int index) { - checkIndex(index); - if (chunks == null) { - return null; - } - return chunks[index]; + * {@inheritDoc} + */ + @Override + public Class chunkClass() { + return Chunk.class; } /** - * Returns the chunk data of a chunk in this file. - * @param chunkX The x-coordinate of the chunk. - * @param chunkZ The z-coordinate of the chunk. - * @return The chunk data. - * */ - public Chunk getChunk(int chunkX, int chunkZ) { - return getChunk(getChunkIndex(chunkX, chunkZ)); + * {@inheritDoc} + */ + @Override + public Chunk createChunk() { + return Chunk.newChunk(defaultDataVersion); } /** - * Calculates the index of a chunk from its x- and z-coordinates in this region. - * This works with absolute and relative coordinates. - * @param chunkX The x-coordinate of the chunk. - * @param chunkZ The z-coordinate of the chunk. - * @return The index of this chunk. - * */ - public static int getChunkIndex(int chunkX, int chunkZ) { - return (chunkX & 0x1F) + (chunkZ & 0x1F) * 32; - } - - private int checkIndex(int index) { - if (index < 0 || index > 1023) { - throw new IndexOutOfBoundsException(); - } - return index; - } - - private Chunk createChunkIfMissing(int blockX, int blockZ) { - int chunkX = MCAUtil.blockToChunk(blockX), chunkZ = MCAUtil.blockToChunk(blockZ); - Chunk chunk = getChunk(chunkX, chunkZ); - if (chunk == null) { - chunk = Chunk.newChunk(); - setChunk(getChunkIndex(chunkX, chunkZ), chunk); - } + * {@inheritDoc} + */ + @Override + protected Chunk deserializeChunk(RandomAccessFile raf, long loadFlags, int timestamp) throws IOException { + Chunk chunk = new Chunk(timestamp); + chunk.deserialize(raf, loadFlags); return chunk; } @@ -297,9 +150,4 @@ public void cleanupPalettesAndBlockStates() { } } } - - @Override - public Iterator iterator() { - return Arrays.stream(chunks).iterator(); - } } diff --git a/src/main/java/net/querz/mca/MCAFileBase.java b/src/main/java/net/querz/mca/MCAFileBase.java new file mode 100644 index 00000000..e6fdbf55 --- /dev/null +++ b/src/main/java/net/querz/mca/MCAFileBase.java @@ -0,0 +1,425 @@ +package net.querz.mca; + + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.lang.reflect.Array; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +/** + * An abstract representation of an mca file. + */ +public abstract class MCAFileBase implements Iterable { + + protected int regionX, regionZ; + protected T[] chunks; + protected int minDataVersion; + protected int maxDataVersion; + protected int defaultDataVersion = DataVersion.latest().id(); // data version to use when creating new chunks + + /** + * MCAFile represents a world save file used by Minecraft to store world + * data on the hard drive. + * This constructor needs the x- and z-coordinates of the stored region, + * which can usually be taken from the file name {@code r.x.z.mca} + * + *

Use this constructor when you plan to {@code deserialize(..)} an MCA file. + * If you are creating an MCA file from scratch prefer {@link #MCAFileBase(int, int, int)}. + * @param regionX The x-coordinate of this mca file in region coordinates. + * @param regionZ The z-coordinate of this mca file in region coordinates. + */ + public MCAFileBase(int regionX, int regionZ) { + this.regionX = regionX; + this.regionZ = regionZ; + } + + /** + * Use this constructor to specify a default data version when creating MCA files without loading + * from disk. + * + * @param regionX The x-coordinate of this mca file in region coordinates. + * @param regionZ The z-coordinate of this mca file in region coordinates. + * @param defaultDataVersion Data version which will be used when creating new chunks. + */ + public MCAFileBase(int regionX, int regionZ, int defaultDataVersion) { + this.regionX = regionX; + this.regionZ = regionZ; + this.defaultDataVersion = defaultDataVersion; + this.minDataVersion = defaultDataVersion; + this.maxDataVersion = defaultDataVersion; + } + + /** + * Use this constructor to specify a default data version when creating MCA files without loading + * from disk. + * + * @param regionX The x-coordinate of this mca file in region coordinates. + * @param regionZ The z-coordinate of this mca file in region coordinates. + * @param defaultDataVersion Data version which will be used when creating new chunks. + */ + public MCAFileBase(int regionX, int regionZ, DataVersion defaultDataVersion) { + this(regionX, regionZ, defaultDataVersion.id()); + } + + /** + * Gets the count of non-null chunks. + */ + public int count() { + return (int) stream().filter(Objects::nonNull).count(); + } + + /** + * Get minimum data version of found in loaded chunk data + */ + public int getMinChunkDataVersion() { + return minDataVersion; + } + + /** + * Get maximum data version of found in loaded chunk data + */ + public int getMaxChunkDataVersion() { + return maxDataVersion; + } + + /** + * Get chunk version which will be used when automatically creating new chunks + * and for chunks created by {@link #createChunk()}. + */ + public int getDefaultChunkDataVersion() { + return defaultDataVersion; + } + + /** + * Set chunk version which will be used when automatically creating new chunks + * and for chunks created by {@link #createChunk()}. + */ + public void setDefaultChunkDataVersion(int defaultDataVersion) { + this.defaultDataVersion = defaultDataVersion; + } + + /** + * @return The x-value currently set for this mca file in region coordinates. + */ + public int getRegionX() { + return regionX; + } + + /** + * Sets a new x-value for this mca file in region coordinates. + */ + public void setRegionX(int regionX) { + this.regionX = regionX; + } + + /** + * @return The z-value currently set for this mca file in region coordinates. + */ + public int getRegionZ() { + return regionZ; + } + + /** + * Sets a new z-value for this mca file in region coordinates. + */ + public void setRegionZ(int regionZ) { + this.regionZ = regionZ; + } + + /** + * Sets both the x and z values for this mca file in region coordinates. + * @param regionX New x-value for this mca file in region coordinates. + * @param regionZ New z-value for this mca file in region coordinates. + */ + public void setRegionXZ(int regionX, int regionZ) { + this.regionX = regionX; + this.regionZ = regionZ; + } + + /** + * Returns result of calling {@link MCAUtil#createNameFromRegionLocation(int, int)} + * with current region coordinate values. + * @return A mca filename in the format "r.{regionX}.{regionZ}.mca" + */ + public String createRegionName() { + return MCAUtil.createNameFromRegionLocation(regionX, regionZ); + } + + /** + * @return type of chunk this MCA File holds + */ + public abstract Class chunkClass(); + + /** + * Creates a new chunk properly initialized to be compatible with this MCA file. At a minimum the new + * chunk will have an appropriate data version set. + */ + public abstract T createChunk(); + + /** + * Called to deserialize a Chunk. Caller will have set the position of {@code raf} to start reading. + * @param raf The {@code RandomAccessFile} to read from. + * @param loadFlags A logical or of {@link LoadFlags} constants indicating what data should be loaded + * @param timestamp The timestamp when this chunk was last updated as a UNIX timestamp. + * @return Deserialized chunk. + * @throws IOException if something went wrong during deserialization. + */ + protected abstract T deserializeChunk(RandomAccessFile raf, long loadFlags, int timestamp) throws IOException; + + /** + * Reads an .mca file from a {@code RandomAccessFile} into this object. + * This method does not perform any cleanups on the data. + * @param raf The {@code RandomAccessFile} to read from. + * @throws IOException If something went wrong during deserialization. + */ + public void deserialize(RandomAccessFile raf) throws IOException { + deserialize(raf, LoadFlags.ALL_DATA); + } + + /** + * Reads an .mca file from a {@code RandomAccessFile} into this object. + * This method does not perform any cleanups on the data. + * @param raf The {@code RandomAccessFile} to read from. + * @param loadFlags A logical or of {@link LoadFlags} constants indicating what data should be loaded + * @throws IOException If something went wrong during deserialization. + */ + @SuppressWarnings("unchecked") + public void deserialize(RandomAccessFile raf, long loadFlags) throws IOException { + chunks = (T[]) Array.newInstance(chunkClass(), 1024); + minDataVersion = Integer.MAX_VALUE; + maxDataVersion = Integer.MIN_VALUE; + for (int i = 0; i < 1024; i++) { + raf.seek(i * 4); + int offset = raf.read() << 16; + offset |= (raf.read() & 0xFF) << 8; + offset |= raf.read() & 0xFF; + if (raf.readByte() == 0) { + continue; + } + raf.seek(4096 + i * 4); + int timestamp = raf.readInt(); + raf.seek(4096L * offset + 4); //+4: skip data size + T chunk = deserializeChunk(raf, loadFlags, timestamp); + chunks[i] = chunk; + if (chunk != null && chunk.hasDataVersion()) { + if (chunk.getDataVersion() < minDataVersion) { + minDataVersion = chunk.getDataVersion(); + } + if (chunk.getDataVersion() > maxDataVersion) { + maxDataVersion = chunk.getDataVersion(); + } + } + } + maxDataVersion = Math.max(maxDataVersion, 0); + minDataVersion = Math.min(minDataVersion, maxDataVersion); + defaultDataVersion = maxDataVersion; + } + + /** + * Calls {@link MCAFileBase#serialize(RandomAccessFile, boolean)} without updating any timestamps. + * @see MCAFileBase#serialize(RandomAccessFile, boolean) + * @param raf The {@code RandomAccessFile} to write to. + * @return The amount of chunks written to the file. + * @throws IOException If something went wrong during serialization. + */ + public int serialize(RandomAccessFile raf) throws IOException { + return serialize(raf, false); + } + + /** + * Serializes this object to an .mca file. + * This method does not perform any cleanups on the data. + * @param raf The {@code RandomAccessFile} to write to. + * @param changeLastUpdate Whether it should update all timestamps that show + * when this file was last updated. + * @return The amount of chunks written to the file. + * @throws IOException If something went wrong during serialization. + */ + public int serialize(RandomAccessFile raf, boolean changeLastUpdate) throws IOException { + int globalOffset = 2; + int lastWritten = 0; + int timestamp = (int) (System.currentTimeMillis() / 1000L); + int chunksWritten = 0; + int chunkXOffset = MCAUtil.regionToChunk(regionX); + int chunkZOffset = MCAUtil.regionToChunk(regionZ); + + if (chunks == null) { + return 0; + } + + for (int cz = 0; cz < 32; cz++) { + for (int cx = 0; cx < 32; cx++) { + int index = getChunkIndex(cx, cz); + T chunk = chunks[index]; + if (chunk == null) { + continue; + } + raf.seek(4096L * globalOffset); + lastWritten = chunk.serialize(raf, chunkXOffset + cx, chunkZOffset + cz); + + if (lastWritten == 0) { + continue; + } + + chunksWritten++; + + int sectors = (lastWritten >> 12) + (lastWritten % 4096 == 0 ? 0 : 1); + + raf.seek(index * 4L); + raf.writeByte(globalOffset >>> 16); + raf.writeByte(globalOffset >> 8 & 0xFF); + raf.writeByte(globalOffset & 0xFF); + raf.writeByte(sectors); + + // write timestamp + raf.seek(index * 4L + 4096); + raf.writeInt(changeLastUpdate ? timestamp : chunk.getLastMCAUpdate()); + + globalOffset += sectors; + } + } + + // padding + if (lastWritten % 4096 != 0) { + raf.seek(globalOffset * 4096L - 1); + raf.write(0); + } + return chunksWritten; + } + + /** + * Set a specific Chunk at a specific index. The index must be in range of 0 - 1023. + * Take care as the given chunk is NOT copied by this call. + * @param index The index of the Chunk. + * @param chunk The Chunk to be set. + * @throws IndexOutOfBoundsException If index is not in the range. + */ + @SuppressWarnings("unchecked") + public void setChunk(int index, T chunk) { + checkIndex(index); + if (chunks == null) { + chunks = (T[]) Array.newInstance(chunkClass(), 1024); + } + chunks[index] = chunk; + } + + /** + * Set a specific Chunk at a specific chunk location. + * The x- and z-value can be absolute chunk coordinates or they can be relative to the region origin. + * @param chunkX The x-coordinate of the Chunk. + * @param chunkZ The z-coordinate of the Chunk. + * @param chunk The chunk to be set. + * + */ + public void setChunk(int chunkX, int chunkZ, T chunk) { + setChunk(getChunkIndex(chunkX, chunkZ), chunk); + } + + /** + * Returns the chunk data of a chunk at a specific index in this file. + * @param index The index of the chunk in this file. + * @return The chunk data. + */ + public T getChunk(int index) { + checkIndex(index); + if (chunks == null) { + return null; + } + return chunks[index]; + } + + /** + * Returns the chunk data of a chunk in this file. + * @param chunkX The x-coordinate of the chunk. + * @param chunkZ The z-coordinate of the chunk. + * @return The chunk data. + */ + public T getChunk(int chunkX, int chunkZ) { + return getChunk(getChunkIndex(chunkX, chunkZ)); + } + + /** + * Calculates the index of a chunk from its x- and z-coordinates in this region. + * This works with absolute and relative coordinates. + * @param chunkX The x-coordinate of the chunk. + * @param chunkZ The z-coordinate of the chunk. + * @return The index of this chunk. + */ + public static int getChunkIndex(int chunkX, int chunkZ) { + return ((chunkZ & 0x1F) << 5) | (chunkX & 0x1F); + } + + protected void checkIndex(int index) { + if (index < 0 || index > 1023) { + throw new IndexOutOfBoundsException(); + } + } + + protected T createChunkIfMissing(int blockX, int blockZ) { + int chunkX = MCAUtil.blockToChunk(blockX), chunkZ = MCAUtil.blockToChunk(blockZ); + T chunk = getChunk(chunkX, chunkZ); + if (chunk == null) { + chunk = createChunk(); + setChunk(getChunkIndex(chunkX, chunkZ), chunk); + } + return chunk; + } + + @Override + public ChunkIterator iterator() { + return new ChunkIteratorImpl<>(this); + } + + public Stream stream() { + return StreamSupport.stream(spliterator(), false); + } + + protected static class ChunkIteratorImpl implements ChunkIterator { + private final MCAFileBase owner; + private int currentIndex; + + public ChunkIteratorImpl(MCAFileBase owner) { + this.owner = owner; + currentIndex = -1; + } + + @Override + public boolean hasNext() { + return currentIndex < 1023; + } + + @Override + public I next() { + if (!hasNext()) throw new NoSuchElementException(); + return owner.getChunk(++currentIndex); + } + + @Override + public void remove() { + owner.setChunk(currentIndex, null); + } + + @Override + public void set(I chunk) { + owner.setChunk(currentIndex, chunk); + } + + @Override + public int currentIndex() { + return currentIndex; + } + + @Override + public int currentX() { + return currentIndex & 0x1F; + } + + @Override + public int currentZ() { + return (currentIndex >> 5) & 0x1F; + } + } +} diff --git a/src/main/java/net/querz/mca/MCAUtil.java b/src/main/java/net/querz/mca/MCAUtil.java index f5ddecc5..f995bf6a 100644 --- a/src/main/java/net/querz/mca/MCAUtil.java +++ b/src/main/java/net/querz/mca/MCAUtil.java @@ -11,7 +11,7 @@ /** * Provides main and utility functions to read and write .mca files and * to convert block, chunk and region coordinates. - * */ + */ public final class MCAUtil { private MCAUtil() {} @@ -21,7 +21,7 @@ private MCAUtil() {} * @param file The file to read the data from. * @return An in-memory representation of the MCA file with decompressed chunk data. * @throws IOException if something during deserialization goes wrong. - * */ + */ public static MCAFile read(String file) throws IOException { return read(new File(file), LoadFlags.ALL_DATA); } @@ -31,7 +31,7 @@ public static MCAFile read(String file) throws IOException { * @param file The file to read the data from. * @return An in-memory representation of the MCA file with decompressed chunk data. * @throws IOException if something during deserialization goes wrong. - * */ + */ public static MCAFile read(File file) throws IOException { return read(file, LoadFlags.ALL_DATA); } @@ -42,7 +42,7 @@ public static MCAFile read(File file) throws IOException { * @return An in-memory representation of the MCA file with decompressed chunk data. * @param loadFlags A logical or of {@link LoadFlags} constants indicating what data should be loaded * @throws IOException if something during deserialization goes wrong. - * */ + */ public static MCAFile read(String file, long loadFlags) throws IOException { return read(new File(file), loadFlags); } @@ -53,7 +53,7 @@ public static MCAFile read(String file, long loadFlags) throws IOException { * @return An in-memory representation of the MCA file with decompressed chunk data * @param loadFlags A logical or of {@link LoadFlags} constants indicating what data should be loaded * @throws IOException if something during deserialization goes wrong. - * */ + */ public static MCAFile read(File file, long loadFlags) throws IOException { MCAFile mcaFile = newMCAFile(file); try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { @@ -69,7 +69,7 @@ public static MCAFile read(File file, long loadFlags) throws IOException { * @param mcaFile The data of the MCA file to write. * @return The amount of chunks written to the file. * @throws IOException If something goes wrong during serialization. - * */ + */ public static int write(MCAFile mcaFile, String file) throws IOException { return write(mcaFile, new File(file), false); } @@ -81,7 +81,7 @@ public static int write(MCAFile mcaFile, String file) throws IOException { * @param mcaFile The data of the MCA file to write. * @return The amount of chunks written to the file. * @throws IOException If something goes wrong during serialization. - * */ + */ public static int write(MCAFile mcaFile, File file) throws IOException { return write(mcaFile, file, false); } @@ -93,7 +93,7 @@ public static int write(MCAFile mcaFile, File file) throws IOException { * @param changeLastUpdate Whether to adjust the timestamps of when the file was saved. * @return The amount of chunks written to the file. * @throws IOException If something goes wrong during serialization. - * */ + */ public static int write(MCAFile mcaFile, String file, boolean changeLastUpdate) throws IOException { return write(mcaFile, new File(file), changeLastUpdate); } @@ -108,7 +108,7 @@ public static int write(MCAFile mcaFile, String file, boolean changeLastUpdate) * @param changeLastUpdate Whether to adjust the timestamps of when the file was saved. * @return The amount of chunks written to the file. * @throws IOException If something goes wrong during serialization. - * */ + */ public static int write(MCAFile mcaFile, File file, boolean changeLastUpdate) throws IOException { File to = file; if (file.exists()) { @@ -131,7 +131,7 @@ public static int write(MCAFile mcaFile, File file, boolean changeLastUpdate) th * @param chunkX The x-value of the location of the chunk. * @param chunkZ The z-value of the location of the chunk. * @return A mca filename in the format "r.{regionX}.{regionZ}.mca" - * */ + */ public static String createNameFromChunkLocation(int chunkX, int chunkZ) { return createNameFromRegionLocation( chunkToRegion(chunkX), chunkToRegion(chunkZ)); } @@ -142,17 +142,17 @@ public static String createNameFromChunkLocation(int chunkX, int chunkZ) { * @param blockX The x-value of the location of the block. * @param blockZ The z-value of the location of the block. * @return A mca filename in the format "r.{regionX}.{regionZ}.mca" - * */ + */ public static String createNameFromBlockLocation(int blockX, int blockZ) { return createNameFromRegionLocation(blockToRegion(blockX), blockToRegion(blockZ)); } /** - * Creates a filename string from provided chunk coordinates. + * Creates a filename string from provided region coordinates. * @param regionX The x-value of the location of the region. * @param regionZ The z-value of the location of the region. * @return A mca filename in the format "r.{regionX}.{regionZ}.mca" - * */ + */ public static String createNameFromRegionLocation(int regionX, int regionZ) { return "r." + regionX + "." + regionZ + ".mca"; } @@ -161,7 +161,7 @@ public static String createNameFromRegionLocation(int regionX, int regionZ) { * Turns a block coordinate value into a chunk coordinate value. * @param block The block coordinate value. * @return The chunk coordinate value. - * */ + */ public static int blockToChunk(int block) { return block >> 4; } @@ -170,7 +170,7 @@ public static int blockToChunk(int block) { * Turns a block coordinate value into a region coordinate value. * @param block The block coordinate value. * @return The region coordinate value. - * */ + */ public static int blockToRegion(int block) { return block >> 9; } @@ -179,7 +179,7 @@ public static int blockToRegion(int block) { * Turns a chunk coordinate value into a region coordinate value. * @param chunk The chunk coordinate value. * @return The region coordinate value. - * */ + */ public static int chunkToRegion(int chunk) { return chunk >> 5; } @@ -188,7 +188,7 @@ public static int chunkToRegion(int chunk) { * Turns a region coordinate value into a chunk coordinate value. * @param region The region coordinate value. * @return The chunk coordinate value. - * */ + */ public static int regionToChunk(int region) { return region << 5; } @@ -197,7 +197,7 @@ public static int regionToChunk(int region) { * Turns a region coordinate value into a block coordinate value. * @param region The region coordinate value. * @return The block coordinate value. - * */ + */ public static int regionToBlock(int region) { return region << 9; } @@ -206,7 +206,7 @@ public static int regionToBlock(int region) { * Turns a chunk coordinate value into a block coordinate value. * @param chunk The chunk coordinate value. * @return The block coordinate value. - * */ + */ public static int chunkToBlock(int chunk) { return chunk << 4; } diff --git a/src/main/java/net/querz/mca/Section.java b/src/main/java/net/querz/mca/Section.java index 3b72b969..79d2300d 100644 --- a/src/main/java/net/querz/mca/Section.java +++ b/src/main/java/net/querz/mca/Section.java @@ -1,33 +1,50 @@ package net.querz.mca; +import static net.querz.mca.DataVersion.JAVA_1_16_20W17A; import static net.querz.mca.LoadFlags.*; import net.querz.nbt.tag.ByteArrayTag; import net.querz.nbt.tag.CompoundTag; import net.querz.nbt.tag.ListTag; import net.querz.nbt.tag.LongArrayTag; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; - -public class Section implements Comparable

{ - - private CompoundTag data; - private Map> valueIndexedPalette = new HashMap<>(); - private ListTag palette; - private byte[] blockLight; - private long[] blockStates; - private byte[] skyLight; - private int height; - int dataVersion; + +import java.util.*; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +/** + * Represents a REGION data chunk section. Sections can be thought of as "sub-chunks" + * which are 16x16x16 block cubes stacked atop each other to create a "chunk". + */ +public class Section extends SectionBase
{ + + private int dataVersion; // for internal use only - must be kept in sync with chunk data version + protected Map> valueIndexedPalette = new HashMap<>(); + protected ListTag palette; + protected byte[] blockLight; + protected long[] blockStates; + protected byte[] skyLight; + + public static byte[] createBlockLightBuffer() { + return new byte[2048]; + } + + public static long[] createBlockStates() { + return new long[256]; + } + + public static byte[] createSkyLightBuffer() { + return new byte[2048]; + } public Section(CompoundTag sectionRoot, int dataVersion) { this(sectionRoot, dataVersion, ALL_DATA); } public Section(CompoundTag sectionRoot, int dataVersion, long loadFlags) { - data = sectionRoot; + super(sectionRoot); + if (dataVersion <= 0) { + throw new IllegalArgumentException("Invalid data version - must be GT 0"); + } this.dataVersion = dataVersion; height = sectionRoot.getNumber("Y").byteValue(); @@ -41,23 +58,39 @@ public Section(CompoundTag sectionRoot, int dataVersion, long loadFlags) { putValueIndexedPalette(data, i); } - ByteArrayTag blockLight = sectionRoot.getByteArrayTag("BlockLight"); - LongArrayTag blockStates = sectionRoot.getLongArrayTag("BlockStates"); - ByteArrayTag skyLight = sectionRoot.getByteArrayTag("SkyLight"); - if ((loadFlags & BLOCK_LIGHTS) != 0) { - this.blockLight = blockLight != null ? blockLight.getValue() : null; + ByteArrayTag blockLight = sectionRoot.getByteArrayTag("BlockLight"); + if (blockLight != null) this.blockLight = blockLight.getValue(); } if ((loadFlags & BLOCK_STATES) != 0) { - this.blockStates = blockStates != null ? blockStates.getValue() : null; + LongArrayTag blockStates = sectionRoot.getLongArrayTag("BlockStates"); + if (blockStates != null) this.blockStates = blockStates.getValue(); } if ((loadFlags & SKY_LIGHT) != 0) { - this.skyLight = skyLight != null ? skyLight.getValue() : null; + ByteArrayTag skyLight = sectionRoot.getByteArrayTag("SkyLight"); + if (skyLight != null) this.skyLight = skyLight.getValue(); } } - Section() {} + Section(int dataVersion) { + this.dataVersion = dataVersion; + blockLight = createBlockLightBuffer(); + blockStates = createBlockStates(); + skyLight = createSkyLightBuffer(); + palette = new ListTag<>(CompoundTag.class); + CompoundTag air = new CompoundTag(); + air.putString("Name", "minecraft:air"); + palette.add(air); + } + + private void assureBlockStates() { + if (blockStates == null) blockStates = createBlockStates(); + } + private void assurePalette() { + if (palette == null) palette = new ListTag<>(CompoundTag.class); + } + void putValueIndexedPalette(CompoundTag data, int index) { PaletteIndex leaf = new PaletteIndex(data, index); String name = data.getString("Name"); @@ -89,14 +122,6 @@ PaletteIndex getValueIndexedPalette(CompoundTag data) { return null; } - @Override - public int compareTo(Section o) { - if (o == null) { - return -1; - } - return Integer.compare(height, o.height); - } - private static class PaletteIndex { CompoundTag data; @@ -108,25 +133,6 @@ private static class PaletteIndex { } } - /** - * Checks whether the data of this Section is empty. - * @return true if empty - */ - public boolean isEmpty() { - return data == null; - } - - /** - * @return the Y value of this section. - * */ - public int getHeight() { - return height; - } - - public void setHeight(int height) { - this.height = height; - } - /** * Fetches a block state based on a block location from this section. * The coordinates represent the location of the block inside of this Section. @@ -150,17 +156,23 @@ private CompoundTag getBlockStateAt(int index) { * @param blockY The y-coordinate of the block in this Section * @param blockZ The z-coordinate of the block in this Section * @param state The block state to be set - * @param cleanup When true, it will cleanup the palette of this section. + * @param cleanup When true, it will force a cleanup the palette of this section. * This option should only be used moderately to avoid unnecessary recalculation of the palette indices. * Recalculating the Palette should only be executed once right before saving the Section to file. + * @return True if {@link Section#cleanupPaletteAndBlockStates()} was run as a result of this call. + * Note that it is possible that {@link Section#cleanupPaletteAndBlockStates()} needed to be called even if + * the {@code cleanup} argument was {@code false}. In summary if the last call made to this function returns + * {@code true} you can skip the call to {@link Section#cleanupPaletteAndBlockStates()}. */ - public void setBlockStateAt(int blockX, int blockY, int blockZ, CompoundTag state, boolean cleanup) { + public boolean setBlockStateAt(int blockX, int blockY, int blockZ, CompoundTag state, boolean cleanup) { + assurePalette(); int paletteSizeBefore = palette.size(); int paletteIndex = addToPalette(state); //power of 2 --> bits must increase, but only if the palette size changed //otherwise we would attempt to update all blockstates and the entire palette //every time an existing blockstate was added while having 2^x blockstates in the palette if (paletteSizeBefore != palette.size() && (paletteIndex & (paletteIndex - 1)) == 0) { + assureBlockStates(); adjustBlockStateBits(null, blockStates); cleanup = true; } @@ -169,18 +181,21 @@ public void setBlockStateAt(int blockX, int blockY, int blockZ, CompoundTag stat if (cleanup) { cleanupPaletteAndBlockStates(); + return true; } + return false; } /** * Returns the index of the block data in the palette. * @param blockStateIndex The index of the block in this section, ranging from 0-4095. * @return The index of the block data in the palette. - * */ + */ public int getPaletteIndex(int blockStateIndex) { + assureBlockStates(); int bits = blockStates.length >> 6; - if (dataVersion < 2527) { + if (dataVersion > 0 && dataVersion < JAVA_1_16_20W17A.id()) { double blockStatesIndex = blockStateIndex / (4096D / blockStates.length); int longIndex = (int) blockStatesIndex; int startBit = (int) ((blockStatesIndex - Math.floor(blockStatesIndex)) * 64D); @@ -204,11 +219,12 @@ public int getPaletteIndex(int blockStateIndex) { * @param blockIndex The index of the block in this section, ranging from 0-4095. * @param paletteIndex The block state to be set (index of block data in the palette). * @param blockStates The block states to be updated. - * */ + */ public void setPaletteIndex(int blockIndex, int paletteIndex, long[] blockStates) { + Objects.requireNonNull(blockStates, "blockStates must not be null"); int bits = blockStates.length >> 6; - if (dataVersion < 2527) { + if (dataVersion < JAVA_1_16_20W17A.id()) { double blockStatesIndex = blockIndex / (4096D / blockStates.length); int longIndex = (int) blockStatesIndex; int startBit = (int) ((blockStatesIndex - Math.floor(longIndex)) * 64D); @@ -226,6 +242,31 @@ public void setPaletteIndex(int blockIndex, int paletteIndex, long[] blockStates } } + private void upgradeFromBefore20W17A(final int targetVersion) { + int newBits = 32 - Integer.numberOfLeadingZeros(palette.size() - 1); + newBits = Math.max(newBits, 4); + long[] newBlockStates; + + int newLength = (int) Math.ceil(4096D / (Math.floor(64D / newBits))); + newBlockStates = newBits == blockStates.length / 64 ? blockStates : new long[newLength]; + + for (int i = 0; i < 4096; i++) { + setPaletteIndex(i, getPaletteIndex(i), newBlockStates); + } + this.blockStates = newBlockStates; + this.dataVersion = targetVersion; + } + + protected void setDataVersion(int newDataVersion) { + if (newDataVersion <= 0) { + throw new IllegalArgumentException("Invalid data version - must be GT 0"); + } + if (dataVersion < JAVA_1_16_20W17A.id() && newDataVersion >= JAVA_1_16_20W17A.id()) { + upgradeFromBefore20W17A(newDataVersion); + } + dataVersion = newDataVersion; + } + /** * Fetches the palette of this Section. * @return The palette of this Section. @@ -265,7 +306,7 @@ static long bitRange(long value, int from, int to) { * Recalculating the Palette should only be executed once right before saving the Section to file. */ public void cleanupPaletteAndBlockStates() { - if (blockStates != null) { + if (blockStates != null && palette != null) { Map oldToNewMapping = cleanupPalette(); adjustBlockStateBits(oldToNewMapping, blockStates); } @@ -307,7 +348,7 @@ void adjustBlockStateBits(Map oldToNewMapping, long[] blockSta long[] newBlockStates; - if (dataVersion < 2527) { + if (dataVersion < JAVA_1_16_20W17A.id()) { newBlockStates = newBits == blockStates.length / 64 ? blockStates : new long[newBits * 64]; } else { int newLength = (int) Math.ceil(4096D / (Math.floor(64D / newBits))); @@ -388,16 +429,11 @@ public void setSkyLight(byte[] skyLight) { /** * Creates an empty Section with base values. * @return An empty Section + * @deprecated Dangerous - prefer using {@link Chunk#createSection()} instead. */ + @Deprecated public static Section newSection() { - Section s = new Section(); - s.blockStates = new long[256]; - s.palette = new ListTag<>(CompoundTag.class); - CompoundTag air = new CompoundTag(); - air.putString("Name", "minecraft:air"); - s.palette.add(air); - s.data = new CompoundTag(); - return s; + return new Section(DataVersion.latest().id()); } /** @@ -407,7 +443,9 @@ public static Section newSection() { * @param y The Y-value of this Section * @return A reference to the raw CompoundTag this Section is based on */ + @Override public CompoundTag updateHandle(int y) { + checkY(y); data.putByte("Y", (byte) y); if (palette != null) { data.put("Palette", palette); @@ -424,50 +462,105 @@ public CompoundTag updateHandle(int y) { return data; } - public CompoundTag updateHandle() { - return updateHandle(height); - } - /** * Creates an iterable that iterates over all blocks in this section, in order of their indices. - * An index can be calculated using the following formula: + * XYZ can be calculated with the following formulas: *
 	 * {@code
-	 * index = (blockY & 0xF) * 256 + (blockZ & 0xF) * 16 + (blockX & 0xF);
+	 * x = index & 0xF;
+	 * z = (index >> 4) & 0xF;
+	 * y = index >> 8;
 	 * }
 	 * 
* The CompoundTags are references to this Section's Palette and should only be modified if the intention is to * modify ALL blocks of the same type in this Section at the same time. - * */ - public Iterable blocksStates() { - return new BlockIterator(this); + */ + public BlockStateIterator blocksStates() { + return new BlockStateIteratorImpl(this); } - private static class BlockIterator implements Iterable, Iterator { + protected static class BlockStateIteratorImpl implements BlockStateIterator { - private Section section; + private final Section section; + private final int sectionWorldY; private int currentIndex; + private CompoundTag currentTag; + private boolean dirty; - public BlockIterator(Section section) { + public BlockStateIteratorImpl(Section section) { this.section = section; - currentIndex = 0; + this.sectionWorldY = section.getHeight() * 16; + currentIndex = -1; } @Override public boolean hasNext() { - return currentIndex < 4096; + return currentIndex < 4095; } @Override public CompoundTag next() { - CompoundTag blockState = section.getBlockStateAt(currentIndex); - currentIndex++; - return blockState; + return currentTag = section.getBlockStateAt(++currentIndex); } @Override public Iterator iterator() { return this; } + + @Override + public void setBlockStateAtCurrent(CompoundTag state) { + Objects.requireNonNull(state); + if (currentTag != state) { + dirty = !section.setBlockStateAt(currentX(), currentY(), currentZ(), state, false); + } + } + + @Override + public void cleanupPaletteAndBlockStatesIfDirty() { + if (dirty) section.cleanupPaletteAndBlockStates(); + } + + @Override + public int currentIndex() { + return currentIndex; + } + + @Override + public int currentX() { + return currentIndex & 0xF; + } + + @Override + public int currentZ() { + return (currentIndex >> 4) & 0xF; + } + + @Override + public int currentY() { + return currentIndex >> 8; + } + + @Override + public int currentBlockY() { + return sectionWorldY + (currentIndex >> 8); + } + } + + /** + * Streams all blocks in this section, in order of their indices. + * XYZ can be calculated with the following formulas: + *
+	 * {@code
+	 * x = index & 0xF;
+	 * z = (index >> 4) & 0xF;
+	 * y = index >> 8;
+	 * }
+	 * 
+ * The CompoundTags are references to this Section's Palette and should only be modified if the intention is to + * modify ALL blocks of the same type in this Section at the same time. + */ + public Stream streamBlocksStates() { + return StreamSupport.stream(blocksStates().spliterator(), false); } } diff --git a/src/main/java/net/querz/mca/SectionBase.java b/src/main/java/net/querz/mca/SectionBase.java new file mode 100644 index 00000000..9eb93b93 --- /dev/null +++ b/src/main/java/net/querz/mca/SectionBase.java @@ -0,0 +1,91 @@ +package net.querz.mca; + +import net.querz.nbt.tag.CompoundTag; +import java.util.*; + +public abstract class SectionBase> implements Comparable { + public static final int NO_HEIGHT_SENTINEL = Integer.MIN_VALUE; + protected final CompoundTag data; + protected int height = NO_HEIGHT_SENTINEL; + + public SectionBase(CompoundTag sectionRoot) { + Objects.requireNonNull(sectionRoot, "sectionRoot must not be null"); + this.data = sectionRoot; + } + + public SectionBase() { + data = new CompoundTag(); + } + + @Override + public int compareTo(T o) { + if (o == null) { + return -1; + } + return Integer.compare(height, o.height); + } + + /** + * Checks whether the data of this Section is empty. + * @return true if empty + */ + public boolean isEmpty() { + return data.isEmpty(); + } + + /** + * Gets the height of the bottom of this section relative to Y0 as a section-y value, each 1 section-y is equal to + * 16 blocks. + * This library (as a whole) will attempt to keep the value returned by this function in sync with the actual + * location it has been placed within its chunk. + *

The value returned may be unreliable if this section is placed in multiple chunks at different heights. + * or if user code calls {@link #syncHeight(int)} on a section which is referenced by any chunk.

+ * + * @return The Y value of this section. + * @deprecated Prefer using {@code chunk.getSectionY(section)} which will always be accurate + * within the context of the chunk. + */ + public int getHeight() { + return height; + } + + /** + * This method should only be called from a container of Sections such as implementers of + * {@link SectionedChunkBase} in an effort to keep the value accurate, or when building sections prior to adding + * to a chunk where you want to use this section height property for the convenience of not having to track the + * value separately. + * + * @deprecated To set section height (aka section-y) use + * {@code chunk.putSection(int, SectionBase, boolean)} instead of this function. Setting the section height + * by calling this function WILL NOT have any affect upon the sections height in the Chunk or or MCA data when + * serialized. + */ + @Deprecated + public void setHeight(int height) { + syncHeight(height); + } + + void syncHeight(int height) { + this.height = height; + } + + protected void checkY(int y) { + if (y == NO_HEIGHT_SENTINEL) { + throw new IndexOutOfBoundsException("section height not set"); + } + } + + /** + * Updates the raw CompoundTag that this Section is based on. + * This must be called before saving a Section to disk if the Section was manually created + * to set the Y of this Section. + * @param y The Y-value of this Section to include in the returned tag. + * DOES NOT update this sections height value permanently. + * @return A reference to the raw CompoundTag this Section is based on + */ + public abstract CompoundTag updateHandle(int y); + + public CompoundTag updateHandle() { + return updateHandle(height); + } +} diff --git a/src/main/java/net/querz/mca/SectionIterator.java b/src/main/java/net/querz/mca/SectionIterator.java new file mode 100644 index 00000000..431aca01 --- /dev/null +++ b/src/main/java/net/querz/mca/SectionIterator.java @@ -0,0 +1,12 @@ +package net.querz.mca; + +import java.util.Iterator; + +public interface SectionIterator> extends Iterator { + /** current section y within chunk */ + int sectionY(); + /** current block world level y of the bottom most block in the current section */ + int sectionBlockMinY(); + /** current block world level y of the top most block in the current section */ + int sectionBlockMaxY(); +} diff --git a/src/main/java/net/querz/mca/SectionedChunkBase.java b/src/main/java/net/querz/mca/SectionedChunkBase.java new file mode 100644 index 00000000..3c91f4fd --- /dev/null +++ b/src/main/java/net/querz/mca/SectionedChunkBase.java @@ -0,0 +1,231 @@ +package net.querz.mca; + +import net.querz.nbt.tag.CompoundTag; + +import java.util.*; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +/** + * Abstraction for the base of all chunk types which represent chunks composed of sub-chunks {@link SectionBase}. + * @param Concrete type of section. + */ +public abstract class SectionedChunkBase> extends ChunkBase implements Iterable { + private final TreeMap sections = new TreeMap<>(); + private final Map sectionHeightLookup = new HashMap<>(); + + SectionedChunkBase(int lastMCAUpdate) { + super(lastMCAUpdate); + } + + /** + * Create a new chunk based on raw base data from a region file. + * @param data The raw base data to be used. + */ + public SectionedChunkBase(CompoundTag data) { + super(data); + } + + public boolean containsSection(int sectionY) { + return sections.containsKey(sectionY); + } + + public boolean containsSection(T section) { + return sectionHeightLookup.containsKey(section); + } + + /** + * Sets the section at the specified section-y and synchronizes section-y by calling + * {@code section.setHeight(sectionY);}. + * @param sectionY Section-y to place the section at. It is the developers responsibility to ensure this value is + * reasonable. Remember that sections are 16x16x16 cubes and that 1 section-y equals 16 block-y's. + * @param section Section to set, may be null to remove the section. + * @param moveAllowed If false, and the given section is already present in this chunk {@link IllegalArgumentException} + * is thrown. If ture, and the given section is already present in this chunk its former + * section-y location is set {@code null} and the section is updated to live at the + * specified section-y. + * @return The previous section at section-y, or null if there was none or if the given section was already + * present at sectionY. + * @throws IllegalArgumentException Thrown when the given section is already present in this chunk + * and {@code moveAllowed} is false. + */ + public T putSection(int sectionY, T section, boolean moveAllowed) throws IllegalArgumentException { + checkRaw(); + if (sectionY < Byte.MIN_VALUE || sectionY > Byte.MAX_VALUE) { + throw new IllegalArgumentException( + "sectionY must be in the range of a BYTE [-128..127], given value " + sectionY); + } + if (section != null) { + if (sectionHeightLookup.containsKey(section)) { + final int oldY = sectionHeightLookup.getOrDefault(section, SectionBase.NO_HEIGHT_SENTINEL); + if (sectionY == oldY) return null; + if (!moveAllowed) { + throw new IllegalArgumentException( + String.format("cannot place section at %d, it's already at %d", sectionY, oldY)); + } + final T oldSection = sections.remove(oldY); + sectionHeightLookup.remove(oldSection); + assert(oldSection == section); + assert(sections.size() == sectionHeightLookup.size()); + } + section.syncHeight(sectionY); + sectionHeightLookup.put(section, sectionY); + final T oldSection = sections.put(sectionY, section); + if (oldSection != null) sectionHeightLookup.remove(oldSection); + assert(sections.size() == sectionHeightLookup.size()); + return oldSection; + } else { + final T oldSection = sections.remove(sectionY); + sectionHeightLookup.remove(oldSection); + assert(sections.size() == sectionHeightLookup.size()); + return oldSection; + } + } + + /** + * Sets the section at the specified section-y and synchronizes section-y by calling + * {@code section.setHeight(sectionY);}. + * @param sectionY Section-y to place the section at. It is the developers responsibility to ensure this value is + * reasonable. Remember that sections are 16x16x16 cubes and that 1 section-y equals 16 block-y's. + * @param section Section to set, may be null to remove the section. + * @return The previous section at section-y, or null if there was none or if the given section was already + * present at sectionY. + * @throws IllegalArgumentException Thrown when the given section is already present in this chunk. + * Call {@code putSection(sectionY, section, true)} to not throw this error and to move the section instead. + */ + public T putSection(int sectionY, T section) { + return putSection(sectionY, section, false); + } + + /** + * Fetches the section at the specified section-y and synchronizes section-y by calling + * {@code section.setHeight(sectionY);} before returning it. + * @param sectionY The y-coordinate of the section in this chunk. One section y is equal to 16 world y's + * @return The Section. + */ + public T getSection(int sectionY) { + T section = sections.get(sectionY); + if (section != null) { + section.syncHeight(sectionY); + } + return section; + } + + /** + * Alias for {@link #putSection(int, SectionBase)} + *

Sets a section at a given section y-coordinate. + * @param sectionY The y-coordinate of the section in this chunk. One section y is equal to 16 world y's + * @param section The section to be set. May be null to remove the section. + * @return the previous value associated with {@code sectionY}, or null if there was no section at {@code sectionY} + * or if the section was already at that y. + * @throws IllegalStateException Thrown if adding the given section would result in that section instance occurring + * multiple times in this chunk. Use {@link #putSection} as an alternative to allow moving the section, otherwise + * it is the developers responsibility to first remove the section from this chunk + * ({@code setSection(sectionY, null);}) before placing it at a new section-y. + */ + public T setSection(int sectionY, T section) { + return putSection(sectionY, section, false); + } + + /** + * Looks up the section-y for the given section. This is a safer alternative to using + * {@link SectionBase#getHeight()} as it will always be accurate within the context of this chunk. + * @param section section to lookup the section-y for. + * @return section-y; may be negative for worlds with a min build height below zero. If the given section is + * {@code null} or is not found in this chunk then {@link SectionBase#NO_HEIGHT_SENTINEL} is returned. + */ + public int getSectionY(T section) { + if (section == null) return SectionBase.NO_HEIGHT_SENTINEL; + int y = sectionHeightLookup.getOrDefault(section, SectionBase.NO_HEIGHT_SENTINEL); + section.syncHeight(y); + return y; + } + + /** + * Gets the minimum section y-coordinate. + * @return The y of the lowest populated section in the chunk or {@link SectionBase#NO_HEIGHT_SENTINEL} if there is none. + */ + public int getMinSectionY() { + if (!sections.isEmpty()) { + return sections.firstKey(); + } + return SectionBase.NO_HEIGHT_SENTINEL; + } + + /** + * Gets the minimum section y-coordinate. + * @return The y of the highest populated section in the chunk or {@link SectionBase#NO_HEIGHT_SENTINEL} if there is none. + */ + public int getMaxSectionY() { + if (!sections.isEmpty()) { + return sections.lastKey(); + } + return SectionBase.NO_HEIGHT_SENTINEL; + } + + /*** + * Creates a new section and places it in this chunk at the specified section-y + * @param sectionY section y + * @return new section + * @throws IllegalArgumentException thrown if the specified y already has a section - basically throwns if + * {@link #containsSection(int)} would return true. + */ + public abstract T createSection(int sectionY) throws IllegalArgumentException; + + /** + * Sections provided by {@link Iterator#next()} are guaranteed to have correct values returned from + * calls to {@link SectionBase#getHeight()}. Also note that the iterator itself can be queried via + * {@link SectionIterator#sectionY()} for the true section-y without calling a deprecated method. + * @return Section iterator. Supports {@link Iterator#remove()}. + */ + @Override + public SectionIterator iterator() { + return new SectionIteratorImpl(); + } + + public Stream stream() { + return StreamSupport.stream(spliterator(), false); + } + + protected class SectionIteratorImpl implements SectionIterator { + private final Iterator> iter; + private Map.Entry current; + + public SectionIteratorImpl() { + iter = sections.entrySet().iterator(); + } + + @Override + public boolean hasNext() { + return iter.hasNext(); + } + + @Override + public T next() { + current = iter.next(); + current.getValue().syncHeight(current.getKey()); + return current.getValue(); + } + + @Override + public void remove() { + sectionHeightLookup.remove(current.getValue()); + iter.remove(); + } + + @Override + public int sectionY() { + return current.getKey(); + } + + @Override + public int sectionBlockMinY() { + return sectionY() * 16; + } + + @Override + public int sectionBlockMaxY() { + return sectionY() * 16 + 15; + } + } +} diff --git a/src/main/java/net/querz/mca/TagWrapper.java b/src/main/java/net/querz/mca/TagWrapper.java new file mode 100644 index 00000000..fd603755 --- /dev/null +++ b/src/main/java/net/querz/mca/TagWrapper.java @@ -0,0 +1,18 @@ +package net.querz.mca; + +import net.querz.nbt.tag.CompoundTag; + +public interface TagWrapper { + /** + * Updates the data tag held by this wrapper and returns it. + * @return A reference to the raw CompoundTag this object is based on. + */ + CompoundTag updateHandle(); + + /** + * Provides a reference to the wrapped data tag. + * May be null for objects which support partial loading such as chunks. + * @return A reference to the raw CompoundTag this object is based on. + */ + CompoundTag getHandle(); +} diff --git a/src/main/java/net/querz/mca/VersionedDataContainer.java b/src/main/java/net/querz/mca/VersionedDataContainer.java new file mode 100644 index 00000000..b0fe5871 --- /dev/null +++ b/src/main/java/net/querz/mca/VersionedDataContainer.java @@ -0,0 +1,42 @@ +package net.querz.mca; + +/** + * Interface for any NBT data container which has the "DataVersion" tag. + */ +public interface VersionedDataContainer { + + /** + * @return The exact data version of this container. + */ + int getDataVersion(); + + /** + * Sets the data version value for this container. This does not check if the data of this container + * conforms to that of the data version specified, that is the responsibility of the developer. + * @param dataVersion The numeric data version to be set. + */ + void setDataVersion(int dataVersion); + + /** + * Indicates if the held data version has been set. + */ + default boolean hasDataVersion() { + return getDataVersionEnum() != DataVersion.UNKNOWN; + } + + /** + * Equivalent to calling {@link DataVersion#bestFor(int)} with {@link #getDataVersion()}. + * @return The best matching {@link DataVersion} of this chunk. + */ + default DataVersion getDataVersionEnum() { + return DataVersion.bestFor(getDataVersion()); + } + + /** + * Equivalent to calling {@link #setDataVersion(int)} with {@link DataVersion#id()}. + * @param dataVersion The {@link DataVersion} to set. + */ + default void setDataVersion(DataVersion dataVersion) { + setDataVersion(dataVersion.id()); + } +} diff --git a/src/main/java/net/querz/nbt/io/NBTUtil.java b/src/main/java/net/querz/nbt/io/NBTUtil.java index edd0b869..fd6bdf89 100644 --- a/src/main/java/net/querz/nbt/io/NBTUtil.java +++ b/src/main/java/net/querz/nbt/io/NBTUtil.java @@ -1,18 +1,24 @@ package net.querz.nbt.io; import net.querz.nbt.tag.Tag; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.PushbackInputStream; + +import java.io.*; import java.util.zip.GZIPInputStream; public final class NBTUtil { private NBTUtil() {} + /** + * Writes the value returned by {@link Tag#toString()} to the specified file. + *

Useful for looking at large data structures, sorry it's not pretty printed.

+ */ + public static void debugWrite(Tag tag, File file) throws IOException { + try (PrintWriter pw = new PrintWriter(new FileOutputStream(file))) { + pw.write(tag.toString()); + } + } + public static void write(NamedTag tag, File file, boolean compressed) throws IOException { try (FileOutputStream fos = new FileOutputStream(file)) { new NBTSerializer(compressed).toStream(tag, fos); diff --git a/src/main/java/net/querz/nbt/tag/CompoundTag.java b/src/main/java/net/querz/nbt/tag/CompoundTag.java index f34db876..6a172925 100644 --- a/src/main/java/net/querz/nbt/tag/CompoundTag.java +++ b/src/main/java/net/querz/nbt/tag/CompoundTag.java @@ -36,6 +36,10 @@ public int size() { return getValue().size(); } + public boolean isEmpty() { + return getValue().isEmpty(); + } + public Tag remove(String key) { return getValue().remove(key); } @@ -137,6 +141,11 @@ public ListTag getListTag(String key) { return get(key, ListTag.class); } + @SuppressWarnings("unchecked") + public > R getListTagAutoCast(String key) { + return (R) get(key, ListTag.class); + } + public CompoundTag getCompoundTag(String key) { return get(key, CompoundTag.class); } diff --git a/src/test/java/net/querz/AutoTypingDemoTest.java b/src/test/java/net/querz/AutoTypingDemoTest.java new file mode 100644 index 00000000..991125c5 --- /dev/null +++ b/src/test/java/net/querz/AutoTypingDemoTest.java @@ -0,0 +1,48 @@ +package net.querz; + +import junit.framework.TestCase; + +/** + * Demonstration of auto return typing pattern using the simplest possible constructs for the reader + * to grasp its operation. + *

This pattern does have one key weakness, and that is if the returned type does not match the + * expected type a ClassCastException is thrown from the call site. There is no way for the auto + * function, {@code create(String)} below, to trap this exception and add details or hints. + * The caller needs to be aware of this.

+ */ +public class AutoTypingDemoTest extends TestCase { + + private static abstract class Base {public abstract String str();} + private static class ImplA extends Base {public String str() {return "A";}} + private static class ImplB extends Base {public String str() {return "B";}} + + @SuppressWarnings("unchecked") + private T create(String hint) { + if ("A".equals(hint)) return (T) new ImplA(); + if ("B".equals(hint)) return (T) new ImplB(); + throw new IllegalArgumentException(); + } + + public void testAutoReturnTyping_directAssignmentDemo() { + ImplA a = create("A"); + ImplB b = create("B"); + try { + ImplA bad = create("B"); + fail(); + } catch (ClassCastException expected) { + // Note, it's not possible to trap and clarify this exception within #create(String) + // Ideally I'd like to be able to provide the caller with a more helpful message / hint for correction + // but this is a shortcoming of the pattern + } + } + + public void testAutoReturnTyping_handlingReturnValueWhenCallerDoesntKnowTheTypeAtTimeOfCall() { + ImplA a; + ImplB b; + Base unknown = create("A"); + + if (unknown instanceof ImplA) a = (ImplA) unknown; + else if (unknown instanceof ImplB) b = (ImplB) unknown; + else throw new UnsupportedOperationException(); // or ignore it, or whatever you want + } +} diff --git a/src/test/java/net/querz/NBTTestCase.java b/src/test/java/net/querz/NBTTestCase.java index 7d412a59..9fdcece0 100644 --- a/src/test/java/net/querz/NBTTestCase.java +++ b/src/test/java/net/querz/NBTTestCase.java @@ -1,5 +1,6 @@ package net.querz; +import junit.framework.AssertionFailedError; import junit.framework.TestCase; import net.querz.nbt.io.NBTDeserializer; import net.querz.nbt.io.NBTSerializer; @@ -19,6 +20,8 @@ import java.math.BigInteger; import java.net.URL; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.UUID; @@ -151,20 +154,19 @@ protected void assertThrowsException(ExceptionSupplier< } } - protected T assertThrowsNoException(ExceptionSupplier r) { - return assertThrowsNoException(r, false); + private static class UnexpectedExceptionThrownException extends AssertionFailedError { + public UnexpectedExceptionThrownException(Exception ex) { + super("Threw exception " + ex.getClass().getName() + " with message \"" + ex.getMessage() + "\""); + this.setStackTrace(ex.getStackTrace()); + } } - protected T assertThrowsNoException(ExceptionSupplier r, boolean printStackTrace) { + protected T assertThrowsNoException(ExceptionSupplier r) { try { return r.run(); } catch (Exception ex) { - if (printStackTrace) { - ex.printStackTrace(); - } - TestCase.fail("Threw exception " + ex.getClass().getName() + " with message \"" + ex.getMessage() + "\""); + throw new UnexpectedExceptionThrownException(ex); } - return null; } protected void assertThrowsRuntimeException(Runnable r, Class e) { @@ -213,25 +215,19 @@ protected T assertThrowsNoRuntimeException(Supplier r) { } protected File getNewTmpFile(String name) { - String workingDir = System.getProperty("user.dir"); - File tmpDir = new File(workingDir, "tmp"); - if (!tmpDir.exists()) { - tmpDir.mkdirs(); - } - File tmpFile = new File(tmpDir, name); - if (tmpFile.exists()) { - tmpFile = new File(tmpDir, UUID.randomUUID() + name); + final String workingDir = System.getProperty("user.dir"); + Path tmpPath = Paths.get( + workingDir, + "tmp", + this.getClass().getSimpleName(), + getName(), + UUID.randomUUID().toString(), + name); + File dir = tmpPath.getParent().toFile(); + if (!dir.exists()) { + dir.mkdirs(); } - return tmpFile; - } - - protected File getTmpFile(String name) { - String workingDir = System.getProperty("user.dir"); - File tmpDir = new File(workingDir, "tmp"); - if (!tmpDir.exists()) { - tmpDir.mkdirs(); - } - return new File(tmpDir, name); + return tmpPath.toFile(); } protected File copyResourceToTmp(String resource) { @@ -243,16 +239,18 @@ protected File copyResourceToTmp(String resource) { return tmpFile; } - protected void cleanupTmpDir() { - String workingDir = System.getProperty("user.dir"); - File tmpDir = new File(workingDir, "tmp"); - File[] tmpFiles = tmpDir.listFiles(); - if (tmpFiles != null && tmpFiles.length != 0) { - for (File file : tmpFiles) { - file.delete(); + private void deleteRecursive(File deleteMe) { + File[] contents = deleteMe.listFiles(); + if (contents != null) { + for (File file : contents) { + deleteRecursive(file); } } - tmpDir.delete(); + deleteMe.delete(); + } + + protected void cleanupTmpDir() { + deleteRecursive(new File(System.getProperty("user.dir"), "tmp")); } protected String calculateFileMD5(File file) { diff --git a/src/test/java/net/querz/mca/DataVersionTest.java b/src/test/java/net/querz/mca/DataVersionTest.java new file mode 100644 index 00000000..cc025b30 --- /dev/null +++ b/src/test/java/net/querz/mca/DataVersionTest.java @@ -0,0 +1,40 @@ +package net.querz.mca; + +import junit.framework.TestCase; + +public class DataVersionTest extends TestCase { + + public void testBestForNegativeValue() { + assertEquals(DataVersion.UNKNOWN, DataVersion.bestFor(-42)); + } + + public void testBestForExactFirst() { + assertEquals(DataVersion.UNKNOWN, DataVersion.bestFor(0)); + } + + public void testBestForExactArbitrary() { + assertEquals(DataVersion.JAVA_1_15_0, DataVersion.bestFor(2225)); + } + + public void testBestForBetween() { + assertEquals(DataVersion.JAVA_1_10_2, DataVersion.bestFor(DataVersion.JAVA_1_11_0.id() - 1)); + assertEquals(DataVersion.JAVA_1_11_0, DataVersion.bestFor(DataVersion.JAVA_1_11_0.id() + 1)); + } + + public void testBestForExactLast() { + final DataVersion last = DataVersion.values()[DataVersion.values().length - 1]; + assertEquals(last, DataVersion.bestFor(last.id())); + } + + public void testBestForAfterLast() { + final DataVersion last = DataVersion.values()[DataVersion.values().length - 1]; + assertEquals(last, DataVersion.bestFor(last.id() + 123)); + } + + public void testToString() { + assertEquals("2724 (1.17)", DataVersion.JAVA_1_17_0.toString()); + assertEquals("2730 (1.17.1)", DataVersion.JAVA_1_17_1.toString()); + assertEquals("UNKNOWN", DataVersion.UNKNOWN.toString()); + assertEquals("2529 (1.16 20w17a)", DataVersion.JAVA_1_16_20W17A.toString()); + } +} diff --git a/src/test/java/net/querz/mca/MCAFileTest.java b/src/test/java/net/querz/mca/MCAFileTest.java index dc9c130d..86418d2e 100644 --- a/src/test/java/net/querz/mca/MCAFileTest.java +++ b/src/test/java/net/querz/mca/MCAFileTest.java @@ -1,13 +1,13 @@ package net.querz.mca; import net.querz.nbt.tag.CompoundTag; -import net.querz.nbt.tag.EndTag; import net.querz.nbt.tag.ListTag; import static net.querz.mca.LoadFlags.*; -import java.io.File; -import java.io.IOException; -import java.io.RandomAccessFile; + +import java.io.*; import java.util.Arrays; +import java.util.Iterator; +import java.util.Objects; public class MCAFileTest extends MCATestCase { @@ -113,6 +113,7 @@ private Chunk createChunkWithPos() { CompoundTag data = new CompoundTag(); CompoundTag level = new CompoundTag(); data.put("Level", level); + data.putInt("DataVersion", DataVersion.JAVA_1_16_5.id()); return new Chunk(data); } @@ -161,7 +162,7 @@ public void testSetters() { assertEquals(getSomeListTagList(), f.getChunk(1023).getPostProcessing()); f.getChunk(1023).setStructures(getSomeCompoundTag()); assertEquals(getSomeCompoundTag(), f.getChunk(1023).getStructures()); - Section s = new Section(); + Section s = f.getChunk(1023).createSection(); f.getChunk(1023).setSection(0, s); assertEquals(s, f.getChunk(1023).getSection(0)); assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(null), NullPointerException.class); @@ -181,22 +182,33 @@ public void testSetters() { assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setSkyLight(null)); } - public void testGetBiomeAt() { + public void testGetBiomeAt2D() { MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); assertEquals(21, f.getBiomeAt(1024, 1024)); assertEquals(-1, f.getBiomeAt(1040, 1024)); f.setChunk(0, 1, Chunk.newChunk(2201)); assertEquals(-1, f.getBiomeAt(1024, 1040)); - } - public void testSetBiomeAt() { - MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca")), true); + public void testSetBiomeAt_2D_2dBiomeWorld() { + MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); f.setBiomeAt(1024, 1024, 20); assertEquals(20, f.getChunk(64, 64).updateHandle(64, 64).getCompoundTag("Level").getIntArray("Biomes")[0]); f.setBiomeAt(1039, 1039, 47); assertEquals(47, f.getChunk(64, 64).updateHandle(64, 64).getCompoundTag("Level").getIntArray("Biomes")[255]); f.setBiomeAt(1040, 1024, 20); + + int[] biomes = f.getChunk(65, 64).updateHandle(65, 64).getCompoundTag("Level").getIntArray("Biomes"); + assertEquals(256, biomes.length); + assertEquals(20, biomes[0]); + for (int i = 1; i < 256; i++) { + assertEquals(-1, biomes[i]); + } + } + + public void testSetBiomeAt_2D_3DBiomeWorld() { + MCAFile f = new MCAFile(2, 2, DataVersion.JAVA_1_15_0); + f.setBiomeAt(1040, 1024, 20); int[] biomes = f.getChunk(65, 64).updateHandle(65, 64).getCompoundTag("Level").getIntArray("Biomes"); assertEquals(1024, biomes.length); for (int i = 0; i < 1024; i++) { @@ -234,8 +246,21 @@ public void testCleanupPaletteAndBlockStates() { assertEquals(256, s.updateHandle(0).getLongArray("BlockStates").length); } + public void testMaxAndMinSectionY() { + MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); + Chunk c = f.getChunk(0, 0); + assertEquals(0, c.getMinSectionY()); + assertEquals(5, c.getMaxSectionY()); + c.setSection(-64 / 16, Section.newSection()); + c.setSection((320 - 16) / 16, Section.newSection()); + assertEquals(-4, c.getMinSectionY()); + assertEquals(19, c.getMaxSectionY()); + } + public void testSetBlockDataAt() { MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); + assertEquals(f.getMaxChunkDataVersion(), f.getMinChunkDataVersion()); + assertTrue(f.getDefaultChunkDataVersion() > 0); Section section = f.getChunk(0, 0).getSection(0); assertEquals(10, section.getPalette().size()); assertEquals(0b0001000100010001000100010001000100010001000100010001000100010001L, section.getBlockStates()[0]); @@ -260,6 +285,7 @@ public void testSetBlockDataAt() { assertNull(f.getChunk(1, 0)); f.setBlockStateAt(17, 0, 0, block("minecraft:test"), false); assertNotNull(f.getChunk(1, 0)); + assertEquals(f.getDefaultChunkDataVersion(), f.getChunk(1, 0).getDataVersion()); ListTag s = f.getChunk(1, 0).updateHandle(65, 64).getCompoundTag("Level").getListTag("Sections").asCompoundTagList(); assertEquals(1, s.size()); assertEquals(2, s.get(0).getListTag("Palette").size()); @@ -436,7 +462,7 @@ public void testPartialLoad() { } } - public void test1_15GetBiomeAt() throws IOException { + public void test1_15GetBiomeAt() { MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.0.0.mca"))); assertEquals(162, f.getBiomeAt(31, 0, 63)); assertEquals(4, f.getBiomeAt(16, 0, 48)); @@ -451,4 +477,125 @@ public void test1_15GetBiomeAt() throws IOException { assertEquals(4, f.getBiomeAt(16, 106, 63)); assertEquals(162, f.getBiomeAt(31, 106, 48)); } + + public void testChunkSectionPutSection() { + MCAFile mca = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("1_17_1/region/r.-3.-2.mca"))); + Chunk chunk = mca.stream().filter(Objects::nonNull).findFirst().orElse(null); + assertNotNull(chunk); + final Section section2 = chunk.getSection(2); + assertNull(chunk.putSection(2, section2)); // no error to replace self + assertThrowsException(() -> chunk.putSection(3, section2), IllegalArgumentException.class); // should fail + assertNotSame(section2, chunk.getSection(3)); // shouldn't have updated section 3 + final Section newSection = chunk.createSection(); + final Section prevSection2 = chunk.putSection(2, newSection); // replace existing section 2 with the new one + assertNotNull(prevSection2); + assertSame(section2, prevSection2); // check we got the existing section 2 when we replaced it + assertSame(newSection, chunk.getSection(2)); // verify we put section 2 + assertEquals(2, newSection.getHeight()); // insertion should update section height + final Section section3 = chunk.putSection(3, section2); // should be OK to put old section 2 into section 3 place now + + final Section section1 = chunk.getSection(1); + final Section prevSection5 = chunk.putSection(5, section1, true); // move section 1 into section 5 + assertNotNull(prevSection5); + assertNull(chunk.getSection(1)); // verify we 'moved' section one out + assertNotSame(section1, prevSection5); // make sure the return value isn't stupid + assertNull(chunk.putSection(1, prevSection5, true)); // moving 5 into empty slot is OK + + // guard against section y default(0) case + final Section section0 = chunk.getSection(0); + final Section newSection0 = chunk.createSection(); + assertSame(section0, chunk.putSection(0, newSection0)); + + // and finally direct removal via putting null + assertSame(newSection0, chunk.putSection(0, null)); + assertNull(chunk.getSection(0)); + assertNull(chunk.putSection(0, null)); + chunk.putSection(0, section0); + assertSame(section0, chunk.putSection(0, null, true)); + assertNull(chunk.getSection(0)); + + assertThrowsException(() -> chunk.putSection(Byte.MIN_VALUE - 1, chunk.createSection()), IllegalArgumentException.class); + assertThrowsException(() -> chunk.putSection(Byte.MAX_VALUE + 1, chunk.createSection()), IllegalArgumentException.class); + + assertThrowsNoException(() -> chunk.putSection(Byte.MIN_VALUE, chunk.createSection())); + assertThrowsNoException(() -> chunk.putSection(Byte.MAX_VALUE, chunk.createSection())); + } + + public void testChunkSectionGetSectionY() { + MCAFile mca = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("1_17_1/region/r.-3.-2.mca"))); + Chunk chunk = mca.stream().filter(Objects::nonNull).findFirst().orElse(null); + assertNotNull(chunk); + assertEquals(SectionBase.NO_HEIGHT_SENTINEL, chunk.getSectionY(null)); + assertEquals(SectionBase.NO_HEIGHT_SENTINEL, chunk.getSectionY(chunk.createSection())); + Section section = chunk.getSection(5); + section.setHeight(-5); + assertEquals(5, chunk.getSectionY(section)); + assertEquals(5, section.getHeight()); // getSectionY should sync Y + } + + public void testChunkSectionMinMaxSectionY() { + Chunk chunk = new Chunk(42); + chunk.setDataVersion(DataVersion.JAVA_1_17_1.id()); + assertEquals(SectionBase.NO_HEIGHT_SENTINEL, chunk.getMinSectionY()); + assertEquals(SectionBase.NO_HEIGHT_SENTINEL, chunk.getMaxSectionY()); + Section section = chunk.createSection(3); + + } + + public void testMCAFileChunkIterator() { + MCAFile mca = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("1_17_1/region/r.-3.-2.mca"))); + ChunkIterator iter = mca.iterator(); + assertEquals(-1, iter.currentIndex()); + final int populatedX = -65 & 0x1F; + final int populatedZ = -42 & 0x1F; + int i = 0; + for (int z = 0; z < 32; z++) { + for (int x = 0; x < 32; x++) { + assertTrue(iter.hasNext()); + Chunk chunk = iter.next(); + assertEquals(i, iter.currentIndex()); + assertEquals(x, iter.currentX()); + assertEquals(z, iter.currentZ()); + if (x == populatedX && z == populatedZ) { + assertNotNull(chunk); + } else { + assertNull(chunk); + } + if (i == 1023) { + iter.set(mca.createChunk()); + } + i++; + } + } + assertFalse(iter.hasNext()); + assertNotNull(mca.getChunk(1023)); + } + + public void testChunkSectionIterator() { + MCAFile mca = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("1_17_1/region/r.-3.-2.mca"))); + assertEquals(1, mca.count()); + Chunk chunk = mca.stream().filter(Objects::nonNull).findFirst().orElse(null); + assertNotNull(chunk); + final int minY = chunk.getMinSectionY(); + final int maxY = chunk.getMaxSectionY(); + assertNotNull(chunk.getSection(minY)); + assertNotNull(chunk.getSection(maxY)); + SectionIterator
iter = chunk.iterator(); + for (int y = minY; y <= maxY; y++) { + assertTrue(iter.hasNext()); + Section section = iter.next(); + assertNotNull(section); + assertEquals(y, iter.sectionY()); + assertEquals(y, section.getHeight()); + if (y > maxY - 2) { + iter.remove(); + } + } + assertFalse(iter.hasNext()); + assertEquals(minY, chunk.getMinSectionY()); + assertEquals(maxY - 2, chunk.getMaxSectionY()); + assertNull(chunk.getSection(maxY)); + assertNull(chunk.getSection(maxY - 1)); + assertNotNull(chunk.getSection(maxY - 2)); + } } diff --git a/src/test/java/net/querz/mca/MCAUtilTest.java b/src/test/java/net/querz/mca/MCAUtilTest.java index a63dde4e..73447a4d 100644 --- a/src/test/java/net/querz/mca/MCAUtilTest.java +++ b/src/test/java/net/querz/mca/MCAUtilTest.java @@ -85,7 +85,8 @@ public void testMakeMyCoverageGreatAgain() { // test overwriting file MCAFile m = new MCAFile(0, 0); m.setChunk(0, Chunk.newChunk()); - assertThrowsNoException(() -> MCAUtil.write(m, getTmpFile("r.0.0.mca"), false), true); - assertThrowsNoException(() -> MCAUtil.write(m, getTmpFile("r.0.0.mca"), false), true); + File target = getNewTmpFile("r.0.0.mca"); + assertThrowsNoException(() -> MCAUtil.write(m, target, false)); + assertThrowsNoException(() -> MCAUtil.write(m, target, false)); } } diff --git a/src/test/resources/1_17_1/region/r.-3.-2.mca b/src/test/resources/1_17_1/region/r.-3.-2.mca new file mode 100644 index 00000000..d816f059 Binary files /dev/null and b/src/test/resources/1_17_1/region/r.-3.-2.mca differ diff --git a/src/test/resources/1_18/region/r.-1.-1.mca b/src/test/resources/1_18/region/r.-1.-1.mca new file mode 100644 index 00000000..293c5442 Binary files /dev/null and b/src/test/resources/1_18/region/r.-1.-1.mca differ diff --git a/src/test/resources/1_18/region/r.15.-9.mca b/src/test/resources/1_18/region/r.15.-9.mca new file mode 100644 index 00000000..bf1c5b0f Binary files /dev/null and b/src/test/resources/1_18/region/r.15.-9.mca differ diff --git a/src/test/resources/ABOUT_TEST_DATA.md b/src/test/resources/ABOUT_TEST_DATA.md new file mode 100644 index 00000000..b7906dbf --- /dev/null +++ b/src/test/resources/ABOUT_TEST_DATA.md @@ -0,0 +1,23 @@ +## 1.17.1 (DV 2730) + +### 1_17_1/r.-3.2.mca (seed: -592955240269541309) +Village, with villager with POI of cartography table and bed + +Chunks: +- -65 -42 + +## 1.18 21w44a (DV 2845) +Vanilla world height from Y -64 to 320 + +Region chunk NBT data is not wrapped in the "Level" tag. +### 1_18/r.-1.-1.mca (seed: 9014880806510125335) +Village, with POI entries, and villager + +Chunks: +- -24, -12 + +### 1_18/r.15.-9.mca (seed: 9014880806510125335) +3D biomes - lush cave in selected chunk around Y0. + +Chunks: +- 483, -263 \ No newline at end of file