diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml new file mode 100644 index 00000000..2af46165 --- /dev/null +++ b/.github/workflows/gradle-publish.yml @@ -0,0 +1,44 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a package using Gradle and then publish it to GitHub packages when a release is created +# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#Publishing-using-gradle + +name: Gradle Package + +on: + release: + types: [created] + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + server-id: github # Value of the distributionManagement/repository/id field of the pom.xml + settings-path: ${{ github.workspace }} # location for the settings.xml file + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@417ae3ccd767c252f5661f1ace9f835f9654f2b5 # v3.1.0 + + - name: Build with Gradle + run: ./gradlew build + + # The USERNAME and TOKEN need to correspond to the credentials environment variables used in + # the publishing section of your build.gradle + - name: Publish to GitHub Packages + run: ./gradlew publish + env: + USERNAME: ${{ github.actor }} + TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 7a2ac475..153042bd 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,6 @@ doc/ out/ *.patch +TESTDBG/ +ScratchpadTest.java +/.run/publishToMavenLocal.run.xml diff --git a/README.md b/README.md index b629063b..cdb3c23e 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,89 @@ # NBT -[![Build Status](https://travis-ci.org/Querz/NBT.svg?branch=master)](https://travis-ci.org/Querz/NBT) [![Coverage Status](https://img.shields.io/coveralls/github/Querz/NBT/master.svg)](https://coveralls.io/github/Querz/NBT?branch=master) [![Release](https://jitpack.io/v/Querz/NBT.svg)](https://jitpack.io/#Querz/NBT) -#### A java implementation of the [NBT protocol](http://minecraft.gamepedia.com/NBT_format) for Minecraft Java Edition. + +A java implementation of the [NBT protocol](https://minecraft.gamepedia.com/NBT_format) for Minecraft Java Edition. + +**!!! THIS FORK IS UNDER HEAVY DEVELOPMENT - May 2024!!!** + +But, it is ready for early adopters! + +This library includes a rich [NBT](https://minecraft.gamepedia.com/NBT_format) experience and powerful tools +for working with .mca files. The NBT portion is largely stable while the mca library is still going through heavy +iteration. + +#### Highlights of the NBT library +* [NbtPath](src/main/java/io/github/ensgijs/nbt/query/NbtPath.java) Allows you to easily get and put nested tags. +* [TextNbtHelpers](src/main/java/io/github/ensgijs/nbt/io/TextNbtHelpers.java) Utilities for reading and writing text nbt data - with pretty printing! Yes, pretty printed text nbt data is also parseable. +* [BinaryNbtHelpers](src/main/java/io/github/ensgijs/nbt/io/BinaryNbtHelpers.java) Utilities for working with binary nbt files, compressed or uncompressed. + +#### Highlights of the MCA library +* Version support from Minecraft Java 1.9.0 to 1.21.3 and beyond (no Bedrock support currently or planed at this time). + * [DataVersion](src/main/java/io/github/ensgijs/nbt/mca/DataVersion.java) nearly complete data version to minecraft version mapping and detection back to 1.9.0. +* Rich javadocs on most classes and methods. +* Excellent code coverage and extensive unit testing as well as integration testing for multiple Minecraft versions see [test resources](src/test/resources) + * _There's always room for improvement of the richness of integration data samples - some of the samples are a little less interesting than I would prefer._ +* Supports editing of non-vanilla world files without data loss. +* Support for terrain (region), entities, and poi mca files - the various chunk class types are due for a heavy refactor, and I plan to add an abstraction layer to wrap all 3 mca/chunk types to provide a more seamless processing / editing experience. +* Safely relocate (move) chunks - all chunk implementations fully support being moved to a new location. All contents are updated to exist in the new chunk location. This feature is the primary reason I dusted off this project after 3 years and is well tested and very ready for consumption. + * [RegionFileRelocator](src/main/java/io/github/ensgijs/nbt/mca/io/RegionFileRelocator.java) relocate entire region files. +* Multiple options for reading and writing chunk data to .mca files using one of the following classes. + * [RandomAccessMcaFile](src/main/java/io/github/ensgijs/nbt/mca/io/RandomAccessMcaFile.java) read and write chunks with minimal memory overhead. + * [McaFileChunkIterator](src/main/java/io/github/ensgijs/nbt/mca/io/McaFileChunkIterator.java) iterate through the chunks in an mca file one after the other, again keeping memory overhead down. + * [McaFileStreamingWriter](src/main/java/io/github/ensgijs/nbt/mca/io/McaFileStreamingWriter.java) write an entire mca file one chunk at a time. + +#### Powerful Utilities +* [LongArrayTagPackedIntegers](src/main/java/io/github/ensgijs/nbt/mca/util/LongArrayTagPackedIntegers.java) a comprehensive solution to working with all long[] packed values (block palettes, biome palettes, Heightmaps) across all DataVersions. +* [PalettizedCuboid](src/main/java/io/github/ensgijs/nbt/mca/util/PalettizedCuboid.java) powerful class for working with block and biome palettes. This class supports all MC versions that use data palettes; I may add block and biome child classes to improve usability in the future. + +#### How to get started with 0.1-SNAPSHOT +This package is not yet published to a public repository - but using it from a local build is easy! + +Download the source, open the project in your IDE of choice (Intellij, etc), run the gradle rule +`gradle publishToMavenLocal` to build the source and stash the 0.1-SNAPSHOT in your local maven cache. + +Gradle: +``` +dependencies { + ... + implementation 'io.github.ensgijs:ens-nbt:0.1-SNAPSHOT' +} +``` + +Maven: +``` + + io.github.ensgijs + ens-nbt + 0.1-SNAPSHOT + compile + +``` + + --- -### Specification +### NBT Specification According to the [specification](https://minecraft.gamepedia.com/NBT_format), there are currently 13 different types of tags: | Tag class | Superclass | ID | Payload | | --------- | ---------- | -- | ----------- | -| [EndTag](src/main/java/net/querz/nbt/EndTag.java) | [Tag](src/main/java/net/querz/nbt/Tag.java) | 0 | None | -| [ByteTag](src/main/java/net/querz/nbt/ByteTag.java) | [NumberTag](src/main/java/net/querz/nbt/NumberTag.java) | 1 | 1 byte / 8 bits, signed | -| [ShortTag](src/main/java/net/querz/nbt/ShortTag.java) | [NumberTag](src/main/java/net/querz/nbt/NumberTag.java) | 2 | 2 bytes / 16 bits, signed, big endian | -| [IntTag](src/main/java/net/querz/nbt/IntTag.java) | [NumberTag](src/main/java/net/querz/nbt/NumberTag.java) | 3 | 4 bytes / 32 bits, signed, big endian | -| [LongTag](src/main/java/net/querz/nbt/LongTag.java) | [NumberTag](src/main/java/net/querz/nbt/NumberTag.java) | 4 | 8 bytes / 64 bits, signed, big endian | -| [FloatTag](src/main/java/net/querz/nbt/FloatTag.java) | [NumberTag](src/main/java/net/querz/nbt/NumberTag.java) | 5 | 4 bytes / 32 bits, signed, big endian, IEEE 754-2008, binary32 | -| [DoubleTag](src/main/java/net/querz/nbt/DoubleTag.java) | [NumberTag](src/main/java/net/querz/nbt/NumberTag.java) | 6 | 8 bytes / 64 bits, signed, big endian, IEEE 754-2008, binary64 | -| [ByteArrayTag](src/main/java/net/querz/nbt/ByteArrayTag.java) | [ArrayTag](src/main/java/net/querz/nbt/ArrayTag.java) | 7 | `IntTag` payload *size*, then *size* `ByteTag` payloads | -| [StringTag](src/main/java/net/querz/nbt/StringTag.java) | [Tag](src/main/java/net/querz/nbt/Tag.java) | 8 | `ShortTag` payload *length*, then a UTF-8 string with size *length* | -| [ListTag](src/main/java/net/querz/nbt/ListTag.java) | [Tag](src/main/java/net/querz/nbt/Tag.java) | 9 | `ByteTag` payload *tagId*, then `IntTag` payload *size*, then *size* tags' payloads, all of type *tagId* | -| [CompoundTag](src/main/java/net/querz/nbt/CompoundTag.java) | [Tag](src/main/java/net/querz/nbt/Tag.java) | 10 | Fully formed tags, followed by an `EndTag` | -| [IntArrayTag](src/main/java/net/querz/nbt/IntArrayTag.java) | [ArrayTag](src/main/java/net/querz/nbt/ArrayTag.java) | 11 | `IntTag` payload *size*, then *size* `IntTag` payloads | -| [LongArrayTag](src/main/java/net/querz/nbt/LongArrayTag.java) | [ArrayTag](src/main/java/net/querz/nbt/ArrayTag.java) | 12 | `IntTag` payload *size*, then *size* `LongTag` payloads | +| [EndTag](src/main/java/io/github/ensgijs/nbt/tag/EndTag.java) | [Tag](src/main/java/io/github/ensgijs/nbt/tag/Tag.java) | 0 | None | +| [ByteTag](src/main/java/io/github/ensgijs/nbt/tag/ByteTag.java) | [NumberTag](src/main/java/io/github/ensgijs/nbt/tag/NumberTag.java) | 1 | 1 byte / 8 bits, signed | +| [ShortTag](src/main/java/io/github/ensgijs/nbt/tag/ShortTag.java) | [NumberTag](src/main/java/io/github/ensgijs/nbt/tag/NumberTag.java) | 2 | 2 bytes / 16 bits, signed, big endian | +| [IntTag](src/main/java/io/github/ensgijs/nbt/tag/IntTag.java) | [NumberTag](src/main/java/io/github/ensgijs/nbt/tag/NumberTag.java) | 3 | 4 bytes / 32 bits, signed, big endian | +| [LongTag](src/main/java/io/github/ensgijs/nbt/tag/LongTag.java) | [NumberTag](src/main/java/io/github/ensgijs/nbt/tag/NumberTag.java) | 4 | 8 bytes / 64 bits, signed, big endian | +| [FloatTag](src/main/java/io/github/ensgijs/nbt/tag/FloatTag.java) | [NumberTag](src/main/java/io/github/ensgijs/nbt/tag/NumberTag.java) | 5 | 4 bytes / 32 bits, signed, big endian, IEEE 754-2008, binary32 | +| [DoubleTag](src/main/java/io/github/ensgijs/nbt/tag/DoubleTag.java) | [NumberTag](src/main/java/io/github/ensgijs/nbt/tag/NumberTag.java) | 6 | 8 bytes / 64 bits, signed, big endian, IEEE 754-2008, binary64 | +| [ByteArrayTag](src/main/java/io/github/ensgijs/nbt/tag/ByteArrayTag.java) | [ArrayTag](src/main/java/io/github/ensgijs/nbt/tag/ArrayTag.java) | 7 | `IntTag` payload *size*, then *size* `ByteTag` payloads | +| [StringTag](src/main/java/io/github/ensgijs/nbt/tag/StringTag.java) | [Tag](src/main/java/io/github/ensgijs/nbt/tag/Tag.java) | 8 | `ShortTag` payload *length*, then a UTF-8 string with size *length* | +| [ListTag](src/main/java/io/github/ensgijs/nbt/tag/ListTag.java) | [Tag](src/main/java/io/github/ensgijs/nbt/tag/Tag.java) | 9 | `ByteTag` payload *tagId*, then `IntTag` payload *size*, then *size* tags' payloads, all of type *tagId* | +| [CompoundTag](src/main/java/io/github/ensgijs/nbt/tag/CompoundTag.java) | [Tag](src/main/java/io/github/ensgijs/nbt/tag/Tag.java) | 10 | Fully formed tags, followed by an `EndTag` | +| [IntArrayTag](src/main/java/io/github/ensgijs/nbt/tag/IntArrayTag.java) | [ArrayTag](src/main/java/io/github/ensgijs/nbt/tag/ArrayTag.java) | 11 | `IntTag` payload *size*, then *size* `IntTag` payloads | +| [LongArrayTag](src/main/java/io/github/ensgijs/nbt/tag/LongArrayTag.java) | [ArrayTag](src/main/java/io/github/ensgijs/nbt/tag/ArrayTag.java) | 12 | `IntTag` payload *size*, then *size* `LongTag` payloads | * The `EndTag` is only used to mark the end of a `CompoundTag` in its serialized state or an empty `ListTag`. * The maximum depth of the NBT structure is 512. If the depth exceeds this restriction during serialization, deserialization or String conversion, a `MaxDepthReachedException` is thrown. This usually happens when a circular reference exists in the NBT structure. The NBT specification does not allow circular references, as there is no tag to represent this. + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 48a12040..b9cc14ca 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'com.github.kt3k.coveralls' version '2.4.0' - id 'maven' + id 'maven-publish' } apply plugin: 'java' @@ -8,45 +8,68 @@ apply plugin: 'eclipse' apply plugin: 'idea' apply plugin: 'jacoco' -group = 'net.querz.nbt' -archivesBaseName = 'nbt' -version = '6.1' -sourceCompatibility = '1.8' -targetCompatibility = '1.8' +group = 'io.github.ensgijs' +archivesBaseName = 'ens-nbt' +version = '0.1-SNAPSHOT' +sourceCompatibility = JavaLanguageVersion.of(17) +targetCompatibility = JavaLanguageVersion.of(17) compileJava.options.encoding = 'UTF-8' +compileTestJava.options.encoding = 'UTF-8' repositories { - jcenter() + mavenCentral() +} + +publishing { + repositories { + maven { + name = "GitHubPackages" + url = "https://maven.pkg.github.com/octocat/hello-world" + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } + } + publications { + maven(MavenPublication) { + groupId = System.getenv("group") + artifactId = 'ens-nbt' + version = System.getenv("version") + + from components.java + } + } } dependencies { - testCompile 'junit:junit:4.12' + testImplementation 'junit:junit:4.13.2' } javadoc { source = sourceSets.main.allJava - classpath = configurations.compile destinationDir = file("./doc/") - options.windowTitle 'NBT' + include 'io/github/ensgijs/nbt/**' + options.windowTitle 'NBT (ENS)' options.encoding 'UTF-8' options.linkSource true - options.links 'https://docs.oracle.com/javase/8/docs/api/' + options.links 'https://docs.oracle.com/javase/17/docs/api/' } task sourcesJar(type: Jar, dependsOn: classes) { - classifier = 'sources' + archiveClassifier = 'sources' from sourceSets.main.allSource } task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' + archiveClassifier = 'javadoc' from javadoc.destinationDir } jacocoTestReport { reports { - xml.enabled = true - html.enabled = true + xml.required = true + html.required = true } } @@ -54,3 +77,8 @@ artifacts { archives sourcesJar archives javadocJar } + +java { + withJavadocJar() + withSourcesJar() +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 62d4c053..7454180f 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 622ab64a..48c0a02c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index fbd7c515..c53aefaa 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index a9f778a7..107acd32 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,104 +1,89 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/java/net/querz/nbt/io/NBTInputStream.java b/src/main/java/io/github/ensgijs/nbt/io/BigEndianNbtInputStream.java similarity index 53% rename from src/main/java/net/querz/nbt/io/NBTInputStream.java rename to src/main/java/io/github/ensgijs/nbt/io/BigEndianNbtInputStream.java index dd69c3c7..ef3f832a 100644 --- a/src/main/java/net/querz/nbt/io/NBTInputStream.java +++ b/src/main/java/io/github/ensgijs/nbt/io/BigEndianNbtInputStream.java @@ -1,30 +1,29 @@ -package net.querz.nbt.io; - -import net.querz.io.ExceptionBiFunction; -import net.querz.io.MaxDepthIO; -import net.querz.nbt.tag.ByteArrayTag; -import net.querz.nbt.tag.ByteTag; -import net.querz.nbt.tag.CompoundTag; -import net.querz.nbt.tag.DoubleTag; -import net.querz.nbt.tag.EndTag; -import net.querz.nbt.tag.FloatTag; -import net.querz.nbt.tag.IntArrayTag; -import net.querz.nbt.tag.IntTag; -import net.querz.nbt.tag.ListTag; -import net.querz.nbt.tag.LongArrayTag; -import net.querz.nbt.tag.LongTag; -import net.querz.nbt.tag.ShortTag; -import net.querz.nbt.tag.StringTag; -import net.querz.nbt.tag.Tag; +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.ByteArrayTag; +import io.github.ensgijs.nbt.tag.ByteTag; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.DoubleTag; +import io.github.ensgijs.nbt.tag.EndTag; +import io.github.ensgijs.nbt.tag.FloatTag; +import io.github.ensgijs.nbt.tag.IntArrayTag; +import io.github.ensgijs.nbt.tag.IntTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.tag.LongArrayTag; +import io.github.ensgijs.nbt.tag.LongTag; +import io.github.ensgijs.nbt.tag.ShortTag; +import io.github.ensgijs.nbt.tag.StringTag; +import io.github.ensgijs.nbt.tag.Tag; import java.io.DataInputStream; import java.io.IOException; import java.io.InputStream; import java.util.HashMap; import java.util.Map; -public class NBTInputStream extends DataInputStream implements NBTInput, MaxDepthIO { +/** Use for Minecraft Java edition data. */ +public class BigEndianNbtInputStream extends DataInputStream implements NbtInput, MaxDepthIO { - private static Map, IOException>> readers = new HashMap<>(); + private static Map, IOException>> readers = new HashMap<>(); private static Map> idClassMapping = new HashMap<>(); static { @@ -37,18 +36,18 @@ public class NBTInputStream extends DataInputStream implements NBTInput, MaxDept put(DoubleTag.ID, (i, d) -> readDouble(i), DoubleTag.class); put(ByteArrayTag.ID, (i, d) -> readByteArray(i), ByteArrayTag.class); put(StringTag.ID, (i, d) -> readString(i), StringTag.class); - put(ListTag.ID, NBTInputStream::readListTag, ListTag.class); - put(CompoundTag.ID, NBTInputStream::readCompound, CompoundTag.class); + put(ListTag.ID, BigEndianNbtInputStream::readListTag, ListTag.class); + put(CompoundTag.ID, BigEndianNbtInputStream::readCompound, CompoundTag.class); put(IntArrayTag.ID, (i, d) -> readIntArray(i), IntArrayTag.class); put(LongArrayTag.ID, (i, d) -> readLongArray(i), LongArrayTag.class); } - private static void put(byte id, ExceptionBiFunction, IOException> reader, Class clazz) { + private static void put(byte id, ExceptionBiFunction, IOException> reader, Class clazz) { readers.put(id, reader); idClassMapping.put(id, clazz); } - public NBTInputStream(InputStream in) { + public BigEndianNbtInputStream(InputStream in) { super(in); } @@ -63,48 +62,48 @@ public Tag readRawTag(int maxDepth) throws IOException { } private Tag readTag(byte type, int maxDepth) throws IOException { - ExceptionBiFunction, IOException> f; + ExceptionBiFunction, IOException> f; if ((f = readers.get(type)) == null) { throw new IOException("invalid tag id \"" + type + "\""); } return f.accept(this, maxDepth); } - private static ByteTag readByte(NBTInputStream in) throws IOException { + private static ByteTag readByte(BigEndianNbtInputStream in) throws IOException { return new ByteTag(in.readByte()); } - private static ShortTag readShort(NBTInputStream in) throws IOException { + private static ShortTag readShort(BigEndianNbtInputStream in) throws IOException { return new ShortTag(in.readShort()); } - private static IntTag readInt(NBTInputStream in) throws IOException { + private static IntTag readInt(BigEndianNbtInputStream in) throws IOException { return new IntTag(in.readInt()); } - private static LongTag readLong(NBTInputStream in) throws IOException { + private static LongTag readLong(BigEndianNbtInputStream in) throws IOException { return new LongTag(in.readLong()); } - private static FloatTag readFloat(NBTInputStream in) throws IOException { + private static FloatTag readFloat(BigEndianNbtInputStream in) throws IOException { return new FloatTag(in.readFloat()); } - private static DoubleTag readDouble(NBTInputStream in) throws IOException { + private static DoubleTag readDouble(BigEndianNbtInputStream in) throws IOException { return new DoubleTag(in.readDouble()); } - private static StringTag readString(NBTInputStream in) throws IOException { + private static StringTag readString(BigEndianNbtInputStream in) throws IOException { return new StringTag(in.readUTF()); } - private static ByteArrayTag readByteArray(NBTInputStream in) throws IOException { + private static ByteArrayTag readByteArray(BigEndianNbtInputStream in) throws IOException { ByteArrayTag bat = new ByteArrayTag(new byte[in.readInt()]); in.readFully(bat.getValue()); return bat; } - private static IntArrayTag readIntArray(NBTInputStream in) throws IOException { + private static IntArrayTag readIntArray(BigEndianNbtInputStream in) throws IOException { int l = in.readInt(); int[] data = new int[l]; IntArrayTag iat = new IntArrayTag(data); @@ -114,7 +113,7 @@ private static IntArrayTag readIntArray(NBTInputStream in) throws IOException { return iat; } - private static LongArrayTag readLongArray(NBTInputStream in) throws IOException { + private static LongArrayTag readLongArray(BigEndianNbtInputStream in) throws IOException { int l = in.readInt(); long[] data = new long[l]; LongArrayTag iat = new LongArrayTag(data); @@ -124,7 +123,7 @@ private static LongArrayTag readLongArray(NBTInputStream in) throws IOException return iat; } - private static ListTag readListTag(NBTInputStream in, int maxDepth) throws IOException { + private static ListTag readListTag(BigEndianNbtInputStream in, int maxDepth) throws IOException { byte listType = in.readByte(); ListTag list = ListTag.createUnchecked(idClassMapping.get(listType)); int length = in.readInt(); @@ -137,7 +136,7 @@ private static ListTag readListTag(NBTInputStream in, int maxDepth) throws IO return list; } - private static CompoundTag readCompound(NBTInputStream in, int maxDepth) throws IOException { + private static CompoundTag readCompound(BigEndianNbtInputStream in, int maxDepth) throws IOException { CompoundTag comp = new CompoundTag(); for (int id = in.readByte() & 0xFF; id != 0; id = in.readByte() & 0xFF) { String key = in.readUTF(); diff --git a/src/main/java/net/querz/nbt/io/NBTOutputStream.java b/src/main/java/io/github/ensgijs/nbt/io/BigEndianNbtOutputStream.java similarity index 51% rename from src/main/java/net/querz/nbt/io/NBTOutputStream.java rename to src/main/java/io/github/ensgijs/nbt/io/BigEndianNbtOutputStream.java index ae1a16b0..f658dfd1 100644 --- a/src/main/java/net/querz/nbt/io/NBTOutputStream.java +++ b/src/main/java/io/github/ensgijs/nbt/io/BigEndianNbtOutputStream.java @@ -1,30 +1,29 @@ -package net.querz.nbt.io; - -import net.querz.io.ExceptionTriConsumer; -import net.querz.io.MaxDepthIO; -import net.querz.nbt.tag.ByteArrayTag; -import net.querz.nbt.tag.ByteTag; -import net.querz.nbt.tag.CompoundTag; -import net.querz.nbt.tag.DoubleTag; -import net.querz.nbt.tag.EndTag; -import net.querz.nbt.tag.FloatTag; -import net.querz.nbt.tag.IntArrayTag; -import net.querz.nbt.tag.IntTag; -import net.querz.nbt.tag.ListTag; -import net.querz.nbt.tag.LongArrayTag; -import net.querz.nbt.tag.LongTag; -import net.querz.nbt.tag.ShortTag; -import net.querz.nbt.tag.StringTag; -import net.querz.nbt.tag.Tag; +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.ByteArrayTag; +import io.github.ensgijs.nbt.tag.ByteTag; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.DoubleTag; +import io.github.ensgijs.nbt.tag.EndTag; +import io.github.ensgijs.nbt.tag.FloatTag; +import io.github.ensgijs.nbt.tag.IntArrayTag; +import io.github.ensgijs.nbt.tag.IntTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.tag.LongArrayTag; +import io.github.ensgijs.nbt.tag.LongTag; +import io.github.ensgijs.nbt.tag.ShortTag; +import io.github.ensgijs.nbt.tag.StringTag; +import io.github.ensgijs.nbt.tag.Tag; import java.io.DataOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.HashMap; import java.util.Map; -public class NBTOutputStream extends DataOutputStream implements NBTOutput, MaxDepthIO { +/** Use for Minecraft Java edition data. */ +public class BigEndianNbtOutputStream extends DataOutputStream implements NbtOutput, MaxDepthIO { - private static Map, Integer, IOException>> writers = new HashMap<>(); + private static Map, Integer, IOException>> writers = new HashMap<>(); private static Map, Byte> classIdMapping = new HashMap<>(); static { @@ -37,18 +36,18 @@ public class NBTOutputStream extends DataOutputStream implements NBTOutput, MaxD put(DoubleTag.ID, (o, t, d) -> writeDouble(o, t), DoubleTag.class); put(ByteArrayTag.ID, (o, t, d) -> writeByteArray(o, t), ByteArrayTag.class); put(StringTag.ID, (o, t, d) -> writeString(o, t), StringTag.class); - put(ListTag.ID, NBTOutputStream::writeList, ListTag.class); - put(CompoundTag.ID, NBTOutputStream::writeCompound, CompoundTag.class); + put(ListTag.ID, BigEndianNbtOutputStream::writeList, ListTag.class); + put(CompoundTag.ID, BigEndianNbtOutputStream::writeCompound, CompoundTag.class); put(IntArrayTag.ID, (o, t, d) -> writeIntArray(o, t), IntArrayTag.class); put(LongArrayTag.ID, (o, t, d) -> writeLongArray(o, t), LongArrayTag.class); } - private static void put(byte id, ExceptionTriConsumer, Integer, IOException> f, Class clazz) { + private static void put(byte id, ExceptionTriConsumer, Integer, IOException> f, Class clazz) { writers.put(id, f); classIdMapping.put(clazz, id); } - public NBTOutputStream(OutputStream out) { + public BigEndianNbtOutputStream(OutputStream out) { super(out); } @@ -69,7 +68,7 @@ public void writeTag(Tag tag, int maxDepth) throws IOException { } public void writeRawTag(Tag tag, int maxDepth) throws IOException { - ExceptionTriConsumer, Integer, IOException> f; + ExceptionTriConsumer, Integer, IOException> f; if ((f = writers.get(tag.getID())) == null) { throw new IOException("invalid tag \"" + tag.getID() + "\""); } @@ -84,54 +83,54 @@ static byte idFromClass(Class clazz) { return id; } - private static void writeByte(NBTOutputStream out, Tag tag) throws IOException { + private static void writeByte(BigEndianNbtOutputStream out, Tag tag) throws IOException { out.writeByte(((ByteTag) tag).asByte()); } - private static void writeShort(NBTOutputStream out, Tag tag) throws IOException { + private static void writeShort(BigEndianNbtOutputStream out, Tag tag) throws IOException { out.writeShort(((ShortTag) tag).asShort()); } - private static void writeInt(NBTOutputStream out, Tag tag) throws IOException { + private static void writeInt(BigEndianNbtOutputStream out, Tag tag) throws IOException { out.writeInt(((IntTag) tag).asInt()); } - private static void writeLong(NBTOutputStream out, Tag tag) throws IOException { + private static void writeLong(BigEndianNbtOutputStream out, Tag tag) throws IOException { out.writeLong(((LongTag) tag).asLong()); } - private static void writeFloat(NBTOutputStream out, Tag tag) throws IOException { + private static void writeFloat(BigEndianNbtOutputStream out, Tag tag) throws IOException { out.writeFloat(((FloatTag) tag).asFloat()); } - private static void writeDouble(NBTOutputStream out, Tag tag) throws IOException { + private static void writeDouble(BigEndianNbtOutputStream out, Tag tag) throws IOException { out.writeDouble(((DoubleTag) tag).asDouble()); } - private static void writeString(NBTOutputStream out, Tag tag) throws IOException { + private static void writeString(BigEndianNbtOutputStream out, Tag tag) throws IOException { out.writeUTF(((StringTag) tag).getValue()); } - private static void writeByteArray(NBTOutputStream out, Tag tag) throws IOException { + private static void writeByteArray(BigEndianNbtOutputStream out, Tag tag) throws IOException { out.writeInt(((ByteArrayTag) tag).length()); out.write(((ByteArrayTag) tag).getValue()); } - private static void writeIntArray(NBTOutputStream out, Tag tag) throws IOException { + private static void writeIntArray(BigEndianNbtOutputStream out, Tag tag) throws IOException { out.writeInt(((IntArrayTag) tag).length()); for (int i : ((IntArrayTag) tag).getValue()) { out.writeInt(i); } } - private static void writeLongArray(NBTOutputStream out, Tag tag) throws IOException { + private static void writeLongArray(BigEndianNbtOutputStream out, Tag tag) throws IOException { out.writeInt(((LongArrayTag) tag).length()); for (long l : ((LongArrayTag) tag).getValue()) { out.writeLong(l); } } - private static void writeList(NBTOutputStream out, Tag tag, int maxDepth) throws IOException { + private static void writeList(BigEndianNbtOutputStream out, Tag tag, int maxDepth) throws IOException { out.writeByte(idFromClass(((ListTag) tag).getTypeClass())); out.writeInt(((ListTag) tag).size()); for (Tag t : ((ListTag) tag)) { @@ -139,14 +138,14 @@ private static void writeList(NBTOutputStream out, Tag tag, int maxDepth) thr } } - private static void writeCompound(NBTOutputStream out, Tag tag, int maxDepth) throws IOException { - for (Map.Entry> entry : (CompoundTag) tag) { - if (entry.getValue().getID() == 0) { + private static void writeCompound(BigEndianNbtOutputStream out, Tag tag, int maxDepth) throws IOException { + for (NamedTag entry : (CompoundTag) tag) { + if (entry.getTag().getID() == 0) { throw new IOException("end tag not allowed"); } - out.writeByte(entry.getValue().getID()); - out.writeUTF(entry.getKey()); - out.writeRawTag(entry.getValue(), out.decrementMaxDepth(maxDepth)); + out.writeByte(entry.getTag().getID()); + out.writeUTF(entry.getName()); + out.writeRawTag(entry.getTag(), out.decrementMaxDepth(maxDepth)); } out.writeByte(0); } diff --git a/src/main/java/io/github/ensgijs/nbt/io/BinaryNbtDeserializer.java b/src/main/java/io/github/ensgijs/nbt/io/BinaryNbtDeserializer.java new file mode 100644 index 00000000..f6ffc4b9 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/io/BinaryNbtDeserializer.java @@ -0,0 +1,35 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.Tag; +import java.io.IOException; +import java.io.InputStream; + +public class BinaryNbtDeserializer implements Deserializer { + private CompressionType compression; + private boolean littleEndian; + + public BinaryNbtDeserializer(CompressionType compression) { + this(compression, false); + } + + /** + * @param compression Compressions strategy to use. + * @param littleEndian Minecraft bedrock data is stored in little endian while MC Java is stored big endian. + */ + public BinaryNbtDeserializer(CompressionType compression, boolean littleEndian) { + this.compression = compression; + this.littleEndian = littleEndian; + } + + @Override + public NamedTag fromStream(InputStream stream) throws IOException { + NbtInput nbtIn; + InputStream input = compression.decompress(stream); + if (!littleEndian) { + nbtIn = new BigEndianNbtInputStream(input); + } else { + nbtIn = new LittleEndianNbtInputStream(input); + } + return nbtIn.readTag(Tag.DEFAULT_MAX_DEPTH); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/io/BinaryNbtHelpers.java b/src/main/java/io/github/ensgijs/nbt/io/BinaryNbtHelpers.java new file mode 100644 index 00000000..54f1a84f --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/io/BinaryNbtHelpers.java @@ -0,0 +1,128 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.Tag; + +import java.io.*; +import java.nio.file.Path; + +/** Utilities for reading and writing {@link Tag}'s to and from binary NBT data. */ +public final class BinaryNbtHelpers { + private BinaryNbtHelpers() {} + + // + public static Path write(NamedTag tag, File file, CompressionType compression) throws IOException { + try (FileOutputStream fos = new FileOutputStream(file)) { + new BinaryNbtSerializer(compression).toStream(tag, fos); + } + return file.toPath(); + } + + public static Path write(NamedTag tag, String file, CompressionType compression) throws IOException { + return write(tag, new File(file), compression); + } + + public static Path write(NamedTag tag, Path path, CompressionType compression) throws IOException { + return write(tag, path.toFile(), compression); + } + + public static Path write(Tag tag, File file, CompressionType compression) throws IOException { + return write(new NamedTag(null, tag), file, compression); + } + + public static Path write(Tag tag, String file, CompressionType compression) throws IOException { + return write(new NamedTag(null, tag), new File(file), compression); + } + + public static Path write(Tag tag, Path path, CompressionType compression) throws IOException { + return write(new NamedTag(null, tag), path.toFile(), compression); + } + + public static NamedTag read(File file, CompressionType compression) throws IOException { + try (FileInputStream fis = new FileInputStream(file)) { + return new BinaryNbtDeserializer(compression).fromStream(fis); + } + } + + public static NamedTag read(String file, CompressionType compression) throws IOException { + return read(new File(file), compression); + } + + public static NamedTag read(Path path, CompressionType compression) throws IOException { + return read(path.toFile(), compression); + } + + /** + * Note that Paper's ItemStack#serializeAsBytes returns binary nbt data with {@link CompressionType#GZIP}. + */ + public static NamedTag deserializeBytes(byte[] bytes, CompressionType compression) throws IOException { + return new BinaryNbtDeserializer(compression).fromStream(new ByteArrayInputStream(bytes)); + } + + /** + * Note that Paper's ItemStack#serializeAsBytes returns binary nbt data with {@link CompressionType#GZIP}. + */ + public static byte[] serializeAsBytes(Tag tag, CompressionType compression) throws IOException { + return serializeAsBytes(new NamedTag(null, tag), compression); + } + + /** + * Note that Paper's ItemStack#serializeAsBytes returns binary nbt data with {@link CompressionType#GZIP}. + */ + public static byte[] serializeAsBytes(NamedTag tag, CompressionType compression) throws IOException { + try (ByteArrayOutputStream fos = new ByteArrayOutputStream(1024)) { + new BinaryNbtSerializer(compression).toStream(tag, fos); + return fos.toByteArray(); + } + } + + /** + * Note that Paper's ItemStack#serializeAsBytes returns binary nbt data with {@link CompressionType#GZIP}. + */ + public static NamedTag serializeAsBytes(byte[] bytes, CompressionType compression) throws IOException { + return new BinaryNbtDeserializer(compression).fromStream(new ByteArrayInputStream(bytes)); + } + // + + // + public static Path writeLittleEndian(NamedTag tag, File file, CompressionType compression) throws IOException { + try (FileOutputStream fos = new FileOutputStream(file)) { + new BinaryNbtSerializer(compression, true).toStream(tag, fos); + } + return file.toPath(); + } + + public static Path writeLittleEndian(NamedTag tag, String file, CompressionType compression) throws IOException { + return writeLittleEndian(tag, new File(file), compression); + } + + public static Path writeLittleEndian(NamedTag tag, Path path, CompressionType compression) throws IOException { + return writeLittleEndian(tag, path.toFile(), compression); + } + + public static Path writeLittleEndian(Tag tag, File file, CompressionType compression) throws IOException { + return writeLittleEndian(new NamedTag(null, tag), file, compression); + } + + public static Path writeLittleEndian(Tag tag, String file, CompressionType compression) throws IOException { + return writeLittleEndian(new NamedTag(null, tag), new File(file), compression); + } + + public static Path writeLittleEndian(Tag tag, Path path, CompressionType compression) throws IOException { + return writeLittleEndian(new NamedTag(null, tag), path.toFile(), compression); + } + + public static NamedTag readLittleEndian(File file, CompressionType compression) throws IOException { + try (FileInputStream fis = new FileInputStream(file)) { + return new BinaryNbtDeserializer(compression, true).fromStream(fis); + } + } + + public static NamedTag readLittleEndian(String file, CompressionType compression) throws IOException { + return readLittleEndian(new File(file), compression); + } + + public static NamedTag readLittleEndian(Path path, CompressionType compression) throws IOException { + return readLittleEndian(path.toFile(), compression); + } + // +} diff --git a/src/main/java/io/github/ensgijs/nbt/io/BinaryNbtSerializer.java b/src/main/java/io/github/ensgijs/nbt/io/BinaryNbtSerializer.java new file mode 100644 index 00000000..c7d59007 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/io/BinaryNbtSerializer.java @@ -0,0 +1,33 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.Tag; +import java.io.IOException; +import java.io.OutputStream; + +public class BinaryNbtSerializer implements Serializer { + private CompressionType compression; + private boolean littleEndian; + + public BinaryNbtSerializer(CompressionType compression) { + this(compression, false); + } + + public BinaryNbtSerializer(CompressionType compression, boolean littleEndian) { + this.compression = compression; + this.littleEndian = littleEndian; + } + + @Override + public void toStream(NamedTag object, OutputStream out) throws IOException { + NbtOutput nbtOut; + OutputStream output = compression.compress(out); + if (!littleEndian) { + nbtOut = new BigEndianNbtOutputStream(output); + } else { + nbtOut = new LittleEndianNbtOutputStream(output); + } + nbtOut.writeTag(object, Tag.DEFAULT_MAX_DEPTH); + compression.finish(output); + nbtOut.flush(); + } +} diff --git a/src/main/java/net/querz/mca/CompressionType.java b/src/main/java/io/github/ensgijs/nbt/io/CompressionType.java similarity index 50% rename from src/main/java/net/querz/mca/CompressionType.java rename to src/main/java/io/github/ensgijs/nbt/io/CompressionType.java index 9ae41d4c..aec5e804 100644 --- a/src/main/java/net/querz/mca/CompressionType.java +++ b/src/main/java/io/github/ensgijs/nbt/io/CompressionType.java @@ -1,4 +1,4 @@ -package net.querz.mca; +package io.github.ensgijs.nbt.io; import java.io.IOException; import java.io.InputStream; @@ -9,18 +9,24 @@ import java.util.zip.InflaterInputStream; public enum CompressionType { - NONE(0, t -> t, t -> t), + /** Most used compression type for binary nbt data files. */ GZIP(1, GZIPOutputStream::new, GZIPInputStream::new), + /** Default compression type used by the vanilla jar to store chunks in mca files. */ ZLIB(2, DeflaterOutputStream::new, InflaterInputStream::new); - private byte id; - private ExceptionFunction compressor; - private ExceptionFunction decompressor; + @FunctionalInterface + private interface IOExceptionFunction { + R accept(T t) throws IOException; + } + + private final byte id; + private final IOExceptionFunction compressor; + private final IOExceptionFunction decompressor; CompressionType(int id, - ExceptionFunction compressor, - ExceptionFunction decompressor) { + IOExceptionFunction compressor, + IOExceptionFunction decompressor) { this.id = (byte) id; this.compressor = compressor; this.decompressor = decompressor; @@ -38,6 +44,16 @@ public InputStream decompress(InputStream in) throws IOException { return decompressor.accept(in); } + /** + * Finishes writing compressed data to the output stream without closing it. + * @exception IOException if an I/O error has occurred + */ + public void finish(OutputStream out) throws IOException { + if (out instanceof DeflaterOutputStream) { + ((DeflaterOutputStream) out).finish(); + } + } + public static CompressionType getFromID(byte id) { for (CompressionType c : CompressionType.values()) { if (c.id == id) { diff --git a/src/main/java/net/querz/io/Deserializer.java b/src/main/java/io/github/ensgijs/nbt/io/Deserializer.java similarity index 96% rename from src/main/java/net/querz/io/Deserializer.java rename to src/main/java/io/github/ensgijs/nbt/io/Deserializer.java index 1849fe9d..82acb10a 100644 --- a/src/main/java/net/querz/io/Deserializer.java +++ b/src/main/java/io/github/ensgijs/nbt/io/Deserializer.java @@ -1,4 +1,4 @@ -package net.querz.io; +package io.github.ensgijs.nbt.io; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; @@ -37,6 +37,4 @@ default T fromURL(URL url) throws IOException { return fromStream(stream); } } - - } diff --git a/src/main/java/net/querz/io/ExceptionBiFunction.java b/src/main/java/io/github/ensgijs/nbt/io/ExceptionBiFunction.java similarity index 78% rename from src/main/java/net/querz/io/ExceptionBiFunction.java rename to src/main/java/io/github/ensgijs/nbt/io/ExceptionBiFunction.java index c34dba72..120915f3 100644 --- a/src/main/java/net/querz/io/ExceptionBiFunction.java +++ b/src/main/java/io/github/ensgijs/nbt/io/ExceptionBiFunction.java @@ -1,4 +1,4 @@ -package net.querz.io; +package io.github.ensgijs.nbt.io; @FunctionalInterface public interface ExceptionBiFunction { diff --git a/src/main/java/net/querz/io/ExceptionTriConsumer.java b/src/main/java/io/github/ensgijs/nbt/io/ExceptionTriConsumer.java similarity index 79% rename from src/main/java/net/querz/io/ExceptionTriConsumer.java rename to src/main/java/io/github/ensgijs/nbt/io/ExceptionTriConsumer.java index d49ccc90..4d5a2b7a 100644 --- a/src/main/java/net/querz/io/ExceptionTriConsumer.java +++ b/src/main/java/io/github/ensgijs/nbt/io/ExceptionTriConsumer.java @@ -1,4 +1,4 @@ -package net.querz.io; +package io.github.ensgijs.nbt.io; @FunctionalInterface public interface ExceptionTriConsumer { diff --git a/src/main/java/net/querz/nbt/io/LittleEndianNBTInputStream.java b/src/main/java/io/github/ensgijs/nbt/io/LittleEndianNbtInputStream.java similarity index 72% rename from src/main/java/net/querz/nbt/io/LittleEndianNBTInputStream.java rename to src/main/java/io/github/ensgijs/nbt/io/LittleEndianNbtInputStream.java index 7c7d8ed2..a53bebb0 100644 --- a/src/main/java/net/querz/nbt/io/LittleEndianNBTInputStream.java +++ b/src/main/java/io/github/ensgijs/nbt/io/LittleEndianNbtInputStream.java @@ -1,21 +1,19 @@ -package net.querz.nbt.io; - -import net.querz.io.ExceptionBiFunction; -import net.querz.io.MaxDepthIO; -import net.querz.nbt.tag.ByteArrayTag; -import net.querz.nbt.tag.ByteTag; -import net.querz.nbt.tag.CompoundTag; -import net.querz.nbt.tag.DoubleTag; -import net.querz.nbt.tag.EndTag; -import net.querz.nbt.tag.FloatTag; -import net.querz.nbt.tag.IntArrayTag; -import net.querz.nbt.tag.IntTag; -import net.querz.nbt.tag.ListTag; -import net.querz.nbt.tag.LongArrayTag; -import net.querz.nbt.tag.LongTag; -import net.querz.nbt.tag.ShortTag; -import net.querz.nbt.tag.StringTag; -import net.querz.nbt.tag.Tag; +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.ByteArrayTag; +import io.github.ensgijs.nbt.tag.ByteTag; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.DoubleTag; +import io.github.ensgijs.nbt.tag.EndTag; +import io.github.ensgijs.nbt.tag.FloatTag; +import io.github.ensgijs.nbt.tag.IntArrayTag; +import io.github.ensgijs.nbt.tag.IntTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.tag.LongArrayTag; +import io.github.ensgijs.nbt.tag.LongTag; +import io.github.ensgijs.nbt.tag.ShortTag; +import io.github.ensgijs.nbt.tag.StringTag; +import io.github.ensgijs.nbt.tag.Tag; import java.io.Closeable; import java.io.DataInput; import java.io.DataInputStream; @@ -25,11 +23,12 @@ import java.util.HashMap; import java.util.Map; -public class LittleEndianNBTInputStream implements DataInput, NBTInput, MaxDepthIO, Closeable { +/** Use for Minecraft Bedrock edition data. */ +public class LittleEndianNbtInputStream implements DataInput, NbtInput, MaxDepthIO, Closeable { private final DataInputStream input; - private static Map, IOException>> readers = new HashMap<>(); + private static Map, IOException>> readers = new HashMap<>(); private static Map> idClassMapping = new HashMap<>(); static { @@ -42,22 +41,22 @@ public class LittleEndianNBTInputStream implements DataInput, NBTInput, MaxDepth put(DoubleTag.ID, (i, d) -> readDouble(i), DoubleTag.class); put(ByteArrayTag.ID, (i, d) -> readByteArray(i), ByteArrayTag.class); put(StringTag.ID, (i, d) -> readString(i), StringTag.class); - put(ListTag.ID, LittleEndianNBTInputStream::readListTag, ListTag.class); - put(CompoundTag.ID, LittleEndianNBTInputStream::readCompound, CompoundTag.class); + put(ListTag.ID, LittleEndianNbtInputStream::readListTag, ListTag.class); + put(CompoundTag.ID, LittleEndianNbtInputStream::readCompound, CompoundTag.class); put(IntArrayTag.ID, (i, d) -> readIntArray(i), IntArrayTag.class); put(LongArrayTag.ID, (i, d) -> readLongArray(i), LongArrayTag.class); } - private static void put(byte id, ExceptionBiFunction, IOException> reader, Class clazz) { + private static void put(byte id, ExceptionBiFunction, IOException> reader, Class clazz) { readers.put(id, reader); idClassMapping.put(id, clazz); } - public LittleEndianNBTInputStream(InputStream in) { + public LittleEndianNbtInputStream(InputStream in) { input = new DataInputStream(in); } - public LittleEndianNBTInputStream(DataInputStream in) { + public LittleEndianNbtInputStream(DataInputStream in) { input = in; } @@ -72,48 +71,48 @@ public Tag readRawTag(int maxDepth) throws IOException { } private Tag readTag(byte type, int maxDepth) throws IOException { - ExceptionBiFunction, IOException> f; + ExceptionBiFunction, IOException> f; if ((f = readers.get(type)) == null) { throw new IOException("invalid tag id \"" + type + "\""); } return f.accept(this, maxDepth); } - private static ByteTag readByte(LittleEndianNBTInputStream in) throws IOException { + private static ByteTag readByte(LittleEndianNbtInputStream in) throws IOException { return new ByteTag(in.readByte()); } - private static ShortTag readShort(LittleEndianNBTInputStream in) throws IOException { + private static ShortTag readShort(LittleEndianNbtInputStream in) throws IOException { return new ShortTag(in.readShort()); } - private static IntTag readInt(LittleEndianNBTInputStream in) throws IOException { + private static IntTag readInt(LittleEndianNbtInputStream in) throws IOException { return new IntTag(in.readInt()); } - private static LongTag readLong(LittleEndianNBTInputStream in) throws IOException { + private static LongTag readLong(LittleEndianNbtInputStream in) throws IOException { return new LongTag(in.readLong()); } - private static FloatTag readFloat(LittleEndianNBTInputStream in) throws IOException { + private static FloatTag readFloat(LittleEndianNbtInputStream in) throws IOException { return new FloatTag(in.readFloat()); } - private static DoubleTag readDouble(LittleEndianNBTInputStream in) throws IOException { + private static DoubleTag readDouble(LittleEndianNbtInputStream in) throws IOException { return new DoubleTag(in.readDouble()); } - private static StringTag readString(LittleEndianNBTInputStream in) throws IOException { + private static StringTag readString(LittleEndianNbtInputStream in) throws IOException { return new StringTag(in.readUTF()); } - private static ByteArrayTag readByteArray(LittleEndianNBTInputStream in) throws IOException { + private static ByteArrayTag readByteArray(LittleEndianNbtInputStream in) throws IOException { ByteArrayTag bat = new ByteArrayTag(new byte[in.readInt()]); in.readFully(bat.getValue()); return bat; } - private static IntArrayTag readIntArray(LittleEndianNBTInputStream in) throws IOException { + private static IntArrayTag readIntArray(LittleEndianNbtInputStream in) throws IOException { int l = in.readInt(); int[] data = new int[l]; IntArrayTag iat = new IntArrayTag(data); @@ -123,7 +122,7 @@ private static IntArrayTag readIntArray(LittleEndianNBTInputStream in) throws IO return iat; } - private static LongArrayTag readLongArray(LittleEndianNBTInputStream in) throws IOException { + private static LongArrayTag readLongArray(LittleEndianNbtInputStream in) throws IOException { int l = in.readInt(); long[] data = new long[l]; LongArrayTag iat = new LongArrayTag(data); @@ -133,7 +132,7 @@ private static LongArrayTag readLongArray(LittleEndianNBTInputStream in) throws return iat; } - private static ListTag readListTag(LittleEndianNBTInputStream in, int maxDepth) throws IOException { + private static ListTag readListTag(LittleEndianNbtInputStream in, int maxDepth) throws IOException { byte listType = in.readByte(); ListTag list = ListTag.createUnchecked(idClassMapping.get(listType)); int length = in.readInt(); @@ -146,7 +145,7 @@ private static ListTag readListTag(LittleEndianNBTInputStream in, int maxDept return list; } - private static CompoundTag readCompound(LittleEndianNBTInputStream in, int maxDepth) throws IOException { + private static CompoundTag readCompound(LittleEndianNbtInputStream in, int maxDepth) throws IOException { CompoundTag comp = new CompoundTag(); for (int id = in.readByte() & 0xFF; id != 0; id = in.readByte() & 0xFF) { String key = in.readUTF(); diff --git a/src/main/java/net/querz/nbt/io/LittleEndianNBTOutputStream.java b/src/main/java/io/github/ensgijs/nbt/io/LittleEndianNbtOutputStream.java similarity index 70% rename from src/main/java/net/querz/nbt/io/LittleEndianNBTOutputStream.java rename to src/main/java/io/github/ensgijs/nbt/io/LittleEndianNbtOutputStream.java index 9cda01b2..3d380e1a 100644 --- a/src/main/java/net/querz/nbt/io/LittleEndianNBTOutputStream.java +++ b/src/main/java/io/github/ensgijs/nbt/io/LittleEndianNbtOutputStream.java @@ -1,21 +1,19 @@ -package net.querz.nbt.io; - -import net.querz.io.ExceptionTriConsumer; -import net.querz.io.MaxDepthIO; -import net.querz.nbt.tag.ByteArrayTag; -import net.querz.nbt.tag.ByteTag; -import net.querz.nbt.tag.CompoundTag; -import net.querz.nbt.tag.DoubleTag; -import net.querz.nbt.tag.EndTag; -import net.querz.nbt.tag.FloatTag; -import net.querz.nbt.tag.IntArrayTag; -import net.querz.nbt.tag.IntTag; -import net.querz.nbt.tag.ListTag; -import net.querz.nbt.tag.LongArrayTag; -import net.querz.nbt.tag.LongTag; -import net.querz.nbt.tag.ShortTag; -import net.querz.nbt.tag.StringTag; -import net.querz.nbt.tag.Tag; +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.ByteArrayTag; +import io.github.ensgijs.nbt.tag.ByteTag; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.DoubleTag; +import io.github.ensgijs.nbt.tag.EndTag; +import io.github.ensgijs.nbt.tag.FloatTag; +import io.github.ensgijs.nbt.tag.IntArrayTag; +import io.github.ensgijs.nbt.tag.IntTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.tag.LongArrayTag; +import io.github.ensgijs.nbt.tag.LongTag; +import io.github.ensgijs.nbt.tag.ShortTag; +import io.github.ensgijs.nbt.tag.StringTag; +import io.github.ensgijs.nbt.tag.Tag; import java.io.Closeable; import java.io.DataOutput; import java.io.DataOutputStream; @@ -25,11 +23,12 @@ import java.util.HashMap; import java.util.Map; -public class LittleEndianNBTOutputStream implements DataOutput, NBTOutput, MaxDepthIO, Closeable { +/** Use for Minecraft Bedrock edition data. */ +public class LittleEndianNbtOutputStream implements DataOutput, NbtOutput, MaxDepthIO, Closeable { private final DataOutputStream output; - private static Map, Integer, IOException>> writers = new HashMap<>(); + private static Map, Integer, IOException>> writers = new HashMap<>(); private static Map, Byte> classIdMapping = new HashMap<>(); static { @@ -42,22 +41,22 @@ public class LittleEndianNBTOutputStream implements DataOutput, NBTOutput, MaxDe put(DoubleTag.ID, (o, t, d) -> writeDouble(o, t), DoubleTag.class); put(ByteArrayTag.ID, (o, t, d) -> writeByteArray(o, t), ByteArrayTag.class); put(StringTag.ID, (o, t, d) -> writeString(o, t), StringTag.class); - put(ListTag.ID, LittleEndianNBTOutputStream::writeList, ListTag.class); - put(CompoundTag.ID, LittleEndianNBTOutputStream::writeCompound, CompoundTag.class); + put(ListTag.ID, LittleEndianNbtOutputStream::writeList, ListTag.class); + put(CompoundTag.ID, LittleEndianNbtOutputStream::writeCompound, CompoundTag.class); put(IntArrayTag.ID, (o, t, d) -> writeIntArray(o, t), IntArrayTag.class); put(LongArrayTag.ID, (o, t, d) -> writeLongArray(o, t), LongArrayTag.class); } - private static void put(byte id, ExceptionTriConsumer, Integer, IOException> f, Class clazz) { + private static void put(byte id, ExceptionTriConsumer, Integer, IOException> f, Class clazz) { writers.put(id, f); classIdMapping.put(clazz, id); } - public LittleEndianNBTOutputStream(OutputStream out) { + public LittleEndianNbtOutputStream(OutputStream out) { output = new DataOutputStream(out); } - public LittleEndianNBTOutputStream(DataOutputStream out) { + public LittleEndianNbtOutputStream(DataOutputStream out) { output = out; } @@ -78,7 +77,7 @@ public void writeTag(Tag tag, int maxDepth) throws IOException { } public void writeRawTag(Tag tag, int maxDepth) throws IOException { - ExceptionTriConsumer, Integer, IOException> f; + ExceptionTriConsumer, Integer, IOException> f; if ((f = writers.get(tag.getID())) == null) { throw new IOException("invalid tag \"" + tag.getID() + "\""); } @@ -93,54 +92,54 @@ static byte idFromClass(Class clazz) { return id; } - private static void writeByte(LittleEndianNBTOutputStream out, Tag tag) throws IOException { + private static void writeByte(LittleEndianNbtOutputStream out, Tag tag) throws IOException { out.writeByte(((ByteTag) tag).asByte()); } - private static void writeShort(LittleEndianNBTOutputStream out, Tag tag) throws IOException { + private static void writeShort(LittleEndianNbtOutputStream out, Tag tag) throws IOException { out.writeShort(((ShortTag) tag).asShort()); } - private static void writeInt(LittleEndianNBTOutputStream out, Tag tag) throws IOException { + private static void writeInt(LittleEndianNbtOutputStream out, Tag tag) throws IOException { out.writeInt(((IntTag) tag).asInt()); } - private static void writeLong(LittleEndianNBTOutputStream out, Tag tag) throws IOException { + private static void writeLong(LittleEndianNbtOutputStream out, Tag tag) throws IOException { out.writeLong(((LongTag) tag).asLong()); } - private static void writeFloat(LittleEndianNBTOutputStream out, Tag tag) throws IOException { + private static void writeFloat(LittleEndianNbtOutputStream out, Tag tag) throws IOException { out.writeFloat(((FloatTag) tag).asFloat()); } - private static void writeDouble(LittleEndianNBTOutputStream out, Tag tag) throws IOException { + private static void writeDouble(LittleEndianNbtOutputStream out, Tag tag) throws IOException { out.writeDouble(((DoubleTag) tag).asDouble()); } - private static void writeString(LittleEndianNBTOutputStream out, Tag tag) throws IOException { + private static void writeString(LittleEndianNbtOutputStream out, Tag tag) throws IOException { out.writeUTF(((StringTag) tag).getValue()); } - private static void writeByteArray(LittleEndianNBTOutputStream out, Tag tag) throws IOException { + private static void writeByteArray(LittleEndianNbtOutputStream out, Tag tag) throws IOException { out.writeInt(((ByteArrayTag) tag).length()); out.write(((ByteArrayTag) tag).getValue()); } - private static void writeIntArray(LittleEndianNBTOutputStream out, Tag tag) throws IOException { + private static void writeIntArray(LittleEndianNbtOutputStream out, Tag tag) throws IOException { out.writeInt(((IntArrayTag) tag).length()); for (int i : ((IntArrayTag) tag).getValue()) { out.writeInt(i); } } - private static void writeLongArray(LittleEndianNBTOutputStream out, Tag tag) throws IOException { + private static void writeLongArray(LittleEndianNbtOutputStream out, Tag tag) throws IOException { out.writeInt(((LongArrayTag) tag).length()); for (long l : ((LongArrayTag) tag).getValue()) { out.writeLong(l); } } - private static void writeList(LittleEndianNBTOutputStream out, Tag tag, int maxDepth) throws IOException { + private static void writeList(LittleEndianNbtOutputStream out, Tag tag, int maxDepth) throws IOException { out.writeByte(idFromClass(((ListTag) tag).getTypeClass())); out.writeInt(((ListTag) tag).size()); for (Tag t : ((ListTag) tag)) { @@ -148,14 +147,14 @@ private static void writeList(LittleEndianNBTOutputStream out, Tag tag, int m } } - private static void writeCompound(LittleEndianNBTOutputStream out, Tag tag, int maxDepth) throws IOException { - for (Map.Entry> entry : (CompoundTag) tag) { - if (entry.getValue().getID() == 0) { + private static void writeCompound(LittleEndianNbtOutputStream out, Tag tag, int maxDepth) throws IOException { + for (NamedTag entry : (CompoundTag) tag) { + if (entry.getTag().getID() == 0) { throw new IOException("end tag not allowed"); } - out.writeByte(entry.getValue().getID()); - out.writeUTF(entry.getKey()); - out.writeRawTag(entry.getValue(), out.decrementMaxDepth(maxDepth)); + out.writeByte(entry.getTag().getID()); + out.writeUTF(entry.getName()); + out.writeRawTag(entry.getTag(), out.decrementMaxDepth(maxDepth)); } out.writeByte(0); } diff --git a/src/main/java/net/querz/io/MaxDepthIO.java b/src/main/java/io/github/ensgijs/nbt/io/MaxDepthIO.java similarity index 90% rename from src/main/java/net/querz/io/MaxDepthIO.java rename to src/main/java/io/github/ensgijs/nbt/io/MaxDepthIO.java index 0a5fc3e7..70cf438a 100644 --- a/src/main/java/net/querz/io/MaxDepthIO.java +++ b/src/main/java/io/github/ensgijs/nbt/io/MaxDepthIO.java @@ -1,4 +1,4 @@ -package net.querz.io; +package io.github.ensgijs.nbt.io; public interface MaxDepthIO { diff --git a/src/main/java/net/querz/io/MaxDepthReachedException.java b/src/main/java/io/github/ensgijs/nbt/io/MaxDepthReachedException.java similarity index 88% rename from src/main/java/net/querz/io/MaxDepthReachedException.java rename to src/main/java/io/github/ensgijs/nbt/io/MaxDepthReachedException.java index eb903228..5a5d3bd6 100644 --- a/src/main/java/net/querz/io/MaxDepthReachedException.java +++ b/src/main/java/io/github/ensgijs/nbt/io/MaxDepthReachedException.java @@ -1,4 +1,4 @@ -package net.querz.io; +package io.github.ensgijs.nbt.io; /** * Exception indicating that the maximum (de-)serialization depth has been reached. diff --git a/src/main/java/io/github/ensgijs/nbt/io/NamedTag.java b/src/main/java/io/github/ensgijs/nbt/io/NamedTag.java new file mode 100644 index 00000000..2062780e --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/io/NamedTag.java @@ -0,0 +1,131 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.Tag; +import io.github.ensgijs.nbt.util.ArgValidator; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +public class NamedTag implements Cloneable, Comparable { + private static final Pattern TAG_NAME_NON_QUOTE_PATTERN = Pattern.compile("^[a-zA-Z0-9_+\\-]+$"); + private static final Predicate IS_INTEGER_STRING = Pattern.compile("^(?:\\+|-)?\\d+$").asPredicate(); + + private String name; + private Tag tag; + + protected NamedTag() {} + + /** + * Copy constructor. Performs a deep copy of the given other NamedTag (calls {@code other.getTag().clone()}). + * Other's name may be null but the tag may not be. + */ + public NamedTag(NamedTag other) { + ArgValidator.requireValue(other, "other"); + ArgValidator.requireValue(other.tag, "other.tag"); + this.name = other.name; + this.tag = other.getTag().clone(); + } + + /** + * Creates a NamedTag that references the given tag. + * @param name nullable name + * @param tag non-null tag + */ + public NamedTag(String name, Tag tag) { + ArgValidator.requireValue(tag, "tag"); + this.name = name; + this.tag = tag; + } + + public NamedTag(Map.Entry> entry) { + this(entry.getKey(), Objects.requireNonNull(entry.getValue())); + } + + /** nullable */ + public void setName(String name) { + this.name = name; + } + + /** must be non-null */ + public void setTag(Tag tag) { + ArgValidator.requireValue(tag, "tag"); + this.tag = tag; + } + + /** nullable */ + public String getName() { + return name; + } + + /** non-null */ + public Tag getTag() { + return tag; + } + + /** non-null */ + @SuppressWarnings("unchecked") + public > T getTagAutoCast() { + return (T) getTag(); + } + + /** + * Wraps the name in quotes if it contains anything other than ascii letters (a-z), numbers, underscore, or dash. + * If name is null, then null is returned. + */ + public String getEscapedName() { + return escapeName(getName()); + } + + public static String escapeName(String name) { + if (name != null && !TAG_NAME_NON_QUOTE_PATTERN.matcher(name).matches()) { + StringBuilder sb = new StringBuilder(); + sb.append('"'); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c == '\\' || c == '"') { + sb.append('\\'); + } + sb.append(c); + } + sb.append('"'); + return sb.toString(); + } + return name; + } + + + @Override + public boolean equals(Object o) { + if (!(o instanceof NamedTag)) return false; + NamedTag other = (NamedTag) o; + return Objects.equals(this.getName(), other.getName()) && + Objects.equals(this.getTag(), other.getTag()); + } + + public static int compare(NamedTag o1, NamedTag o2) { + if (o1 == o2) return 0; + if (o1 == null) return -1; + if (o2 == null) return 1; + String n1Lower = o1.getName().toLowerCase(Locale.ENGLISH); + String n2Lower = o2.getName().toLowerCase(Locale.ENGLISH); + if (IS_INTEGER_STRING.test(n1Lower) && IS_INTEGER_STRING.test(n2Lower)) { + return Long.compare(Long.parseLong(n1Lower), Long.parseLong(n2Lower)); + } else { + int result = n1Lower.compareTo(n2Lower); + return result != 0 ? result : o1.getName().compareTo(o2.getName()); + } + } + + @Override + public NamedTag clone() { + return new NamedTag(this); + } + + @Override + public int compareTo(NamedTag o) { + return compare(this, o); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/io/NbtInput.java b/src/main/java/io/github/ensgijs/nbt/io/NbtInput.java new file mode 100644 index 00000000..a3f3db72 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/io/NbtInput.java @@ -0,0 +1,13 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.Tag; + +import java.io.IOException; + +/** If there is no content to parse (aka empty file) then null should be returned. */ +public interface NbtInput { + + NamedTag readTag(int maxDepth) throws IOException; + + Tag readRawTag(int maxDepth) throws IOException; +} diff --git a/src/main/java/net/querz/nbt/io/NBTOutput.java b/src/main/java/io/github/ensgijs/nbt/io/NbtOutput.java similarity index 65% rename from src/main/java/net/querz/nbt/io/NBTOutput.java rename to src/main/java/io/github/ensgijs/nbt/io/NbtOutput.java index 39f6d688..4f30c32e 100644 --- a/src/main/java/net/querz/nbt/io/NBTOutput.java +++ b/src/main/java/io/github/ensgijs/nbt/io/NbtOutput.java @@ -1,9 +1,10 @@ -package net.querz.nbt.io; +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.Tag; -import net.querz.nbt.tag.Tag; import java.io.IOException; -public interface NBTOutput { +public interface NbtOutput { void writeTag(NamedTag tag, int maxDepth) throws IOException; diff --git a/src/main/java/net/querz/nbt/io/ParseException.java b/src/main/java/io/github/ensgijs/nbt/io/ParseException.java similarity index 75% rename from src/main/java/net/querz/nbt/io/ParseException.java rename to src/main/java/io/github/ensgijs/nbt/io/ParseException.java index c62e0610..d7370f73 100644 --- a/src/main/java/net/querz/nbt/io/ParseException.java +++ b/src/main/java/io/github/ensgijs/nbt/io/ParseException.java @@ -1,4 +1,4 @@ -package net.querz.nbt.io; +package io.github.ensgijs.nbt.io; import java.io.IOException; @@ -22,4 +22,10 @@ private static String formatError(String value, int index) { builder.append("<--[HERE]"); return builder.toString(); } + + public static class SilentParseException extends RuntimeException { + public SilentParseException(ParseException cause) { + super(cause); + } + } } diff --git a/src/main/java/io/github/ensgijs/nbt/io/PositionTrackingInputStream.java b/src/main/java/io/github/ensgijs/nbt/io/PositionTrackingInputStream.java new file mode 100644 index 00000000..21f42378 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/io/PositionTrackingInputStream.java @@ -0,0 +1,120 @@ +package io.github.ensgijs.nbt.io; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; + +/** + * Wraps an InputStream to track the read position and to allow you to skip ahead to a known position. + * Note: position 0 isn't necessarily the first byte in the stream - it's the first byte will be read + * after this object is created. + */ +public class PositionTrackingInputStream extends InputStream { + private long pos = 0; + private long markedPos = -1; + private final InputStream stream; + private long softEof = 0; + + public PositionTrackingInputStream(InputStream in) { + stream = in; + } + + public long pos() { + return pos; + } + + /** + * Soft EOF will prevent byte[] reads that cross this position. Such reads won't fail, they'll simply only be + * filled up to softEof - 1. Set to LE0 to disable completely (default). + */ + public void setSoftEof(long softEof) { + this.softEof = softEof; + } + + @Override + public int read() throws IOException { + int ret = stream.read(); + if (ret >= 0) pos++; + return ret; + } + + @Override + public int read(byte[] b) throws IOException { + int len = b.length; + if (pos < softEof && (pos + len) > softEof) { + len = (int) (softEof - pos); + } + int ret = stream.read(b, 0, len); + if (ret > 0) pos += ret; + return ret; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (pos < softEof && (pos + len) >= softEof) { + len = (int) (softEof - pos); + } + int ret = stream.read(b, off, len); + if (ret > 0) pos += ret; + return ret; + } + + @Override + public long skip(long n) throws IOException { + long ret = stream.skip(n); + if (ret > 0) pos += ret; + return ret; + } + + @Override + public int available() throws IOException { + return stream.available(); + } + + @Override + public void close() throws IOException { + stream.close(); + } + + /** + * Sets soft EOF automatically (pos() + readLimit). + * @see #setSoftEof(long) + */ + @Override + public synchronized void mark(int readlimit) { + if (super.markSupported()) { + markedPos = pos; + } + stream.mark(readlimit); + setSoftEof(pos + readlimit); + } + + @Override + public synchronized void reset() throws IOException { + stream.reset(); + if (markedPos < 0) throw new IOException("mark position is unknown!"); + pos = markedPos; + } + + @Override + public boolean markSupported() { + return stream.markSupported(); + } + + public void skipTo(long pos) throws IOException { + if (pos < this.pos) + throw new IOException( + "cannot skip backwards from 0x" + Long.toString(this.pos, 16) + " (" + this.pos + + ") to 0x" + Long.toString(pos, 16)+ " (" + pos + ")"); + final long originalPos = this.pos; + while (this.pos < pos) { + if (skip(pos - this.pos) <= 0) { + throw new EOFException(String.format( + "Asked to skip from %,d to %,d (%,d bytes) but only skipped %,d bytes; new pos = %,d; soft EOF = %,d", + originalPos, pos, pos - originalPos, this.pos - originalPos, this.pos, softEof)); + } + } + if (pos != this.pos) + throw new IllegalStateException(); + } +} diff --git a/src/main/java/net/querz/io/Serializer.java b/src/main/java/io/github/ensgijs/nbt/io/Serializer.java similarity index 95% rename from src/main/java/net/querz/io/Serializer.java rename to src/main/java/io/github/ensgijs/nbt/io/Serializer.java index a6c9377a..6a27b613 100644 --- a/src/main/java/net/querz/io/Serializer.java +++ b/src/main/java/io/github/ensgijs/nbt/io/Serializer.java @@ -1,4 +1,4 @@ -package net.querz.io; +package io.github.ensgijs.nbt.io; import java.io.BufferedOutputStream; import java.io.ByteArrayOutputStream; diff --git a/src/main/java/io/github/ensgijs/nbt/io/SilentIOException.java b/src/main/java/io/github/ensgijs/nbt/io/SilentIOException.java new file mode 100644 index 00000000..17a6bc8d --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/io/SilentIOException.java @@ -0,0 +1,25 @@ +package io.github.ensgijs.nbt.io; + +import java.io.IOException; + +/** + * Used to wrap/throw IOExceptions in contests where checked exceptions cannot be used, + * such as when implementing Iterator.next() + */ +public class SilentIOException extends RuntimeException { + public SilentIOException() { + super(); + } + + public SilentIOException(String message) { + super(message); + } + + public SilentIOException(String message, IOException cause) { + super(message, cause); + } + + public SilentIOException(IOException cause) { + super(cause); + } +} diff --git a/src/main/java/net/querz/nbt/io/StringPointer.java b/src/main/java/io/github/ensgijs/nbt/io/StringPointer.java similarity index 88% rename from src/main/java/net/querz/nbt/io/StringPointer.java rename to src/main/java/io/github/ensgijs/nbt/io/StringPointer.java index fe37b20d..4a351f1b 100644 --- a/src/main/java/net/querz/nbt/io/StringPointer.java +++ b/src/main/java/io/github/ensgijs/nbt/io/StringPointer.java @@ -1,4 +1,4 @@ -package net.querz.nbt.io; +package io.github.ensgijs.nbt.io; public class StringPointer { @@ -7,6 +7,20 @@ public class StringPointer { public StringPointer(String value) { this.value = value; + skipUtf8Bom(); + } + + public void reset() { + index = 0; + skipUtf8Bom(); + } + + /** Skips the UTF8 BOM (byte order mark) if the current index is 0, else does nothing.*/ + private void skipUtf8Bom() { + if (index != 0) return; + if (hasNext() && next() != '\uFEFF') { + index = 0; + } } public int getIndex() { diff --git a/src/main/java/io/github/ensgijs/nbt/io/TextNbtDeserializer.java b/src/main/java/io/github/ensgijs/nbt/io/TextNbtDeserializer.java new file mode 100644 index 00000000..9e9d2c49 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/io/TextNbtDeserializer.java @@ -0,0 +1,47 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.Tag; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; + +public class TextNbtDeserializer implements Deserializer { + + public NamedTag fromReader(Reader reader, int maxDepth) throws IOException { + BufferedReader bufferedReader; + if (reader instanceof BufferedReader) { + bufferedReader = (BufferedReader) reader; + } else { + bufferedReader = new BufferedReader(reader); + } + return new TextNbtParser(bufferedReader.lines().collect(Collectors.joining())).readTag(maxDepth); + } + + public NamedTag fromReader(Reader reader) throws IOException { + return fromReader(reader, Tag.DEFAULT_MAX_DEPTH); + } + + public NamedTag fromString(String s) throws IOException { + return fromReader(new StringReader(s)); + } + + @Override + public NamedTag fromStream(InputStream stream) throws IOException { + try (Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { + return fromReader(reader); + } + } + + @Override + public NamedTag fromFile(File file) throws IOException { + try (Reader reader = new FileReader(file)) { + return fromReader(reader); + } + } + + @Override + public NamedTag fromBytes(byte[] data) throws IOException { + return fromReader(new StringReader(new String(data))); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/io/TextNbtHelpers.java b/src/main/java/io/github/ensgijs/nbt/io/TextNbtHelpers.java new file mode 100644 index 00000000..2b464795 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/io/TextNbtHelpers.java @@ -0,0 +1,275 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.Tag; +import io.github.ensgijs.nbt.util.ArgValidator; +import io.github.ensgijs.nbt.util.JsonPrettyPrinter; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Locale; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +/** + * Utilities for converting {@link Tag}'s and {@link NamedTag}'s to and from string NBT text data and files. + *

NOTE: {@link #readTextNbtFile(File)}, and its variants, can read both uncompressed (plain text) and GZ + * compressed files (usually ending in .gz file extension - but the extension itself is not evaluated, instead + * the gzip magic number/bom is looked for).

+ *

NOTE: {@link #writeTextNbtFile(File, Tag)}, and its variants, can write both uncompressed (plain text) and GZ + * compressed files. If the given file name ends in the '.gz' extension it will be written as a compressed file, + * otherwise it will be written as plain text.

+ */ +public final class TextNbtHelpers { + private TextNbtHelpers() {} + + // + public static String toTextNbt(NamedTag namedTag, boolean prettyPrint, boolean sortCompoundTagEntries) { + String snbt = new TextNbtSerializer(sortCompoundTagEntries).toString(namedTag); + return !prettyPrint ? snbt : JsonPrettyPrinter.prettyPrintJson(snbt); + } + + /** defaults to sortCompoundTagEntries=true */ + public static String toTextNbt(NamedTag namedTag, boolean prettyPrint) { + return toTextNbt(namedTag, prettyPrint, true); + } + + /** defaults to sortCompoundTagEntries=false */ + public static String toTextNbtUnsorted(NamedTag namedTag, boolean prettyPrint) { + return toTextNbt(namedTag, prettyPrint, false); + } + + /** defaults to prettyPrint=true, sortCompoundTagEntries=true */ + public static String toTextNbt(NamedTag namedTag) { + return toTextNbt(namedTag, true, true); + } + + /** defaults to prettyPrint=true, sortCompoundTagEntries=false */ + public static String toTextNbtUnsorted(NamedTag namedTag) { + return toTextNbt(namedTag, true, false); + } + + public static String toTextNbt(Tag tag, boolean prettyPrint, boolean sortCompoundTagEntries) { + return toTextNbt(new NamedTag(null, tag), prettyPrint, sortCompoundTagEntries); + } + + /** defaults to sortCompoundTagEntries=true */ + public static String toTextNbt(Tag tag, boolean prettyPrint) { + return toTextNbt(new NamedTag(null, tag), prettyPrint, true); + } + + /** defaults to sortCompoundTagEntries=false */ + public static String toTextNbtUnsorted(Tag tag, boolean prettyPrint) { + return toTextNbt(new NamedTag(null, tag), prettyPrint, false); + } + + /** defaults to prettyPrint=true, sortCompoundTagEntries=true */ + public static String toTextNbt(Tag tag) { + return toTextNbt(new NamedTag(null, tag), true, true); + } + + /** defaults to prettyPrint=true, sortCompoundTagEntries=false */ + public static String toTextNbtUnsorted(Tag tag) { + return toTextNbt(new NamedTag(null, tag), true, false); + } + + public static NamedTag fromTextNbt(String string) throws IOException { + return new TextNbtDeserializer().fromString(string); + } + // + + + private static Path writeTextNbtFile0(Path filePath, Object tag, boolean prettyPrint, boolean sortCompoundTagEntries) throws IOException { + if (!filePath.getParent().toFile().exists()) { + ArgValidator.check(filePath.getParent().toFile().mkdirs(), + "Failed to create parent directory for " + filePath.toAbsolutePath()); + } + byte[] data; + if (tag instanceof NamedTag) { + data = toTextNbt((NamedTag) tag, prettyPrint, sortCompoundTagEntries).getBytes(StandardCharsets.UTF_8); + } else { + data = toTextNbt((Tag) tag, prettyPrint, sortCompoundTagEntries).getBytes(StandardCharsets.UTF_8); + } + if (!filePath.getFileName().toString().toLowerCase(Locale.ENGLISH).endsWith(".gz")) { + Files.write(filePath, data); + } else { + try (GZIPOutputStream gzOut = new GZIPOutputStream(new FileOutputStream(filePath.toFile()))) { + gzOut.write(data); + } + } + return filePath; + } + + // + public static Path writeTextNbtFile(Path filePath, Tag tag, boolean prettyPrint, boolean sortCompoundTagEntries) throws IOException { + return writeTextNbtFile0(filePath, tag, prettyPrint, sortCompoundTagEntries); + } + + /** defaults to sortCompoundTagEntries=true */ + public static Path writeTextNbtFile(Path filePath, Tag tag, boolean prettyPrint) throws IOException { + return writeTextNbtFile(filePath, tag, prettyPrint, true); + } + + /** defaults to sortCompoundTagEntries=true */ + public static Path writeTextNbtFile(File file, Tag tag, boolean prettyPrint) throws IOException { + return writeTextNbtFile(file.toPath(), tag, prettyPrint, true); + } + + /** defaults to sortCompoundTagEntries=true */ + public static Path writeTextNbtFile(String file, Tag tag, boolean prettyPrint) throws IOException { + return writeTextNbtFile(Paths.get(file), tag, prettyPrint, true); + } + + + /** defaults to prettyPrint=true, sortCompoundTagEntries=true */ + public static Path writeTextNbtFile(Path filePath, Tag tag) throws IOException { + return writeTextNbtFile(filePath, tag, true, true); + } + + /** defaults to prettyPrint=true, sortCompoundTagEntries=true */ + public static Path writeTextNbtFile(File file, Tag tag) throws IOException { + return writeTextNbtFile(file.toPath(), tag, true, true); + } + + /** defaults to prettyPrint=true, sortCompoundTagEntries=true */ + public static Path writeTextNbtFile(String file, Tag tag) throws IOException { + return writeTextNbtFile(Paths.get(file), tag, true, true); + } + + + /** defaults to sortCompoundTagEntries=false */ + public static Path writeTextNbtFileUnsorted(Path filePath, Tag tag, boolean prettyPrint) throws IOException { + return writeTextNbtFile(filePath, tag, prettyPrint, false); + } + + /** defaults to sortCompoundTagEntries=false */ + public static Path writeTextNbtFileUnsorted(File file, Tag tag, boolean prettyPrint) throws IOException { + return writeTextNbtFile(file.toPath(), tag, prettyPrint, false); + } + + /** defaults to sortCompoundTagEntries=false */ + public static Path writeTextNbtFileUnsorted(String file, Tag tag, boolean prettyPrint) throws IOException { + return writeTextNbtFile(Paths.get(file), tag, prettyPrint, false); + } + + + /** defaults to prettyPrint=true, sortCompoundTagEntries=false */ + public static Path writeTextNbtFileUnsorted(Path filePath, Tag tag) throws IOException { + return writeTextNbtFile(filePath, tag, true, false); + } + + /** defaults to prettyPrint=true, sortCompoundTagEntries=false */ + public static Path writeTextNbtFileUnsorted(File file, Tag tag) throws IOException { + return writeTextNbtFile(file.toPath(), tag, true, false); + } + + /** defaults to prettyPrint=true, sortCompoundTagEntries=false */ + public static Path writeTextNbtFileUnsorted(String file, Tag tag) throws IOException { + return writeTextNbtFile(Paths.get(file), tag, true, false); + } + // + + + // + public static Path writeTextNbtFile(Path filePath, NamedTag tag, boolean prettyPrint, boolean sortCompoundTagEntries) throws IOException { + return writeTextNbtFile0(filePath, tag, prettyPrint, sortCompoundTagEntries); + } + + /** defaults to sortCompoundTagEntries=true */ + public static Path writeTextNbtFile(Path filePath, NamedTag tag, boolean prettyPrint) throws IOException { + return writeTextNbtFile(filePath, tag, prettyPrint, true); + } + + /** defaults to sortCompoundTagEntries=true */ + public static Path writeTextNbtFile(File file, NamedTag tag, boolean prettyPrint) throws IOException { + return writeTextNbtFile(file.toPath(), tag, prettyPrint, true); + } + + /** defaults to sortCompoundTagEntries=true */ + public static Path writeTextNbtFile(String file, NamedTag tag, boolean prettyPrint) throws IOException { + return writeTextNbtFile(Paths.get(file), tag, prettyPrint, true); + } + + + /** defaults to prettyPrint=true, sortCompoundTagEntries=true */ + public static Path writeTextNbtFile(Path filePath, NamedTag tag) throws IOException { + return writeTextNbtFile(filePath, tag, true, true); + } + + /** defaults to prettyPrint=true, sortCompoundTagEntries=true */ + public static Path writeTextNbtFile(File file, NamedTag tag) throws IOException { + return writeTextNbtFile(file.toPath(), tag, true, true); + } + + /** defaults to prettyPrint=true, sortCompoundTagEntries=true */ + public static Path writeTextNbtFile(String file, NamedTag tag) throws IOException { + return writeTextNbtFile(Paths.get(file), tag, true, true); + } + + + /** defaults to sortCompoundTagEntries=false */ + public static Path writeTextNbtFileUnsorted(Path filePath, NamedTag tag, boolean prettyPrint) throws IOException { + return writeTextNbtFile(filePath, tag, prettyPrint, false); + } + + /** defaults to sortCompoundTagEntries=false */ + public static Path writeTextNbtFileUnsorted(File file, NamedTag tag, boolean prettyPrint) throws IOException { + return writeTextNbtFile(file.toPath(), tag, prettyPrint, false); + } + + /** defaults to sortCompoundTagEntries=false */ + public static Path writeTextNbtFileUnsorted(String file, NamedTag tag, boolean prettyPrint) throws IOException { + return writeTextNbtFile(Paths.get(file), tag, prettyPrint, false); + } + + + /** defaults to prettyPrint=true, sortCompoundTagEntries=false */ + public static Path writeTextNbtFileUnsorted(Path filePath, NamedTag tag) throws IOException { + return writeTextNbtFile(filePath, tag, true, false); + } + + /** defaults to prettyPrint=true, sortCompoundTagEntries=false */ + public static Path writeTextNbtFileUnsorted(File file, NamedTag tag) throws IOException { + return writeTextNbtFile(file.toPath(), tag, true, false); + } + + /** defaults to prettyPrint=true, sortCompoundTagEntries=false */ + public static Path writeTextNbtFileUnsorted(String file, NamedTag tag) throws IOException { + return writeTextNbtFile(Paths.get(file), tag, true, false); + } + // + + // + /** The given file can be either plain text (uncompressed) or gz compressed. */ + public static NamedTag readTextNbtFile(File file) throws IOException { + try (DataInputStream dis = new DataInputStream(detectDecompression(new FileInputStream(file)))) { + return new TextNbtDeserializer().fromStream(dis); + } + } + + /** The given file can be either plain text (uncompressed) or gz compressed. */ + public static NamedTag readTextNbtFile(String file) throws IOException { + return readTextNbtFile(new File(file)); + } + + /** The given file can be either plain text (uncompressed) or gz compressed. */ + public static NamedTag readTextNbtFile(Path path) throws IOException { + return readTextNbtFile(path.toFile()); + } + // + + static InputStream detectDecompression(InputStream is) throws IOException { + PushbackInputStream pbis = new PushbackInputStream(is, 2); + int b0 = pbis.read(); + int b1 = pbis.read(); + int signature = (b0 & 0xFF) | (b1 << 8); + if (b1 >= 0) pbis.unread(b1); + if (b0 >= 0) pbis.unread(b0); + if (signature == GZIPInputStream.GZIP_MAGIC) { + return new GZIPInputStream(pbis); + } + return pbis; + } +} diff --git a/src/main/java/net/querz/nbt/io/SNBTParser.java b/src/main/java/io/github/ensgijs/nbt/io/TextNbtParser.java similarity index 71% rename from src/main/java/net/querz/nbt/io/SNBTParser.java rename to src/main/java/io/github/ensgijs/nbt/io/TextNbtParser.java index 7d8d2dae..54ebee4f 100644 --- a/src/main/java/net/querz/nbt/io/SNBTParser.java +++ b/src/main/java/io/github/ensgijs/nbt/io/TextNbtParser.java @@ -1,26 +1,27 @@ -package net.querz.nbt.io; - -import net.querz.io.MaxDepthIO; -import net.querz.nbt.tag.ArrayTag; -import net.querz.nbt.tag.ByteArrayTag; -import net.querz.nbt.tag.ByteTag; -import net.querz.nbt.tag.CompoundTag; -import net.querz.nbt.tag.DoubleTag; -import net.querz.nbt.tag.EndTag; -import net.querz.nbt.tag.FloatTag; -import net.querz.nbt.tag.IntArrayTag; -import net.querz.nbt.tag.IntTag; -import net.querz.nbt.tag.ListTag; -import net.querz.nbt.tag.LongArrayTag; -import net.querz.nbt.tag.LongTag; -import net.querz.nbt.tag.ShortTag; -import net.querz.nbt.tag.StringTag; -import net.querz.nbt.tag.Tag; +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.ArrayTag; +import io.github.ensgijs.nbt.tag.ByteArrayTag; +import io.github.ensgijs.nbt.tag.ByteTag; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.DoubleTag; +import io.github.ensgijs.nbt.tag.EndTag; +import io.github.ensgijs.nbt.tag.FloatTag; +import io.github.ensgijs.nbt.tag.IntArrayTag; +import io.github.ensgijs.nbt.tag.IntTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.tag.LongArrayTag; +import io.github.ensgijs.nbt.tag.LongTag; +import io.github.ensgijs.nbt.tag.ShortTag; +import io.github.ensgijs.nbt.tag.StringTag; +import io.github.ensgijs.nbt.tag.Tag; + +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; -public final class SNBTParser implements MaxDepthIO { +public final class TextNbtParser implements MaxDepthIO, NbtInput { private static final Pattern FLOAT_LITERAL_PATTERN = Pattern.compile("^[-+]?(?:\\d+\\.?|\\d*\\.\\d+)(?:e[-+]?\\d+)?f$", Pattern.CASE_INSENSITIVE), @@ -34,16 +35,51 @@ public final class SNBTParser implements MaxDepthIO { private StringPointer ptr; - public SNBTParser(String string) { + public TextNbtParser(String string) { this.ptr = new StringPointer(string); } + @Override + public NamedTag readTag(int maxDepth) throws IOException { + ptr.reset(); + ptr.skipWhitespace(); + if (!ptr.hasNext()) return null; + String name = ptr.currentChar() == '"' ? ptr.parseQuotedString() : ptr.parseSimpleString(); + // note to future self: if you're ever compelled to set NamedTag's name to null if it's empty + // consider changing TextNbtWriter#writeAnything(NamedTag, int)'s behavior to match + ptr.skipWhitespace(); + if (ptr.hasNext() && ptr.next() ==':') { + ptr.skipWhitespace(); + if (!ptr.hasNext()) { + throw ptr.parseException("unexpected end of input - no value after name:"); + } + return new NamedTag(name, parseAnything(maxDepth)); + } + return new NamedTag(null, readRawTag(maxDepth)); + } + + @Override + public Tag readRawTag(int maxDepth) throws IOException { + ptr.reset(); + ptr.skipWhitespace(); + if (!ptr.hasNext()) return null; + return parseAnything(maxDepth); + } + + /** + * + * @param maxDepth + * @param lenient allows trailing content to follow the text nbt data - this could be useful if multiple + * text nbt's are present without a ListTag being used. + * @return + * @throws ParseException + */ public Tag parse(int maxDepth, boolean lenient) throws ParseException { Tag tag = parseAnything(maxDepth); if (!lenient) { ptr.skipWhitespace(); if (ptr.hasNext()) { - throw ptr.parseException("invalid characters after end of snbt"); + throw ptr.parseException("invalid characters after end of text nbt"); } } return tag; @@ -57,6 +93,21 @@ public Tag parse() throws ParseException { return parse(Tag.DEFAULT_MAX_DEPTH, false); } + /** + * Useful for parsing a text nbt tag used in code - generally {@link #parse()}, or one of its overloads, + * should be used for all other situations. + *

Traps and rethrows any checked {@link ParseException}'s as a runtime + * {@link ParseException.SilentParseException}.

+ */ + @SuppressWarnings("unchecked") + public static > T parseInline(String nbtText) throws ParseException.SilentParseException { + try { + return (T) new TextNbtParser(nbtText).parse(); + } catch (ParseException ex) { + throw new ParseException.SilentParseException(ex); + } + } + public int getReadChars() { return ptr.getIndex() + 1; } @@ -82,7 +133,7 @@ private Tag parseStringOrLiteral() throws ParseException { } String s = ptr.parseSimpleString(); if (s.isEmpty()) { - throw new ParseException("expected non empty value"); + throw ptr.parseException("expected non empty value"); } if (FLOAT_LITERAL_PATTERN.matcher(s).matches()) { return new FloatTag(Float.parseFloat(s.substring(0, s.length() - 1))); @@ -132,7 +183,7 @@ private CompoundTag parseCompoundTag(int maxDepth) throws ParseException { ptr.skipWhitespace(); String key = ptr.currentChar() == '"' ? ptr.parseQuotedString() : ptr.parseSimpleString(); if (key.isEmpty()) { - throw new ParseException("empty keys are not allowed"); + throw ptr.parseException("empty keys are not allowed"); } ptr.expectChar(':'); @@ -178,7 +229,7 @@ private ArrayTag parseNumArray() throws ParseException { case 'L': return parseLongArrayTag(); } - throw new ParseException("invalid array type '" + arrayType + "'"); + throw ptr.parseException("invalid array type '" + arrayType + "'"); } private ByteArrayTag parseByteArrayTag() throws ParseException { diff --git a/src/main/java/io/github/ensgijs/nbt/io/TextNbtSerializer.java b/src/main/java/io/github/ensgijs/nbt/io/TextNbtSerializer.java new file mode 100644 index 00000000..662bc2b0 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/io/TextNbtSerializer.java @@ -0,0 +1,51 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.Tag; + +import java.io.*; + +public class TextNbtSerializer implements Serializer { + private boolean sortCompoundTagEntries; + + public TextNbtSerializer(boolean sortCompoundTagEntries) { + this.sortCompoundTagEntries = sortCompoundTagEntries; + } + + public void toWriter(NamedTag tag, Writer writer) throws IOException { + TextNbtWriter.write(tag, writer, sortCompoundTagEntries, Tag.DEFAULT_MAX_DEPTH); + } + + public void toWriter(NamedTag tag, Writer writer, int maxDepth) throws IOException { + TextNbtWriter.write(tag, writer, sortCompoundTagEntries, maxDepth); + } + + public String toString(NamedTag object) { + return toString(object, Tag.DEFAULT_MAX_DEPTH); + } + + public String toString(NamedTag object, int maxDepth) { + Writer writer = new StringWriter(); + try { + toWriter(object, writer, maxDepth); + writer.flush(); + } catch (IOException ex) { + // this case should (probably) never happen so just wrap and toss if it ever does + throw new RuntimeException(ex); + } + return writer.toString(); + } + + @Override + public void toStream(NamedTag object, OutputStream stream) throws IOException { + Writer writer = new OutputStreamWriter(stream); + toWriter(object, writer); + writer.flush(); + } + + @Override + public void toFile(NamedTag object, File file) throws IOException { + try (Writer writer = new FileWriter(file)) { + toWriter(object, writer); + } + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/io/TextNbtWriter.java b/src/main/java/io/github/ensgijs/nbt/io/TextNbtWriter.java new file mode 100644 index 00000000..85ebe6c9 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/io/TextNbtWriter.java @@ -0,0 +1,133 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.ByteArrayTag; +import io.github.ensgijs.nbt.tag.ByteTag; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.DoubleTag; +import io.github.ensgijs.nbt.tag.EndTag; +import io.github.ensgijs.nbt.tag.FloatTag; +import io.github.ensgijs.nbt.tag.IntArrayTag; +import io.github.ensgijs.nbt.tag.IntTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.tag.LongArrayTag; +import io.github.ensgijs.nbt.tag.LongTag; +import io.github.ensgijs.nbt.tag.ShortTag; +import io.github.ensgijs.nbt.tag.StringTag; +import io.github.ensgijs.nbt.tag.Tag; +import java.io.IOException; +import java.io.Writer; +import java.lang.reflect.Array; +import java.util.Iterator; + +/** + * TextNbtWriter creates a text NBT String. + */ +public final class TextNbtWriter implements MaxDepthIO { + + private Writer writer; + + private TextNbtWriter(Writer writer) { + this.writer = writer; + } + + public static void write(NamedTag tag, Writer writer, boolean sortCompoundTagEntries, int maxDepth) throws IOException { + new TextNbtWriter(writer).writeAnything(tag, sortCompoundTagEntries, maxDepth); + } + + public static void write(NamedTag tag, Writer writer, int maxDepth) throws IOException { + new TextNbtWriter(writer).writeAnything(tag, false, maxDepth); + } + + public static void write(NamedTag tag, Writer writer) throws IOException { + write(tag, writer, Tag.DEFAULT_MAX_DEPTH); + } + + public static void write(Tag tag, Writer writer, int maxDepth) throws IOException { + new TextNbtWriter(writer).writeAnything(tag, false, maxDepth); + } + + public static void write(Tag tag, Writer writer) throws IOException { + write(tag, writer, Tag.DEFAULT_MAX_DEPTH); + } + + private void writeAnything(NamedTag tag, boolean sortCompoundTagEntries, int maxDepth) throws IOException { + // note to future self: if you're ever compelled not write an empty name be sure to + // consider what that means for TextNbtParser#readTag(int) + if (tag.getName() != null) { + writer.write(tag.getEscapedName()); + writer.write(':'); + } + writeAnything(tag.getTag(), sortCompoundTagEntries, maxDepth); + } + + private void writeAnything(Tag tag, boolean sortCompoundTagEntries, int maxDepth) throws IOException { + switch (tag.getID()) { + case EndTag.ID: + //do nothing + break; + case ByteTag.ID: + writer.append(Byte.toString(((ByteTag) tag).asByte())).write('b'); + break; + case ShortTag.ID: + writer.append(Short.toString(((ShortTag) tag).asShort())).write('s'); + break; + case IntTag.ID: + writer.write(Integer.toString(((IntTag) tag).asInt())); + break; + case LongTag.ID: + writer.append(Long.toString(((LongTag) tag).asLong())).write('l'); + break; + case FloatTag.ID: + writer.append(Float.toString(((FloatTag) tag).asFloat())).write('f'); + break; + case DoubleTag.ID: + writer.append(Double.toString(((DoubleTag) tag).asDouble())).write('d'); + break; + case ByteArrayTag.ID: + writeArray(((ByteArrayTag) tag).getValue(), ((ByteArrayTag) tag).length(), "B"); + break; + case StringTag.ID: + writer.write(StringTag.escapeString(((StringTag) tag).getValue(), true)); + break; + case ListTag.ID: + writer.write('['); + for (int i = 0; i < ((ListTag) tag).size(); i++) { + writer.write(i == 0 ? "" : ","); + writeAnything(((ListTag) tag).get(i), sortCompoundTagEntries, decrementMaxDepth(maxDepth)); + } + writer.write(']'); + break; + case CompoundTag.ID: + writer.write('{'); + boolean first = true; + Iterator iter; + if (sortCompoundTagEntries) iter = ((CompoundTag) tag).stream().sorted(NamedTag::compare).iterator(); + else iter = ((CompoundTag) tag).iterator(); + while (iter.hasNext()) { + NamedTag entry = iter.next(); + writer.write(first ? "" : ","); + writer.append(NamedTag.escapeName(entry.getName())).write(':'); + writeAnything(entry.getTag(), sortCompoundTagEntries, decrementMaxDepth(maxDepth)); + first = false; + } + writer.write('}'); + break; + case IntArrayTag.ID: + writeArray(((IntArrayTag) tag).getValue(), ((IntArrayTag) tag).length(), "I"); + break; + case LongArrayTag.ID: + writeArray(((LongArrayTag) tag).getValue(), ((LongArrayTag) tag).length(), "L"); + break; + default: + throw new IOException("unknown tag with id \"" + tag.getID() + "\""); + } + } + + private void writeArray(Object array, int length, String prefix) throws IOException { + writer.append('[').append(prefix).write(';'); + for (int i = 0; i < length; i++) { + writer.append(i == 0 ? "" : ",").write(Array.get(array, i).toString()); + } + writer.write(']'); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/ChunkBase.java b/src/main/java/io/github/ensgijs/nbt/mca/ChunkBase.java new file mode 100644 index 00000000..ed25a330 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/ChunkBase.java @@ -0,0 +1,478 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.io.BinaryNbtDeserializer; +import io.github.ensgijs.nbt.io.BinaryNbtSerializer; +import io.github.ensgijs.nbt.io.CompressionType; +import io.github.ensgijs.nbt.io.NamedTag; +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.mca.io.MoveChunkFlags; +import io.github.ensgijs.nbt.mca.util.*; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.Tag; +import io.github.ensgijs.nbt.util.ObservedCompoundTag; +import io.github.ensgijs.nbt.query.NbtPath; + +import java.io.*; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; + +/** + * Abstraction for the base of all chunk types. Not all chunks types are sectioned, that layer comes further up + * the hierarchy. + *

+ * Cautionary note to implementors - DO NOT USE INLINE MEMBER INITIALIZATION IN YOUR CLASSES
+ * Define all member initialization in {@link #initMembers()} or be very confused! + * Due to how Java initializes objects, this base class will call {@link #initReferences(long)} before any inline + * member initialization has occurred. The symptom of using inline member initialization is that you will get + * very confusing {@link NullPointerException}'s from within {@link #initReferences(long)} for members which + * are accessed by your {@link #initReferences(long)} implementation that you have defined inline initializers for + * because those initializers will not run until AFTER {@link #initReferences(long)} returns. + *

+ * It is however "safe" to use inline member initialization for any members which are not accessed from within + * {@link #initReferences(long)} - but unless you really fully understand the warning above and its full + * ramifications just don't do it. + *

+ * @see SectionedChunkBase + */ +public abstract class ChunkBase implements VersionedDataContainer, TagWrapper, TracksUnreadDataTags { + + public static final int NO_CHUNK_COORD_SENTINEL = Integer.MIN_VALUE; + + protected final long originalLoadFlags; + protected int dataVersion; + protected int chunkX = NO_CHUNK_COORD_SENTINEL; + protected int chunkZ = NO_CHUNK_COORD_SENTINEL; + // TODO: this partial state thing is questionable - evaluate if the semantics are valid or broken + protected boolean partial; + protected boolean raw; + protected int lastMCAUpdate; + /** Should be treated as effectively read-only by child classes until after {@link #initReferences} + * invocation has returned. */ + protected CompoundTag data; + protected Set unreadDataTagKeys; + + /** + * {@inheritDoc} + */ + public Set getUnreadDataTagKeys() { + return unreadDataTagKeys; + } + + /** + * {@inheritDoc} + * @return NotNull - if LoadFlags specified {@link LoadFlags#RAW} then the raw data is returned - else a new + * CompoundTag populated, by reference, with values that were not read during {@link #initReferences(long)}. + */ + public CompoundTag getUnreadDataTags() { + if (raw) return data; + CompoundTag unread = new CompoundTag(unreadDataTagKeys.size()); + data.forEach((k, v) -> { + if (unreadDataTagKeys.contains(k)) { + unread.put(k, v); + } + }); + return unread; + } + + /** + * Due to how Java initializes objects and how this class hierarchy is setup it is ill-advised to use inline member + * initialization because {@link #initReferences(long)} will be called before members are initialized which WILL + * result in very confusing {@link NullPointerException}'s being thrown from within {@link #initReferences(long)}. + * This is not a problem that can be solved by moving initialization into your constructors, because you must call + * the super constructor as the first line of your child constructor! + *

So, to get around this hurdle, perform all member initialization you would normally inline in your + * class def, within this method instead. Implementers should never need to call this method themselves + * as ChunkBase will always call it, even from the default constructor. Remember to call {@code super();} + * from your default constructors to maintain this behavior.

+ */ + protected void initMembers() { } + + protected ChunkBase(int dataVersion) { + this.dataVersion = dataVersion; + this.originalLoadFlags = LoadFlags.LOAD_ALL_DATA; + this.lastMCAUpdate = (int)(System.currentTimeMillis() / 1000); + initMembers(); + } + + /** + * 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, LoadFlags.LOAD_ALL_DATA); + } + + /** + * Create a new chunk based on raw base data from a region file. + * @param data The raw base data to be used. + * @param loadFlags Union of {@link LoadFlags} to process. + */ + public ChunkBase(CompoundTag data, long loadFlags) { + this.data = data; + this.originalLoadFlags = loadFlags; + initMembers(); + initReferences0(loadFlags); + } + + private void initReferences0(long loadFlags) { + Objects.requireNonNull(data, "data cannot be null"); + if ((loadFlags & LoadFlags.RAW) != 0) { + dataVersion = data.getInt("DataVersion"); + raw = true; + } else { + final ObservedCompoundTag observedData = new ObservedCompoundTag(data); + dataVersion = observedData.getInt("DataVersion"); + if (dataVersion == 0) { + throw new IllegalArgumentException("data does not contain \"DataVersion\" tag"); + } + + data = observedData; + initReferences(loadFlags); + if (data != observedData) { + throw new IllegalStateException("this.data was replaced during initReferences execution - this breaks unreadDataTagKeys behavior!"); + } + unreadDataTagKeys = observedData.unreadKeys(); + + if ((loadFlags & LoadFlags.RELEASE_CHUNK_DATA_TAG) != 0) { + data = null; + // this is questionable... maybe if we also check that data version is within the known bounds too + // (to count it as non-partial) we could be reasonably confidant that the saved chunk would at least + // have a vanilla level of data. + if ((loadFlags & LoadFlags.LOAD_ALL_DATA) != LoadFlags.LOAD_ALL_DATA) partial = true; + } else { + // stop observing the data tag + data = observedData.wrappedTag(); + } + } + } + + /** + * Child classes should not call this method directly, it will be called for them. + * 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); + + /** + * @return one of: region, entities, poi + */ + public abstract String getMcaType(); + + /** + * {@inheritDoc} + */ + public int getDataVersion() { + return dataVersion; + } + + /** + * {@inheritDoc} + */ + public void setDataVersion(int dataVersion) { + this.dataVersion = Math.max(0, dataVersion); + } + + /** + * Gets this chunk's chunk-x coordinate. Returns {@link #NO_CHUNK_COORD_SENTINEL} if not supported or unknown. + * @see #moveChunk(int, int, long, boolean) + */ + public int getChunkX() { + return chunkX; + } + + /** + * Gets this chunk's chunk-z coordinate. Returns {@link #NO_CHUNK_COORD_SENTINEL} if not supported or unknown. + * @see #moveChunk(int, int, long, boolean) + */ + public int getChunkZ() { + return chunkZ; + } + + /** + * Gets this chunk's chunk-xz coordinates. Returns x = z = {@link #NO_CHUNK_COORD_SENTINEL} if not supported or unknown. + * @see #moveChunk(int, int, long, boolean) + */ + public IntPointXZ getChunkXZ() { + return new IntPointXZ(getChunkX(), getChunkZ()); + } + + /** + * Indicates if this chunk implementation supports calling {@link #moveChunk(int, int, long, boolean)}. + * @return false if {@link #moveChunk(int, int, long, boolean)} is not implemented (calling it will always throw). + */ + public abstract boolean moveChunkImplemented(); + + /** + * Indicates if the current chunk can be be moved with confidence or not. If this function returns false + * and {@link #moveChunkImplemented()} returns true then you must use {@code moveChunk(x, z, true)} to attempt + * a best effort move. + */ + public abstract boolean moveChunkHasFullVersionSupport(); + + /** + * Attempts to update all tags that use absolute positions within this chunk. + *

Call {@link #moveChunkImplemented()} to check if this implementation supports chunk relocation. Also + * check the result of {@link #moveChunkHasFullVersionSupport()} to get an idea of the level of support + * this implementation has for the current chunk. + *

If {@code force = true} the result of calling this function cannot be guaranteed to be complete and + * may still throw {@link UnsupportedOperationException}. + * @param newChunkX new absolute chunk-x + * @param newChunkZ new absolute chunk-z + * @param moveChunkFlags {@link MoveChunkFlags} OR'd together to control move chunk behavior. + * @param force true to ignore the guidance of {@link #moveChunkHasFullVersionSupport()} and make a best effort + * anyway. + * @return true if any data was changed as a result of this call + * @throws UnsupportedOperationException thrown if this chunk implementation doest support moves, or moves + * for this chunks version (possibly even if force = true). + */ + public abstract boolean moveChunk(int newChunkX, int newChunkZ, long moveChunkFlags, boolean force); + + /** + * Calls {@code moveChunk(newChunkX, newChunkZ, moveChunkFlags, false);} + * @see #moveChunk(int, int, long, boolean) + */ + public boolean moveChunk(int chunkX, int chunkZ, long moveChunkFlags) { + return moveChunk(chunkX, chunkZ, moveChunkFlags, false); + } + + /** + * Serializes this chunk to a DataOutput sink. + * @param sink The DataOutput to be written to. + * @param xPos The x-coordinate of the chunk. + * @param zPos The z-coordinate of the chunk. + * @param compressionType Chunk compression strategy to use. + * @param writeByteLengthPrefixInt when true the first thing written to the sink will be the total bytes written + * (a value equal to 4 less than the return value). + * @return The amount of bytes written to the DataOutput. + * @throws UnsupportedOperationException When something went wrong during writing. + * @throws IOException When something went wrong during writing. + */ + public int serialize(DataOutput sink, int xPos, int zPos, CompressionType compressionType, boolean writeByteLengthPrefixInt) throws IOException { + if (partial) { + throw new UnsupportedOperationException("Partially loaded chunks cannot be serialized"); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(4096); + new BinaryNbtSerializer(compressionType).toStream(new NamedTag(null, updateHandle(xPos, zPos)), baos); +// try (BufferedOutputStream nbtOut = new BufferedOutputStream(compressionType.compress(baos))) { +// new BinaryNbtSerializer(false).toStream(new NamedTag(null, updateHandle(xPos, zPos)), nbtOut); +// } + byte[] rawData = baos.toByteArray(); + if (writeByteLengthPrefixInt) + sink.writeInt(rawData.length + 1); // including the byte to store the compression type + sink.writeByte(compressionType.getID()); + sink.write(rawData); + return rawData.length + (writeByteLengthPrefixInt ? 5 : 1); + } + + /** + * Reads chunk data from a RandomAccessFile. The RandomAccessFile must already be at the correct position. + *

It is expected that the byte size int has already been read and the next byte indicates the compression + * used. Essentially this method is symmetrical to {@link #serialize(DataOutput, int, int, CompressionType, boolean)} + * when passing writeByteLengthPrefixInt=false

+ * @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 + * @param lastMCAUpdateTimestamp Last mca update timestamp - epoch seconds. If LT0 the current system timestamp will be used. + * @param chunkAbsXHint The absolute chunk x-coord which should be used if the nbt data doesn't contain this information. + * @param chunkAbsZHint The absolute chunk z-coord which should be used if the nbt data doesn't contain this information. + * @throws IOException When something went wrong during reading. + */ + public void deserialize(RandomAccessFile raf, long loadFlags, int lastMCAUpdateTimestamp, int chunkAbsXHint, int chunkAbsZHint) throws IOException { + deserialize(new FileInputStream(raf.getFD()), loadFlags, lastMCAUpdateTimestamp, chunkAbsXHint, chunkAbsZHint); + } + + /** + * Reads chunk data from an InputStream. The InputStream must already be at the correct position. + *

It is expected that the byte size int has already been read and the next byte indicates the compression + * used. Essentially this method is symmetrical to {@link #serialize(DataOutput, int, int, CompressionType, boolean)} + * when passing writeByteLengthPrefixInt=false

+ * @param inputStream The stream to read the chunk data from. + * @param loadFlags A logical or of {@link LoadFlags} constants indicating what data should be loaded + * @param lastMCAUpdateTimestamp Last mca update timestamp - epoch seconds. If LT0 the current system timestamp will be used. + * @param chunkAbsXHint The absolute chunk x-coord which should be used if the nbt data doesn't contain this information. + * @param chunkAbsZHint The absolute chunk z-coord which should be used if the nbt data doesn't contain this information. + * @throws IOException When something went wrong during reading. + */ + public void deserialize(InputStream inputStream, long loadFlags, int lastMCAUpdateTimestamp, int chunkAbsXHint, int chunkAbsZHint) throws IOException { + int compressionTypeByte = inputStream.read(); + if (compressionTypeByte < 0) + throw new EOFException(); + CompressionType compressionType = CompressionType.getFromID((byte) compressionTypeByte); + if (compressionType == null) { + throw new IOException("invalid compression type " + compressionTypeByte); + } + NamedTag tag = new BinaryNbtDeserializer(compressionType).fromStream(inputStream); + if (tag != null && tag.getTag() instanceof CompoundTag) { + data = (CompoundTag) tag.getTag(); + this.lastMCAUpdate = lastMCAUpdateTimestamp >= 0 ? lastMCAUpdateTimestamp : (int)(System.currentTimeMillis() / 1000); + this.chunkX = chunkAbsXHint; + this.chunkZ = chunkAbsZHint; + 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; + } + + /** + * @throws UnsupportedOperationException thrown if raw is true + */ + protected void checkRaw() { + if (raw) { + throw new UnsupportedOperationException("Cannot use helpers for this field when working with raw data"); + } + } + + protected void checkPartial() { + if (data == null) { + throw new UnsupportedOperationException("Chunk was only partially loaded due to LoadFlags used"); + } + } + + protected void checkChunkXZ() { + if (chunkX == NO_CHUNK_COORD_SENTINEL || chunkZ == NO_CHUNK_COORD_SENTINEL) { + throw new UnsupportedOperationException("This chunk doesn't know its XZ location"); + } + } + + /** + * 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 (data == null) { + throw new UnsupportedOperationException( + "Cannot updateHandle() because data tag is null. This is probably because "+ + "the LoadFlag RELEASE_CHUNK_DATA_TAG was specified"); + } + 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(); + } + + + /** + * @param vaPath version aware nbt path + * @param Return Type + * @return tag value, or null if there is none, or if the given vaPath doesn't support the current version + */ + protected > R getTag(VersionAware vaPath) { + NbtPath path = vaPath.get(dataVersion); + if (path == null) return null; // not supported by this version + return path.getTag(data); + } + + /** + * Simple but powerful helper - example usage + *
{@code long myLong = getTagValue(vaPath, LongTag::asLong, 0L);}
+ * @param vaPath version aware nbt path + * @param evaluator value provider, given the tag (iff not null) + * @param defaultValue value to return if the tag specified by vaPath does not exist + * @param Tag Type + * @param Return Type + * @return result of calling evaluator, or defaultValue if the tag didn't exist + */ + protected , R> R getTagValue(VersionAware vaPath, Function evaluator, R defaultValue) { + TT tag = getTag(vaPath); + return tag != null ? evaluator.apply(tag) : defaultValue; + } + + /** + * @param vaPath version aware nbt path + * @param evaluator value provider, given the tag (iff not null) + * @param Tag Type + * @param Return Type + * @return result of calling evaluator, or NULL if the tag didn't exist + */ + protected , R> R getTagValue(VersionAware vaPath, Function evaluator) { + return getTagValue(vaPath, evaluator, null); + } + + /** + * Sets the given tag, or removes it if null. If tag is not null, parent CompoundTags will be created as-needed. + * If the given vaPath does not support the current data version, then NO ACTION is performed. + * @param vaPath version aware nbt path + * @param tag tag value to set - if null then the value is REMOVED + */ + protected void setTag(VersionAware vaPath, Tag tag) { + NbtPath path = vaPath.get(dataVersion); + if (path == null) return; // not supported by this version + path.putTag(data, tag, tag != null); + } + + /** + * Sets the given tag (if it's not null). Creates parent CompoundTags as-needed. + * If the given vaPath does not support the current data version, then NO ACTION is performed. + * @param vaPath version aware nbt path + * @param tag tag value to set - nothing happens if this value is null + */ + protected void setTagIfNotNull(VersionAware vaPath, Tag tag) { + if (tag != null) { + setTag(vaPath, tag); + } + } + + /** + * @return Index of this chunk in its owning region file or -1 if either chunk X or Z is + * {@link #NO_CHUNK_COORD_SENTINEL}. + */ + public int getIndex() { + if (getChunkX() != NO_CHUNK_COORD_SENTINEL && getChunkZ() != NO_CHUNK_COORD_SENTINEL) { + return McaFileBase.getChunkIndex(getChunkX(), getChunkZ()); + } + return -1; + } + + /** + * Gets the region file X coord which this chunk should belong to given its current {@link #getChunkX()}. + * Returns {@link #NO_CHUNK_COORD_SENTINEL} if {@link #getChunkX()} returns {@link #NO_CHUNK_COORD_SENTINEL}. + */ + public int getRegionX() { + int x = getChunkX(); + return x != NO_CHUNK_COORD_SENTINEL ? x >> 5 : NO_CHUNK_COORD_SENTINEL; + } + + /** + * Gets the region file Z coord which this chunk should belong to given its current {@link #getChunkZ()}. + * Returns {@link #NO_CHUNK_COORD_SENTINEL} if {@link #getChunkX()} returns {@link #NO_CHUNK_COORD_SENTINEL}. + */ + public int getRegionZ() { + int z = getChunkZ(); + return z != NO_CHUNK_COORD_SENTINEL ? z >> 5 : NO_CHUNK_COORD_SENTINEL; + } + + /** + * Gets the region file XZ coord which this chunk should belong to given its current {@link #getChunkXZ()}. + * Returns XZ({@link #NO_CHUNK_COORD_SENTINEL}, {@link #NO_CHUNK_COORD_SENTINEL}) if {@link #getChunkXZ()} returns + * XZ({@link #NO_CHUNK_COORD_SENTINEL}, {@link #NO_CHUNK_COORD_SENTINEL}). + */ + public IntPointXZ getRegionXZ() { + return new IntPointXZ(getRegionX(), getRegionZ()); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/DataVersion.java b/src/main/java/io/github/ensgijs/nbt/mca/DataVersion.java new file mode 100644 index 00000000..fca1562d --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/DataVersion.java @@ -0,0 +1,1050 @@ +package io.github.ensgijs.nbt.mca; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.Locale; + +// 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 +// google sheet to help generate enum values https://docs.google.com/spreadsheets/d/1VVGUPe9sfsd3rsFYcBGDnt1bTifBh3dUkKV9vQvNWQY +// - paste rows from the fandom table into the sheet and sort ascending by data version (if you don't sort it the mc version WILL BE WRONG!) +// +// As the wiki has been lacking in freshness lately the test DataVersionTest#testFetchMissingDataVersionInformation +// will help keep this enum updated for all official builds - which may exclude experimental builds, but it sure +// beats having to farm the data by hand. + +/** + * List of MC versions and MCA data versions back to 1.9.0 + *

+ * TODO: weekly builds don't really fit with having a version but it's annoying to not have a version too - what to do? + *

+ */ +public enum DataVersion { + // TODO: document change history by digging through net.minecraft.util.datafix.DataConverterRegistry + // Kept in ASC order (unit test enforced) + UNKNOWN(0, 0, 0), + JAVA_1_9_15W32A(100, 9, 0, "15w32a"), + JAVA_1_9_0(169, 9, 0), + JAVA_1_9_1_PRE1(170, 9, 1, "PRE1"), + JAVA_1_9_1_PRE2(171, 9, 1, "PRE2"), + JAVA_1_9_1_PRE3(172, 9, 1, "PRE3"), + JAVA_1_9_1(175, 9, 1), + JAVA_1_9_2(176, 9, 2), + JAVA_1_9_3_16W14A(177, 9, 3, "16w14a"), + JAVA_1_9_3_16W15A(178, 9, 3, "16w15a"), + JAVA_1_9_3_16W15B(179, 9, 3, "16w15b"), + JAVA_1_9_3_PRE1(180, 9, 3, "PRE1"), + JAVA_1_9_3_PRE2(181, 9, 3, "PRE2"), + JAVA_1_9_3_PRE3(182, 9, 3, "PRE3"), + JAVA_1_9_3(183, 9, 3), + JAVA_1_9_4(184, 9, 4), + JAVA_1_10_16W20A(501, 10, 0, "16w20a"), + JAVA_1_10_16W21A(503, 10, 0, "16w21a"), + JAVA_1_10_16W21B(504, 10, 0, "16w21b"), + JAVA_1_10_PRE1(506, 10, 0, "PRE1"), + JAVA_1_10_PRE2(507, 10, 0, "PRE2"), + JAVA_1_10_0(510, 10, 0), + JAVA_1_10_1(511, 10, 1), + JAVA_1_10_2(512, 10, 2), + JAVA_1_11_16W32A(800, 11, 0, "16w32a"), + JAVA_1_11_16W32B(801, 11, 0, "16w32b"), + JAVA_1_11_16W33A(802, 11, 0, "16w33a"), + JAVA_1_11_16W35A(803, 11, 0, "16w35a"), + JAVA_1_11_16W36A(805, 11, 0, "16w36a"), + JAVA_1_11_16W38A(807, 11, 0, "16w38a"), + JAVA_1_11_16W39A(809, 11, 0, "16w39a"), + JAVA_1_11_16W39B(811, 11, 0, "16w39b"), + JAVA_1_11_16W39C(812, 11, 0, "16w39c"), + JAVA_1_11_16W40A(813, 11, 0, "16w40a"), + JAVA_1_11_16W41A(814, 11, 0, "16w41a"), + JAVA_1_11_16W42A(815, 11, 0, "16w42a"), + JAVA_1_11_16W43A(816, 11, 0, "16w43a"), + JAVA_1_11_16W44A(817, 11, 0, "16w44a"), + JAVA_1_11_PRE1(818, 11, 0, "PRE1"), + JAVA_1_11_0(819, 11, 0), + JAVA_1_11_1_16W50A(920, 11, 1, "16w50a"), + JAVA_1_11_1(921, 11, 1), + JAVA_1_11_2(922, 11, 2), + JAVA_1_12_17W06A(1022, 12, 0, "17w06a"), + JAVA_1_12_17W13A(1122, 12, 0, "17w13a"), + JAVA_1_12_17W13B(1123, 12, 0, "17w13b"), + JAVA_1_12_17W14A(1124, 12, 0, "17w14a"), + JAVA_1_12_17W15A(1125, 12, 0, "17w15a"), + JAVA_1_12_17W16A(1126, 12, 0, "17w16a"), + JAVA_1_12_17W16B(1127, 12, 0, "17w16b"), + JAVA_1_12_17W17A(1128, 12, 0, "17w17a"), + JAVA_1_12_17W17B(1129, 12, 0, "17w17b"), + JAVA_1_12_17W18A(1130, 12, 0, "17w18a"), + JAVA_1_12_17W18B(1131, 12, 0, "17w18b"), + JAVA_1_12_PRE1(1132, 12, 0, "PRE1"), + JAVA_1_12_PRE2(1133, 12, 0, "PRE2"), + JAVA_1_12_PRE3(1134, 12, 0, "PRE3"), + JAVA_1_12_PRE4(1135, 12, 0, "PRE4"), + JAVA_1_12_PRE5(1136, 12, 0, "PRE5"), + JAVA_1_12_PRE6(1137, 12, 0, "PRE6"), + JAVA_1_12_PRE7(1138, 12, 0, "PRE7"), + JAVA_1_12_0(1139, 12, 0), + JAVA_1_12_1_17W31A(1239, 12, 1, "17w31a"), + JAVA_1_12_1_PRE1(1240, 12, 1, "PRE1"), + JAVA_1_12_1(1241, 12, 1), + JAVA_1_12_2_PRE1(1341, 12, 2, "PRE1"), + JAVA_1_12_2_PRE2(1342, 12, 2, "PRE2"), + JAVA_1_12_2(1343, 12, 2), + JAVA_1_13_17W43A(1444, 13, 0, "17w43a"), + JAVA_1_13_17W43B(1445, 13, 0, "17w43b"), + JAVA_1_13_17W45A(1447, 13, 0, "17w45a"), + JAVA_1_13_17W45B(1448, 13, 0, "17w45b"), + JAVA_1_13_17W46A(1449, 13, 0, "17w46a"), + /** "Blocks" and "Data" were replaced with block palette */ + JAVA_1_13_17W47A(1451, 13, 0, "17w47a"), + JAVA_1_13_17W47B(1452, 13, 0, "17w47b"), + JAVA_1_13_17W48A(1453, 13, 0, "17w48a"), + JAVA_1_13_17W49A(1454, 13, 0, "17w49a"), + JAVA_1_13_17W49B(1455, 13, 0, "17w49b"), + JAVA_1_13_17W50A(1457, 13, 0, "17w50a"), + JAVA_1_13_18W01A(1459, 13, 0, "18w01a"), + JAVA_1_13_18W02A(1461, 13, 0, "18w02a"), + JAVA_1_13_18W03A(1462, 13, 0, "18w03a"), + JAVA_1_13_18W03B(1463, 13, 0, "18w03b"), + JAVA_1_13_18W05A(1464, 13, 0, "18w05a"), + /** + * Biome data now stored in IntArrayTag instead of ByteArrayTag (still 2D using only 256 entries). + *

Tags Removed

+ *
    + *
  • region: Level.Biomes <ByteArrayTag> (type changed)
  • + *
  • region: Level.HeightMap <IntArrayTag>
  • + *
  • region: Level.LightPopulated <ByteTag>
  • + *
  • region: Level.TerrainPopulated <ByteTag> (replaced by Status string)
  • + *
+ *

Tags Added

+ *
    + *
  • region: Level.Biomes <IntArrayTag>
  • + *
  • region: Level.Heightmaps <CompoundTag>
  • + *
  • region: Level.Heightmaps.LIGHT <LongArrayTag>
  • + *
  • region: Level.Heightmaps.LIQUID <LongArrayTag>
  • + *
  • region: Level.Heightmaps.RAIN <LongArrayTag>
  • + *
  • region: Level.Heightmaps.SOLID <LongArrayTag>
  • + *
  • region: Level.Lights <ListTag<ListTag<ShortTag>>>
  • + *
  • region: Level.PostProcessing <ListTag<ListTag<ShortTag>>>
  • + *
  • region: Level.Status <StringTag>
  • + *
  • region: Level.Structures <CompoundTag>
  • + *
  • region: Level.Structures.References <CompoundTag> + *
    Keys are the name of a structure type such as "Desert_Pyramid". + *
    Values are <LongArrayTag> which are packed chunk coordinates where Z is packed in the high 32 bits and X is in the low 32 bits.
  • + *
  • region: Level.Structures.Starts <CompoundTag> + *
    Keys are the name of a structure type such as "Desert_Pyramid". + *
    Values are <CompoundTag> defining structure bounds and generation information.
  • + *
  • region: Level.ToBeTicked <ListTag<ListTag<ShortTag>>>
  • + *
+ */ + JAVA_1_13_18W06A(1466, 13, 0, "18w06a"), + JAVA_1_13_18W07A(1467, 13, 0, "18w07a"), + JAVA_1_13_18W07B(1468, 13, 0, "18w07b"), + JAVA_1_13_18W07C(1469, 13, 0, "18w07c"), + JAVA_1_13_18W08A(1470, 13, 0, "18w08a"), + JAVA_1_13_18W08B(1471, 13, 0, "18w08b"), + JAVA_1_13_18W09A(1472, 13, 0, "18w09a"), + JAVA_1_13_18W10A(1473, 13, 0, "18w10a"), + JAVA_1_13_18W10B(1474, 13, 0, "18w10b"), + JAVA_1_13_18W10C(1476, 13, 0, "18w10c"), + JAVA_1_13_18W10D(1477, 13, 0, "18w10d"), + JAVA_1_13_18W11A(1478, 13, 0, "18w11a"), + JAVA_1_13_18W14A(1479, 13, 0, "18w14a"), + JAVA_1_13_18W14B(1481, 13, 0, "18w14b"), + JAVA_1_13_18W15A(1482, 13, 0, "18w15a"), + /** + *

Tags Added

+ *
    + *
  • region: Level.LiquidTicks <ListTag<CompoundTag>>
  • + *
  • region: Level.LiquidTicks[].i <StringTag>
  • + *
  • region: Level.LiquidTicks[].p <IntTag>
  • + *
  • region: Level.LiquidTicks[].t <IntTag>
  • + *
  • region: Level.LiquidTicks[].x <IntTag>
  • + *
  • region: Level.LiquidTicks[].y <IntTag>
  • + *
  • region: Level.LiquidTicks[].z <IntTag>
  • + *
  • region: Level.LiquidsToBeTicked <ListTag<ListTag<ShortTag>>>
  • + *
+ */ + JAVA_1_13_18W16A(1483, 13, 0, "18w16a"), + + /** + * + *

Tags Removed

+ *
    + *
  • region: Level.Heightmaps.LIGHT <LongArrayTag>
  • + *
  • region: Level.Heightmaps.LIQUID <LongArrayTag>
  • + *
  • region: Level.Heightmaps.RAIN <LongArrayTag>
  • + *
  • region: Level.Heightmaps.SOLID <LongArrayTag>
  • + *
+ *

Tags Added

+ *
    + *
  • region: Level.CarvingMasks <CompoundTag>
  • + *
  • region: Level.CarvingMasks.AIR <ByteArrayTag>
  • + *
  • region: Level.CarvingMasks.LIQUID <ByteArrayTag>
  • + *
  • region: Level.Heightmaps.LIGHT_BLOCKING <LongArrayTag>
  • + *
  • region: Level.Heightmaps.MOTION_BLOCKING <LongArrayTag>
  • + *
  • region: Level.Heightmaps.MOTION_BLOCKING_NO_LEAVES <LongArrayTag>
  • + *
  • region: Level.Heightmaps.OCEAN_FLOOR <LongArrayTag>
  • + *
  • region: Level.Heightmaps.OCEAN_FLOOR_WG <LongArrayTag>
  • + *
  • region: Level.Heightmaps.WORLD_SURFACE_WG <LongArrayTag>
  • + *
+ */ + JAVA_1_13_18W19A(1484, 13, 0, "18w19a"), + JAVA_1_13_18W19B(1485, 13, 0, "18w19b"), + JAVA_1_13_18W20A(1489, 13, 0, "18w20a"), + JAVA_1_13_18W20B(1491, 13, 0, "18w20b"), + /** Believe this to be the end of the Level.hasLegacyStructureData tag */ + JAVA_1_13_18W20C(1493, 13, 0, "18w20c"), + JAVA_1_13_18W21A(1495, 13, 0, "18w21a"), + JAVA_1_13_18W21B(1496, 13, 0, "18w21b"), + JAVA_1_13_18W22A(1497, 13, 0, "18w22a"), + JAVA_1_13_18W22B(1498, 13, 0, "18w22b"), + JAVA_1_13_18W22C(1499, 13, 0, "18w22c"), + JAVA_1_13_PRE1(1501, 13, 0, "PRE1"), + JAVA_1_13_PRE2(1502, 13, 0, "PRE2"), + /** + *

Tags Added

+ *
    + *
  • region: Level.Heightmaps.WORLD_SURFACE <LongArrayTag>
  • + *
+ */ + JAVA_1_13_PRE3(1503, 13, 0, "PRE3"), + JAVA_1_13_PRE4(1504, 13, 0, "PRE4"), + // 1506 -- legacy biome id mapping changed + JAVA_1_13_PRE5(1511, 13, 0, "PRE5"), + JAVA_1_13_PRE6(1512, 13, 0, "PRE6"), + JAVA_1_13_PRE7(1513, 13, 0, "PRE7"), + JAVA_1_13_PRE8(1516, 13, 0, "PRE8"), + JAVA_1_13_PRE9(1517, 13, 0, "PRE9"), + JAVA_1_13_PRE10(1518, 13, 0, "PRE10"), + JAVA_1_13_0(1519, 13, 0), + JAVA_1_13_1_18W30A(1620, 13, 1, "18w30a"), + JAVA_1_13_1_18W30B(1621, 13, 1, "18w30b"), + JAVA_1_13_1_18W31A(1622, 13, 1, "18w31a"), + JAVA_1_13_1_18W32A(1623, 13, 1, "18w32a"), + JAVA_1_13_1_18W33A(1625, 13, 1, "18w33a"), + JAVA_1_13_1_PRE1(1626, 13, 1, "PRE1"), + JAVA_1_13_1_PRE2(1627, 13, 1, "PRE2"), + JAVA_1_13_1(1628, 13, 1), + JAVA_1_13_2_PRE1(1629, 13, 2, "PRE1"), + JAVA_1_13_2_PRE2(1630, 13, 2, "PRE2"), + JAVA_1_13_2(1631, 13, 2), + JAVA_1_14_18W43A(1901, 14, 0, "18w43a"), + JAVA_1_14_18W43B(1902, 14, 0, "18w43b"), + JAVA_1_14_18W43C(1903, 14, 0, "18w43c"), + /** + *

Tags Added

+ *
    + *
  • region: Level.NoiseMask <ByteArrayTag>
  • + *
+ */ + JAVA_1_14_18W44A(1907, 14, 0, "18w44a"), + JAVA_1_14_18W45A(1908, 14, 0, "18w45a"), + /** + *

Tags Removed

+ *
    + *
  • region: Level.NoiseMask <ByteArrayTag>
  • + *
+ */ + JAVA_1_14_18W46A(1910, 14, 0, "18w46a"), + JAVA_1_14_18W47A(1912, 14, 0, "18w47a"), + JAVA_1_14_18W47B(1913, 14, 0, "18w47b"), + JAVA_1_14_18W48A(1914, 14, 0, "18w48a"), + JAVA_1_14_18W48B(1915, 14, 0, "18w48b"), + JAVA_1_14_18W49A(1916, 14, 0, "18w49a"), + + /** + * FIRST SEEN (may have been added before this version). Villagers gain professions? + *
    + *
  • region: Level.Entities[].VillagerData.level <IntTag>
  • + *
  • region: Level.Entities[].VillagerData.profession <StringTag>
  • + *
  • region: Level.Entities[].VillagerData.type <StringTag>
  • + *
+ */ + JAVA_1_14_18W50A(1919, 14, 0, "18w50a"), + /** + *

Tags Added

+ *
    + *
  • region: Level.isLightOn <ByteTag>
  • + *
+ */ + JAVA_1_14_19W02A(1921, 14, 0, "19w02a"), + JAVA_1_14_19W03A(1922, 14, 0, "19w03a"), + JAVA_1_14_19W03B(1923, 14, 0, "19w03b"), + JAVA_1_14_19W03C(1924, 14, 0, "19w03c"), + JAVA_1_14_19W04A(1926, 14, 0, "19w04a"), + JAVA_1_14_19W04B(1927, 14, 0, "19w04b"), + JAVA_1_14_19W05A(1930, 14, 0, "19w05a"), + JAVA_1_14_19W06A(1931, 14, 0, "19w06a"), + JAVA_1_14_19W07A(1932, 14, 0, "19w07a"), + JAVA_1_14_19W08A(1933, 14, 0, "19w08a"), + JAVA_1_14_19W08B(1934, 14, 0, "19w08b"), + JAVA_1_14_19W09A(1935, 14, 0, "19w09a"), + /** + * /poi/r.X.Z.mca files introduced with a premature nbt structure. POI files not supported by this library until + * {@link #JAVA_1_14_PRE1}. Note this poi format did not include a DataVersion. + *

Temporary POI Structure

+ *
    + *
  • poi: # <ListTag<CompoundTag>> - the keys <#> are a number literal indicating the chunk section Y
  • + *
  • poi: #[].free_tickets <IntTag>
  • + *
  • poi: #[].pos <IntArrayTag>
  • + *
  • poi: #[].type <StringTag>
  • + *
+ *

Villagers got brains ({@code Entities[].Brain}) in the region file data.

+ */ + JAVA_1_14_19W11A(1937, 14, 0, "19w11a"), + JAVA_1_14_19W11B(1938, 14, 0, "19w11b"), + JAVA_1_14_19W12A(1940, 14, 0, "19w12a"), + JAVA_1_14_19W12B(1941, 14, 0, "19w12b"), + JAVA_1_14_19W13A(1942, 14, 0, "19w13a"), + JAVA_1_14_19W13B(1943, 14, 0, "19w13b"), + JAVA_1_14_19W14A(1944, 14, 0, "19w14a"), + JAVA_1_14_19W14B(1945, 14, 0, "19w14b"), + /** + * POI tag structure changed. Begin this library's support of POI files. + *

Final POI Structure

+ *
    + *
  • poi: DataVersion <IntTag>
  • + *
  • poi: Sections <CompoundTag>
  • + *
  • poi: Sections.# <CompoundTag> - the keys <#> are a number literal indicating the chunk section Y
  • + *
  • poi: Sections.#.Records <ListTag<CompoundTag>>
  • + *
  • poi: Sections.#.Records[].free_tickets <IntTag>
  • + *
  • poi: Sections.#.Records[].pos <IntArrayTag>
  • + *
  • poi: Sections.#.Records[].type <StringTag>
  • + *
  • poi: Sections.#.Valid <ByteTag> (boolean)
  • + *
+ */ + JAVA_1_14_PRE1(1947, 14, 0, "PRE1"), + JAVA_1_14_PRE2(1948, 14, 0, "PRE2"), + /** + *

Tags Removed

+ *
    + *
  • region: Level.CarvingMasks.AIR <ByteArrayTag>
  • + *
  • region: Level.CarvingMasks.LIQUID <ByteArrayTag>
  • + *
  • region: Level.LiquidsToBeTicked <ListTag<ListTag<ShortTag>>> - NOTE: JAVA_1_18_21W43A change notes make reference to this tag so IDK
  • + *
  • region: Level.ToBeTicked <ListTag<ListTag<ShortTag>>> - NOTE: JAVA_1_18_21W43A change notes make reference to this tag so IDK
  • + *
+ */ + JAVA_1_14_PRE3(1949, 14, 0, "PRE3"), + JAVA_1_14_PRE4(1950, 14, 0, "PRE4"), + JAVA_1_14_PRE5(1951, 14, 0, "PRE5"), + JAVA_1_14_0(1952, 14, 0), + JAVA_1_14_1_PRE1(1955, 14, 1, "PRE1"), + JAVA_1_14_1_PRE2(1956, 14, 1, "PRE2"), + JAVA_1_14_1(1957, 14, 1), + JAVA_1_14_2_PRE1(1958, 14, 2, "PRE1"), + JAVA_1_14_2_PRE2(1959, 14, 2, "PRE2"), + JAVA_1_14_2_PRE3(1960, 14, 2, "PRE3"), + JAVA_1_14_2_PRE4(1962, 14, 2, "PRE4"), + JAVA_1_14_2(1963, 14, 2), + JAVA_1_14_3_PRE1(1964, 14, 3, "PRE1"), + JAVA_1_14_3_PRE2(1965, 14, 3, "PRE2"), + JAVA_1_14_3_PRE3(1966, 14, 3, "PRE3"), + JAVA_1_14_3_PRE4(1967, 14, 3, "PRE4"), + JAVA_1_14_3(1968, 14, 3), + JAVA_1_14_4_PRE1(1969, 14, 4, "PRE1"), + JAVA_1_14_4_PRE2(1970, 14, 4, "PRE2"), + JAVA_1_14_4_PRE3(1971, 14, 4, "PRE3"), + JAVA_1_14_4_PRE4(1972, 14, 4, "PRE4"), + JAVA_1_14_4_PRE5(1973, 14, 4, "PRE5"), + JAVA_1_14_4_PRE6(1974, 14, 4, "PRE6"), + JAVA_1_14_4_PRE7(1975, 14, 4, "PRE7"), + /** First version where Mojang published jar deobfuscation mappings. */ + JAVA_1_14_4(1976, 14, 4), +// JAVA_1_14_3_CT1(2067, 14, 3, "CT1"), +// JAVA_1_15_CT2(2068, 15, 0, "CT2"), +// JAVA_1_15_CT3(2069, 15, 0, "CT3"), + /** Bees introduced. */ + JAVA_1_15_19W34A(2200, 15, 0, "19w34a"), + JAVA_1_15_19W35A(2201, 15, 0, "19w35a"), + /** + * 3D Biomes added. Biomes array in the Level tag for each chunk changed + * to contain 1024 integers instead of 256 see {@link TerrainChunk} + */ + JAVA_1_15_19W36A(2203, 15, 0, "19w36a"), + JAVA_1_15_19W37A(2204, 15, 0, "19w37a"), + JAVA_1_15_19W38A(2205, 15, 0, "19w38a"), + JAVA_1_15_19W38B(2206, 15, 0, "19w38b"), + JAVA_1_15_19W39A(2207, 15, 0, "19w39a"), + JAVA_1_15_19W40A(2208, 15, 0, "19w40a"), + JAVA_1_15_19W41A(2210, 15, 0, "19w41a"), + JAVA_1_15_19W42A(2212, 15, 0, "19w42a"), + JAVA_1_15_19W44A(2213, 15, 0, "19w44a"), + JAVA_1_15_19W45A(2214, 15, 0, "19w45a"), + JAVA_1_15_19W45B(2215, 15, 0, "19w45b"), + JAVA_1_15_19W46A(2216, 15, 0, "19w46a"), + JAVA_1_15_19W46B(2217, 15, 0, "19w46b"), + JAVA_1_15_PRE1(2218, 15, 0, "PRE1"), + JAVA_1_15_PRE2(2219, 15, 0, "PRE2"), + JAVA_1_15_PRE3(2220, 15, 0, "PRE3"), + JAVA_1_15_PRE4(2221, 15, 0, "PRE4"), + JAVA_1_15_PRE5(2222, 15, 0, "PRE5"), + JAVA_1_15_PRE6(2223, 15, 0, "PRE6"), + JAVA_1_15_PRE7(2224, 15, 0, "PRE7"), + JAVA_1_15_0(2225, 15, 0), + JAVA_1_15_1_PRE1(2226, 15, 1, "PRE1"), + JAVA_1_15_1(2227, 15, 1), + JAVA_1_15_2_PRE1(2228, 15, 2, "PRE1"), + JAVA_1_15_2_PRE2(2229, 15, 2, "PRE2"), + JAVA_1_15_2(2230, 15, 2), +// JAVA_1_16_CT4(2320, 16, 0, "CT4"), +// JAVA_1_16_CT5(2321, 16, 0, "CT5"), + JAVA_1_16_20W06A(2504, 16, 0, "20w06a"), + JAVA_1_16_20W07A(2506, 16, 0, "20w07a"), + JAVA_1_16_20W08A(2507, 16, 0, "20w08a"), + JAVA_1_16_20W09A(2510, 16, 0, "20w09a"), + JAVA_1_16_20W10A(2512, 16, 0, "20w10a"), + JAVA_1_16_20W11A(2513, 16, 0, "20w11a"), + /** + * Entity UUID data storage changed. + * + *

Tags Removed

+ *
    + *
  • region: Level.Entities[].Attributes[].Modifiers[].UUIDLeast <LongTag>
  • + *
  • region: Level.Entities[].Attributes[].Modifiers[].UUIDMost <LongTag>
  • + *
  • region: Level.Entities[].UUIDLeast <LongTag>
  • + *
  • region: Level.Entities[].UUIDMost <LongTag>
  • + *
+ *

Tags Added

+ *
    + *
  • region: Level.Entities[].Attributes[].Modifiers[].UUID <IntArrayTag[4]>
  • + *
  • region: Level.Entities[].UUID <IntArrayTag>
  • + *
+ */ + JAVA_1_16_20W12A(2515, 16, 0, "20w12a"), + JAVA_1_16_20W13A(2520, 16, 0, "20w13a"), + JAVA_1_16_20W13B(2521, 16, 0, "20w13b"), + JAVA_1_16_20W14A(2524, 16, 0, "20w14a"), + JAVA_1_16_20W15A(2525, 16, 0, "20w15a"), + JAVA_1_16_20W16A(2526, 16, 0, "20w16a"), + /** Block palette packing changed in this version - see {@link TerrainSection} */ + JAVA_1_16_20W17A(2529, 16, 0, "20w17a"), + JAVA_1_16_20W18A(2532, 16, 0, "20w18a"), + JAVA_1_16_20W19A(2534, 16, 0, "20w19a"), + /** The server.jar build of this version was DOA with a null pointer exception on initialization. */ + JAVA_1_16_20W20A(2536, 16, 0, "20w20a"), + JAVA_1_16_20W20B(2537, 16, 0, "20w20b"), + /** + * Structure name format changed from Caps_Snake_Case to lower_snake_case. + *

Example: Level.Structures.References.Desert_Pyramid became Level.Structures.References.desert_pyramid

+ *

Example: Level.Structures.Starts.Desert_Pyramid became Level.Structures.Starts.desert_pyramid

+ * + */ + JAVA_1_16_20W21A(2554, 16, 0, "20w21a"), + JAVA_1_16_20W22A(2555, 16, 0, "20w22a"), + /** + *

Tags Removed

+ *
    + *
  • region: Level.Entities[].Angry <ByteTag>
  • + *
  • region: Level.TileEntities[].Bees[].EntityData.Anger <IntTag>
  • + *
+ *

Tags Added

+ *
    + *
  • region: Level.Entities[].AngerTime <IntTag>
  • + *
  • region: Level.TileEntities[].Bees[].EntityData.AngerTime <IntTag>
  • + *
+ */ + JAVA_1_16_PRE1(2556, 16, 0, "PRE1"), + JAVA_1_16_PRE2(2557, 16, 0, "PRE2"), + JAVA_1_16_PRE3(2559, 16, 0, "PRE3"), + JAVA_1_16_PRE4(2560, 16, 0, "PRE4"), + /** + * FIRST SEEN (may have been added prior to this version) + *
    + *
  • region: Level.Entities[].AngryAt <IntArrayTag>
  • + *
+ */ + JAVA_1_16_PRE5(2561, 16, 0, "PRE5"), + JAVA_1_16_PRE6(2562, 16, 0, "PRE6"), + JAVA_1_16_PRE7(2563, 16, 0, "PRE7"), + JAVA_1_16_PRE8(2564, 16, 0, "PRE8"), + JAVA_1_16_RC1(2565, 16, 0, "RC1"), + JAVA_1_16_0(2566, 16, 0), + JAVA_1_16_1(2567, 16, 1), + JAVA_1_16_2_20W27A(2569, 16, 2, "20w27a"), + JAVA_1_16_2_20W28A(2570, 16, 2, "20w28a"), + JAVA_1_16_2_20W29A(2571, 16, 2, "20w29a"), + JAVA_1_16_2_20W30A(2572, 16, 2, "20w30a"), + JAVA_1_16_2_PRE1(2573, 16, 2, "PRE1"), + JAVA_1_16_2_PRE2(2574, 16, 2, "PRE2"), + JAVA_1_16_2_PRE3(2575, 16, 2, "PRE3"), + JAVA_1_16_2_RC1(2576, 16, 2, "RC1"), + JAVA_1_16_2_RC2(2577, 16, 2, "RC2"), + JAVA_1_16_2(2578, 16, 2), + JAVA_1_16_3_RC1(2579, 16, 3, "RC1"), + JAVA_1_16_3(2580, 16, 3), + JAVA_1_16_4_PRE1(2581, 16, 4, "PRE1"), + JAVA_1_16_4_PRE2(2582, 16, 4, "PRE2"), + JAVA_1_16_4_RC1(2583, 16, 4, "RC1"), + JAVA_1_16_4(2584, 16, 4), + JAVA_1_16_5_RC1(2585, 16, 5, "RC1"), + 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 AND (allegedly) during some phases of + * chunk generation. + *

https://www.minecraft.net/en-us/article/minecraft-snapshot-20w45a

+ */ + JAVA_1_17_20W45A(2681, 17, 0, "20w45a"), + JAVA_1_17_20W46A(2682, 17, 0, "20w46a"), + JAVA_1_17_20W48A(2683, 17, 0, "20w48a"), + JAVA_1_17_20W49A(2685, 17, 0, "20w49a"), + JAVA_1_17_20W51A(2687, 17, 0, "20w51a"), + JAVA_1_17_21W03A(2689, 17, 0, "21w03a"), + JAVA_1_17_21W05A(2690, 17, 0, "21w05a"), + JAVA_1_17_21W05B(2692, 17, 0, "21w05b"), + JAVA_1_17_21W06A(2694, 17, 0, "21w06a"), + JAVA_1_17_21W07A(2695, 17, 0, "21w07a"), + JAVA_1_17_21W08A(2697, 17, 0, "21w08a"), + JAVA_1_17_21W08B(2698, 17, 0, "21w08b"), + JAVA_1_17_21W10A(2699, 17, 0, "21w10a"), +// JAVA_1_17_CT6(2701, 17, 0, "CT6"), +// JAVA_1_17_CT7(2702, 17, 0, "CT7"), + JAVA_1_17_21W11A(2703, 17, 0, "21w11a"), +// JAVA_1_17_CT7B(2703, 17, 0, "CT7b"), -- ambiguous data version +// JAVA_1_17_CT7C(2704, 17, 0, "CT7c"), + JAVA_1_17_21W13A(2705, 17, 0, "21w13a"), +// JAVA_1_17_CT8(2705, 17, 0, "CT8"), -- ambiguous data version + JAVA_1_17_21W14A(2706, 17, 0, "21w14a"), +// JAVA_1_17_CT8B(2706, 17, 0, "CT8b"), -- ambiguous data version +// JAVA_1_17_CT8C(2707, 17, 0, "CT8c"), + JAVA_1_17_21W15A(2709, 17, 0, "21w15a"), + JAVA_1_17_21W16A(2711, 17, 0, "21w16a"), + JAVA_1_17_21W17A(2712, 17, 0, "21w17a"), + JAVA_1_17_21W18A(2713, 17, 0, "21w18a"), + JAVA_1_17_21W19A(2714, 17, 0, "21w19a"), + JAVA_1_17_21W20A(2715, 17, 0, "21w20a"), + JAVA_1_17_PRE1(2716, 17, 0, "PRE1"), + JAVA_1_17_PRE2(2718, 17, 0, "PRE2"), + JAVA_1_17_PRE3(2719, 17, 0, "PRE3"), + JAVA_1_17_PRE4(2720, 17, 0, "PRE4"), + JAVA_1_17_PRE5(2721, 17, 0, "PRE5"), + JAVA_1_17_RC1(2722, 17, 0, "RC1"), + JAVA_1_17_RC2(2723, 17, 0, "RC2"), + JAVA_1_17_0(2724, 17, 0), + JAVA_1_17_1_PRE1(2725, 17, 1, "PRE1"), + JAVA_1_17_1_PRE2(2726, 17, 1, "PRE2"), + JAVA_1_17_1_PRE3(2727, 17, 1, "PRE3"), + JAVA_1_17_1_RC1(2728, 17, 1, "RC1"), + JAVA_1_17_1_RC2(2729, 17, 1, "RC2"), + JAVA_1_17_1(2730, 17, 1), +// JAVA_1_18_XS1(2825, 18, 0, "XS1"), +// JAVA_1_18_XS2(2826, 18, 0, "XS2"), +// JAVA_1_18_XS3(2827, 18, 0, "XS3"), +// JAVA_1_18_XS4(2828, 18, 0, "XS4"), +// JAVA_1_18_XS5(2829, 18, 0, "XS5"), +// JAVA_1_18_XS6(2830, 18, 0, "XS6"), +// JAVA_1_18_XS7(2831, 18, 0, "XS7"), + /** + * article 21w39a + * (yes, they didn't document these changes until a later weekly snapshot). + *
    + *
  • 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 + *
+ *

Tags Removed

+ *
    + *
  • region: Level.Biomes <IntArrayTag>
  • + *
  • region: Level.Sections[].BlockStates <LongArrayTag>
  • + *
  • region: Level.Sections[].Palette <ListTag<CompoundTag>>
  • + *
+ *

Tags Added

+ *
    + *
  • region: Level.Sections[].biomes <CompoundTag>
  • + *
  • region: Level.Sections[].biomes.data <LongArrayTag>
  • + *
  • region: Level.Sections[].biomes.palette <ListTag<StringTag>>
  • + *
  • region: Level.Sections[].block_states <CompoundTag>
  • + *
  • region: Level.Sections[].block_states.data <LongArrayTag>
  • + *
  • region: Level.Sections[].block_states.palette <ListTag<CompoundTag>>
  • + *
+ *

About the New Biome Palette

+ *
  • Consists of 64 entries, representing 4×4×4 biome regions in the chunk section.
  • + *
  • When `palette` contains a single entry `data` will be omitted and the full chunk section is composed of a single biome.
+ */ + // 2832 -- exact point of above noted changes + JAVA_1_18_21W37A(2834, 18, 0, "21w37a"), + JAVA_1_18_21W38A(2835, 18, 0, "21w38a"), + JAVA_1_18_21W39A(2836, 18, 0, "21w39a"), + JAVA_1_18_21W40A(2838, 18, 0, "21w40a"), + JAVA_1_18_21W41A(2839, 18, 0, "21w41a"), + JAVA_1_18_21W42A(2840, 18, 0, "21w42a"), + /** + * 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 -- entities are stored in the terrain region file during chunk generation + *
    It actually appears this tag may have been removed entirely from region mca files until {@link #JAVA_1_18_2_22W03A} + *
    Note: Hilariously, the name remains capitalized in entities mca files. + *
  • 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 + *
  • 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, 0, "21w43a"), + JAVA_1_18_21W44A(2845, 18, 0, "21w44a"), + JAVA_1_18_PRE1(2847, 18, 0, "PRE1"), + JAVA_1_18_PRE2(2848, 18, 0, "PRE2"), + JAVA_1_18_PRE3(2849, 18, 0, "PRE3"), + JAVA_1_18_PRE4(2850, 18, 0, "PRE4"), + JAVA_1_18_PRE5(2851, 18, 0, "PRE5"), + JAVA_1_18_PRE6(2853, 18, 0, "PRE6"), + JAVA_1_18_PRE7(2854, 18, 0, "PRE7"), + JAVA_1_18_PRE8(2855, 18, 0, "PRE8"), + JAVA_1_18_RC1(2856, 18, 0, "RC1"), + JAVA_1_18_RC2(2857, 18, 0, "RC2"), + JAVA_1_18_RC3(2858, 18, 0, "RC3"), + JAVA_1_18_RC4(2859, 18, 0, "RC4"), + JAVA_1_18_0(2860, 18, 0), + JAVA_1_18_1_PRE1(2861, 18, 1, "PRE1"), + JAVA_1_18_1_RC1(2862, 18, 1, "RC1"), + JAVA_1_18_1_RC2(2863, 18, 1, "RC2"), + JAVA_1_18_1_RC3(2864, 18, 1, "RC3"), + JAVA_1_18_1(2865, 18, 1), + /** + * article 21w39a (This change was + * noted on an earlier snapshot but didn't make it into the codebase until this one!) + *
    + *
  • Level.CarvingMasks[] is now CompoundTag containing <LongArrayTag> + * instead of CompoundTag containing <ByteArrayTag>. + *
+ *

This version is also the first time the mca scan data shows the `entities` tag being present in region chunks + * again (probably during some stage(s) of world generation). I find it unlikely that the scanned mca versions + * between {@link #JAVA_1_18_21W43A} and this one just happen to not have any entities in the right state to be + * stored in the region mca file - that was 20 * 25 world spawns generated and scanned between these 2 versions!

+ */ + JAVA_1_18_2_22W03A(2966, 18, 2, "22w03a"), + JAVA_1_18_2_22W05A(2967, 18, 2, "22w05a"), + JAVA_1_18_2_22W06A(2968, 18, 2, "22w06a"), + /** + * `structures.References.*` and `structures.starts.*` entry name format changed to include the "minecraft:" prefix. + * Ex. old: "buried_treasure", new: "minecraft:buried_treasure" + */ + JAVA_1_18_2_22W07A(2969, 18, 2, "22w07a"), + JAVA_1_18_2_PRE1(2971, 18, 2, "PRE1"), + JAVA_1_18_2_PRE2(2972, 18, 2, "PRE2"), + JAVA_1_18_2_PRE3(2973, 18, 2, "PRE3"), + JAVA_1_18_2_RC1(2974, 18, 2, "RC1"), + JAVA_1_18_2(2975, 18, 2), +// JAVA_1_19_XS1(3066, 19, 0, "XS1"), + JAVA_1_19_22W11A(3080, 19, 0, "22w11a"), + JAVA_1_19_22W12A(3082, 19, 0, "22w12a"), + JAVA_1_19_22W13A(3085, 19, 0, "22w13a"), + JAVA_1_19_22W14A(3088, 19, 0, "22w14a"), + JAVA_1_19_22W15A(3089, 19, 0, "22w15a"), + JAVA_1_19_22W16A(3091, 19, 0, "22w16a"), + JAVA_1_19_22W16B(3092, 19, 0, "22w16b"), + JAVA_1_19_22W17A(3093, 19, 0, "22w17a"), + JAVA_1_19_22W18A(3095, 19, 0, "22w18a"), + JAVA_1_19_22W19A(3096, 19, 0, "22w19a"), + JAVA_1_19_PRE1(3098, 19, 0, "PRE1"), + JAVA_1_19_PRE2(3099, 19, 0, "PRE2"), + JAVA_1_19_PRE3(3100, 19, 0, "PRE3"), + JAVA_1_19_PRE4(3101, 19, 0, "PRE4"), + JAVA_1_19_PRE5(3102, 19, 0, "PRE5"), + JAVA_1_19_RC1(3103, 19, 0, "RC1"), + JAVA_1_19_RC2(3104, 19, 0, "RC2"), + JAVA_1_19_0(3105, 19, 0), + JAVA_1_19_1_22W24A(3106, 19, 1, "22w24a"), + JAVA_1_19_1_PRE1(3107, 19, 1, "PRE1"), + JAVA_1_19_1_RC1(3109, 19, 1, "RC1"), + JAVA_1_19_1_PRE2(3110, 19, 1, "PRE2"), + JAVA_1_19_1_PRE3(3111, 19, 1, "PRE3"), + JAVA_1_19_1_PRE4(3112, 19, 1, "PRE4"), + JAVA_1_19_1_PRE5(3113, 19, 1, "PRE5"), + JAVA_1_19_1_PRE6(3114, 19, 1, "PRE6"), + JAVA_1_19_1_RC2(3115, 19, 1, "RC2"), + JAVA_1_19_1_RC3(3116, 19, 1, "RC3"), + JAVA_1_19_1(3117, 19, 1), + JAVA_1_19_2_RC1(3118, 19, 2, "RC1"), + JAVA_1_19_2_RC2(3119, 19, 2, "RC2"), + JAVA_1_19_2(3120, 19, 2), + JAVA_1_19_3_22W42A(3205, 19, 3, "22w42a"), + JAVA_1_19_3_22W43A(3206, 19, 3, "22w43a"), + /** {@code Entities[].listener.selector} appears for the first time. */ + JAVA_1_19_3_22W44A(3207, 19, 3, "22w44a"), + JAVA_1_19_3_22W45A(3208, 19, 3, "22w45a"), + JAVA_1_19_3_22W46A(3210, 19, 3, "22w46a"), + JAVA_1_19_3_PRE1(3211, 19, 3, "PRE1"), + JAVA_1_19_3_PRE2(3212, 19, 3, "PRE2"), + JAVA_1_19_3_PRE3(3213, 19, 3, "PRE3"), + JAVA_1_19_3_RC1(3215, 19, 3, "RC1"), + JAVA_1_19_3_RC2(3216, 19, 3, "RC2"), + JAVA_1_19_3_RC3(3217, 19, 3, "RC3"), + JAVA_1_19_3(3218, 19, 3), + JAVA_1_19_4_23W03A(3320, 19, 4, "23w03a"), + JAVA_1_19_4_23W04A(3321, 19, 4, "23w04a"), + JAVA_1_19_4_23W05A(3323, 19, 4, "23w05a"), + JAVA_1_19_4_23W06A(3326, 19, 4, "23w06a"), + JAVA_1_19_4_23W07A(3329, 19, 4, "23w07a"), + JAVA_1_19_4_PRE1(3330, 19, 4, "PRE1"), + JAVA_1_19_4_PRE2(3331, 19, 4, "PRE2"), + JAVA_1_19_4_PRE3(3332, 19, 4, "PRE3"), + JAVA_1_19_4_PRE4(3333, 19, 4, "PRE4"), + JAVA_1_19_4_RC1(3334, 19, 4, "RC1"), + JAVA_1_19_4_RC2(3335, 19, 4, "RC2"), + JAVA_1_19_4_RC3(3336, 19, 4, "RC3"), + JAVA_1_19_4(3337, 19, 4), + JAVA_1_20_23W12A(3442, 20, 0, "23w12a"), + JAVA_1_20_23W13A(3443, 20, 0, "23w13a"), + JAVA_1_20_23W14A(3445, 20, 0, "23w14a"), + JAVA_1_20_23W16A(3449, 20, 0, "23w16a"), + JAVA_1_20_23W17A(3452, 20, 0, "23w17a"), + JAVA_1_20_23W18A(3453, 20, 0, "23w18a"), + JAVA_1_20_PRE1(3454, 20, 0, "PRE1"), + JAVA_1_20_PRE2(3455, 20, 0, "PRE2"), + JAVA_1_20_PRE3(3456, 20, 0, "PRE3"), + JAVA_1_20_PRE4(3457, 20, 0, "PRE4"), + JAVA_1_20_PRE5(3458, 20, 0, "PRE5"), + JAVA_1_20_PRE6(3460, 20, 0, "PRE6"), + JAVA_1_20_PRE7(3461, 20, 0, "PRE7"), + JAVA_1_20_RC1(3462, 20, 0, "RC1"), + JAVA_1_20_0(3463, 20, 0), + JAVA_1_20_1_RC1(3464, 20, 1, "RC1"), + JAVA_1_20_1(3465, 20, 1), + JAVA_1_20_2_23W31A(3567, 20, 2, "23w31a"), + JAVA_1_20_2_23W32A(3569, 20, 2, "23w32a"), + JAVA_1_20_2_23W33A(3570, 20, 2, "23w33a"), + JAVA_1_20_2_23W35A(3571, 20, 2, "23w35a"), + JAVA_1_20_2_PRE1(3572, 20, 2, "PRE1"), + JAVA_1_20_2_PRE2(3573, 20, 2, "PRE2"), + JAVA_1_20_2_PRE3(3574, 20, 2, "PRE3"), + JAVA_1_20_2_PRE4(3575, 20, 2, "PRE4"), + JAVA_1_20_2_RC1(3576, 20, 2, "RC1"), + JAVA_1_20_2_RC2(3577, 20, 2, "RC2"), + JAVA_1_20_2(3578, 20, 2), + JAVA_1_20_3_23W40A(3679, 20, 3, "23w40a"), + JAVA_1_20_3_23W41A(3681, 20, 3, "23w41a"), + JAVA_1_20_3_23W42A(3684, 20, 3, "23w42a"), + JAVA_1_20_3_23W43A(3686, 20, 3, "23w43a"), + JAVA_1_20_3_23W43B(3687, 20, 3, "23w43b"), + JAVA_1_20_3_23W44A(3688, 20, 3, "23w44a"), + JAVA_1_20_3_23W45A(3690, 20, 3, "23w45a"), + JAVA_1_20_3_23W46A(3691, 20, 3, "23w46a"), + JAVA_1_20_3_PRE1(3693, 20, 3, "PRE1"), + JAVA_1_20_3_PRE2(3694, 20, 3, "PRE2"), + JAVA_1_20_3_PRE3(3695, 20, 3, "PRE3"), + JAVA_1_20_3_PRE4(3696, 20, 3, "PRE4"), + JAVA_1_20_3_RC1(3697, 20, 3, "RC1"), + JAVA_1_20_3(3698, 20, 3), + JAVA_1_20_4_RC1(3699, 20, 4, "RC1"), + JAVA_1_20_4(3700, 20, 4), + JAVA_1_20_5_23W51A(3801, 20, 5, "23w51a"), + JAVA_1_20_5_23W51B(3802, 20, 5, "23w51b"), + JAVA_1_20_5_24W03A(3804, 20, 5, "24w03a"), + JAVA_1_20_5_24W03B(3805, 20, 5, "24w03b"), + JAVA_1_20_5_24W04A(3806, 20, 5, "24w04a"), + JAVA_1_20_5_24W05A(3809, 20, 5, "24w05a"), + JAVA_1_20_5_24W05B(3811, 20, 5, "24w05b"), + JAVA_1_20_5_24W06A(3815, 20, 5, "24w06a"), + JAVA_1_20_5_24W07A(3817, 20, 5, "24w07a"), + JAVA_1_20_5_24W09A(3819, 20, 5, "24w09a"), + JAVA_1_20_5_24W10A(3821, 20, 5, "24w10a"), + JAVA_1_20_5_24W11A(3823, 20, 5, "24w11a"), + JAVA_1_20_5_24W12A(3824, 20, 5, "24w12a"), + JAVA_1_20_5_24W13A(3826, 20, 5, "24w13a"), + JAVA_1_20_5_24W14A(3827, 20, 5, "24w14a"), + JAVA_1_20_5_PRE1(3829, 20, 5, "PRE1"), + JAVA_1_20_5_PRE2(3830, 20, 5, "PRE2"), + JAVA_1_20_5_PRE3(3831, 20, 5, "PRE3"), + JAVA_1_20_5_PRE4(3832, 20, 5, "PRE4"), + JAVA_1_20_5_RC1(3834, 20, 5, "RC1"), + JAVA_1_20_5_RC2(3835, 20, 5, "RC2"), + JAVA_1_20_5_RC3(3836, 20, 5, "RC3"), + JAVA_1_20_5(3837, 20, 5), + JAVA_1_20_6_RC1(3838, 20, 6, "RC1"), + JAVA_1_20_6(3839, 20, 6), + JAVA_1_21_24W18A(3940, 21, 0, "24w18a"), + JAVA_1_21_24W19A(3941, 21, 0, "24w19a"), + JAVA_1_21_24W19B(3942, 21, 0, "24w19b"), + JAVA_1_21_24W20A(3944, 21, 0, "24w20a"), + JAVA_1_21_24W21A(3946, 21, 0, "24w21a"), + JAVA_1_21_24W21B(3947, 21, 0, "24w21b"), + JAVA_1_21_PRE1(3948, 21, 0, "PRE1"), + JAVA_1_21_PRE2(3949, 21, 0, "PRE2"), + JAVA_1_21_PRE3(3950, 21, 0, "PRE3"), + JAVA_1_21_PRE4(3951, 21, 0, "PRE4"), + JAVA_1_21_RC1(3952, 21, 0, "RC1"), + JAVA_1_21_0(3953, 21, 0), + JAVA_1_21_1_RC1(3954, 21, 1, "RC1"), + JAVA_1_21_1(3955, 21, 1), + JAVA_1_21_2_24W33A(4058, 21, 2, "24w33a"), + JAVA_1_21_2_24W34A(4060, 21, 2, "24w34a"), + JAVA_1_21_2_24W35A(4062, 21, 2, "24w35a"), + JAVA_1_21_2_24W36A(4063, 21, 2, "24w36a"), + JAVA_1_21_2_24W37A(4065, 21, 2, "24w37a"), + JAVA_1_21_2_24W38A(4066, 21, 2, "24w38a"), + JAVA_1_21_2_24W39A(4069, 21, 2, "24w39a"), + JAVA_1_21_2_24W40A(4072, 21, 2, "24w40a"), + JAVA_1_21_2_PRE1(4073, 21, 2, "PRE1"), + JAVA_1_21_2_PRE2(4074, 21, 2, "PRE2"), + JAVA_1_21_2_PRE3(4075, 21, 2, "PRE3"), + JAVA_1_21_2_PRE4(4076, 21, 2, "PRE4"), + JAVA_1_21_2_PRE5(4077, 21, 2, "PRE5"), + JAVA_1_21_2_RC1(4078, 21, 2, "RC1"), + JAVA_1_21_2_RC2(4079, 21, 2, "RC2"), + JAVA_1_21_2(4080, 21, 2), + JAVA_1_21_3(4082, 21, 3), + JAVA_1_21_4_24W44A(4174, 21, 4, "24w44a"), + JAVA_1_21_4_24W45A(4177, 21, 4, "24w45a"), + JAVA_1_21_4_24W46A(4178, 21, 4, "24w46a"), + JAVA_1_21_4_PRE1(4179, 21, 4, "PRE1"), + JAVA_1_21_4_PRE2(4182, 21, 4, "PRE2"), + JAVA_1_21_4_PRE3(4183, 21, 4, "PRE3"), + JAVA_1_21_4_RC1(4184, 21, 4, "RC1"), + JAVA_1_21_4_RC2(4186, 21, 4, "RC2"), + JAVA_1_21_4_RC3(4188, 21, 4, "RC3"), + JAVA_1_21_4(4189, 21, 4), + JAVA_1_21_5_25W02A(4298, 21, 5, "25w02a"), + JAVA_1_21_5_25W03A(4304, 21, 5, "25w03a"), + JAVA_1_21_5_25W04A(4308, 21, 5, "25w04a"), + JAVA_1_21_5_25W05A(4310, 21, 5, "25w05a"), + JAVA_1_21_5_25W06A(4313, 21, 5, "25w06a"), + JAVA_1_21_5_25W07A(4315, 21, 5, "25w07a"), + JAVA_1_21_5_25W08A(4316, 21, 5, "25w08a"), + JAVA_1_21_5_25W09A(4317, 21, 5, "25w09a"), + JAVA_1_21_5_25W09B(4318, 21, 5, "25w09b"), + JAVA_1_21_5_25W10A(4319, 21, 5, "25w10a"), + JAVA_1_21_5_PRE1(4320, 21, 5, "PRE1"),; + + 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 boolean isWeeklyRelease; + private final String buildDescription; + private final String str; + private final String simpleStr; + + static { + // enum is maintained in order with a unit test to enforce the convention - so no need to sort + ids = Arrays.stream(values()).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 (unit test enforced):
    + *
  • NULL (given value ignored) 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)
  • + *
  • PRE# for pre-releases (e.g. PRE1, PRE2)
  • + *
  • RC# for release candidates (e.g. RC1, RC2)
+ */ + DataVersion(int id, int minor, int patch, String buildDescription) { + this.isFullRelease = buildDescription == null || "FINAL".equalsIgnoreCase(buildDescription); + if (!isFullRelease && buildDescription.isEmpty()) + throw new IllegalArgumentException("buildDescription required for non-full releases"); + this.isWeeklyRelease = buildDescription != null && buildDescription.length() >= 5 && buildDescription.charAt(2) == 'w'; + this.id = id; + this.minor = minor; + this.patch = patch; + 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(); + } + + StringBuilder simpleStrBuilder = new StringBuilder(); + if (isWeeklyRelease) { + simpleStrBuilder.append(buildDescription); + } else { + simpleStrBuilder.append("1.").append(minor); + if (patch != 0) { + simpleStrBuilder.append('.').append(patch); + } + if (buildDescription != null) { + simpleStrBuilder.append('-').append(buildDescription.toLowerCase(Locale.ENGLISH)); + } + } + simpleStr = simpleStrBuilder.toString(); + } + + 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 + */ + 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; + } + + public boolean isWeeklyRelease() { + return isWeeklyRelease; + } + + /** + * 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 JAVA_1_14_PRE1 + * Indicates if point of interest .mca files exist. E.g. 'poi/r.0.0.mca' + *

Technically poi files were introduced with {@link #JAVA_1_14_19W11A} but the nbt structure was quickly + * changed and this 3 week span of weekly versions isn't worth the hassle of supporting.

+ * @since {@link #JAVA_1_14_PRE1} + */ + public boolean hasPoiMca() { + return this.id >= JAVA_1_14_PRE1.id; + } + + /** + * TRUE as of 1.17 + * Entities were pulled out of terrain 'region/r.X.Z.mca' files into their own .mca files. E.g. 'entities/r.0.0.mca' + */ + public boolean hasEntitiesMca() { + return this.id >= JAVA_1_17_20W45A.id; + } + + 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]; + } + + /** + * @param simpleVersionStr such as "1.12", "21w13a", "1.19.1-pre3" + * @return exact match or null + */ + public static DataVersion find(String simpleVersionStr) { + final String seeking = simpleVersionStr.toLowerCase(Locale.ENGLISH); + return Arrays.stream(values()).filter(v -> v.simpleStr.equals(seeking)).findFirst().orElse(null); + } + + /** + * @return The previous known data version or null if there is none. + */ + public DataVersion previous() { + if (this.ordinal() > 0) + return values()[this.ordinal() - 1]; + else + return null; + } + + /** + * @return The next known data version or null if there is none. + */ + public DataVersion next() { + if (this.ordinal() < ids.length - 1) + return values()[this.ordinal() + 1]; + else + return null; + } + + /** + * @return The latest full release (non-weekly, non pre-release, etc) version defined. + */ + public static DataVersion latest() { + return latestFullReleaseVersion; + } + + @Override + public String toString() { + return str; + } + + public String toSimpleString() { + return simpleStr; + } + + /** + * Indicates if this version would be crossed by the transition between versionA and versionB. + * This is useful for determining if a data upgrade or downgrade would be required to support + * changing from versionA to versionB. The order of A and B don't matter. + * + *

When using this function, call it on the data version in which a change exists. For + * example if you need to know if changing from A to B would require changing to/from 3D + * biomes then use {@code JAVA_1_15_19W36A.isCrossedByTransition(A, B)} as + * {@link #JAVA_1_15_19W36A} is the version which added 3D biomes.

+ * + *

In short, if this function returns true then the act of changing data versions from A + * to B can be said to "cross" this version which is an indication that such a change should + * either be considered illegal or that upgrade/downgrade action is required.

+ * + * @param versionA older or newer data version than B + * @param versionB older or newer data version than A + * @return true if chaining from version A to version B, or form B to A, would result in + * crossing this version. This version is considered to be crossed if {@code A != B} and + * {@code min(A, B) < this.id <= max(A, B)} + * @see #throwUnsupportedVersionChangeIfCrossed(int, int) + */ + public boolean isCrossedByTransition(int versionA, int versionB) { + if (versionA == versionB) return false; + if (versionA < versionB) { + return versionA < id && id <= versionB; + } else { + return versionB < id && id <= versionA; + } + } + + /** + * Throws {@link UnsupportedVersionChangeException} if {@link #isCrossedByTransition(int, int)} + * were to return true for the given arguments. + */ + public void throwUnsupportedVersionChangeIfCrossed(int versionA, int versionB) { + if (isCrossedByTransition(versionA, versionB)) { + throw new UnsupportedVersionChangeException(this, versionA, versionB); + } + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/EntitiesChunk.java b/src/main/java/io/github/ensgijs/nbt/mca/EntitiesChunk.java new file mode 100644 index 00000000..7c83a6ee --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/EntitiesChunk.java @@ -0,0 +1,32 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.mca.io.McaFileHelpers; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.mca.entities.Entity; +import io.github.ensgijs.nbt.mca.entities.EntityFactory; + +/** + * Thin default implementation of {@link EntitiesChunkBase}. + * + * @see EntitiesChunkBase + * @see EntityFactory + * @see McaFileHelpers#MCA_CREATORS + */ +public class EntitiesChunk extends EntitiesChunkBase { + + protected EntitiesChunk(int dataVersion) { + super(dataVersion); + } + + public EntitiesChunk(CompoundTag data) { + super(data); + } + + public EntitiesChunk(CompoundTag data, long loadFlags) { + super(data, loadFlags); + } + + public EntitiesChunk() { + super(DataVersion.latest().id()); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/EntitiesChunkBase.java b/src/main/java/io/github/ensgijs/nbt/mca/EntitiesChunkBase.java new file mode 100644 index 00000000..9c518818 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/EntitiesChunkBase.java @@ -0,0 +1,412 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.io.NamedTag; +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.mca.io.McaFileHelpers; +import io.github.ensgijs.nbt.mca.io.MoveChunkFlags; +import io.github.ensgijs.nbt.query.NbtPath; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.DoubleTag; +import io.github.ensgijs.nbt.tag.IntArrayTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.mca.entities.Entity; +import io.github.ensgijs.nbt.mca.entities.EntityFactory; +import io.github.ensgijs.nbt.mca.entities.EntityUtil; +import io.github.ensgijs.nbt.mca.util.RegionBoundingRectangle; +import io.github.ensgijs.nbt.util.ArgValidator; +import io.github.ensgijs.nbt.mca.util.ChunkBoundingRectangle; +import io.github.ensgijs.nbt.mca.util.VersionAware; + +import java.util.*; +import java.util.function.Consumer; +import java.util.stream.Stream; + +/** + * Provides all the basic functionality necessary for this type of chunk with abstraction hooks + * making it easy to extend this class and modify the factory behavior of {@link McaFileHelpers} to create + * instances of your custom class. + * + * @see EntitiesChunk + * @see EntityFactory + * @see McaFileHelpers#MCA_CREATORS + */ +public abstract class EntitiesChunkBase extends ChunkBase implements Iterable { + // Private to keep child classes clean (and well behaved) - child classes should access this via getEntities() + // Not populated until getEntities() is called. + private List entities; + // Not populated if loaded in RAW mode or if load flags did not include ENTITIES + protected ListTag entitiesTag; + + protected static final VersionAware POSITION_PATH = new VersionAware() + .register(DataVersion.JAVA_1_17_20W45A.id(), NbtPath.of("Position")); + + protected static final VersionAware ENTITIES_PATH = new VersionAware() + .register(DataVersion.JAVA_1_17_20W45A.id(), NbtPath.of("Entities")); + + /** relative to ENTITIES_PATH[] */ + protected static final VersionAware ENTITIES_BRAIN_MEMORIES_PATH = new VersionAware() + .register(0, NbtPath.of("Brain.memories")); + + protected EntitiesChunkBase(int dataVersion) { + super(dataVersion); + } + + public EntitiesChunkBase(CompoundTag data) { + super(data); + } + + public EntitiesChunkBase(CompoundTag data, long loadFlags) { + super(data, loadFlags); + } + + @Override + protected void initReferences(long loadFlags) { + // remember: this isn't called when loaded in RAW mode, see base class + if (dataVersion < DataVersion.JAVA_1_17_20W45A.id()) { + throw new UnsupportedOperationException( + "This class can only be used to read entities mca files introduced in JAVA_1_17_20W45A"); + } + + int[] posXZ = getTagValue(POSITION_PATH, IntArrayTag::getValue); + if (posXZ == null || posXZ.length != 2) { + throw new IllegalArgumentException("POSITION tag missing or invalid"); + } + chunkX = posXZ[0]; + chunkZ = posXZ[1]; + + if ((loadFlags & LoadFlags.ENTITIES) > 0) { + entitiesTag = getTag(ENTITIES_PATH); + ArgValidator.check(entitiesTag != null, "ENTITIES tag not found"); + // Don't call initEntities() here, let getEntities do this lazily to keep things lean + } + } + + /** + * Called to initialize entity wrappers - implementers should respect the {@code raw} setting and DO NOTHING + * if called when raw is set. + */ + protected void initEntities() { + if (raw) return; + if (entitiesTag == null) { + // This branch should not be reachable in any typical usage scenario. The only way + // this state should happen is if there is a bug in the implementers of this class. + throw new IllegalStateException("Entities nbt tag was not loaded for this chunk"); + } + entities = EntityFactory.fromListTag(entitiesTag, dataVersion); + } + + /** {@inheritDoc} */ + public String getMcaType() { + return "entities"; + } + + /** + * Gets the list of entity object instances representing all entities in this chunk. + * This list is lazy-instantiated to avoid the memory and compute costs of populating it if it's unused. + * Translation, calling this for the first time will be slower than making successive calls. + *

If performance is everything for you, but you would still like to work with higher level objects + * than nbt tags, you can use {@link #getEntitiesTag()}, find an entity record you want to manipulate + * and use {@link EntityFactory#create(CompoundTag, int)} to get an entity instance then call + * {@link Entity#updateHandle()} to apply your changes all the way back to the entities tag held + * for this chunk.

+ */ + public List getEntities() { + checkRaw(); + if (entities == null) initEntities(); + return entities; + } + + /** + * Gets an indication of if the result of {@link #getEntities()} has been computed, or if calling it + * will trigger lazy instantiation. + *

+ * @return true if the result of {@link #getEntities()} is already computed; false if calling {@link #getEntities()} + * will trigger creation of wrapper objects. + */ + public boolean areWrappedEntitiesGenerated() { + return entities != null; + } + + /** + * Sets the entities in this chunk. You should probably follow this call with a call to + * {@link #fixEntityLocations(long)} unless you are sure all of the given entities are already + * within the chunks bounds. + *

Does not trigger a handle update. The result of calling {@link #getEntitiesTag()} + * will not change until {@link #updateHandle()} has been called.

+ * @param entities Entities to set, not null, may be empty. + * @throws UnsupportedOperationException if loaded in raw mode + * @see #clearEntities() + */ + public void setEntities(List entities) { + checkRaw(); + ArgValidator.requireValue(entities); + this.entities = entities; + } + + /** + * Gets the entities nbt tag by reference. + * Result may be null if chunk was loaded with a LoadFlags that excluded Entities. + * If you have called {@link #setEntities(List)} you will need to call {@link #updateHandle()} for the + * result of this method to be updated. + * @throws UnsupportedOperationException if loaded in raw mode + */ + public ListTag getEntitiesTag() { + checkRaw(); + return entitiesTag; + } + + /** + * Sets the entities tag and causes the next call to {@link #getEntities()} to recreate wrapped entities. + * The given tag is also set as the entities tag in the underlying CompoundTag handle in the version appropriate + * location. I.e. calling this method or modifying the tag passed after calling this method will affect the + * value returned by {@link #getHandle()}. + *

Raw mode behavior: supported!
+ * Sets the given tag in the held nbt data handle in its version correct place. Does not make calling + * {@link #getEntitiesTag()} or {@link #getEntities()} legal for chunks loaded in raw mode. + *

+ * + * @param entitiesTag Not null. If you want to clear the entities tag use {@link #clearEntities()} instead or if + * operating in raw mode you can pass a new empty tag to take advantage of this classes + * version awareness to place the tag in the correct location within the nbt data tag + * as returned by {@link #getHandle()} and {@link #updateHandle()} + */ + public void setEntitiesTag(ListTag entitiesTag) { + checkPartial(); + ArgValidator.requireValue(entitiesTag); + setEntitiesTagInternal(entitiesTag); + } + + protected void setEntitiesTagInternal(ListTag entitiesTag) { + if (data != null) { // only sync the data tag if we have it - data will be null if chunk was partially loaded + setTag(ENTITIES_PATH, entitiesTag); + } + if (!raw) { + this.entitiesTag = entitiesTag; + } + // respect lazy loading and cause the next call to getEntities() to rebuild the wrapped entities + entities = null; + } + + /** + * Clears the entities known to this chunk. If you have previously retrieved the list of entities from + * {@link #getEntities()} that list is unaffected by this call. + * Likewise a new entities tag is also created and any result previously returned from {@link #getEntitiesTag()} + * is also unaffected by this call. + * @throws UnsupportedOperationException if loaded in raw mode + */ + public void clearEntities() { + checkRaw(); + setEntitiesTagInternal(new ListTag<>(CompoundTag.class)); + } + + /** {@inheritDoc} */ + @Override + public boolean moveChunkImplemented() { + return entities != null || entitiesTag != null || ENTITIES_PATH.get(dataVersion).exists(data); + } + + /** {@inheritDoc} */ + @Override + public boolean moveChunkHasFullVersionSupport() { + return moveChunkImplemented(); + } + + /** + * Sets this chunks absolute XZ and calls {@link #fixEntityLocations(long)} returning its result. + *

Moving while in RAW mode is supported.

+ * @param newChunkX new absolute chunk-x + * @param newChunkZ new absolute chunk-z + * @param moveChunkFlags {@link MoveChunkFlags} OR'd together to control move chunk behavior. + * @param force unused + * @return true if any data was changed as a result of this call + * @throws UnsupportedOperationException if loaded in raw mode + */ + @Override + public boolean moveChunk(int newChunkX, int newChunkZ, long moveChunkFlags, boolean force) { + if (!moveChunkImplemented()) + throw new UnsupportedOperationException("Missing the data required to move this chunk!"); + if (!RegionBoundingRectangle.MAX_WORLD_BORDER_BOUNDS.containsChunk(newChunkX, newChunkZ)) { + throw new IllegalArgumentException("Chunk XZ must be within the maximum world bounds."); + } + if (this.chunkX == newChunkX && this.chunkZ == newChunkZ) return false; + this.chunkX = newChunkX; + this.chunkZ = newChunkZ; + if (raw) { + setTag(POSITION_PATH, new IntArrayTag(newChunkX, newChunkZ)); + } + if (fixEntityLocations(moveChunkFlags)) { + if ((moveChunkFlags & MoveChunkFlags.AUTOMATICALLY_UPDATE_HANDLE) > 0) { + updateHandle(); + } + } + return true; + } + + /** + * Scans all entities and moves any which are outside this chunks bounds into it preserving their + * relative location from their source chunk. + *

Fixing entity locations while in RAW mode is supported.

+ * @return true if any entity locations were changed; false if no changes were made. + * @throws UnsupportedOperationException if loaded in raw mode + */ + public boolean fixEntityLocations(long moveChunkFlags) { + if (!moveChunkImplemented()) + throw new UnsupportedOperationException("Missing the data required to move this chunk!"); + if (this.chunkX == NO_CHUNK_COORD_SENTINEL || this.chunkZ == NO_CHUNK_COORD_SENTINEL) { + throw new IllegalStateException("Chunk XZ not known"); + } + boolean changed = false; + if (entities != null) { + final NbtPath brainMemoriesPath = ENTITIES_BRAIN_MEMORIES_PATH.get(dataVersion); + final NbtPath memoryPosPath = NbtPath.of("value.pos"); + final ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(chunkX, chunkZ); + for (ET entity : entities) { + if (!cbr.containsBlock(entity.getX(), entity.getZ())) { + entity.setX(cbr.relocateX(entity.getX())); + entity.setZ(cbr.relocateZ(entity.getZ())); + if ((moveChunkFlags & MoveChunkFlags.RANDOMIZE_ENTITY_UUID) > 0) { + entity.setUuid(UUID.randomUUID()); + } + changed = true; + } + if (brainMemoriesPath.exists(entity.getHandle())) { + CompoundTag memoriesTag = brainMemoriesPath.getTag(entity.getHandle()); + for (NamedTag entry : memoriesTag) { + int[] pos = memoryPosPath.getIntArray(entry.getTag()); + if (pos != null && !cbr.containsBlock(pos[0], pos[2])) { + // TODO: dimension is also in this data + pos[0] = cbr.relocateX(pos[0]); + pos[2] = cbr.relocateZ(pos[2]); + changed = true; + } + } + } + } + } else if (entitiesTag != null) { + changed = fixEntityLocations(dataVersion, moveChunkFlags, entitiesTag, new ChunkBoundingRectangle(chunkX, chunkZ)); + } else if (raw) { + ListTag tag = getTag(ENTITIES_PATH); + if (tag == null) + throw new UnsupportedOperationException("Missing the data required to move this chunk! Didn't find '" + + ENTITIES_PATH.get(dataVersion) + + "' tag while in RAW mode."); + changed = fixEntityLocations(dataVersion, moveChunkFlags, tag, new ChunkBoundingRectangle(chunkX, chunkZ)); + } + return changed; + } + + static boolean fixEntityLocations(int dataVersion, long moveChunkFlags, ListTag entityTags, ChunkBoundingRectangle cbr) { + if (entityTags == null || entityTags.isEmpty()) { + return false; + } + boolean changed = false; + final NbtPath brainMemoriesPath = ENTITIES_BRAIN_MEMORIES_PATH.get(dataVersion); + final NbtPath memoryPosPath = NbtPath.of("value.pos"); + for (CompoundTag entityTag : entityTags) { + ListTag posTag = entityTag.getListTag("Pos").asDoubleTagList(); + double x = posTag.get(0).asDouble(); + double z = posTag.get(2).asDouble(); + if (!cbr.containsBlock(x, z)) { + posTag.set(0, new DoubleTag(cbr.relocateX(x))); + posTag.set(2, new DoubleTag(cbr.relocateZ(z))); + if ((moveChunkFlags & MoveChunkFlags.RANDOMIZE_ENTITY_UUID) > 0) { + EntityUtil.setUuid(dataVersion, entityTag, UUID.randomUUID()); + } + changed = true; + } + + if (brainMemoriesPath.exists(entityTag)) { + CompoundTag memoriesTag = brainMemoriesPath.getTag(entityTag); + for (NamedTag entry : memoriesTag) { + int[] pos = memoryPosPath.getIntArray(entry.getTag()); + if (pos != null && !cbr.containsBlock(pos[0], pos[2])) { + // TODO: dimension is also in this data + pos[0] = cbr.relocateX(pos[0]); + pos[2] = cbr.relocateZ(pos[2]); + changed = true; + } + } + } + + // This is correct even for boats visually straddling a chunk border, the passengers share the boat + // location and the order of the passengers apparently controls their visual offset in game. + // Example (trimmed down) F3+I capture of such a boat: + // /summon minecraft:boat -1002.50 63.00 -672.01 {Type:"acacia", + // Passengers:[ + // {id:"minecraft:cow",Pos:[-1002.5d,63.04d,-672.01d]}, + // {id:"minecraft:pig",Pos:[-1002.5d,63.04d,-672.01d]} + // ],Rotation:[-180.0f,0.0f]} + if (entityTag.containsKey("Passengers")) { + changed |= fixEntityLocations(dataVersion, moveChunkFlags, entityTag.getListTag("Passengers").asCompoundTagList(), cbr); + } + } + return changed; + } + + /** {@inheritDoc} */ + @Override + public Iterator iterator() { + return getEntities().iterator(); + } + + /** {@inheritDoc} */ + @Override + public void forEach(Consumer action) { + getEntities().forEach(action); + } + + /** {@inheritDoc} */ + @Override + public Spliterator spliterator() { + return getEntities().spliterator(); + } + + /** {@inheritDoc} */ + public Stream stream() { + return getEntities().stream(); + } + + /** {@inheritDoc} */ + @Override + public void setDataVersion(int dataVersion) { + DataVersion.JAVA_1_17_20W45A.throwUnsupportedVersionChangeIfCrossed(this.dataVersion, dataVersion); + super.setDataVersion(dataVersion); + } + + @Override + public CompoundTag updateHandle() { + checkPartial(); + if (!raw) { + super.updateHandle(); + if (chunkX != NO_CHUNK_COORD_SENTINEL && chunkZ != NO_CHUNK_COORD_SENTINEL) { + setTag(POSITION_PATH, new IntArrayTag(chunkX, chunkZ)); + } + + // if getEntities() was never called then don't rebuild entitiesTag + if (entities != null) { + // WARN: If this chunk was loaded without the ENTITIES LoadFlag but 'entities' is not null + // this indicates the user called setEntities() which initialized entitiesTag + // so no NPE risk here - assuming someone didn't extend this class and break the contract of + // setEntities + entitiesTag.clear(); + for (ET entity : entities) { + entitiesTag.add(entity.updateHandle()); + } + } + setTagIfNotNull(ENTITIES_PATH, entitiesTag); + } + return data; + } + + @Override + public CompoundTag updateHandle(int xPos, int zPos) { + if (!raw) { + if (chunkX == NO_CHUNK_COORD_SENTINEL) chunkX = xPos; + if (chunkZ == NO_CHUNK_COORD_SENTINEL) chunkZ = zPos; + ArgValidator.check(xPos == chunkX && zPos == chunkZ, + "Attempted to write chunk with incorrect chunk XZ. Chunk must be moved with moveChunk(..) first."); + updateHandle(); + } + return data; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/McaEntitiesFile.java b/src/main/java/io/github/ensgijs/nbt/mca/McaEntitiesFile.java new file mode 100644 index 00000000..5f204548 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/McaEntitiesFile.java @@ -0,0 +1,31 @@ +package io.github.ensgijs.nbt.mca; + +/** + * Represents an Entities data mca file (one that lives in the /entities folder). Entity mca files were added in 1.17 + * but this class can be used to read older /region/*.mca files as well - for an example of this see + * EntitiesMCAFileTest testLoadingOldRegionMcaAsEntityMca + */ +public class McaEntitiesFile extends McaFileBase { + public McaEntitiesFile(int regionX, int regionZ) { + super(regionX, regionZ); + } + + public McaEntitiesFile(int regionX, int regionZ, int defaultDataVersion) { + super(regionX, regionZ, defaultDataVersion); + } + + public McaEntitiesFile(int regionX, int regionZ, DataVersion defaultDataVersion) { + super(regionX, regionZ, defaultDataVersion); + } + + @Override + public Class chunkClass() { + return EntitiesChunk.class; + } + + @Override + public EntitiesChunk createChunk() { + EntitiesChunk chunk = new EntitiesChunk(getDefaultChunkDataVersion()); + return chunk; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/McaFileBase.java b/src/main/java/io/github/ensgijs/nbt/mca/McaFileBase.java new file mode 100644 index 00000000..f83bfdc4 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/McaFileBase.java @@ -0,0 +1,506 @@ +package io.github.ensgijs.nbt.mca; + + +import io.github.ensgijs.nbt.io.CompressionType; +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.mca.io.McaFileHelpers; +import io.github.ensgijs.nbt.mca.util.ChunkIterator; +import io.github.ensgijs.nbt.util.ArgValidator; +import io.github.ensgijs.nbt.mca.util.IntPointXZ; + +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 (aka "region") 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 + + /** + * MCA file 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; + } + + public DataVersion getDefaultChunkDataVersionEnum() { + return DataVersion.bestFor(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; + } + + public void setDefaultChunkDataVersion(DataVersion defaultDataVersion) { + this.defaultDataVersion = defaultDataVersion.id(); + } + + /** + * @return The x-value currently set for this mca file in region coordinates. + * @see #moveRegion(int, int, long, boolean) + */ + public int getRegionX() { + return regionX; + } + + /** + * @return The z-value currently set for this mca file in region coordinates. + * @see #moveRegion(int, int, long, boolean) + */ + public int getRegionZ() { + return regionZ; + } + + /** + * Returns result of calling {@link McaFileHelpers#createNameFromRegionLocation(int, int)} + * with current region coordinate values. + * @return A mca filename in the format "r.{regionX}.{regionZ}.mca" + */ + public String createRegionName() { + return McaFileHelpers.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. + * @param chunkAbsXZ Absolute chunk XZ coord as calculated from region location and chunk index. + * @return Deserialized chunk. + * @throws IOException if something went wrong during deserialization. + */ + protected T deserializeChunk(RandomAccessFile raf, long loadFlags, int timestamp, IntPointXZ chunkAbsXZ) throws IOException { + T chunk = createChunk(); + chunk.deserialize(raf, loadFlags, timestamp, chunkAbsXZ.getX(), chunkAbsXZ.getZ()); + // I'm going to leave this as an "idea" for now +// if (!chunkAbsXZ.equals(chunk.getChunkX(), chunk.getChunkZ())) { +// // this would be a good place for a logger warning +// if (chunk.moveChunkImplemented() && chunk.moveChunkHasFullVersionSupport()) { +// chunk.moveChunk(chunkAbsXZ.getX(), chunkAbsXZ.getZ()); +// } +// } + return chunk; + } + + /** + * 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.LOAD_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; + final IntPointXZ chunkOffsetXZ = new IntPointXZ(regionX * 32, regionZ * 32); + for (int i = 0; i < 1024; i++) { + // Location information for a chunk consists of four bytes split into two fields: + // the first three bytes are a (big-endian) offset in 4KiB sectors from the start of the file, + // and a remaining byte that gives the length of the chunk (also in 4KiB sectors, rounded up). + // Chunks are always less than 1MiB in size. If a chunk isn't present in the region file + // (e.g. because it hasn't been generated or migrated yet), both fields are zero. + 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, + getRelativeChunkXZ(i).add(chunkOffsetXZ)); + 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, CompressionType, boolean)} with GZIP chunk compression and + * without updating any timestamps. + * @see McaFileBase#serialize(RandomAccessFile, CompressionType, 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, CompressionType.ZLIB, false); + } + + /** + * Calls {@link McaFileBase#serialize(RandomAccessFile, CompressionType, boolean)} without updating any timestamps. + * @see McaFileBase#serialize(RandomAccessFile, CompressionType, 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, CompressionType chunkCompressionType) throws IOException { + return serialize(raf, chunkCompressionType, 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, CompressionType chunkCompressionType, boolean changeLastUpdate) throws IOException { + ArgValidator.requireValue(raf, "raf"); + int globalOffset = 2; + int lastWritten = 0; + int timestamp = (int) (System.currentTimeMillis() / 1000L); + int chunksWritten = 0; + int chunkXOffset = McaFileHelpers.regionToChunk(regionX); + int chunkZOffset = McaFileHelpers.regionToChunk(regionZ); + + // ensure that the mca header tables always exist + raf.seek(0x2000 - 4); + raf.writeInt(0); + + 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, chunkCompressionType, true); + + chunksWritten++; + + // compute the count of 4kb sectors the chunk data occupies + 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); + } + // TODO: figure out how best to sync chunk abs xz +// getRelativeChunkXZ(index).add(regionX * 32, regionZ * 32); + 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)); + } + + /** + * Removes the chunk at the given index (sets it to null) and returns the previous value. + * @param index chunk index [0..1024) + * @return chunk which was removed, or null if there was none. + */ + public T removeChunk(int index) { + T chunk = chunks[index]; + chunks[index] = null; + return chunk; + } + + /** + * Removes the chunk at the given xz (sets it to null) and returns the previous value. + * Works with absolute and relative coordinates. + * @param chunkX chunk x + * @param chunkZ chunk z + * @return chunk which was removed, or null if there was none. + */ + public T removeChunk(int chunkX, int chunkZ) { + return removeChunk(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 or -1 if either chunkX or chunkZ were {@link ChunkBase#NO_CHUNK_COORD_SENTINEL}. + */ + public static int getChunkIndex(int chunkX, int chunkZ) { + if (chunkX != ChunkBase.NO_CHUNK_COORD_SENTINEL && chunkZ != ChunkBase.NO_CHUNK_COORD_SENTINEL) { + return ((chunkZ & 0x1F) << 5) | (chunkX & 0x1F); + } + return -1; + } + + /** + * Calculates the relative x z of a chunk within the current region given an index. + * + * @param index index of chunk in range [0..1024) + * @return x z location of the chunk in region relative coordinates where x and z each range [0..32) + */ + public static IntPointXZ getRelativeChunkXZ(int index) { + checkIndex(index); + return new IntPointXZ(index & 0x1F, index >> 5); + } + + protected static void checkIndex(int index) { + if (index < 0 || index > 1023) { + throw new IndexOutOfBoundsException(); + } + } + + protected T createChunkIfMissing(int blockX, int blockZ) { + int chunkX = McaFileHelpers.blockToChunk(blockX), chunkZ = McaFileHelpers.blockToChunk(blockZ); + T chunk = getChunk(chunkX, chunkZ); + if (chunk == null) { + chunk = createChunk(); + setChunk(getChunkIndex(chunkX, chunkZ), chunk); + } + return chunk; + } + + public boolean moveRegion(int newRegionX, int newRegionZ, long moveChunkFlags, boolean force) { + // Testing note: don't forget that updateHandle() needs to be called to see the results of this move! + boolean changed = false; + IntPointXZ newRegionMinChunkXZ = new IntPointXZ(newRegionX, newRegionZ).transformRegionToChunk(); + ChunkIterator iter = this.iterator(); + while (iter.hasNext()) { + T chunk = iter.next(); + if (chunk != null) { + IntPointXZ newChunkXZ = iter.currentXZ().add(newRegionMinChunkXZ); + changed |= chunk.moveChunk(newChunkXZ.getX(), newChunkXZ.getZ(), moveChunkFlags, force); + } + } + this.regionX = newRegionX; + this.regionZ = newRegionZ; + return changed; + } + + @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 currentAbsoluteX() { + return currentX() + owner.getRegionX() * 32; + } + + @Override + public int currentAbsoluteZ() { + return currentZ() + owner.getRegionZ() * 32; + } + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/McaPoiFile.java b/src/main/java/io/github/ensgijs/nbt/mca/McaPoiFile.java new file mode 100644 index 00000000..66f6bf6f --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/McaPoiFile.java @@ -0,0 +1,33 @@ +package io.github.ensgijs.nbt.mca; + +/** + * POI files are best thought of as an INDEX the game uses to be able to quickly locate certain blocks. + * However, the names of the indexed locations is not necessarily a block type but often a description of its usage + * and one poi type may map to multiple block types (e.g. poi of 'minecraft:home' maps to any of the bed blocks). + * + *

See {@link PoiRecord} for more information and for a list of POI types and how they map to blocks.

+ */ +public class McaPoiFile extends McaFileBase { + public McaPoiFile(int regionX, int regionZ) { + super(regionX, regionZ); + } + + public McaPoiFile(int regionX, int regionZ, int defaultDataVersion) { + super(regionX, regionZ, defaultDataVersion); + } + + public McaPoiFile(int regionX, int regionZ, DataVersion defaultDataVersion) { + super(regionX, regionZ, defaultDataVersion); + } + + @Override + public Class chunkClass() { + return PoiChunk.class; + } + + @Override + public PoiChunk createChunk() { + PoiChunk chunk = new PoiChunk(getDefaultChunkDataVersion()); + return chunk; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/McaRegionFile.java b/src/main/java/io/github/ensgijs/nbt/mca/McaRegionFile.java new file mode 100644 index 00000000..4f7abe09 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/McaRegionFile.java @@ -0,0 +1,138 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.mca.io.McaFileHelpers; + +/** + * Represents a Terrain data mca file (one that lives in the /region folder). + * Prior to MC 1.14 /region/*.mca files where the only ones that existed, 1.14 introduced /poi/*.mca + * and 1.17 added /entities/*.mca - this class (currently) supports both legacy region files (that contain + * entity data) as well as modern ones that do not. + */ +public class McaRegionFile extends McaFileBase implements Iterable { + /** + * 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(); + + /** + * {@inheritDoc} + */ + public McaRegionFile(int regionX, int regionZ) { + super(regionX, regionZ); + } + + /** + * {@inheritDoc} + */ + public McaRegionFile(int regionX, int regionZ, int defaultDataVersion) { + super(regionX, regionZ, defaultDataVersion); + } + + /** + * {@inheritDoc} + */ + public McaRegionFile(int regionX, int regionZ, DataVersion defaultDataVersion) { + super(regionX, regionZ, defaultDataVersion); + } + + /** + * {@inheritDoc} + */ + @Override + public Class chunkClass() { + return TerrainChunk.class; + } + + /** + * {@inheritDoc} + */ + @Override + public TerrainChunk createChunk() { + return TerrainChunk.newChunk(getDefaultChunkDataVersion()); + } + + /** + * @deprecated Use {@link #setBiomeAt(int, int, int, int)} instead + */ + @Deprecated + public void setBiomeAt(int blockX, int blockZ, int biomeID) { + createChunkIfMissing(blockX, blockZ).setLegacyBiomeAt(blockX, blockZ, biomeID); + } + + public void setBiomeAt(int blockX, int blockY, int blockZ, int biomeID) { + createChunkIfMissing(blockX, blockZ).setLegacyBiomeAt(blockX, blockY, blockZ, biomeID); + } + + /** + * @deprecated Use {@link #getBiomeAt(int, int, int)} instead + */ + @Deprecated + public int getBiomeAt(int blockX, int blockZ) { + int chunkX = McaFileHelpers.blockToChunk(blockX), chunkZ = McaFileHelpers.blockToChunk(blockZ); + TerrainChunk chunk = getChunk(getChunkIndex(chunkX, chunkZ)); + if (chunk == null) { + return -1; + } + return chunk.getLegacyBiomeAt(blockX, blockZ); + } + + /** + * Fetches the biome id at a specific block. + * @param blockX The x-coordinate of the block. + * @param blockY The y-coordinate of the block. + * @param blockZ The z-coordinate of the block. + * @return The biome id if the chunk exists and the chunk has biomes, otherwise -1. + * @deprecated unsupported after JAVA_1_18_21W38A + */ + public int getBiomeAt(int blockX, int blockY, int blockZ) { + int chunkX = McaFileHelpers.blockToChunk(blockX), chunkZ = McaFileHelpers.blockToChunk(blockZ); + TerrainChunk chunk = getChunk(getChunkIndex(chunkX, chunkZ)); + if (chunk == null) { + return -1; + } + return chunk.getLegacyBiomeAt(blockX,blockY, blockZ); + } + +// /** +// * Set a block state at a specific block location. +// * The block coordinates can be absolute coordinates or they can be relative to the region. +// * @param blockX The x-coordinate of the block. +// * @param blockY The y-coordinate of the block. +// * @param blockZ The z-coordinate of the block. +// * @param state The block state to be set. +// * @param cleanup Whether the Palette and the BLockStates should be recalculated after adding the block state. +// */ +// public void setBlockStateAt(int blockX, int blockY, int blockZ, CompoundTag state, boolean cleanup) { +// createChunkIfMissing(blockX, blockZ).setBlockStateAt(blockX, blockY, blockZ, state, cleanup); +// } +// +// /** +// * Fetches a block state at a specific block location. +// * The block coordinates can be absolute coordinates or they can be relative to the region. +// * @param blockX The x-coordinate of the block. +// * @param blockY The y-coordinate of the block. +// * @param blockZ The z-coordinate of the block. +// * @return The block state or null if the chunk or the section do not exist. +// */ +// public CompoundTag getBlockStateAt(int blockX, int blockY, int blockZ) { +// int chunkX = McaFileHelpers.blockToChunk(blockX), chunkZ = McaFileHelpers.blockToChunk(blockZ); +// TerrainChunk chunk = getChunk(chunkX, chunkZ); +// if (chunk == null) { +// return null; +// } +// return chunk.getBlockStateAt(blockX, blockY, blockZ); +// } +// +// /** +// * Recalculates the Palette and the BlockStates of all chunks and sections of this region. +// */ +// public void cleanupPalettesAndBlockStates() { +// for (TerrainChunk chunk : chunks) { +// if (chunk != null) { +// chunk.cleanupPalettesAndBlockStates(); +// } +// } +// } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/PoiChunk.java b/src/main/java/io/github/ensgijs/nbt/mca/PoiChunk.java new file mode 100644 index 00000000..bcf3538f --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/PoiChunk.java @@ -0,0 +1,28 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.tag.CompoundTag; + +public class PoiChunk extends PoiChunkBase{ + + protected PoiChunk(int dataVersion) { + super(dataVersion); + } + + public PoiChunk() { + super(DataVersion.latest().id()); + } + + public PoiChunk(CompoundTag data) { + super(data); + } + + public PoiChunk(CompoundTag data, long loadFlags) { + super(data, loadFlags); + } + + @Override + protected PoiRecord createPoiRecord(CompoundTag recordTag) { + return new PoiRecord(recordTag); + } + +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/PoiChunkBase.java b/src/main/java/io/github/ensgijs/nbt/mca/PoiChunkBase.java new file mode 100644 index 00000000..74e1bc19 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/PoiChunkBase.java @@ -0,0 +1,430 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.io.NamedTag; +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.mca.io.McaFileHelpers; +import io.github.ensgijs.nbt.mca.io.MoveChunkFlags; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.IntArrayTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.mca.util.ChunkBoundingRectangle; +import io.github.ensgijs.nbt.mca.util.RegionBoundingRectangle; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Provides all the basic functionality necessary for this type of chunk with abstraction hooks + * making it easy to extend this class and modify the factory behavior of {@link McaFileHelpers} to create + * instances of your custom class. + */ +public abstract class PoiChunkBase extends ChunkBase implements Collection { + // private to preserve the ability to change how records are stored to optimize lookups later + private List records; + + // Valid: True (1) when created by the game, however, if the decoding of POI NBT (from the region file) data fails, + // and the game then save the region file again, it might save false (0). This key is internally set to true when + // the POI section is refreshed, and a refresh always happens when the chunk section (with terrain data) at the + // same coordinates is decoded. To sum up, it is very unlikely to get false. + protected Map poiSectionValidity; + + @Override + protected void initMembers() { + records = null; + poiSectionValidity = new HashMap<>(); + } + + protected PoiChunkBase(int dataVersion) { + super(dataVersion); + records = new ArrayList<>(); + } + + public PoiChunkBase(CompoundTag data) { + super(data); + } + + public PoiChunkBase(CompoundTag data, long loadFlags) { + super(data, loadFlags); + } + + @Override + protected void initReferences(long loadFlags) { + if ((loadFlags & LoadFlags.POI_RECORDS) != 0) { + records = new ArrayList<>(); + CompoundTag sectionsTag = data.getCompoundTag("Sections"); + if (sectionsTag == null) { + throw new IllegalArgumentException("Sections tag not found!"); + } + for (NamedTag sectionTag : sectionsTag) { + int sectionY = Integer.parseInt(sectionTag.getName()); + boolean valid = ((CompoundTag) sectionTag.getTag()).getBoolean("Valid", true); + poiSectionValidity.put(sectionY, valid); + ListTag recordTags = ((CompoundTag) sectionTag.getTag()).getListTagAutoCast("Records"); + if (recordTags != null) { + for (CompoundTag recordTag : recordTags) { + T record = createPoiRecord(recordTag); + if (sectionY != record.getSectionY()) { + poiSectionValidity.put(sectionY, false); + } + records.add(record); + } + } + } + } + } + + /** {@inheritDoc} */ + public String getMcaType() { + return "poi"; + } + + @Override + public boolean moveChunkImplemented() { + return records != null || data != null; + } + + @Override + public boolean moveChunkHasFullVersionSupport() { + return records != null || data != null; + } + + /** {@inheritDoc} */ + @Override + public boolean moveChunk(int newChunkX, int newChunkZ, long moveChunkFlags, boolean force) { + if (!moveChunkImplemented()) + throw new UnsupportedOperationException("Missing the data required to move this chunk!"); + if (!RegionBoundingRectangle.MAX_WORLD_BORDER_BOUNDS.containsChunk(chunkX, chunkZ)) { + throw new IllegalArgumentException("Chunk XZ must be within the maximum world bounds."); + } + // remember poi chunk nbt doesn't contain XZ location + this.chunkX = newChunkX; + this.chunkZ = newChunkZ; + if (fixPoiLocations(moveChunkFlags)) { + if ((moveChunkFlags & MoveChunkFlags.AUTOMATICALLY_UPDATE_HANDLE) > 0) { + updateHandle(); + } + return true; + } + return false; + } + + public boolean fixPoiLocations(long moveChunkFlags) { + if (!moveChunkImplemented()) + throw new UnsupportedOperationException("Missing the data required to move this chunk!"); + if (this.chunkX == NO_CHUNK_COORD_SENTINEL || this.chunkZ == NO_CHUNK_COORD_SENTINEL) { + throw new IllegalStateException("Chunk XZ not known"); + } + boolean changed = false; + final ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(chunkX, chunkZ); + if (!raw && records != null) { + for (T entity : records) { + if (!cbr.containsBlock(entity.getX(), entity.getZ())) { + entity.setX(cbr.relocateX(entity.getX())); + entity.setZ(cbr.relocateZ(entity.getZ())); + changed = true; + } + } + } else { // fix raw data + if (data == null) { + throw new UnsupportedOperationException( + "Cannot fix POI locations when RELEASE_CHUNK_DATA_TAG was set and POI_RECORDS was not set."); + } + CompoundTag sectionsTag = data.getCompoundTag("Sections"); + if (sectionsTag == null) { + throw new IllegalArgumentException("Sections tag not found!"); + } + for (NamedTag sectionTag : sectionsTag) { + ListTag recordTags = ((CompoundTag) sectionTag.getTag()).getListTagAutoCast("Records"); + if (recordTags != null) { + for (CompoundTag recordTag : recordTags) { + IntArrayTag posTag = recordTag.getIntArrayTag("pos"); + int[] pos = posTag.getValue(); // by ref + int x = pos[0]; + int z = pos[2]; + if (!cbr.containsBlock(x, z)) { + pos[0] = cbr.relocateX(x); + pos[2] = cbr.relocateZ(z); + changed = true; + // Don't need to call recordTag.getIntArrayTag("Pos").setValue(pos); + } + } + } + } + } + return changed; + } + + /** + * Called from {@link #initReferences(long)}. Exists to provide a hook for custom implementations to override to + * add support for modded poi's, etc. without having to implement {@link #initReferences(long)} logic fully. + */ + protected abstract T createPoiRecord(CompoundTag recordTag); + + @Override + public boolean add(T record) { + if (record == null) { + throw new IllegalArgumentException("record must not be null"); + } + return records.add(record); + } + + /** + * Gets the first poi record found with the exact xyz given + * @param x world block x + * @param y world block y + * @param z world block z + * @return poi record if found, otherwise null + */ + public T getFirst(final int x, final int y, final int z) { + return records.stream().filter(r -> r.matches(x, y, z)).findFirst().orElse(null); + } + + /** + * Gets a shallow COPY of the set of poi records in this chunk. + * Modifications to the list will have no affect on this chunk, but modifying items in that list will. + *

However, you can {@link #getAll()} modify the returned list, then call {@link #set(Collection)} + * with your modified list to update the records in this chunk.

+ */ + public List getAll() { + // don't return actual records list, retain the freedom to make it something other than a list for + // optimizations later! + return new ArrayList<>(records); + } + + /** + * Gets all poi record found with the exact xyz given. Really there should be only one - but nothing + * is stopping you from messing it up. + * @param x world block x + * @param y world block y + * @param z world block z + * @return new list of poi records at the given xyz + */ + public List getAll(final int x, final int y, final int z) { + return records.stream().filter(r -> r.matches(x, y, z)).collect(Collectors.toList()); + } + + /** + * Gets all poi records of the given type + * @param poiType poi type + * @return new list of poi records matching the given poi type + */ + public List getAll(final String poiType) { + List list = records.stream().filter(r -> r.matches(poiType)).collect(Collectors.toList()); + return list; + } + + /** + * Removes the given record from ths poi chunk both by reference and by equality. + * @param record record to remove + * @return true if any record was removed + */ + @Override + public boolean remove(Object record) { + if (!(record instanceof PoiRecord)) return false; + return records.removeIf(r -> r == record || r.equals(record)); + } + + @Override + public boolean removeAll(Collection c) { + return records.removeAll(c); + } + + /** + * Removes all records at the given xyz. + * @param x world block x + * @param y world block y + * @param z world block z + * @return True if any records were removed + */ + public boolean removeAll(final int x, final int y, final int z) { + return records.removeIf(r -> r.matches(x, y, z)); + } + + /** + * Removes all PoiRecords with the given type. + * @param poiType poi type to remove + * @return true if any records were removed + */ + public boolean removeAll(final String poiType) { + if (poiType == null || poiType.isEmpty()) { + return false; + } + return records.removeIf(r -> r.matches(poiType)); + } + + /** + * Removes the FIRST PoiRecord at the given xyz. + * @param x world block x + * @param y world block y + * @param z world block z + * @return Removed PoiRecord or null if no such record + */ + public T removeFirst(final int x, final int y, final int z) { + Iterator iter = records.iterator(); + while (iter.hasNext()) { + T record = iter.next(); + if (record.matches(x, y, z)) { + iter.remove(); + return record; + } + } + return null; + } + + @Override + public boolean containsAll(Collection c) { + return records.containsAll(c); + } + + @Override + public boolean addAll(Collection c) { + boolean changed = false; + for (T r : c) { + if (r != null) { + records.add(r); + changed = true; + } + } + return changed; + } + + @Override + public boolean retainAll(Collection c) { + return records.retainAll(c); + } + + /** + * Removes all poi record data from this chunk. This WILL NOT provide any signal to Minecraft that the + * poi records for this chunk should be recalculated. Calling this function is only the correct action + * if you have removed all poi blocks from the chunk or if you plan to rebuild the poi records. + *

Also resets all poi chunk section validity flags to indicate "is valid = true".

+ */ + @Override + public void clear() { + records.clear(); + poiSectionValidity.clear(); + } + + @Override + public int size() { + return records.size(); + } + + @Override + public boolean isEmpty() { + return records.isEmpty(); + } + + @Override + public boolean contains(Object o) { + return records.contains(o); + } + + @Override + public Iterator iterator() { + return records.iterator(); + } + + /** + * Provides an iterator over poi records with the given type. This is a convenience function and does not provide + * any real optimization v.s. iterating over all elements. + * @param poiType poi type, if null or empty an empty iterator is returned + * @return Never null, but may be empty. Does not support {@link Iterator#remove()} + */ + public Iterator iterator(final String poiType) { + if (poiType == null || poiType.isEmpty()) { + return Collections.emptyIterator(); + } + return records.stream().filter(r -> r.matches(poiType)).iterator(); + } + + public Stream stream() { + return records.stream(); + } + + @Override + public Object[] toArray() { + return records.toArray(); + } + + @Override + public T1[] toArray(T1[] a) { + return records.toArray(a); + } + + /** + * Clears the poi records from this chunk by first calling {@link #clear()}, then repopulates them by + * taking a shallow copy from the given collection. If the collection is null the affect of this + * function is the same as {@link #clear()}. + * @param c collection to shallow copy poi records from, any null entries will be ignored. + */ + public void set(Collection c) { + clear(); + if (c != null) { + addAll(c); + } + } + + /** + * Marks the given subchunk invalid so that Minecraft will recompute POI for it when loaded. + * @param sectionY subchunk section-y to invalidate + */ + public void invalidateSection(int sectionY) { + if (sectionY < Byte.MIN_VALUE || sectionY > Byte.MAX_VALUE) + throw new IllegalArgumentException("sectionY must be in range [-128..127]"); + poiSectionValidity.put(sectionY, false); + } + + /** + * Checks if the given section has been marked invalid either by calling {@link #invalidateSection(int)} or if + * it was already invalidated in the poi mca file. + */ + public boolean isPoiSectionValid(int sectionY) { + if (sectionY < Byte.MIN_VALUE || sectionY > Byte.MAX_VALUE) + throw new IllegalArgumentException("sectionY must be in range [-128..127]"); + return poiSectionValidity.getOrDefault(sectionY, true); + } + + /** + * Checks if the given poi record resides in a section that has been marked invalid either by calling + * {@link #invalidateSection(int)} or was already invalidated in the poi mca file. + */ + public boolean isPoiSectionValid(PoiRecord record) { + return record == null || poiSectionValidity.getOrDefault(record.getSectionY(), true); + } + + /** + * {@inheritDoc} + */ + @Override + public CompoundTag updateHandle() { + if (raw) return data; + super.updateHandle(); + Map> sectionedLists = records.stream().collect(Collectors.groupingBy(PoiRecord::getSectionY)); + // ensure that all invalidated sections are in sectionedLists so we can just do one processing pass + for (int sectionY : poiSectionValidity.keySet()) { + if (!sectionedLists.containsKey(sectionY)) { + sectionedLists.put(sectionY, Collections.emptyList()); + } + } + + CompoundTag sectionContainerTag = new CompoundTag(sectionedLists.size()); + data.put("Sections", sectionContainerTag); + for (Map.Entry> entry : sectionedLists.entrySet()) { + CompoundTag sectionTag = new CompoundTag(); + List sectionRecords = entry.getValue(); + boolean isValid = poiSectionValidity.getOrDefault(entry.getKey(), true); + if (!isValid || !sectionRecords.isEmpty()) { + sectionContainerTag.put(Integer.toString(entry.getKey()), sectionTag); + ListTag recordsTag = new ListTag<>(CompoundTag.class, sectionRecords.size()); + sectionTag.putBoolean("Valid", isValid); + sectionTag.put("Records", recordsTag); + for (PoiRecord record : sectionRecords) { + recordsTag.add(record.updateHandle()); + } + } + } + return data; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/PoiRecord.java b/src/main/java/io/github/ensgijs/nbt/mca/PoiRecord.java new file mode 100644 index 00000000..345a6d04 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/PoiRecord.java @@ -0,0 +1,330 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.mca.util.TagWrapper; + +import java.util.Objects; + +/** + *

+ * In summary, if you have changed the block at a POI location, or altered the blocks in a {@link TerrainChunk} + * in such a way that may have added or removed POI blocks you have a few options (MC 1.14+) + *

    + *
  1. calculate an accurate new poi state yourself by removing and adding poi records on the {@link PoiChunk}, + * and to be truly accurate you must also modify villager "brains", but they will figure things out when + * they try to interact with their poi's and find them of the wrong type.
  2. + *
  3. invalidate the poi sub-chunk within which you have made alterations with {@link PoiChunk#invalidateSection(int)}
  4. + *
  5. remove the poi chunk from the poi file with {@link McaPoiFile#removeChunk(int)} or {@link McaPoiFile#removeChunk(int, int)}
  6. + *
  7. delete the entire poi mca file
  8. + *
+ * All of the options, other than calculating poi state yourself, will trigger Minecraft to re-calculate poi records + * without causing errant behavior. The worst thing you can do is to do nothing - Minecraft will eventually notice + * but it may cause "strange behavior" and various WTF's until the game sorts itself out. + *

About this class
+ * A record as found in POI MCA files (points of interest). Hashable and equatable, but does not consider + * {@code freeTickets} in those operations as that field is largely MC internal state. POI mca files were added in + * MC 1.14 to improve villager performance and only contained locations of blocks villagers interacted with. Over time + * POI mca has evolved to include locations of other block types to optimize game performance - such as improving + * nether portal lag by storing portal block locations in the poi files so the game doesn't need to scan every block + * in every chunk until it finds a destination portal. + *

At time of writing, 1.14 to 1.17.1, this class exposes all poi record fields. For now, there is no support for + * reading or storing extra fields which this class does not wrap.

+ *

POI types As of 1.17 + *

    + *
  • minecraft:unemployed - does not map to a block type
  • + *
  • minecraft:armorer - block: blast_furnace
  • + *
  • minecraft:butcher - block: smoker
  • + *
  • minecraft:cartographer - block: cartography_table
  • + *
  • minecraft:cleric - block: brewing_stand
  • + *
  • minecraft:farmer - block: composter
  • + *
  • minecraft:fisherman - block: barrel
  • + *
  • minecraft:fletcher - block: fletching_table
  • + *
  • minecraft:leatherworker - block: any cauldron block
  • + *
  • minecraft:librarian - block: lectern
  • + *
  • minecraft:mason - block: stonecutter
  • + *
  • minecraft:nitwit - does not map to a block type
  • + *
  • minecraft:shepherd - block: loom
  • + *
  • minecraft:toolsmith - block: smithing_table
  • + *
  • minecraft:weaponsmith - block: grindstone
  • + *
  • minecraft:home - block: any bed
  • + *
  • minecraft:meeting - block: bell
  • + *
  • minecraft:beehive - block: beehive
  • + *
  • minecraft:bee_nest - block: bee_nest
  • + *
  • minecraft:nether_portal - block: nether_portal
  • + *
  • minecraft:lodestone - block: lodestone
  • + *
  • minecraft:lightning_rod - block: lightning_rod
  • + *
+ * + *
+ * What are "Tickets"? + *

+ * Tickets are only used for blocks/poi's (points of interest) which villagers interact with. Internally + * Minecraft specifies a max tickets for each such poi type. This is the maximum number of villagers which + * can "take a ticket" (aka be using that poi at the same time; aka max number of villagers which + * can claim that poi and store it in their "brain"). For all villager eligible poi's that limit + * is one (1), with the single exception being minecraft:meeting (block minecraft:bell) which has a + * limit of 32. + *

+ * Poi entries which are not for villager interaction such as beehives, nether portals, + * lighting rods, etc., have a max ticket count of zero (0). + *

+ * A truly valid POI Record is one that satisfies all of the following conditions + *

    + *
  • the block at the poi location is appropriate for the poi type
  • + *
  • free tickets is never GT max tickets for that poi type
  • + *
  • {@link #getFreeTickets()} equals the count of all villagers who have stored the poi location in their + * "brain" subtracted from the max tickets for that poi type
  • + *
+ */ +public class PoiRecord implements TagWrapper, Comparable { + protected String type; + protected int freeTickets; + protected int x; + protected int y; + protected int z; + + public PoiRecord() { } + + /** + * copy constructor + */ + public PoiRecord(PoiRecord other) { + this.type = other.type; + this.freeTickets = other.freeTickets; + this.x = other.x; + this.y = other.y; + this.z = other.z; + } + + public PoiRecord(CompoundTag data) { + this.freeTickets = data.getInt("free_tickets"); + this.type = data.getString("type"); + int[] pos = data.getIntArray("pos"); + this.x = pos[0]; + this.y = pos[1]; + this.z = pos[2]; + } + + /** + * Defaults free tickets to result of passing the given type to {@link #maxFreeTickets(String)} + * @param x world block x + * @param y world block y - must be a within the absolute maximum limit of blocks + * theoretically supportable by chunk sections [-2048..2032) + * @param z world block z + * @param type required, poi type name + */ + public PoiRecord(int x, int y, int z, String type) { + this(x, y, z, type, maxFreeTickets(type)); + } + + /** + * @param x world block x + * @param y world block y - must be a within the absolute maximum limit of blocks + * theoretically supportable by chunk sections [-2048..2048) + * @param z world block z + * @param type required, poi type name + * @param freeTickets must be GT 0 + */ + public PoiRecord(int x, int y, int z, String type, int freeTickets) { + this.type = validateType(type); + this.freeTickets = validateFreeTickets(freeTickets); + this.y = validateY(y); + this.x = x; + this.z = z; + } + + private String validateType(String type) { + if (type == null || type.isEmpty()) { + throw new IllegalArgumentException("poi type must not be null or empty"); + } + return type; + } + + private int validateFreeTickets(int freeTickets) { + if (freeTickets < 0) { + throw new IllegalArgumentException("freeTickets must be GE 0"); + } + return freeTickets; + } + + private int validateY(int y) { + if (y < Byte.MIN_VALUE * 16 || y > Byte.MAX_VALUE * 16 + 15) { + throw new IndexOutOfBoundsException(String.format( + "Given Y value %d is out of range for any legal block. Y must be in range [%d..%d]", + y, Byte.MIN_VALUE * 16, Byte.MAX_VALUE * 16 + 15)); + } + return y; + } + + /** + * Returns a {@link CompoundTag} representing this record. + * The tag returned is newly created and not a reference to a tag held by any other object. This is a different + * behavior than most other {@code getHandle()} implementations. + */ + @Override + public CompoundTag updateHandle() { + CompoundTag data = new CompoundTag(); + data.putInt("free_tickets", freeTickets); + data.putString("type", type); + data.putIntArray("pos", new int[] {x, y, z}); + return data; + } + + /** + * Returns a {@link CompoundTag} representing this record. + * The tag returned is newly created and not a reference to a tag held by any other object. This is a different + * behavior than most other {@code getHandle()} implementations. + * @return data handle, never null + */ + @Override + public CompoundTag getHandle() { + return updateHandle(); + } + + /** + * See class doc {@link PoiRecord} + */ + public int getFreeTickets() { + return freeTickets; + } + + /** + * See class doc {@link PoiRecord} + */ + public PoiRecord setFreeTickets(int freeTickets) { + this.freeTickets = validateFreeTickets(freeTickets); + return this; + } + + /** + * Sets freeTickets to the default max free tickets for this poi type. + * see class doc {@link PoiRecord} + */ + public PoiRecord resetFreeTickets() { + this.freeTickets = maxFreeTickets(this.type); + return this; + } + + /** Type of the point, for example: minecraft:home, minecraft:meeting, minecraft:butcher, minecraft:nether_portal */ + public String getType() { + return type; + } + + /** Type of the point, for example: minecraft:home, minecraft:meeting, minecraft:butcher, minecraft:nether_portal */ + public PoiRecord setType(String type) { + this.type = validateType(type); + return this; + } + + /** world x location */ + public int getX() { + return x; + } + + /** world x location */ + public PoiRecord setX(int x) { + this.x = x; + return this; + } + + /** world y location */ + public int getY() { + return y; + } + + /** + * @param y must be a within the absolute maximum limit of blocks + * theoretically supportable by chunk sections [-2048..2048) + */ + public PoiRecord setY(int y) { + this.y = validateY(y); + return this; + } + + /** world z location */ + public int getZ() { + return z; + } + + /** world z location */ + public PoiRecord setZ(int z) { + this.z = z; + return this; + } + + /** + * Sets XYZ + * @param x world block x + * @param y world block y + * @param z world block z + * @return self + */ + public PoiRecord setYXZ(int x, int y, int z) { + this.x = x; + this.y = y; + this.z = z; + return this; + } + + @Override + public int hashCode() { + return Objects.hash(type, x, y, z); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof PoiRecord)) return false; + PoiRecord that = (PoiRecord) other; + return this.y == that.y && this.x == that.x && this.z == that.z && Objects.equals(this.type, that.type); + } + + @Override + public int compareTo(PoiRecord other) { + if (other == null) { + return -1; + } + return Integer.compare(this.y, other.y); + } + + public boolean matches(int x, int y, int z) { + return this.y == y && this.x == x && this.z == z; + } + + public boolean matches(String type) { + return this.type.equals(type); + } + + public int getSectionY() { + return this.y >> 4; + } + + /** + * Gets the default max free tickets for the given poi type. + * @param poiType poi type - NOT block type + * @return default (vanilla) max free tickets for the given type. + */ + public static int maxFreeTickets(String poiType) { + switch (poiType) { + case "minecraft:unemployed": + case "minecraft:armorer": + case "minecraft:butcher": + case "minecraft:cartographer": + case "minecraft:cleric": + case "minecraft:farmer": + case "minecraft:fisherman": + case "minecraft:fletcher": + case "minecraft:leatherworker": + case "minecraft:librarian": + case "minecraft:mason": + case "minecraft:nitwit": + case "minecraft:shepherd": + case "minecraft:toolsmith": + case "minecraft:weaponsmith": + case "minecraft:home": + return 1; + case "minecraft:meeting": + return 32; + } + return 0; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/SectionBase.java b/src/main/java/io/github/ensgijs/nbt/mca/SectionBase.java new file mode 100644 index 00000000..98bbcc05 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/SectionBase.java @@ -0,0 +1,186 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.util.ObservedCompoundTag; +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.mca.util.TagWrapper; +import io.github.ensgijs.nbt.mca.util.TracksUnreadDataTags; + +import java.util.*; + +import static io.github.ensgijs.nbt.mca.io.LoadFlags.RAW; +import static io.github.ensgijs.nbt.mca.io.LoadFlags.RELEASE_CHUNK_DATA_TAG; + +/** + * Sections can be thought of as "sub-chunks" which are 16x16x16 block cubes + * stacked atop each other to create a "chunk". + */ +public abstract class SectionBase> implements Comparable, TagWrapper, TracksUnreadDataTags { + /** Used to indicate an unset section Y value. */ + public static final int NO_SECTION_Y_SENTINEL = Integer.MIN_VALUE; + /** for internal use only - modify with extreme care and precision - must be kept in sync with chunk data version */ + protected int dataVersion; + private boolean raw; + protected CompoundTag data; + protected Set unreadDataTagKeys; + /** + * 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. + *

AKA: "height"

+ */ + protected int sectionY = NO_SECTION_Y_SENTINEL; + + /** + * {@inheritDoc} + */ + public Set getUnreadDataTagKeys() { + return unreadDataTagKeys; + } + + /** + * {@inheritDoc} + * @return NotNull - if LoadFlags specified {@link LoadFlags#RAW} then the raw data is returned - else a new + * CompoundTag populated, by reference, with values that were not read during {@link #initReferences(long)}. + */ + public CompoundTag getUnreadDataTags() { + if (raw) return data; + CompoundTag unread = new CompoundTag(unreadDataTagKeys.size()); + data.forEach((k, v) -> { + if (unreadDataTagKeys.contains(k)) { + unread.put(k, v); + } + }); + return unread; + } + + /** + * Due to how Java initializes objects and how this class hierarchy is setup it is ill-advised to use inline member + * initialization because {@link #initReferences(long)} will be called before members are initialized which WILL + * result in very confusing {@link NullPointerException}'s being thrown from within {@link #initReferences(long)}. + * This is not a problem that can be solved by moving initialization into your constructors, because you must call + * the super constructor as the first line of your child constructor! + *

So, to get around this hurdle, perform all member initialization you would normally inline in your + * class def, within this method instead. Implementers should never need to call this method themselves + * as ChunkBase will always call it, even from the default constructor. Remember to call {@code super();} + * from your default constructors to maintain this behavior.

+ */ + protected void initMembers() { } + + protected SectionBase(int dataVersion) { + data = new CompoundTag(); + this.dataVersion = dataVersion; + initMembers(); + } + + protected SectionBase(CompoundTag sectionRoot, int dataVersion, long loadFlags) { + Objects.requireNonNull(sectionRoot, "sectionRoot must not be null"); + this.data = sectionRoot; + this.dataVersion = dataVersion; + initMembers(); + initReferences0(loadFlags); + } + + private void initReferences0(long loadFlags) { + Objects.requireNonNull(data, "data cannot be null"); + // Note that if RAW was specified in the loadFlags section data will not be loaded by chunk loading. + // However, the user may decide to not use chunk loading and create an instance of this class directly, + // so we should honor it anyway. + if ((loadFlags & RAW) != 0) { + raw = true; + } else { + final ObservedCompoundTag observedData = new ObservedCompoundTag(data); + data = observedData; + initReferences(loadFlags); + if (data != observedData) { + throw new IllegalStateException("this.data was replaced during initReferences execution - this breaks unreadDataTagKeys behavior!"); + } + unreadDataTagKeys = observedData.unreadKeys(); + + if ((loadFlags & RELEASE_CHUNK_DATA_TAG) != 0) { + data = new CompoundTag(); + } else { + // stop observing the data tag + data = observedData.wrappedTag(); + } + } + } + + /** + * Child classes should not call this method directly, it will be called for them. + * Raw and partial data handling is taken care of, this method will not be called if {@code loadFlags} + * contains {@link LoadFlags#RAW}. + */ + protected abstract void initReferences(final long loadFlags); + + /** Section data version must be kept in sync with chunk data version. Use with extreme care! */ + protected void syncDataVersion(int newDataVersion) { + if (newDataVersion <= 0) { + throw new IllegalArgumentException("Invalid data version - must be GT 0"); + } + this.dataVersion = newDataVersion; + } + + @Override + public int compareTo(T o) { + if (o == null) { + return -1; + } + return Integer.compare(sectionY, o.sectionY); + } + + /** + * 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 this section is an instance of {@link TerrainSection} and user code calls {@link TerrainSection#setHeight(int)} + * on a section which is referenced by any chunk.

+ *

Prefer using {@link TerrainChunk#getSectionY(SectionBase)} which will always be accurate within the context of the + * chunk.

+ * @return The Y value of this section. + */ + public int getSectionY() { + return sectionY; + } + + protected void syncHeight(int height) { + this.sectionY = height; + } + + protected void checkY(int y) { + if (y == NO_SECTION_Y_SENTINEL) { + throw new IndexOutOfBoundsException("section Y (aka 'height') not set"); + } + if (y < Byte.MIN_VALUE | y > Byte.MAX_VALUE) { + throw new IndexOutOfBoundsException("section Y (aka 'height') must be in range of BYTE [-128..127] was: " + y); + } + } + + /** + * {@inheritDoc} + */ + public CompoundTag getHandle() { + return data; + } + + /** + * {@inheritDoc} + */ + public CompoundTag updateHandle() { + if (data == null) { + throw new UnsupportedOperationException( + "Cannot updateHandle() because data tag is null. This is probably because "+ + "the LoadFlag RELEASE_CHUNK_DATA_TAG was specified"); + } + return data; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/SectionedChunkBase.java b/src/main/java/io/github/ensgijs/nbt/mca/SectionedChunkBase.java new file mode 100644 index 00000000..de1bbcd2 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/SectionedChunkBase.java @@ -0,0 +1,256 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.mca.util.SectionIterator; + +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<>(); + + protected SectionedChunkBase(int dataVersion) { + super(dataVersion); + } + + /** + * 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 SectionedChunkBase(CompoundTag data, long loadFlags) { + super(data, loadFlags); + } + + public boolean hasSections() { + return !sections.isEmpty(); + } + + 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_SECTION_Y_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#getSectionY()} 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_SECTION_Y_SENTINEL} is returned. + */ + public int getSectionY(T section) { + if (section == null) return SectionBase.NO_SECTION_Y_SENTINEL; + int y = sectionHeightLookup.getOrDefault(section, SectionBase.NO_SECTION_Y_SENTINEL); + section.syncHeight(y); + return y; + } + + /** + * Gets the minimum section y-coordinate. + *

NOTE: fully generated terrain chunks MAY have a dummy section -1 below the world, the returned value + * WILL be this value - {@link TerrainSection} will exist for this Y but it will be completely empty of the + * standard tags you would expect to see (blocks, biomes, etc).

+ * @return The y of the lowest populated section in the chunk or {@link SectionBase#NO_SECTION_Y_SENTINEL} if there is none. + * @see #getSectionY(SectionBase) + */ + public int getMinSectionY() { + if (!sections.isEmpty()) { + return sections.firstKey(); + } + return SectionBase.NO_SECTION_Y_SENTINEL; + } + + /** + * Gets the minimum section y-coordinate. + * @return The y of the highest populated section in the chunk or {@link SectionBase#NO_SECTION_Y_SENTINEL} if there is none. + */ + public int getMaxSectionY() { + if (!sections.isEmpty()) { + return sections.lastKey(); + } + return SectionBase.NO_SECTION_Y_SENTINEL; + } + + /** min block Y, inclusive */ + public int getWorldMinBlockY() { + return getMinSectionY() * 16; + } + + /** max block Y, inclusive */ + public int getWorldMaxBlockY() { + return getMaxSectionY() * 16 + 15; + } + + /*** + * Creates a new section and places it in this chunk at the specified section-y UNLESS + * the given sectionY is {@link SectionBase#NO_SECTION_Y_SENTINEL} in which case the new + * section is not added to this chunk. + * @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#getSectionY()}. 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/io/github/ensgijs/nbt/mca/TerrainChunk.java b/src/main/java/io/github/ensgijs/nbt/mca/TerrainChunk.java new file mode 100644 index 00000000..d9398f9c --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/TerrainChunk.java @@ -0,0 +1,82 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.tag.CompoundTag; + +/** + * Represents a TERRAIN data mca chunk (from mca files that come from the /region save folder). + * Terrain chunks are composed of a set of {@link TerrainSection} where any empty/null + * section is filled with air blocks by the game. When altering existing chunks for MC 1.14+, be sure to have read and + * understood the documentation on {@link PoiRecord} to avoid problems with villagers, nether portal linking, + * lodestones, bees, and probably more as Minecraft continues to evolve. + * + *

It is my (Ross / Ens) hope that in the future this class can be repurposed to serve as an abstraction + * layer over all the various chunk types (terrain, poi, entity - at the time of writing) and that it + * can take care of keeping them all in sync. But I've already put a lot of time into this library and need + * to return to other things so for now that goal must remain unrealized.

+ */ +public class TerrainChunk extends TerrainChunkBase { + /** + * 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 TerrainChunk(int dataVersion) { + super(dataVersion); + } + + public TerrainChunk(CompoundTag data) { + super(data); + } + + public TerrainChunk(CompoundTag data, long loadFlags) { + super(data, loadFlags); + } + + public TerrainChunk() { + super(DataVersion.latest().id()); + data = new CompoundTag(); + } + + @Override + protected TerrainSection createSection(CompoundTag section, int dataVersion, long loadFlags) { + return new TerrainSection(section, dataVersion, loadFlags); + } + + /** + * {@inheritDoc} + */ + @Override + public TerrainSection createSection(int sectionY) throws IllegalArgumentException { + if (containsSection(sectionY)) throw new IllegalArgumentException("section already exists at section-y " + sectionY); + TerrainSection section = new TerrainSection(dataVersion); + if (sectionY != SectionBase.NO_SECTION_Y_SENTINEL) { + putSection(sectionY, section); // sets section height & validates range + } + return section; + } + + public TerrainSection createSection() { + return createSection(SectionBase.NO_SECTION_Y_SENTINEL); + } + + /** + * @deprecated Dangerous - assumes the latest full release data version defined by {@link DataVersion} + * prefer using {@link McaFileBase#createChunk()} or {@link McaFileBase#createChunkIfMissing(int, int)}. + */ + @Deprecated + public static TerrainChunk newChunk() { + return newChunk(DataVersion.latest().id()); + } + + public static TerrainChunk newChunk(int dataVersion) { + TerrainChunk c = new TerrainChunk(dataVersion); + c.data = new CompoundTag(); + if (dataVersion < DataVersion.JAVA_1_18_21W39A.id()) { + c.data.put("Level", new CompoundTag()); + } + c.status = "mobs_spawned"; + return c; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/TerrainChunkBase.java b/src/main/java/io/github/ensgijs/nbt/mca/TerrainChunkBase.java new file mode 100644 index 00000000..57573723 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/TerrainChunkBase.java @@ -0,0 +1,1330 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.io.NamedTag; +import io.github.ensgijs.nbt.mca.util.*; +import io.github.ensgijs.nbt.tag.*; +import io.github.ensgijs.nbt.mca.io.MoveChunkFlags; +import io.github.ensgijs.nbt.query.NbtPath; +import io.github.ensgijs.nbt.util.ArgValidator; + +import java.util.*; + +import static io.github.ensgijs.nbt.mca.DataVersion.*; +import static io.github.ensgijs.nbt.mca.io.LoadFlags.*; +import static io.github.ensgijs.nbt.mca.io.MoveChunkFlags.*; + +/** + * Represents a Terrain data mca chunk. Terrain chunks are composed of a set of {@link TerrainSection} where any empty/null + * section is filled with air blocks by the game. When altering existing chunks for MC 1.14+, be sure to have read and + * understood the documentation on {@link PoiRecord} to avoid problems with villagers, nether portal linking, + * lodestones, bees, and probably more as Minecraft continues to evolve. + */ +public abstract class TerrainChunkBase extends SectionedChunkBase { + + protected long lastUpdateTick; + /** Tick when the chunk was last saved. */ + public static final VersionAware LAST_UPDATE_TICK_PATH = new VersionAware() + .register(0, NbtPath.of("Level.LastUpdate")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("LastUpdate")); + + protected long inhabitedTimeTicks; + /** Cumulative amount of time players have spent in this chunk in ticks. */ + public static final VersionAware INHABITED_TIME_TICKS_PATH = new VersionAware() + .register(0, NbtPath.of("Level.InhabitedTime")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("InhabitedTime")); + + protected int[] legacyBiomes; + /** + * Only populated for data versions < JAVA_1_18_21W39A. For later data versions use + * {@link PalettizedCuboid} and load biomes from {@link TerrainSectionBase#getBiomes()}. + * @see minecraft.fandom.com/wiki/Biome/IDs_before_1.13 + * @see minecraft.fandom.com/wiki/Biome/ID + */ + public static final VersionAware LEGACY_BIOMES_PATH = new VersionAware() + .register(0, NbtPath.of("Level.Biomes")) // ByteArrayTag + .register(JAVA_1_13_18W06A.id(), NbtPath.of("Level.Biomes")) // IntArrayTag + .register(JAVA_1_18_21W37A.id(), null); // biomes are now paletted and live in a similar container structure in sections[].biomes + + protected IntArrayTag legacyHeightMap; + public static final VersionAware LEGACY_HEIGHT_MAP_PATH = new VersionAware() + .register(0, NbtPath.of("Level.HeightMap")) + .register(JAVA_1_13_18W06A.id(), null); + + protected CompoundTag heightMaps; + /** + * {@link CompoundTag} mapping various heightmap names to 256 (16x16) values, long[] packed, + * min bits per value of 9. Heightmap values are "number of blocks above bottom of world", this is not + * the same as block Y position. To compute the block Y value use {@code highestBlockY = + * (chunk.yPos * 16) - 1 + heightmap_entry_value}. + *
    + *
  • MOTION_BLOCKING
  • + *
  • MOTION_BLOCKING_NO_LEAVES
  • + *
  • OCEAN_FLOOR
  • + *
  • OCEAN_FLOOR_WG
  • + *
  • WORLD_SURFACE
  • + *
  • WORLD_SURFACE_WG
  • + *
+ * @since {@link DataVersion#JAVA_1_13_18W06A} + * @see LongArrayTagPackedIntegers + */ + public static final VersionAware HEIGHT_MAPS_PATH = new VersionAware() + .register(JAVA_1_13_18W06A.id(), NbtPath.of("Level.Heightmaps")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("Heightmaps")); + + protected CompoundTag carvingMasks; + public static final VersionAware CARVING_MASKS_PATH = new VersionAware() + .register(JAVA_1_13_18W19A.id(), NbtPath.of("Level.CarvingMasks")) // CompoundTag containing named ByteArrayTag's + .register(JAVA_1_18_2_22W03A.id(), NbtPath.of("Level.CarvingMasks")) // CompoundTag containing named LongArrayTag's + .register(JAVA_1_18_21W43A.id(), NbtPath.of("CarvingMasks")); // CompoundTag containing named LongArrayTag's + + protected ListTag entities; // usage changed for chunk versions >= 2724 (1.17) after which entities are only stored in terrain chunks during world generation. + public static final VersionAware ENTITIES_PATH = new VersionAware() + .register(0, NbtPath.of("Level.Entities")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("entities")); + + protected ListTag tileEntities; + public static final VersionAware TILE_ENTITIES_PATH = new VersionAware() + .register(0, NbtPath.of("Level.TileEntities")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("block_entities")); + + protected ListTag tileTicks; + public static final VersionAware TILE_TICKS_PATH = new VersionAware() + .register(0, NbtPath.of("Level.TileTicks")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("block_ticks")); + + protected ListTag> toBeTicked; + public static final VersionAware TO_BE_TICKED_PATH = new VersionAware() + .register(JAVA_1_13_18W06A.id(), NbtPath.of("Level.ToBeTicked")) + .register(JAVA_1_18_21W43A.id(), null); // unsure when this was removed - but notes on JAVA_1_18_21W43A say it was also "moved to block_ticks" - but the mca scans last saw it in JAVA_1_14_PRE2 + + protected ListTag liquidTicks; + public static final VersionAware LIQUID_TICKS_PATH = new VersionAware() + .register(0, NbtPath.of("Level.LiquidTicks")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("fluid_ticks")); + + protected ListTag> liquidsToBeTicked; + public static final VersionAware LIQUIDS_TO_BE_TICKED_PATH = new VersionAware() + .register(0, NbtPath.of("Level.LiquidsToBeTicked")) + .register(JAVA_1_18_21W43A.id(), null); // unsure when this was removed - but notes on JAVA_1_18_21W43A say it was also "moved to block_ticks" - but the mca scans last saw it in JAVA_1_14_PRE2 + + protected ListTag> lights; + public static final VersionAware LIGHTS_PATH = new VersionAware() + .register(JAVA_1_13_18W06A.id(), NbtPath.of("Level.Lights")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("Lights")); + + protected ListTag> postProcessing; + public static final VersionAware POST_PROCESSING_PATH = new VersionAware() + .register(JAVA_1_13_18W06A.id(), NbtPath.of("Level.PostProcessing")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("PostProcessing")); + + protected String status; + public static final VersionAware STATUS_PATH = new VersionAware() + .register(JAVA_1_13_18W06A.id(), NbtPath.of("Level.Status")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("Status")); + + protected CompoundTag structures; + public static final VersionAware STRUCTURES_PATH = new VersionAware() + .register(0, NbtPath.of("Level.Structures")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("structures")); + /** Relative to {@link #STRUCTURES_PATH} */ + public static final VersionAware STRUCTURES_STARTS_PATH = new VersionAware() + .register(0, NbtPath.of("Starts")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("starts")); + /** Relative to {@link #STRUCTURES_PATH} */ + public static final VersionAware STRUCTURES_REFERENCES_PATH = new VersionAware() + .register(0, NbtPath.of("References")); + + public static final VersionAware IS_LIGHT_POPULATED_PATH = new VersionAware() + .register(0, NbtPath.of("Level.LightPopulated")) + .register(JAVA_1_13_18W06A.id(), null); // probably replaced by Level.Status progression + + protected Boolean isLightOn; + public static final VersionAware IS_LIGHT_ON_PATH = new VersionAware() + .register(JAVA_1_14_19W02A.id(), NbtPath.of("Level.isLightOn")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("isLightOn")); + + protected Boolean isTerrainPopulated; + public static final VersionAware TERRAIN_POPULATED_PATH = new VersionAware() + .register(0, NbtPath.of("Level.TerrainPopulated")) + .register(JAVA_1_13_18W06A.id(), null); // replaced by Level.Status progression + + protected Boolean hasLegacyStructureData; + public static final VersionAware HAS_LEGACY_STRUCTURE_DATA_PATH = new VersionAware() + .register(0, NbtPath.of("Level.hasLegacyStructureData")) + .register(JAVA_1_13_18W20C.id(), null); // might not be exactly correct + + protected CompoundTag upgradeData; + public static final VersionAware UPGRADE_DATA_PATH = new VersionAware() + .register(0, NbtPath.of("Level.UpgradeData")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("UpgradeData")); + + public static final VersionAware SECTIONS_PATH = new VersionAware() + .register(0, NbtPath.of("Level.Sections")) + .register(JAVA_1_18_21W37A.id(), NbtPath.of("sections")); + + public static final VersionAware X_POS_PATH = new VersionAware() + .register(0, NbtPath.of("Level.xPos")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("xPos")); + + public static final VersionAware Z_POS_PATH = new VersionAware() + .register(0, NbtPath.of("Level.zPos")) + .register(JAVA_1_18_21W43A.id(), NbtPath.of("zPos")); + + /** + * Represents world bottom - note there may exist a dummy chunk -1 below this depending on MC flavor and current chunk state. + * @since {@link DataVersion#JAVA_1_18_21W43A} + */ + protected int yPos = NO_CHUNK_COORD_SENTINEL; + public static final VersionAware Y_POS_PATH = new VersionAware() + .register(JAVA_1_18_21W43A.id(), NbtPath.of("yPos")); + public static final VersionAware DEFAULT_WORLD_BOTTOM_Y_POS = new VersionAware() + .register(0, 0) + .register(JAVA_1_18_21W43A.id(), -4); // TODO: IDK when exactly they actually enabled deep worlds + + /** @since {@link DataVersion#JAVA_1_18_21W43A} */ + protected CompoundTag belowZeroRetrogen; + public static final VersionAware BELOW_ZERO_RETROGEN_PATH = new VersionAware() + .register(JAVA_1_18_21W43A.id(), NbtPath.of("below_zero_retrogen")); + + /** @since {@link DataVersion#JAVA_1_18_21W43A} */ + protected CompoundTag blendingData; + public static final VersionAware BLENDING_DATA_PATH = new VersionAware() + .register(JAVA_1_18_21W43A.id(), NbtPath.of("blending_data")); + + + protected TerrainChunkBase(int dataVersion) { + super(dataVersion); + } + + /** + * Create a new chunk based on raw base data from a Terrain region file. + * @param data The raw base data to be used. + */ + public TerrainChunkBase(CompoundTag data) { + super(data); + } + + public TerrainChunkBase(CompoundTag data, long loadFlags) { + super(data, loadFlags); + } + + @Override + protected void initMembers() { + // give this a reasonable default + yPos = DEFAULT_WORLD_BOTTOM_Y_POS.get(dataVersion); + } + + @Override + protected void initReferences(final long loadFlags) { + if (dataVersion < JAVA_1_18_21W39A.id()) { + if (data.getCompoundTag("Level") == null) { + throw new IllegalArgumentException("data does not contain \"Level\" tag"); + } + } + + inhabitedTimeTicks = getTagValue(INHABITED_TIME_TICKS_PATH, LongTag::asLong, 0L); + lastUpdateTick = getTagValue(LAST_UPDATE_TICK_PATH, LongTag::asLong, 0L); + if (dataVersion < JAVA_1_18_21W37A.id() && (loadFlags & BIOMES) != 0) { + if (dataVersion >= DataVersion.JAVA_1_13_18W06A.id()) { + legacyBiomes = getTagValue(LEGACY_BIOMES_PATH, IntArrayTag::getValue); + } else { + byte[] byteBiomes = getTagValue(LEGACY_BIOMES_PATH, ByteArrayTag::getValue); + legacyBiomes = new int[byteBiomes.length]; + for (int i = 0; i < legacyBiomes.length; i++) { + legacyBiomes[i] = byteBiomes[i]; + } + } + if (legacyBiomes != null && legacyBiomes.length == 0) legacyBiomes = null; + } // palette biomes are stored at the section, not chunk, level. + + if ((loadFlags & HEIGHTMAPS) != 0) { + legacyHeightMap = getTag(LEGACY_HEIGHT_MAP_PATH); + heightMaps = getTag(HEIGHT_MAPS_PATH); + } + if ((loadFlags & CARVING_MASKS) != 0) { + carvingMasks = getTag(CARVING_MASKS_PATH); + } + if ((loadFlags & ENTITIES) != 0) { + entities = getTag(ENTITIES_PATH); + } + if ((loadFlags & TILE_ENTITIES) != 0) { + tileEntities = getTag(TILE_ENTITIES_PATH); + } + if ((loadFlags & TILE_TICKS) != 0) { + tileTicks = getTag(TILE_TICKS_PATH); + } + if ((loadFlags & TO_BE_TICKED) != 0) { + toBeTicked = getTag(TO_BE_TICKED_PATH); + } + if ((loadFlags & LIGHTS) != 0) { + lights = getTag(LIGHTS_PATH); + } + if ((loadFlags & LIQUID_TICKS) != 0) { + liquidTicks = getTag(LIQUID_TICKS_PATH); + } + if ((loadFlags & LIQUIDS_TO_BE_TICKED) != 0) { + liquidsToBeTicked = getTag(LIQUIDS_TO_BE_TICKED_PATH); + } + if ((loadFlags & POST_PROCESSING) != 0) { + postProcessing = getTag(POST_PROCESSING_PATH); + } + + status = getTagValue(STATUS_PATH, StringTag::getValue); + isLightOn = getTagValue(IS_LIGHT_ON_PATH, ByteTag::asBoolean); + isTerrainPopulated = getTagValue(TERRAIN_POPULATED_PATH, ByteTag::asBoolean); + + // TODO: add load flag for this + upgradeData = getTag(UPGRADE_DATA_PATH); + + if ((loadFlags & STRUCTURES) != 0) { + structures = getTag(STRUCTURES_PATH); + hasLegacyStructureData = getTagValue(HAS_LEGACY_STRUCTURE_DATA_PATH, ByteTag::asBoolean); + } + + // chunkXZ may be pre-populated with a solid guess so don't overwrite that guess if we don't have values. + if (X_POS_PATH.get(dataVersion).exists(data)) { + chunkX = getTagValue(X_POS_PATH, t -> ((NumberTag)t).asInt()); + } + if (Z_POS_PATH.get(dataVersion).exists(data)) { + chunkZ = getTagValue(Z_POS_PATH, t -> ((NumberTag)t).asInt()); + } + + yPos = getTagValue(Y_POS_PATH, t -> ((NumberTag)t).asInt(), DEFAULT_WORLD_BOTTOM_Y_POS.get(dataVersion)); + + boolean loadSections = ((loadFlags & (BLOCK_LIGHTS|BLOCK_STATES|SKY_LIGHT)) != 0) + || (dataVersion >= JAVA_1_18_21W37A.id() && ((loadFlags & BIOMES) != 0)); + if (loadSections) { + try { + ListTag sections = getTag(SECTIONS_PATH); + if (sections != null) { + for (CompoundTag section : sections) { + T newSection = createSection(section, dataVersion, loadFlags); + putSection(newSection.getSectionY(), newSection, false); + } + } + } catch (Exception ex) { + throw new RuntimeException("Chunk " + getChunkX() + " " + getChunkZ() + "\n" + ex.getMessage(), ex); + } + } + if ((loadFlags & WORLD_UPGRADE_HINTS) != 0) { + belowZeroRetrogen = getTag(BELOW_ZERO_RETROGEN_PATH); + blendingData = getTag(BLENDING_DATA_PATH); + } + } + + protected abstract T createSection(CompoundTag section, int dataVersion, long loadFlags); + + /** {@inheritDoc} */ + public String getMcaType() { + return "region"; + } + + /** + * May only be used for data versions LT 2203 which includes all of 1.14 + * and up until 19w36a (a 1.15 weekly snapshot). + *

Note: 2D biomes have a resolution of 1x256x1 blocks.

+ * @deprecated unsupported after {@link DataVersion#JAVA_1_15_19W35A} use {@link #getLegacyBiomeAt(int, int, int)} instead for 1.15 and beyond + */ + @Deprecated + public int getLegacyBiomeAt(int blockX, int blockZ) { + if (dataVersion > JAVA_1_15_19W35A.id()) + throw new VersionLacksSupportException(dataVersion, null, JAVA_1_15_19W35A, + "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"); + if (legacyBiomes == null || legacyBiomes.length != 256) { + return -1; + } + return legacyBiomes[getLegacy2dBiomeIndex(blockX, blockZ)]; + } + + /** + * Fetches a biome id at a specific block in this chunk. + * The coordinates can be absolute coordinates or relative to the region or chunk. + *

Note: 3D biomes have a resolution of 4x4x4 blocks.

+ * @param blockX The x-coordinate of the block. + * @param blockY The y-coordinate of the block. + * @param blockZ The z-coordinate of the block. + * @return The biome id or -1 if the biomes are not correctly initialized. + * @deprecated unsupported after {@link DataVersion#JAVA_1_17_1} + */ + public int getLegacyBiomeAt(int blockX, int blockY, int blockZ) { + if (dataVersion > JAVA_1_17_1.id()) + throw new VersionLacksSupportException(dataVersion, null, JAVA_1_17_1, "legacy biomes"); + if (dataVersion >= JAVA_1_15_19W36A.id()) { // 3D biomes + if (legacyBiomes == null || legacyBiomes.length != 1024) { + return -1; + } + int biomeX = (blockX & 0xF) >> 2; + int biomeY = (blockY & 0xF) >> 2; + int biomeZ = (blockZ & 0xF) >> 2; + + return legacyBiomes[getLegacy3dBiomeIndex(biomeX, biomeY, biomeZ)]; + } else { // 2D biomes + return getLegacyBiomeAt(blockX, blockZ); + } + } + + /** + * Should only be used for data versions LT 2203 which includes all of 1.14 + * and up until 19w35a (a 1.15 weekly snapshot). + *

Note: 2D biomes have a resolution of 1x256x1 blocks.

+ * @deprecated unsupported after {@link DataVersion#JAVA_1_17_1} + * @see #setLegacyBiomeAt(int, int, int, int) + */ + @Deprecated + public void setLegacyBiomeAt(int blockX, int blockZ, int biomeID) { + checkRaw(); + if (dataVersion > JAVA_1_17_1.id()) + throw new VersionLacksSupportException(dataVersion, null, JAVA_1_17_1, "2D legacy biomes"); + if (dataVersion < JAVA_1_15_19W36A.id()) { // 2D biomes + if (legacyBiomes == null || legacyBiomes.length != 256) { + legacyBiomes = new int[256]; + Arrays.fill(legacyBiomes, -1); + } + legacyBiomes[getLegacy2dBiomeIndex(blockX, blockZ)] = biomeID; + } else { // 3D biomes + if (legacyBiomes == null || legacyBiomes.length != 1024) { + legacyBiomes = new int[1024]; + Arrays.fill(legacyBiomes, -1); + } + + int biomeX = (blockX & 0xF) >> 2; + int biomeZ = (blockZ & 0xF) >> 2; + + for (int y = 0; y < 64; y++) { + legacyBiomes[getLegacy3dBiomeIndex(biomeX, y, biomeZ)] = biomeID; + } + } + } + + /** + *

Note: 3D biomes have a resolution of 4x4x4 blocks.

+ * @since {@link DataVersion#JAVA_1_15_19W36A} + * @deprecated unsupported after {@link DataVersion#JAVA_1_17_1} + * @see #setLegacyBiomeAt(int, int, int, int) + */ + @Deprecated + public void setLegacyBiomeAt(int blockX, int blockY, int blockZ, int biomeID) { + if (dataVersion < JAVA_1_15_19W36A.id() || dataVersion >= JAVA_1_18_21W37A.id()) + throw new VersionLacksSupportException(dataVersion, JAVA_1_15_19W36A, JAVA_1_18_21W37A.previous(), "3D legacy biomes"); + if (legacyBiomes == null || legacyBiomes.length != 1024) { + legacyBiomes = new int[1024]; + Arrays.fill(legacyBiomes, -1); + } + + int biomeX = (blockX & 0x0F) >> 2; + int biomeY = blockY >> 2; + int biomeZ = (blockZ & 0x0F) >> 2; + legacyBiomes[getLegacy3dBiomeIndex(biomeX, biomeY, biomeZ)] = biomeID; + } + + protected int getLegacy2dBiomeIndex(int blockX, int blockZ) { + return (blockZ & 0xF) * 16 + (blockX & 0xF); + } + protected int getLegacy3dBiomeIndex(int biomeX, int biomeY, int biomeZ) { + return biomeY * 16 + biomeZ * 4 + biomeX; + } + + /** + * {@inheritDoc} + */ + @Override + public void setDataVersion(int dataVersion) { + super.setDataVersion(dataVersion); + for (T section : this) { + if (section != null) { + section.syncDataVersion(dataVersion); + } + } + } + + /** + * @return The generation station of this chunk. + */ + public String getStatus() { + return status != null ? status : getTagValue(STATUS_PATH, StringTag::getValue); + } + + /** + * Sets the generation status of this chunk. + * @param status The generation status of this chunk. + */ + public void setStatus(String status) { + checkRaw(); + this.status = status; + } + + // TODO(javadoc) + public Boolean getLightOn() { + return isLightOn; + } + + // TODO(javadoc) + public void setLightOn(Boolean lightOn) { + isLightOn = lightOn; + } + + // TODO(javadoc) + public Boolean getTerrainPopulated() { + return isTerrainPopulated; + } + + // TODO(javadoc) + public void setTerrainPopulated(Boolean terrainPopulated) { + isTerrainPopulated = terrainPopulated; + } + + // TODO(javadoc) + public Boolean getHasLegacyStructureData() { + return hasLegacyStructureData; + } + + // TODO(javadoc) + public void setHasLegacyStructureData(Boolean hasLegacyStructureData) { + this.hasLegacyStructureData = hasLegacyStructureData; + } + + // TODO(javadoc) + public CompoundTag getUpgradeData() { + return upgradeData; + } + + // TODO(javadoc) + public void setUpgradeData(CompoundTag upgradeData) { + this.upgradeData = upgradeData; + } + + // 2048 bytes recording the amount of block-emitted light in each block. Makes load times faster compared to recomputing at load time. 4 bits per block. + + + /** Tick when the chunk was last saved. */ + public long getLastUpdateTick() { + return lastUpdateTick; + } + + /** Sets the tick when the chunk was last saved. */ + public void setLastUpdateTick(long lastUpdateTick) { + this.lastUpdateTick = lastUpdateTick; + } + + /** + * @return The cumulative amount of time players have spent in this chunk in ticks. + */ + public long getInhabitedTimeTicks() { + return inhabitedTimeTicks; + } + + /** + * Sets the cumulative amount of time players have spent in this chunk in ticks. + * @param inhabitedTimeTicks The time in ticks. + */ + public void setInhabitedTimeTicks(long inhabitedTimeTicks) { + checkRaw(); + this.inhabitedTimeTicks = inhabitedTimeTicks; + } + + /** + * @return A matrix of biome IDs for all block columns in this chunk. + */ + public int[] getLegacyBiomes() { + return legacyBiomes; + } + + /** + * Sets the biome IDs for this chunk. + *

Note: 2D biomes have a resolution of 1x256x1 blocks.

+ *

Note: 3D biomes have a resolution of 4x4x4 blocks.

+ * @param legacyBiomes 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 setLegacyBiomes(int[] legacyBiomes) { + checkRaw(); + if (dataVersion >= JAVA_1_17_1.id()) + throw new VersionLacksSupportException(dataVersion, null, JAVA_1_17_1, "2D/3D legacy biomes"); + if (legacyBiomes != null) { + final int requiredSize = dataVersion >= JAVA_1_15_19W36A.id() ? 1024 : 256; + if (legacyBiomes.length != requiredSize) { + throw new IllegalArgumentException("biomes array must have a length of " + requiredSize); + } + } + this.legacyBiomes = legacyBiomes; + } + + /** {@inheritDoc} */ + @Override + public int getWorldMinBlockY() { + return getChunkY() * 16; + } + + /** + * @return The height maps of this chunk. + */ + public CompoundTag getHeightMaps() { + return heightMaps; + } + + /** + * Sets the height maps of this chunk. + * @param heightMaps The height maps. + */ + public void setHeightMaps(CompoundTag heightMaps) { + checkRaw(); + this.heightMaps = heightMaps; + } + + /** + * 256 (16x16) values. Values are shifted to read as block-y value. A value of {@link #getWorldMinBlockY()} - 1 + * indicates no block present (void). + * @param name typically one of + *
    + *
  • MOTION_BLOCKING
  • + *
  • MOTION_BLOCKING_NO_LEAVES
  • + *
  • OCEAN_FLOOR
  • + *
  • OCEAN_FLOOR_WG
  • + *
  • WORLD_SURFACE
  • + *
  • WORLD_SURFACE_WG
  • + *
+ * @return {@link LongArrayTagPackedIntegers} configured to yield block Y values. + * @since {@link DataVersion#JAVA_1_13_18W06A} + */ + public LongArrayTagPackedIntegers getHeightMap(String name) { + if (getHeightMaps() == null) + return null; + var hm = getHeightMaps().getLongArrayTag(name); + if (hm == null) + return null; + final int minY = getWorldMinBlockY() - 1; + final int maxY = getWorldMaxBlockY(); + return LongArrayTagPackedIntegers.builder() + .dataVersion(dataVersion) + .minBitsPerValue(Math.max(9, LongArrayTagPackedIntegers.calculateBitsRequired(maxY - minY))) + .valueOffset(minY) + .length(256) + .build(hm); + } + + public IntArrayTag getLegacyHeightMap() { + return legacyHeightMap; + } + + public void setLegacyHeightMap(IntArrayTag legacyHeightMap) { + this.legacyHeightMap = legacyHeightMap; + } + + /** + * Returns a copy of the palette value at the specified position in this chunk. + *

Modifying the returned value can be done safely, it will have no effect on this chunk.

+ *

To avoid the overhead of making a copy use {@link #getBiomeAtByRef(int, int, int)} instead.

+ * + *

Never throws IndexOutOfBoundsException. XYZ are always wrapped into bounds.

+ * @return the element at the specified position in this chunk or NULL if Y is above/below build height. + * @since {@link DataVersion#JAVA_1_18_21W37A} + */ + public StringTag getBiomeAt(int x, int y, int z) { + checkRaw(); + if (dataVersion < JAVA_1_18_21W37A.id()) + throw new VersionLacksSupportException(dataVersion, JAVA_1_18_21W37A, null, "3D palette biomes"); + var section = getSection(y / 16); + if (section == null) return null; + return section.getBiomes().get((x & 0xF) / 4, (y & 0xF) / 4, (z & 0xF) / 4); + } + + /** + * Returns the palette value at the specified position in this chunk. + *

WARNING if the returned value is modified it modifies every value which references the same palette + * entry within the same chunk section!

+ * + *

Never throws IndexOutOfBoundsException. XYZ are always wrapped into bounds.

+ * @return the element at the specified position in this chunk or NULL if Y is above/below build height. + * @since {@link DataVersion#JAVA_1_18_21W37A} + */ + public StringTag getBiomeAtByRef(int x, int y, int z) { + checkRaw(); + if (dataVersion < JAVA_1_18_21W37A.id()) + throw new VersionLacksSupportException(dataVersion, JAVA_1_18_21W37A, null, "3D palette biomes"); + var section = getSection(y / 16); + if (section == null) return null; + return section.getBiomes().getByRef((x & 0xF) / 4, (y & 0xF) / 4, (z & 0xF) / 4); + } + + /** + * Replaces the element at the specified position in this chunk with + * the specified element. + * + *

Never throws IndexOutOfBoundsException. XYZ are always wrapped into bounds.

+ * @return true if the section existed and the biome was set (true even if the value was unchanged) + * @since {@link DataVersion#JAVA_1_18_21W37A} + */ + public boolean setBiomeAt(int x, int y, int z, StringTag tag) { + checkRaw(); + if (dataVersion < JAVA_1_18_21W37A.id()) + throw new VersionLacksSupportException(dataVersion, JAVA_1_18_21W37A, null, "3D palette biomes"); + var section = getSection(y / 16); + if (section != null) { + section.getBiomes().set((x & 0xF) / 4, (y & 0xF) / 4, (z & 0xF) / 4, tag); + return true; + } + return false; + } + + /** + * Returns a copy of the block palette value at the specified position in this chunk. + *

Modifying the returned value can be done safely, it will have no effect on this chunk.

+ *

To avoid the overhead of making a copy use {@link #getBlockAtByRef(int, int, int)} instead.

+ * + *

Never throws IndexOutOfBoundsException. XYZ are always wrapped into bounds.

+ * @return the element at the specified position in this chunk or NULL if Y is above/below build height. + * @since {@link DataVersion#JAVA_1_13_17W47A} + * @see BlockStateTag + */ + public CompoundTag getBlockAt(int x, int y, int z) { + checkRaw(); + if (dataVersion < JAVA_1_13_17W47A.id()) + throw new VersionLacksSupportException(dataVersion, JAVA_1_13_17W47A, null, "block palettes"); + var section = getSection(y / 16); + if (section == null) return null; + var bs = section.getBlockStates(); + return bs != null ? bs.get(x & 0xF, y & 0xF, z & 0xF) : null; + } + + /** + * Returns the block palette value at the specified position in this chunk. + *

WARNING if the returned value is modified it modifies every value which references the same palette + * entry within the same chunk section!

+ * + *

Never throws IndexOutOfBoundsException. XYZ are always wrapped into bounds.

+ * @return the element at the specified position in this chunk or NULL if Y is above/below build height. + * @since {@link DataVersion#JAVA_1_13_17W47A} + * @see BlockStateTag + */ + public CompoundTag getBlockAtByRef(int x, int y, int z) { + checkRaw(); + if (dataVersion < JAVA_1_13_17W47A.id()) + throw new VersionLacksSupportException(dataVersion, JAVA_1_13_17W47A, null, "block palettes"); + var section = getSection(y / 16); + if (section == null) return null; + var bs = section.getBlockStates(); + return bs != null ? bs.getByRef(x & 0xF, y & 0xF, z & 0xF) : null; + } + + /** nullable */ + public String getBlockNameAt(int x, int y, int z) { + CompoundTag blockTag = getBlockAtByRef(x, y, z); + return blockTag != null ? blockTag.getString("Name") : null; + } + + /** + * Sets the block at the specified location to be defined by tag. + * + *

Never throws IndexOutOfBoundsException. XYZ are always wrapped into bounds.

+ * @param tag block palette tag, must contain a 'Name' StringTag + * @return true if the section existed and the block was set (true even if the value was unchanged) + * @since {@link DataVersion#JAVA_1_13_17W47A} + * @see BlockStateTag + */ + public boolean setBlockAt(int x, int y, int z, CompoundTag tag) { + checkRaw(); + if (dataVersion < JAVA_1_13_17W47A.id()) + throw new VersionLacksSupportException(dataVersion, JAVA_1_13_17W47A, null, "block palettes"); + ArgValidator.check(tag.containsKey("Name", StringTag.class), "block palette tag must contain a 'Name' StringTag"); + var section = getSection(y / 16); + if (section != null) { + section.getBlockStates().set(x & 0xF, y & 0xF, z & 0xF, tag); + return true; + } + return false; + } + + /** + * @return The carving masks of this chunk. + */ + public CompoundTag getCarvingMasks() { + return carvingMasks; + } + + /** + * Sets the carving masks of this chunk. + * @param carvingMasks The carving masks. + */ + public void setCarvingMasks(CompoundTag carvingMasks) { + checkRaw(); + this.carvingMasks = carvingMasks; + } + + /** + * @return The entities of this chunk. May be null. + */ + public ListTag getEntities() { + return entities; + } + + /** + * Sets the entities of this chunk. + * @param entities The entities. + */ + public void setEntities(ListTag entities) { + checkRaw(); + this.entities = entities; + } + + /** + * @return The tile entities of this chunk. + */ + public ListTag getTileEntities() { + return tileEntities; + } + + /** + * Sets the tile entities of this chunk. + * @param tileEntities The tile entities of this chunk. + */ + public void setTileEntities(ListTag tileEntities) { + checkRaw(); + this.tileEntities = tileEntities; + } + + /** + * @return The tile ticks of this chunk. + */ + public ListTag getTileTicks() { + return tileTicks; + } + + /** + * Sets the tile ticks of this chunk. + * @param tileTicks Thee tile ticks. + */ + public void setTileTicks(ListTag tileTicks) { + checkRaw(); + this.tileTicks = tileTicks; + } + + /** + * @return The liquid ticks of this chunk. + */ + public ListTag getLiquidTicks() { + return liquidTicks; + } + + /** + * Sets the liquid ticks of this chunk. + * @param liquidTicks The liquid ticks. + */ + public void setLiquidTicks(ListTag liquidTicks) { + checkRaw(); + this.liquidTicks = liquidTicks; + } + + /** + * @return The light sources in this chunk. + */ + public ListTag> getLights() { + return lights; + } + + /** + * Sets the light sources in this chunk. + * @param lights The light sources. + */ + public void setLights(ListTag> lights) { + checkRaw(); + this.lights = lights; + } + + /** + * @return The liquids to be ticked in this chunk. + */ + public ListTag> getLiquidsToBeTicked() { + return liquidsToBeTicked; + } + + /** + * Sets the liquids to be ticked in this chunk. + * @param liquidsToBeTicked The liquids to be ticked. + */ + public void setLiquidsToBeTicked(ListTag> liquidsToBeTicked) { + checkRaw(); + this.liquidsToBeTicked = liquidsToBeTicked; + } + + /** + * @return Stuff to be ticked in this chunk. + */ + public ListTag> getToBeTicked() { + return toBeTicked; + } + + /** + * Sets stuff to be ticked in this chunk. + * @param toBeTicked The stuff to be ticked. + */ + public void setToBeTicked(ListTag> toBeTicked) { + checkRaw(); + this.toBeTicked = toBeTicked; + } + + /** + * @return Things that are in post processing in this chunk. + */ + public ListTag> getPostProcessing() { + return postProcessing; + } + + /** + * Sets things to be post processed in this chunk. + * @param postProcessing The things to be post processed. + */ + public void setPostProcessing(ListTag> postProcessing) { + checkRaw(); + this.postProcessing = postProcessing; + } + + /** + * @return Data about structures in this chunk. + */ + public CompoundTag getStructures() { + return structures; + } + + /** + * Sets data about structures in this chunk. + * @param structures The data about structures. + */ + public void setStructures(CompoundTag structures) { + checkRaw(); + this.structures = structures; + } + + /** + * Gets the world-bottom section y in the chunk. + */ + public int getChunkY() { + if (yPos != NO_CHUNK_COORD_SENTINEL) return yPos; + return DEFAULT_WORLD_BOTTOM_Y_POS.get(dataVersion); + } + + /** + * @since {@link DataVersion#JAVA_1_18_21W43A} + */ + public CompoundTag getBelowZeroRetrogen() { + return belowZeroRetrogen; + } + + /** + * @since {@link DataVersion#JAVA_1_18_21W43A} + */ + public CompoundTag getBlendingData() { + return blendingData; + } + + + /** {@inheritDoc} */ + @Override + public boolean moveChunkImplemented() { + return raw || ((this.chunkX != NO_CHUNK_COORD_SENTINEL && this.chunkZ != NO_CHUNK_COORD_SENTINEL) && + (data != null || (getStructures() != null && getTileEntities() != null && getTileTicks() != null && getLiquidTicks() != null))); + } + + /** {@inheritDoc} */ + @Override + public boolean moveChunkHasFullVersionSupport() { + // TODO: Only strongly validated at 1.20.4 - but I believe all versions are supported but should validate + return true; + } + + // For RAW support + private ListTag tagOrFetch(ListTag tag, VersionAware path) { + if (tag != null) return tag; + return getTag(path); + } + private CompoundTag tagOrFetch(CompoundTag tag, VersionAware path) { + if (tag != null) return tag; + return getTag(path); + } + + /** {@inheritDoc} */ + @SuppressWarnings("unchecked") + @Override + public boolean moveChunk(int newChunkX, int newChunkZ, long moveChunkFlags, boolean force) { + if (!moveChunkImplemented()) + throw new UnsupportedOperationException("Missing the data required to move this chunk!"); + if (!RegionBoundingRectangle.MAX_WORLD_BORDER_BOUNDS.containsChunk(chunkX, chunkZ)) { + throw new IllegalArgumentException("Chunk XZ must be within the maximum world bounds."); + } + if (this.chunkX == newChunkX && this.chunkZ == newChunkZ) return false; + + IntPointXZ chunkDeltaXZ; + if (raw) { + // read the old data values so we can compute deltaXZ + this.chunkX = getTagValue(X_POS_PATH, IntTag::asInt); + this.chunkZ = getTagValue(Z_POS_PATH, IntTag::asInt); + setTag(X_POS_PATH, new IntTag(newChunkX)); + setTag(Z_POS_PATH, new IntTag(newChunkZ)); + } + chunkDeltaXZ = new IntPointXZ(newChunkX - this.chunkX, newChunkZ - this.chunkZ); + this.chunkX = newChunkX; + this.chunkZ = newChunkZ; + + boolean changed = false; + ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(newChunkX, newChunkZ); + changed |= fixTileLocations(moveChunkFlags, cbr, tagOrFetch(getTileEntities(), TILE_ENTITIES_PATH)); + changed |= fixTileLocations(moveChunkFlags, cbr, tagOrFetch(getTileTicks(), TILE_TICKS_PATH)); + changed |= fixTileLocations(moveChunkFlags, cbr, tagOrFetch(getLiquidTicks(), LIQUID_TICKS_PATH)); + changed |= moveStructures(moveChunkFlags, tagOrFetch(getStructures(), STRUCTURES_PATH), chunkDeltaXZ); + + CompoundTag upgradeTag = tagOrFetch(getUpgradeData(), UPGRADE_DATA_PATH); + if (upgradeTag != null && !upgradeTag.isEmpty()) { + if ((moveChunkFlags & DISCARD_UPGRADE_DATA) > 0) { + upgradeTag.clear(); + changed = true; + } else { + for (NamedTag entry : upgradeTag) { + if (entry.getTag() instanceof ListTag && ((ListTag) entry.getTag()).getTypeClass().equals(CompoundTag.class)) { + changed |= fixTileLocations(moveChunkFlags, cbr, (ListTag) entry.getTag()); + } + } + } + } + changed |= fixEntitiesLocations(moveChunkFlags, cbr, tagOrFetch(getEntities(), ENTITIES_PATH)); + + if (changed) { + if ((moveChunkFlags & MoveChunkFlags.AUTOMATICALLY_UPDATE_HANDLE) > 0) { + updateHandle(); + } + return true; + } + return false; + + } + + protected boolean fixEntitiesLocations(long moveChunkFlags, ChunkBoundingRectangle cbr, ListTag entitiesTagList) { + return EntitiesChunkBase.fixEntityLocations(dataVersion, moveChunkFlags, entitiesTagList, cbr); + } + + protected boolean fixTileLocations(long moveChunkFlags, ChunkBoundingRectangle cbr, ListTag tagList) { + boolean changed = false; + if (tagList == null) { + return false; + } + for (CompoundTag tag : tagList) { + int x = tag.getInt("x"); + int z = tag.getInt("z"); + if (!cbr.containsBlock(x, z)) { + changed = true; + tag.putInt("x", cbr.relocateX(x)); + tag.putInt("z", cbr.relocateZ(z)); + } + } + return changed; + } + + private static final long REMOVE_SENTINEL = 0x8FFFFFFF_8FFFFFFFL; + protected boolean moveStructures(long moveChunkFlags, CompoundTag structuresTag, IntPointXZ chunkDeltaXZ) { + final CompoundTag references = STRUCTURES_REFERENCES_PATH.get(dataVersion).get(structuresTag); + final CompoundTag starts = STRUCTURES_STARTS_PATH.get(dataVersion).get(structuresTag); + boolean changed = false; + + // Discard structures if directed to do so. + if ((moveChunkFlags & DISCARD_STRUCTURE_DATA) > 0) { + if (references != null && !references.isEmpty()) { + references.clear(); + changed = true; + } + if (starts != null && !starts.isEmpty()) { + starts.clear(); + changed = true; + } + return changed; + } + + // Establish regional bounds iff we are to discard out of region zones + final ChunkBoundingRectangle clippingRect; + if ((moveChunkFlags & DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION) > 0) { + clippingRect = RegionBoundingRectangle.forChunk(chunkX, chunkZ); + } else { + clippingRect = null; + } + + // Fix structure reference locations (XZ packed into a long) + if (references != null && !references.isEmpty()) { + for (Tag tag : references.values()) { + boolean haveRemovals = false; + long[] longs = ((LongArrayTag) tag).getValue(); + for (int i = 0; i < longs.length; i++) { + IntPointXZ newXZ = IntPointXZ.unpack(longs[i]).add(chunkDeltaXZ); + if (clippingRect != null && !clippingRect.containsChunk(newXZ)) { + longs[i] = REMOVE_SENTINEL; + haveRemovals = true; + } else { + longs[i] = IntPointXZ.pack(newXZ); + } + } + if (haveRemovals) { + ((LongArrayTag) tag).setValue( + Arrays.stream(longs) + .filter(l -> l != REMOVE_SENTINEL) + .toArray() + ); + } + } + changed = true; + } + + // Iterate and fix structure 'starts' - starts define the area a structure does, or will, occupy + // and defines what exists in each, what I'll call, zone of that structure. + if (starts != null && !starts.isEmpty()) { + IntPointXZ blockDeltaXZ = chunkDeltaXZ.transformChunkToBlock(); + for (NamedTag startsEntry : starts) { + moveStructureStart((CompoundTag) startsEntry.getTag(), chunkDeltaXZ, blockDeltaXZ, clippingRect); + } + } + return changed; + } + + /** + * NOTE: The given boundsTag tag will be emptied (have a new length of zero) if the move results in an + * out-of-bounds BB (rbr must be non-null for this to happen). In such a case true is always returned. + * So, if true is returned the caller MUST check the length of the boundsTag and take appropriate action! + * @return true if bounds changed + */ + protected static boolean moveBoundingBox(IntArrayTag boundsTag, IntPointXZ blockDeltaXZ, ChunkBoundingRectangle clippingRect) { + boolean changed = false; + if (boundsTag != null) { + int[] bounds = boundsTag.getValue(); + if (!blockDeltaXZ.isZero()) { + bounds[0] = bounds[0] + blockDeltaXZ.getX(); + bounds[2] = bounds[2] + blockDeltaXZ.getZ(); + bounds[3] = bounds[3] + blockDeltaXZ.getX(); + bounds[5] = bounds[5] + blockDeltaXZ.getZ(); + changed = true; + } + if (clippingRect != null && !clippingRect.constrain(bounds)) { + boundsTag.setValue(new int[0]); + return true; + } + } + return changed; + } + + /** + * Moves a single structure start record. + * @see wiki Chunk_format + */ + @SuppressWarnings("unchecked") + protected boolean moveStructureStart(CompoundTag startsTag, IntPointXZ chunkDeltaXZ, IntPointXZ blockDeltaXZ, ChunkBoundingRectangle clippingRect) { + if ("INVALID".equals(startsTag.getString("id"))) return false; + boolean changed = false; + + // If the overall bounding box is invalid then discard and invalidate the entire structure. + // I don't see how this scenario is possible in practice for well formatted chunks. + // FYI the BB tag doesn't exist for all structures at this level. + IntArrayTag startsBbTag = startsTag.getIntArrayTag("BB"); + if (moveBoundingBox(startsBbTag, blockDeltaXZ, clippingRect)) { + if (startsBbTag.length() == 0) { + startsTag.clear(); + startsTag.putString("id", "INVALID"); + return true; + } + changed = true; + } + + if (startsTag.containsKey("ChunkX") && chunkDeltaXZ.getX() != 0) { + startsTag.putInt("ChunkX", chunkDeltaXZ.getX() + startsTag.getInt("ChunkX")); + changed = true; + } + if (startsTag.containsKey("ChunkZ") && chunkDeltaXZ.getZ() != 0) { + startsTag.putInt("ChunkZ", chunkDeltaXZ.getZ() + startsTag.getInt("ChunkZ")); + changed = true; + } + + // List of chunks that have had their piece of the structure created. + // Unsure when this tag shows up - maybe during generation - maybe only for specific structures. + if (startsTag.containsKey("Processed")) { + ListTag processedListTag = startsTag.getCompoundList("Processed"); + Iterator processedIter = processedListTag.iterator(); + while (processedIter.hasNext()) { + CompoundTag processedTag = processedIter.next(); + + if (processedTag.containsKey("X") && blockDeltaXZ.getX() != 0) { + processedTag.putInt("X", blockDeltaXZ.getX() + processedTag.getInt("X")); + changed = true; + } + if (processedTag.containsKey("Z") && blockDeltaXZ.getZ() != 0) { + processedTag.putInt("Z", blockDeltaXZ.getZ() + processedTag.getInt("Z")); + changed = true; + } + if (clippingRect != null && !clippingRect.containsBlock(processedTag.getInt("X"), processedTag.getInt("Z"))) { + processedIter.remove(); + changed = true; + } + } + } + + if (startsTag.containsKey("Children")) { + ListTag childrenListTag = startsTag.getCompoundList("Children"); + Iterator childIter = childrenListTag.iterator(); + while (childIter.hasNext()) { + CompoundTag childTag = childIter.next(); + // bounding box of structure part - note some block geometry may overhang these bounds (like roofs) + IntArrayTag childBbTag = childTag.getIntArrayTag("BB"); + if (moveBoundingBox(childBbTag, blockDeltaXZ, clippingRect)) { + changed = true; + if (childBbTag.length() == 0) { + childIter.remove(); + continue; + } + } + + // List of entrances/exits from the room. Probably for structure generation to know + // where additional structure parts can be placed to continue to grow the structure. + if (childTag.containsKey("Entrances")) { + ListTag entrancesListTag = childTag.getListTagAutoCast("Entrances"); + Iterator entranceIter = entrancesListTag.iterator(); + while (entranceIter.hasNext()) { + IntArrayTag entranceBbTag = entranceIter.next(); + if (moveBoundingBox(entranceBbTag, blockDeltaXZ, clippingRect)) { + changed = true; + if (entranceBbTag.length() == 0) { + entranceIter.remove(); + } + } + } + } + + // coordinate origin of structure part + if (childTag.containsKey("PosX") && blockDeltaXZ.getX() != 0) { + childTag.putInt("PosX", blockDeltaXZ.getX() + childTag.getInt("PosX")); + changed = true; + } + if (childTag.containsKey("PosZ") && blockDeltaXZ.getZ() != 0) { + childTag.putInt("PosZ", blockDeltaXZ.getZ() + childTag.getInt("PosZ")); + changed = true; + } + + // coordinate origin of ocean ruin or shipwreck + if (childTag.containsKey("TPX") && blockDeltaXZ.getX() != 0) { + childTag.putInt("TPX", blockDeltaXZ.getX() + childTag.getInt("TPX")); + changed = true; + } + if (childTag.containsKey("TPZ") && blockDeltaXZ.getZ() != 0) { + childTag.putInt("TPZ", blockDeltaXZ.getZ() + childTag.getInt("TPZ")); + changed = true; + } + + // Anything using jigsaw blocks has a 'junctions' record + if (childTag.containsKey("junctions")) { + ListTag junctionsListTag = childTag.getCompoundList("junctions"); + Iterator junctionIter = junctionsListTag.iterator(); + while (junctionIter.hasNext()) { + CompoundTag junctionTag = junctionIter.next(); + if (blockDeltaXZ.getX() != 0) { + junctionTag.putInt("source_x", blockDeltaXZ.getX() + junctionTag.getInt("source_x")); + changed = true; + } + if (blockDeltaXZ.getZ() != 0) { + junctionTag.putInt("source_z", blockDeltaXZ.getZ() + junctionTag.getInt("source_z")); + changed = true; + } + if (clippingRect != null && !clippingRect.containsBlock(junctionTag.getInt("source_x"), junctionTag.getInt("source_z"))) { + junctionIter.remove(); + changed = true; + } + } + // TODO: unsure how to behave if the junctions list is emptied - maybe remove the child? + // Scenario to look for: village/pillager outpost that abuts a region bound and has a junction + // crossing that bound where on one side there's a leaf node BB that hangs over the bound + // and only has junctions into one of the two regions. + } + } + // TODO: unsure if this logic is needed, or if there are structures which have no Children, so leaving it out for now +// if (childrenListTag.isEmpty() && !startsTag.containsKey("BB")) { +// startsTag.clear(); +// startsTag.putString("id", "INVALID"); +// return true; +// } + } + + return changed; + } + + /** + * {@inheritDoc} + */ + @Override + public CompoundTag updateHandle() { + if (raw) { + return data; + } + this.data = super.updateHandle(); + setTag(LAST_UPDATE_TICK_PATH, new LongTag(lastUpdateTick)); + setTag(INHABITED_TIME_TICKS_PATH, new LongTag(inhabitedTimeTicks)); + if (legacyBiomes != null && dataVersion < JAVA_1_18_21W37A.id()) { + final int requiredSize = dataVersion <= 0 || dataVersion >= JAVA_1_15_19W36A.id() ? 1024 : 256; + if (legacyBiomes.length != requiredSize) + throw new IllegalStateException( + String.format("Biomes array must be %d bytes for version %d, array size is %d", + requiredSize, dataVersion, legacyBiomes.length)); + + if (dataVersion >= DataVersion.JAVA_1_13_18W06A.id()) { + setTag(LEGACY_BIOMES_PATH, new IntArrayTag(legacyBiomes)); + } else { + byte[] byteBiomes = new byte[legacyBiomes.length]; + for (int i = 0; i < legacyBiomes.length; i++) { + byteBiomes[i] = (byte) legacyBiomes[i]; + } + setTag(LEGACY_BIOMES_PATH, new ByteArrayTag(byteBiomes)); + } + } + setTagIfNotNull(LEGACY_HEIGHT_MAP_PATH, legacyHeightMap); + setTagIfNotNull(HEIGHT_MAPS_PATH, heightMaps); + setTagIfNotNull(CARVING_MASKS_PATH, carvingMasks); + setTagIfNotNull(ENTITIES_PATH, entities); + setTagIfNotNull(TILE_ENTITIES_PATH, tileEntities); + setTagIfNotNull(TILE_TICKS_PATH, tileTicks); + setTagIfNotNull(LIQUID_TICKS_PATH, liquidTicks); + setTagIfNotNull(LIGHTS_PATH, lights); + setTagIfNotNull(LIQUIDS_TO_BE_TICKED_PATH, liquidsToBeTicked); + setTagIfNotNull(TO_BE_TICKED_PATH, toBeTicked); + setTagIfNotNull(POST_PROCESSING_PATH, postProcessing); + if (status != null) setTag(STATUS_PATH, new StringTag(status)); + if (isLightOn != null) setTag(IS_LIGHT_ON_PATH, new ByteTag(isLightOn)); + if (isTerrainPopulated != null) setTag(TERRAIN_POPULATED_PATH, new ByteTag(isTerrainPopulated)); + setTagIfNotNull(STRUCTURES_PATH, structures); + if (hasLegacyStructureData != null) setTag(HAS_LEGACY_STRUCTURE_DATA_PATH, new ByteTag(hasLegacyStructureData)); + + // TODO: This logic does not respect original load flags! However, this is a long standing bug so + // simply "fixing" it may break consumers... I no longer care about existing consumers and + // need to figure out what that "fix" I was referring to was -.- + ListTag sections = new ListTag<>(CompoundTag.class); + for (T section : this) { + if (section != null) { + sections.add(section.updateHandle()); // contract of iterator assures correctness of "height" aka section-y + } + } + setTag(SECTIONS_PATH, sections); + + setTag(X_POS_PATH, new IntTag(getChunkX())); + setTag(Z_POS_PATH, new IntTag(getChunkZ())); + if (dataVersion >= JAVA_1_18_21W43A.id()) { + setTag(Y_POS_PATH, new IntTag(getChunkY())); + setTagIfNotNull(BELOW_ZERO_RETROGEN_PATH, belowZeroRetrogen); + setTagIfNotNull(BLENDING_DATA_PATH, blendingData); + } + return data; + } + + @Override + public CompoundTag updateHandle(int xPos, int zPos) { + if (raw) { + return data; + } + // TODO: moveChunk or die if given xPos != chunkX and same for z? + updateHandle(); + if (xPos != NO_CHUNK_COORD_SENTINEL) + setTag(X_POS_PATH, new IntTag(chunkX = xPos)); + // Y_POS_PATH is set in updateHandle() - was added in 1.18 + if (zPos != NO_CHUNK_COORD_SENTINEL) + setTag(Z_POS_PATH, new IntTag(chunkZ = zPos)); + return data; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/TerrainMCAFileBase.java b/src/main/java/io/github/ensgijs/nbt/mca/TerrainMCAFileBase.java new file mode 100644 index 00000000..695b9622 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/TerrainMCAFileBase.java @@ -0,0 +1,135 @@ +//package net.rossquerz.mca; +// +//import CompoundTag; +// +///** +// * Represents a TERRAIN data mca file (those that live in the /region folder). +// * TODO: Unused - was my intention to fraction out McaRegionFile terrain stuff to here and make McaRegionFile a version abstraction layer?And why si this called "base"? +// */ +//public class TerrainMCAFileBase extends McaFileBase implements Iterable { +// /** +// * 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(); +// +// /** +// * {@inheritDoc} +// */ +// public TerrainMCAFileBase(int regionX, int regionZ) { +// super(regionX, regionZ); +// } +// +// /** +// * {@inheritDoc} +// */ +// public TerrainMCAFileBase(int regionX, int regionZ, int defaultDataVersion) { +// super(regionX, regionZ, defaultDataVersion); +// } +// +// /** +// * {@inheritDoc} +// */ +// public TerrainMCAFileBase(int regionX, int regionZ, DataVersion defaultDataVersion) { +// super(regionX, regionZ, defaultDataVersion); +// } +// +// /** +// * {@inheritDoc} +// */ +// @Override +// public Class chunkClass() { +// return TerrainChunk.class; +// } +// +// /** +// * {@inheritDoc} +// */ +// @Override +// public TerrainChunk createChunk() { +// return TerrainChunk.newChunk(defaultDataVersion); +// } +// +// /** +// * @deprecated Use {@link #setBiomeAt(int, int, int, int)} instead +// */ +// @Deprecated +// public void setBiomeAt(int blockX, int blockZ, int biomeID) { +// createChunkIfMissing(blockX, blockZ).setBiomeAt(blockX, blockZ, biomeID); +// } +// +// public void setBiomeAt(int blockX, int blockY, int blockZ, int biomeID) { +// createChunkIfMissing(blockX, blockZ).setBiomeAt(blockX, blockY, blockZ, biomeID); +// } +// +// /** +// * @deprecated Use {@link #getBiomeAt(int, int, int)} instead +// */ +// @Deprecated +// public int getBiomeAt(int blockX, int blockZ) { +// int chunkX = McaFileHelpers.blockToChunk(blockX), chunkZ = McaFileHelpers.blockToChunk(blockZ); +// TerrainChunk chunk = getChunk(getChunkIndex(chunkX, chunkZ)); +// if (chunk == null) { +// return -1; +// } +// return chunk.getBiomeAt(blockX, blockZ); +// } +// +// /** +// * Fetches the biome id at a specific block. +// * @param blockX The x-coordinate of the block. +// * @param blockY The y-coordinate of the block. +// * @param blockZ The z-coordinate of the block. +// * @return The biome id if the chunk exists and the chunk has biomes, otherwise -1. +// */ +// public int getBiomeAt(int blockX, int blockY, int blockZ) { +// int chunkX = McaFileHelpers.blockToChunk(blockX), chunkZ = McaFileHelpers.blockToChunk(blockZ); +// TerrainChunk chunk = getChunk(getChunkIndex(chunkX, chunkZ)); +// if (chunk == null) { +// return -1; +// } +// return chunk.getBiomeAt(blockX,blockY, blockZ); +// } +// +//// /** +//// * Set a block state at a specific block location. +//// * The block coordinates can be absolute coordinates or they can be relative to the region. +//// * @param blockX The x-coordinate of the block. +//// * @param blockY The y-coordinate of the block. +//// * @param blockZ The z-coordinate of the block. +//// * @param state The block state to be set. +//// * @param cleanup Whether the Palette and the BLockStates should be recalculated after adding the block state. +//// */ +//// public void setBlockStateAt(int blockX, int blockY, int blockZ, CompoundTag state, boolean cleanup) { +//// createChunkIfMissing(blockX, blockZ).setBlockStateAt(blockX, blockY, blockZ, state, cleanup); +//// } +//// +//// /** +//// * Fetches a block state at a specific block location. +//// * The block coordinates can be absolute coordinates or they can be relative to the region. +//// * @param blockX The x-coordinate of the block. +//// * @param blockY The y-coordinate of the block. +//// * @param blockZ The z-coordinate of the block. +//// * @return The block state or null if the chunk or the section do not exist. +//// */ +//// public CompoundTag getBlockStateAt(int blockX, int blockY, int blockZ) { +//// int chunkX = McaFileHelpers.blockToChunk(blockX), chunkZ = McaFileHelpers.blockToChunk(blockZ); +//// TerrainChunk chunk = getChunk(chunkX, chunkZ); +//// if (chunk == null) { +//// return null; +//// } +//// return chunk.getBlockStateAt(blockX, blockY, blockZ); +//// } +// +//// /** +//// * Recalculates the Palette and the BlockStates of all chunks and sections of this region. +//// */ +//// public void cleanupPalettesAndBlockStates() { +//// for (TerrainChunk chunk : chunks) { +//// if (chunk != null) { +//// chunk.cleanupPalettesAndBlockStates(); +//// } +//// } +//// } +//} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/TerrainSection.java b/src/main/java/io/github/ensgijs/nbt/mca/TerrainSection.java new file mode 100644 index 00000000..77a9a0c9 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/TerrainSection.java @@ -0,0 +1,65 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.tag.CompoundTag; + +/** + * Represents a Terrain data chunk section. See notes on {@link TerrainChunk} for possible + * future repurposing ideas. + */ +public class TerrainSection extends TerrainSectionBase { + + public TerrainSection(CompoundTag sectionRoot, int dataVersion) { + super(sectionRoot, dataVersion); + } + + public TerrainSection(CompoundTag sectionRoot, int dataVersion, long loadFlags) { + super(sectionRoot, dataVersion, loadFlags); + } + + public TerrainSection(int dataVersion) { + super(dataVersion); + } + + /** + * @return An empty Section initialized using the latest full release data version. + * @deprecated Dangerous - prefer using {@link TerrainChunk#createSection(int)} or using the + * {@link #TerrainSection(int)} constructor instead. + */ + @Deprecated + public static TerrainSection newSection() { + return new TerrainSection(DataVersion.latest().id()); + } + + /** + * This method should only be used for 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); + } + + /** + * Updates the raw CompoundTag that this Section is based on. + * + * @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 + * @deprecated The holding chunk is the authority on this sections y / height and takes care of all updates to it. + */ + @Deprecated + public CompoundTag updateHandle(int y) { + final int oldY = sectionY; + try { + sectionY = y; + return updateHandle(); + } finally { + sectionY = oldY; + } + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/TerrainSectionBase.java b/src/main/java/io/github/ensgijs/nbt/mca/TerrainSectionBase.java new file mode 100644 index 00000000..e7c2999d --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/TerrainSectionBase.java @@ -0,0 +1,288 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.io.TextNbtParser; +import io.github.ensgijs.nbt.tag.*; +import io.github.ensgijs.nbt.mca.util.PalettizedCuboid; + +import static io.github.ensgijs.nbt.mca.DataVersion.*; +import static io.github.ensgijs.nbt.mca.io.LoadFlags.*; + +/** + * Provides the base for all terrain section classes. + */ +public abstract class TerrainSectionBase extends SectionBase { + protected static final CompoundTag AIR_PALETTE_TAG = TextNbtParser.parseInline("{Name: \"minecraft:air\"}"); + /** Use with care! Be sure to clone this value when used or really bad bugs are going to happen. */ + protected static final CompoundTag DEFAULT_BLOCK_SATES_TAG = new PalettizedCuboid<>(16, AIR_PALETTE_TAG).toCompoundTag(); + /** Use with care! Be sure to clone this value when used or really bad bugs are going to happen. */ + protected static final CompoundTag DEFAULT_BIOMES_TAG = new PalettizedCuboid<>(4, new StringTag("minecraft:plains")).toCompoundTag(); + + /** Only populated for MC version < 1.13 - 4096 (16^3) block id's */ + protected byte[] legacyBlockIds; + /** Only populated for MC version < 1.13 - 4096 (16^3) block data values */ + protected byte[] legacyBlockDataValues; + + /** + * Only populated for MC version >= JAVA_1_13_17W47A; note bit packing changed in JAVA_1_16_20W17A + * @see PalettizedCuboid + * @since {@link DataVersion#JAVA_1_13_17W47A} + */ + protected PalettizedCuboid blockStates; + /** + * Only populated for MC version >= JAVA_1_18_21W37A (~ 1.18 pre1) + * @see PalettizedCuboid + * @since {@link DataVersion#JAVA_1_18_21W37A} + */ + protected PalettizedCuboid biomes; + + protected byte[] blockLight; + protected byte[] skyLight; + + public static byte[] createBlockLightBuffer() { + return new byte[2048]; + } + + public static byte[] createSkyLightBuffer() { + return new byte[2048]; + } + + public TerrainSectionBase(CompoundTag sectionRoot, int dataVersion) { + this(sectionRoot, dataVersion, LOAD_ALL_DATA); + } + + public TerrainSectionBase(CompoundTag sectionRoot, int dataVersion, long loadFlags) { + super(sectionRoot, dataVersion, loadFlags); + } + + protected void initReferences(final long loadFlags) { + sectionY = data.getNumber("Y").byteValue(); + if ((loadFlags & BIOMES) != 0) { + // Prior to JAVA_1_18_21W37A biomes were stored at the chunk level in a ByteArrayTag and used fixed ID's + // Currently they are stored in a palette object at the section level + if (dataVersion >= JAVA_1_18_21W37A.id()) { + biomes = PalettizedCuboid.fromCompoundTag(data.getCompoundTag("biomes"), 4, dataVersion); + } + } + if ((loadFlags & BLOCK_LIGHTS) != 0) { + ByteArrayTag blockLight = data.getByteArrayTag("BlockLight"); + if (blockLight != null) this.blockLight = blockLight.getValue(); + } + if ((loadFlags & BLOCK_STATES) != 0) { + // Block palettes were added in 1.13 - prior to this the "Blocks" ByteArrayTag was used with fixed id's + // In JAVA_1_16_20W17A palette data bit packing scheme changed + // In JAVA_1_18_21W37A the section tag structure changed significantly and 'BlockStates' and 'Palette' were moved inside 'block_states' and renamed. + if (dataVersion < JAVA_1_13_17W47A.id()) { + ByteArrayTag legacyBlockIds = data.getByteArrayTag("Blocks"); + if (legacyBlockIds != null) this.legacyBlockIds = legacyBlockIds.getValue(); + ByteArrayTag legacyBlockDataValues = data.getByteArrayTag("Data"); + if (legacyBlockDataValues != null) this.legacyBlockDataValues = legacyBlockDataValues.getValue(); + } else if (dataVersion <= JAVA_1_18_21W37A.id()) { + if (data.containsKey("Palette")) { + ListTag palette = data.getListTag("Palette").asCompoundTagList(); + LongArrayTag blockStatesTag = data.getLongArrayTag("BlockStates"); // may be null + // up-convert to the modern block_states structure to simplify handling + CompoundTag paletteContainerTag = new CompoundTag(2); + paletteContainerTag.put("palette", palette); + if (blockStatesTag != null && blockStatesTag.length() > 0) + paletteContainerTag.put("data", blockStatesTag); + this.blockStates = PalettizedCuboid.fromCompoundTag(paletteContainerTag, 16, dataVersion); + } + } else { + blockStates = PalettizedCuboid.fromCompoundTag(data.getCompoundTag("block_states"), 16, dataVersion); + } + } + if ((loadFlags & SKY_LIGHT) != 0) { + ByteArrayTag skyLight = data.getByteArrayTag("SkyLight"); + if (skyLight != null) this.skyLight = skyLight.getValue(); + } + } + + public TerrainSectionBase(int dataVersion) { + super(dataVersion); + blockLight = createBlockLightBuffer(); + skyLight = createSkyLightBuffer(); + + if (dataVersion >= JAVA_1_13_17W47A.id()) { + // blockStatesTag normalized to 1.18+ + blockStates = PalettizedCuboid.fromCompoundTag(DEFAULT_BLOCK_SATES_TAG.clone(), 16, dataVersion); + } else { + legacyBlockIds = new byte[2048]; + legacyBlockDataValues = new byte[2048]; + } + if (dataVersion >= JAVA_1_18_21W37A.id()) { + biomes = PalettizedCuboid.fromCompoundTag(DEFAULT_BIOMES_TAG.clone(), 4, dataVersion); + } + } + + /** {@inheritDoc} */ + @Override + protected void syncDataVersion(int newDataVersion) { + super.syncDataVersion(newDataVersion); + if (blockStates != null) blockStates.setDataVersion(newDataVersion); + if (biomes != null) biomes.setDataVersion(newDataVersion); + } + + /** + * @return The block light array of this Section + */ + public byte[] getBlockLight() { + return blockLight; + } + + /** + * Sets the block light array for this section. + * @param blockLight The block light array + * @throws IllegalArgumentException When the length of the array is not 2048 + */ + public void setBlockLight(byte[] blockLight) { + if (blockLight != null && blockLight.length != 2048) { + throw new IllegalArgumentException("BlockLight array must have a length of 2048"); + } + this.blockLight = blockLight; + } + + /** + * @return The sky light values of this Section + */ + public byte[] getSkyLight() { + return skyLight; + } + + /** + * Sets the sky light values of this section. + * @param skyLight The custom sky light values + * @throws IllegalArgumentException If the length of the array is not 2048 + */ + public void setSkyLight(byte[] skyLight) { + if (skyLight != null && skyLight.length != 2048) { + throw new IllegalArgumentException("SkyLight array must have a length of 2048"); + } + this.skyLight = skyLight; + } + + /** Only populated for MC version < 1.13 - 4096 (16^3) block data values */ + public byte[] getLegacyBlockDataValues() { + return legacyBlockDataValues; + } + + public TerrainSectionBase setLegacyBlockDataValues(byte[] legacyBlockDataValues) { + if (dataVersion >= JAVA_1_13_17W47A.id()) { + throw new VersionLacksSupportException(dataVersion, null, JAVA_1_13_17W47A.previous(), "legacyBlockDataValues"); + } + this.legacyBlockDataValues = legacyBlockDataValues; + return this; + } + + /** + * @since {@link DataVersion#JAVA_1_13_17W47A} + */ + public PalettizedCuboid getBlockStates() { + return blockStates; + } + + /** + * @since {@link DataVersion#JAVA_1_13_17W47A} + */ + public TerrainSectionBase setBlockStates(PalettizedCuboid blockStates) { + if (dataVersion < JAVA_1_13_17W47A.id()) { + throw new VersionLacksSupportException(dataVersion, JAVA_1_13_17W47A, null, "palettized blockStates"); + } + this.blockStates = blockStates.clone(); + return this; + } + + /** + * @since {@link DataVersion#JAVA_1_18_21W37A} + */ + public PalettizedCuboid getBiomes() { + return biomes; + } + + /** + * @since {@link DataVersion#JAVA_1_18_21W37A} + */ + public TerrainSectionBase setBiomes(PalettizedCuboid biomes) { + if (dataVersion < JAVA_1_18_21W37A.id()) { + throw new VersionLacksSupportException(dataVersion, JAVA_1_18_21W37A, null, "palettized biomes"); + } + this.biomes = biomes.clone(); + return this; + } + + /** + * Null if MC version > 1.12.2 + * See https://minecraft-ids.grahamedgecombe.com/ + */ + public byte[] getLegacyBlockIds() { + return legacyBlockIds; + } + + /** unsupported if MC version > 1.12.2 */ + public TerrainSectionBase setLegacyBlockIds(byte[] legacyBlockIds) { + if (dataVersion >= JAVA_1_13_17W47A.id()) + throw new VersionLacksSupportException(dataVersion, null, JAVA_1_13_17W47A.previous(), + "Legacy block id usage was replaced with block palettes in MC 1.13!"); + this.legacyBlockIds = legacyBlockIds; + return this; + } + + /** + * Null if MC version > 1.12.2 + * See https://minecraft-ids.grahamedgecombe.com/ + */ + public byte[] legacyBlockDataValues() { + return legacyBlockDataValues; + } + + /** unsupported if MC version > 1.12.2 */ + public TerrainSectionBase legacyBlockDataValues(byte[] legacyBlockDataValues) { + if (dataVersion >= JAVA_1_13_17W47A.id()) + throw new VersionLacksSupportException(dataVersion, null, JAVA_1_13_17W47A.previous(), + "Legacy block id usage was replaced with block palettes in MC 1.13!"); + this.legacyBlockDataValues = legacyBlockDataValues; + return this; + } + + /** + * Updates the raw CompoundTag that this Section is based on. + * @return A reference to the raw CompoundTag this Section is based on + */ + @Override + public CompoundTag updateHandle() { + checkY(sectionY); + this.data = super.updateHandle(); + data.putByte("Y", (byte) sectionY); + if (dataVersion < JAVA_1_13_17W47A.id()) { + if (legacyBlockIds != null) { + data.putByteArray("Blocks", legacyBlockIds); + } + if (legacyBlockDataValues != null) { + data.putByteArray("Data", legacyBlockDataValues); + } + } else if (dataVersion < JAVA_1_18_21W37A.id()) { + if (blockStates != null) { + CompoundTag blockStatesTag = blockStates.updateHandle(); + data.put("Palette", blockStatesTag.getListTag("palette")); + if (blockStatesTag.containsKey("data")) { + data.putLongArray("BlockStates", blockStatesTag.getLongArray("data")); + } else { + data.remove("BlockStates"); + } + } + } else { + if (blockStates != null) { + data.put("block_states", blockStates.updateHandle()); + } + } + if (biomes != null && dataVersion >= JAVA_1_18_21W37A.id()) { + data.put("biomes", biomes.updateHandle()); + } + if (blockLight != null) { + data.putByteArray("BlockLight", blockLight); + } + if (skyLight != null) { + data.putByteArray("SkyLight", skyLight); + } + return data; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/UnsupportedVersionChangeException.java b/src/main/java/io/github/ensgijs/nbt/mca/UnsupportedVersionChangeException.java new file mode 100644 index 00000000..134d4517 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/UnsupportedVersionChangeException.java @@ -0,0 +1,28 @@ +package io.github.ensgijs.nbt.mca; + +/** + * Thrown when the requested data version change is not supported because it would require a data upgrade or downgrade + * that is itself, not supported. + */ +public class UnsupportedVersionChangeException extends IllegalArgumentException { + public UnsupportedVersionChangeException() { + super(); + } + + public UnsupportedVersionChangeException(DataVersion criticalCrossing, int fromVersion, int toVersion) { + super(String.format("Migrating data version from %d to %d not supported because it crosses %s", + fromVersion, toVersion, criticalCrossing)); + } + + public UnsupportedVersionChangeException(String message) { + super(message); + } + + public UnsupportedVersionChangeException(String message, Throwable cause) { + super(message, cause); + } + + public UnsupportedVersionChangeException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/VersionLacksSupportException.java b/src/main/java/io/github/ensgijs/nbt/mca/VersionLacksSupportException.java new file mode 100644 index 00000000..638729b5 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/VersionLacksSupportException.java @@ -0,0 +1,68 @@ +package io.github.ensgijs.nbt.mca; + +/** + * Thrown when an attempt is made to do something that can't be supported by a data version. + *

The provided version information is automatically appended to the exception message.

+ */ +public class VersionLacksSupportException extends RuntimeException { + private final int unsupportedDataVersion; + private final DataVersion minimumSupportedDataVersion; // nullable + private final DataVersion maximumSupportedDataVersion; // nullable + + private static String addVersionInfo(String message, int udv, DataVersion mindv, DataVersion maxdv) { + String s = (message != null ? message + " " : "") +"[unsupported data version: " + udv; + if (mindv != null) s += "; minimum supported data version: " + mindv; + if (maxdv != null) s += "; maximum supported data version: " + maxdv; + s += "]"; + return s; + } + + public VersionLacksSupportException( + int unsupportedDataVersion, DataVersion minimumSupportedDataVersion, DataVersion maximumSupportedDataVersion + ) { + super(addVersionInfo(null, unsupportedDataVersion, minimumSupportedDataVersion, maximumSupportedDataVersion)); + this.unsupportedDataVersion = unsupportedDataVersion; + this.minimumSupportedDataVersion = minimumSupportedDataVersion; + this.maximumSupportedDataVersion = maximumSupportedDataVersion; + } + + public VersionLacksSupportException( + int unsupportedDataVersion, DataVersion minimumSupportedDataVersion, DataVersion maximumSupportedDataVersion, String message + ) { + super(addVersionInfo(message, unsupportedDataVersion, minimumSupportedDataVersion, maximumSupportedDataVersion)); + this.unsupportedDataVersion = unsupportedDataVersion; + this.minimumSupportedDataVersion = minimumSupportedDataVersion; + this.maximumSupportedDataVersion = maximumSupportedDataVersion; + } + + public VersionLacksSupportException( + int unsupportedDataVersion, DataVersion minimumSupportedDataVersion, DataVersion maximumSupportedDataVersion, String message, Throwable cause + ) { + super(addVersionInfo(message, unsupportedDataVersion, minimumSupportedDataVersion, maximumSupportedDataVersion), cause); + this.unsupportedDataVersion = unsupportedDataVersion; + this.minimumSupportedDataVersion = minimumSupportedDataVersion; + this.maximumSupportedDataVersion = maximumSupportedDataVersion; + } + + public VersionLacksSupportException( + int unsupportedDataVersion, DataVersion minimumSupportedDataVersion, DataVersion maximumSupportedDataVersion, Throwable cause) { + super(addVersionInfo(null, unsupportedDataVersion, minimumSupportedDataVersion, maximumSupportedDataVersion), cause); + this.unsupportedDataVersion = unsupportedDataVersion; + this.minimumSupportedDataVersion = minimumSupportedDataVersion; + this.maximumSupportedDataVersion = maximumSupportedDataVersion; + } + + public int unsupportedDataVersion() { + return unsupportedDataVersion; + } + + /** nullable */ + public DataVersion minimumSupportedDataVersion() { + return minimumSupportedDataVersion; + } + + /** nullable */ + public DataVersion maximumSupportedDataVersion() { + return maximumSupportedDataVersion; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/entities/DefaultEntityCreator.java b/src/main/java/io/github/ensgijs/nbt/mca/entities/DefaultEntityCreator.java new file mode 100644 index 00000000..533caf8a --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/entities/DefaultEntityCreator.java @@ -0,0 +1,16 @@ +package io.github.ensgijs.nbt.mca.entities; + +import io.github.ensgijs.nbt.tag.CompoundTag; + +/** + * The default {@link EntityCreator} which creates {@link EntityBase} that can represent any, and all, entities + * in vanilla Minecraft MCA files. + */ +public class DefaultEntityCreator implements EntityCreator { + + /** @see EntityCreator#create(String, CompoundTag, int) */ + @Override + public Entity create(String normalizedId, CompoundTag tag, int dataVersion) { + return new EntityBase(tag, dataVersion); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/entities/Entity.java b/src/main/java/io/github/ensgijs/nbt/mca/entities/Entity.java new file mode 100644 index 00000000..d59f060d --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/entities/Entity.java @@ -0,0 +1,393 @@ +package io.github.ensgijs.nbt.mca.entities; + +import io.github.ensgijs.nbt.mca.McaEntitiesFile; +import io.github.ensgijs.nbt.mca.io.McaFileHelpers; +import io.github.ensgijs.nbt.mca.util.TagWrapper; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.util.ArgValidator; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +/** + * Extremely basic, but complete, entity interface to allow users of this library to extend {@link McaEntitiesFile} + * and rewire {@link McaFileHelpers} for easy integration with existing code. + * @see EntityBase + */ +public interface Entity extends TagWrapper { + short AIR_UNSET = Short.MIN_VALUE; + + /** String representation of the entity's ID. Does not exist for the Player entity. */ + String getId(); + + /** @see #getId() */ + void setId(String id); + + /** + * This entity's Universally Unique Identifier. + *

May be null (but required by game). If uuid is null or ZERO when {@link #updateHandle()} is called, + * a random UUID will be generated and assigned. + */ + UUID getUuid(); + + /** + * This entity's Universally Unique Identifier. + * @param uuid Nullable, but required by game. If uuid is null when {@link #updateHandle()} is called, + * a random UUID will be generated and assigned. Prefer calling {@link #generateNewUuid()} which + * will also clear UUID's of any riders instead of setting null here. + */ + void setUuid(UUID uuid); + + /** + * Generates a new random UUID for this entity and all passengers. + * @return New UUID for this entity. + */ + UUID generateNewUuid(); + + /** + * How much air the entity has, in ticks. Decreases by 1 per tick when unable to breathe + * (except suffocating in a block). Increase by 1 per tick when it can breathe. If -20 while still + * unable to breathe, the entity loses 1 health and its air is reset to 0. Most mobs can have a + * maximum of 300 in air, while dolphins can reach up to 4800, and axolotls have 6000. + *

{@link #AIR_UNSET} is used as sentinel value (by this library) to indicate no value. + * However, generally MC stores a default of 300 even on things that don't need air.

+ */ + short getAir(); + + /** + * Set to {@link #AIR_UNSET} to indicate that "Air" should not be included in the NBT data. + * @see #getAir() + */ + void setAir(short air); + + /** + * Distance the entity has fallen. Larger values cause more damage when the entity lands. + */ + float getFallDistance(); + + /** @see #getFallDistance() */ + void setFallDistance(float fallDistance); + + /** + * Number of ticks until the fire is put out. Negative values reflect how long the entity can + * stand in fire before burning. Default -20 or -1 when not on fire. + */ + short getFire(); + + /** @see #getFire() */ + void setFire(short fireTicks); + + /** + * Optional. How many ticks the entity has been freezing. Although this tag is defined for + * all entities, it is actually only used by mobs that are not in the freeze_immune_entity_types + * entity type tag. Ticks up by 1 every tick while in powder snow, up to a maximum of 300 + * (15 seconds), and ticks down by 2 while out of it. + */ + int getTicksFrozen(); + + /** @see #getTicksFrozen() */ + void setTicksFrozen(int ticksFrozen); + + /** + * The number of ticks before which the entity may be teleported back through a nether portal. + * Initially starts at 300 ticks (15 seconds) after teleportation and counts down to 0. + */ + int getPortalCooldown(); + + /** @see #getPortalCooldown() */ + void setPortalCooldown(int portalCooldown); + + /** + * The custom name JSON text component of this entity. Appears in player death messages and villager + * trading interfaces, as well as above the entity when the player's cursor is over it. + * May be empty or not exist. + */ + String getCustomName(); + + /** @see #getCustomName() */ + void setCustomName(String customName); + + /** + * if true, and this entity has a custom name, the name always appears above the entity, regardless of + * where the cursor points. If the entity does not have a custom name, a default name is shown. + *

May not exist. Default NULL

+ */ + boolean isCustomNameVisible(); + + /** @see #isCustomNameVisible() */ + void setCustomNameVisible(boolean visible); + + /** + * true if the entity should not take damage. This applies to living and nonliving entities alike: mobs should + * not take damage from any source (including potion effects), and cannot be moved by fishing rods, attacks, + * explosions, or projectiles, and objects such as vehicles and item frames cannot be destroyed unless their + * supports are removed. + */ + boolean isInvulnerable(); + + /** @see #isInvulnerable() */ + void setInvulnerable(boolean invulnerable); + + /** + * if true, this entity is silenced. + * May not exist. + */ + boolean isSilent(); + + /** @see #isSilent() */ + void setSilent(boolean silent); + + /** true if the entity has a glowing outline. */ + boolean isGlowing(); + + /** @see #isGlowing() */ + void setGlowing(boolean glowing); + + /** If true, the entity does not fall down naturally. Set to true by striders in lava. */ + boolean hasNoGravity(); + + /** @see #hasNoGravity() */ + void setNoGravity(boolean noGravity); + + /** true if the entity is touching the ground. */ + boolean isOnGround(); + + /** @see #isOnGround() */ + void setOnGround(boolean onGround); + + /** If true, the entity visually appears on fire, even if it is not actually on fire. */ + boolean hasVisualFire(); + + /** @see #hasVisualFire() */ + void setHasVisualFire(boolean hasVisualFire); + + double getX(); + void setX(double x); + + double getY(); + void setY(double y); + + double getZ(); + void setZ(double z); + + default void setPosition(double x, double y, double z) { + setX(x); + setY(y); + setZ(z); + } + + default void movePosition(double dx, double dy, double dz) { + if (!isPositionValid()) { + throw new IllegalStateException("cannot move an invalid position"); + } + setX(getX() + dx); + setY(getY() + dy); + setZ(getZ() + dz); + } + + /** + * @return True if x, y, and z all have finite values. Does not check for reasonable finite values. + */ + default boolean isPositionValid() { + return Double.isFinite(getX()) && Double.isFinite(getY()) && Double.isFinite(getZ()); + } + + /** X velocity of the entity in meters per tick. */ + double getMotionDX(); + + /** + * Updates this entities motion and the motion of all passengers. + * @see #getMotionDX() + */ + void setMotionDX(double dx); + + /** Y velocity of the entity in meters per tick. */ + double getMotionDY(); + + /** + * Updates this entities motion and the motion of all passengers. + * @see #getMotionDY() + */ + void setMotionDY(double dy); + + /** Z velocity of the entity in meters per tick. */ + double getMotionDZ(); + + /** + * Updates this entities motion and the motion of all passengers. + * @see #getMotionDZ() + */ + void setMotionDZ(double dz); + + /** + * Updates this entities motion and the motion of all passengers. + */ + default void setMotion(double dx, double dy, double dz) { + setMotionDX(dx); + setMotionDY(dy); + setMotionDZ(dz); + } + + /** + * @return True if dx, dy, and dz all have finite values. Does not check for reasonable finite values. + */ + default boolean isMotionValid() { + return Double.isFinite(getMotionDX()) && Double.isFinite(getMotionDY()) && Double.isFinite(getMotionDZ()); + } + + /** + * The entity's rotation clockwise around the Y axis (called yaw). + * Due south is 0, west is 90, north is 180, east is 270. + * @return yaw in degrees in range [0..360) + * @see #getFacingCardinalAngle() + */ + float getRotationYaw(); + + /** + * Sets entity yaw (clockwise rotation about the y-axis) in degrees. + * Due south is 0, west is 90, north is 180, east is 270. + * @see #getRotationYaw() + * @see #rotate(float) + */ + void setRotationYaw(float yaw); + + /** + * Convenience function for working with yaw values in cardinal angles - + * where 0 is north, 90 is east, 180 is south, 270 is west. + *

Because dealing with yaw values is error prone and somewhat nonsensical. + * @return The direction the entity is facing in cardinal angle in degrees in range [0..360) + */ + default float getFacingCardinalAngle() { + return EntityUtil.normalizeYaw(getRotationYaw() + 180); + } + + /** + * Convenience function for working with yaw values in cardinal angles - + * where 0 is north, 90 is east, 180 is south, 270 is west. + *

Because dealing with yaw values is error prone and somewhat nonsensical. + * @param cardinalAngle Cardinal angle in degrees, used to calculate and set a new yaw value. + */ + default void setFacingCardinalAngle(float cardinalAngle) { + setRotationYaw(EntityUtil.normalizeYaw(cardinalAngle - 180)); + } + + /** + * The entity's declination from the horizon (called pitch). Horizontal is 0. Positive values look downward. + * Does not exceed positive or negative 90 degrees. + */ + float getRotationPitch(); + + /** @see #getRotationPitch() */ + void setRotationPitch(float pitch); + + /** + * @see #getRotationYaw() + * @see #getRotationPitch() + * @see #rotate(float) + */ + default void setRotation(float yaw, float pitch) { + setRotationYaw(yaw); + setRotationPitch(pitch); + } + + /** + * Rotates this entity, and all passengers, by the given angelDegrees. + *

Note that this is different from {@link #setRotationYaw(float)} as this function is relative to + * the current yaw and affects passengers. + * @param angleDegrees Angle in degrees to rotate this entity and all passengers. May be positive or negative. + * @throws IllegalStateException Thrown if current yaw is not finite + */ + default void rotate(float angleDegrees) { + if (angleDegrees == 0f) return; + ArgValidator.check(Float.isFinite(angleDegrees)); + // Given the nature of floating point numbers, an extremely large given angleDegrees might + // squash the current yaw when added to it - this problem can be avoided by first normalizing it. + angleDegrees = EntityUtil.normalizeYaw(angleDegrees); + float currentYaw = getRotationYaw(); + if (!Float.isFinite(currentYaw)) { + throw new IllegalStateException("cannot rotate non-finite yaw"); + } + setRotationYaw(EntityUtil.normalizeYaw(currentYaw + angleDegrees)); + if (hasPassengers()) { + for (Entity passenger : getPassengers()) { + passenger.rotate(angleDegrees); + } + } + } + + /** + * Overload taking a double for your convenience and to provide increased accuracy when passing high magnitude + * values - the given double precision angle is normalized into the range [0..360) before passing it to + * {@link #rotate(float)} + * @see #rotate(float) + */ + default void rotate(double angleDegrees) { + rotate(EntityUtil.normalizeYaw(angleDegrees)); + } + + /** + * @return True if yaw and pitch have finite values. Does not check for reasonable finite values. + */ + default boolean isRotationValid() { + return Float.isFinite(getRotationYaw()) && Float.isFinite(getRotationPitch()); + } + + /** + * @see #getRotationYaw() + * @see #getRotationPitch() + */ + default void setPosition(double x, double y, double z, float yaw, float pitch) { + setPosition(x, y, z); + setRotation(yaw, pitch); + } + + /** + * The data of the entity(s) that is riding this entity. Note that both entities control movement and the + * topmost entity controls spawning conditions when created by a mob spawner. + * May be null. + */ + List getPassengers(); + + /** @see #getPassengers() */ + void setPassengers(List passengers); + + default void setPassengers(Entity... passengers) { + if (passengers == null || passengers.length == 0 || (passengers.length == 1 && passengers[0] == null)) { + clearPassengers(); + return; + } + List list = new ArrayList<>(passengers.length); + list.addAll(Arrays.asList(passengers)); + setPassengers(list); + } + + /** + * Adds a passenger, initializing the passenger list if necessary + * @param passenger non-null passenger + */ + void addPassenger(Entity passenger); + + /** + * Removes all passengers (sets passengers list to null - does not actually clear that list) + */ + void clearPassengers(); + + default boolean hasPassengers() { + return getPassengers() != null && !getPassengers().isEmpty(); + } + + /** + * List of scoreboard tags of this entity. + * Optional - null if not present. + */ + List getScoreboardTags(); + + /** @see #getScoreboardTags() */ + void setScoreboardTags(List scoreboardTags); + + default boolean hasScoreboardTags() { + return getPassengers() != null && !getPassengers().isEmpty(); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/entities/EntityBase.java b/src/main/java/io/github/ensgijs/nbt/mca/entities/EntityBase.java new file mode 100644 index 00000000..445c319d --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/entities/EntityBase.java @@ -0,0 +1,799 @@ +package io.github.ensgijs.nbt.mca.entities; + +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.mca.util.VersionedDataContainer; +import io.github.ensgijs.nbt.util.ArgValidator; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Provides a rich default implementation of {@link Entity} that exposes all properties of all + * entities in vanilla Minecraft and behaves in an intelligent way, making it easier to manipulate them. + *

+ * Features worth special note + *

    + *
  • {@link #generateNewUuid()} - generates a new uuid for this entity and all mounted passengers, + * and their passengers, and so on.
  • + *
  • {@link #setPosition(double, double, double)}, {@link #setX(double)}, {@link #setY(double)}, + * {@link #setZ(double)}, {@link #movePosition(double, double, double)} - + * cascade relative position changes to all passengers, and their passengers, and so on.
  • + *
  • {@link #setMotion(double, double, double)}, {@link #setMotionDX(double)}, {@link #setMotionDY(double)}, + * {@link #setMotionDZ(double)} - cascade changes to passengers, and their passengers, and so on.
  • + *
  • {@link #addPassenger(Entity)} - sets the new passengers motion to match their mount and if + * the passenger has no valid position set, updates it to match their mount as well.
  • + *
  • {@link #setRotationYaw(float)} and {@link #setRotationPitch(float)} - automatically and intelligently + * keep values in range, [0..360) and [-90..90] respectively. These functions DO NOT affect passengers.
  • + *
  • {@link #getFacingCardinalAngle()} and {@link #setFacingCardinalAngle(float)} - + * convenience functions where north is 0 deg, east is 90 deg, south is 180 deg, and west is 270 deg. + * These functions are just wrappers around {@link #getRotationYaw()} and {@link #setRotationYaw(float)} + * that perform the simple 180 deg rotation for you - because working with yaw angles is nonsensical. + *
  • + *
+ */ +public class EntityBase implements Entity, VersionedDataContainer { + + + protected CompoundTag data; + protected int dataVersion; + /** not null */ + protected String id; + /** nullable, if not set {@link #updateHandle()} must calculate a random UUID and assign it. UUID of ZERO must also be treated as unset */ + protected UUID uuid; + /** Note {@link Entity#AIR_UNSET} is used as sentinel value indicating no value. */ + protected short air = AIR_UNSET; + protected int portalCooldown; + protected float fallDistance; + protected short fireTicks = -1; + protected int ticksFrozen; + /** nullable, otherwise text nbt */ + protected String customName; + protected boolean isCustomNameVisible; + protected boolean isInvulnerable; + protected boolean isSilent; + protected boolean isGlowing; + protected boolean isOnGround; + protected boolean noGravity; + protected boolean hasVisualFire; + protected double x = Double.NaN; + protected double y = Double.NaN; + protected double z = Double.NaN; + protected float yaw; + protected float pitch; + // motion + protected double dx; + protected double dy; + protected double dz; + /** nullable */ + protected List scoreboardTags; + /** nullable */ + protected List passengers; + + public EntityBase(int dataVersion) { + this.dataVersion = ArgValidator.check(dataVersion, dataVersion >= 0); + this.data = new CompoundTag(); + } + + public EntityBase(int dataVersion, String id) { + this.dataVersion = ArgValidator.check(dataVersion, dataVersion > 0); + this.id = ArgValidator.requireNotEmpty(id); + this.data = new CompoundTag(); + } + + public EntityBase(int dataVersion, String id, double x, double y, double z) { + this(dataVersion, id, x, y, z, 0, 0); + } + + public EntityBase(int dataVersion, String id, double x, double y, double z, float yaw) { + this(dataVersion, id, x, y, z, yaw, 0); + } + + public EntityBase(int dataVersion, String id, double x, double y, double z, float yaw, float pitch) { + this.dataVersion = ArgValidator.check(dataVersion, dataVersion > 0); + this.id = ArgValidator.requireNotEmpty(id); + this.data = new CompoundTag(); + this.x = x; + this.y = y; + this.z = z; + this.yaw = EntityUtil.normalizeYaw(yaw); + this.pitch = EntityUtil.clampPitch(pitch); + } + + /** + * Copy constructor. + *
    + *
  • Performs a DEEP COPY of this entity and all passengers. + *
  • For passengers, {@link EntityFactory#create(CompoundTag, int)} is invoked to create strongly typed + * entity instances instead of relying on each passengers clone implementation. + *
  • DOES NOT copy UUID's of self or passengers, instead calls {@link #generateNewUuid()} + * to ensure that each gets a new UUID. + *
  • Triggers {@code other.updateHandle()} which may cause {@link #updateHandle()} to throw + * {@link IllegalStateException} if it is not in a valid state. + *
  • New object receives a new {@code data} {@link CompoundTag} cloned from the updated handle of + * {@code other}. + *
+ * @param other Object to clone. + */ + public EntityBase(EntityBase other) { + // need to call update handle to make copying passengers clean and tidy + this.data = other.updateHandle().clone(); + this.dataVersion = other.dataVersion; + this.id = ArgValidator.requireNotEmpty(other.id); + this.uuid = null; + this.portalCooldown = other.portalCooldown; + this.fallDistance = other.fallDistance; + this.ticksFrozen = other.ticksFrozen; + this.customName = other.customName; + this.isCustomNameVisible = other.isCustomNameVisible; + this.isInvulnerable = other.isInvulnerable; + this.isSilent = other.isSilent; + this.isGlowing = other.isGlowing; + this.isOnGround = other.isOnGround; + this.noGravity = other.noGravity; + this.hasVisualFire = other.hasVisualFire; + this.x = other.x; + this.y = other.y; + this.z = other.z; + this.yaw = other.yaw; + this.pitch = other.pitch; + this.dx = other.dx; + this.dy = other.dy; + this.dz = other.dz; + this.scoreboardTags = other.scoreboardTags == null ? null : new ArrayList<>(other.scoreboardTags); + this.passengers = !this.data.containsKey("Passengers") ? null + : StreamSupport.stream(this.data.getListTag("Passengers").asCompoundTagList().spliterator(), false) + .map(tag -> EntityFactory.create(tag, dataVersion)) + .collect(Collectors.toList()); + + this.generateNewUuid(); + } + + public EntityBase(CompoundTag data, int dataVersion) { + this.data = data; + this.dataVersion = dataVersion; + this.id = ArgValidator.requireNotEmpty(data.getString("id", null), "id tag"); + this.uuid = EntityUtil.getUuid(dataVersion, data); + this.air = data.getShort("Air", AIR_UNSET); + this.portalCooldown = data.getInt("PortalCooldown"); + this.fallDistance = data.getFloat("FallDistance"); + this.fireTicks = data.getShort("Fire", (short) -1); + this.ticksFrozen = data.getInt("TicksFrozen", 0); + this.customName = data.getString("CustomName", null); + this.isCustomNameVisible = data.getBoolean("CustomNameVisible"); + this.isInvulnerable = data.getBoolean("Invulnerable"); + this.isSilent = data.getBoolean("Silent"); + this.isGlowing = data.getBoolean("Glowing"); + this.hasVisualFire = data.getBoolean("HasVisualFire"); + this.isOnGround = data.getBoolean("OnGround"); + this.noGravity = data.getBoolean("NoGravity"); + double[] pos = data.getDoubleTagListAsArray("Pos"); + if (pos != null && pos.length == 3) { + this.x = pos[0]; + this.y = pos[1]; + this.z = pos[2]; + } + float[] rotation = data.getFloatTagListAsArray("Rotation"); + if (rotation != null && rotation.length == 2) { + this.yaw = rotation[0]; + this.pitch = rotation[1]; + } + double[] motion = data.getDoubleTagListAsArray("Motion"); + if (motion != null && motion.length == 3) { + this.dx = motion[0]; + this.dy = motion[1]; + this.dz = motion[2]; + } + ListTag passengersTag = data.getListTagAutoCast("Passengers"); + if (passengersTag != null && passengersTag.size() > 0) { + this.passengers = new ArrayList<>(passengersTag.size()); + for (CompoundTag ptag : passengersTag) { + this.passengers.add(EntityFactory.create(ptag, dataVersion)); + } + } + this.scoreboardTags = data.getStringTagListValues("Tags"); + } + + // + + @Override + public int getDataVersion() { + return dataVersion; + } + + @Override + public void setDataVersion(int dataVersion) { + this.dataVersion = dataVersion; + } + + /** {@inheritDoc} */ + @Override + public String getId() { + return id; + } + + /** {@inheritDoc} */ + @Override + public void setId(String id) { + this.id = ArgValidator.requireNotEmpty(id); + } + + /** {@inheritDoc} */ + @Override + public UUID getUuid() { + return uuid; + } + + /** {@inheritDoc} */ + @Override + public void setUuid(UUID uuid) { + this.uuid = uuid; + } + + /** {@inheritDoc} */ + @Override + public UUID generateNewUuid() { + this.uuid = UUID.randomUUID(); + if (passengers != null) { + for (Entity passenger : passengers) { + passenger.generateNewUuid(); + } + } + return uuid; + } + + /** {@inheritDoc} */ + @Override + public short getAir() { + return air; + } + + /** {@inheritDoc} */ + @Override + public void setAir(short air) { + this.air = air; + } + + /** {@inheritDoc} */ + @Override + public float getFallDistance() { + return fallDistance; + } + + /** {@inheritDoc} */ + @Override + public void setFallDistance(float fallDistance) { + this.fallDistance = fallDistance; + } + + /** {@inheritDoc} */ + @Override + public short getFire() { + return fireTicks; + } + + /** {@inheritDoc} */ + @Override + public void setFire(short fireTicks) { + this.fireTicks = fireTicks; + } + + /** {@inheritDoc} */ + @Override + public int getTicksFrozen() { + return ticksFrozen; + } + + /** {@inheritDoc} */ + @Override + public void setTicksFrozen(int ticksFrozen) { + this.ticksFrozen = ticksFrozen; + } + + /** {@inheritDoc} */ + @Override + public int getPortalCooldown() { + return portalCooldown; + } + + /** {@inheritDoc} */ + @Override + public void setPortalCooldown(int portalCooldown) { + this.portalCooldown = portalCooldown; + } + + /** {@inheritDoc} */ + @Override + public String getCustomName() { + return customName; + } + + /** {@inheritDoc} */ + @Override + public void setCustomName(String customName) { + this.customName = customName; + } + + /** {@inheritDoc} */ + @Override + public boolean isCustomNameVisible() { + return isCustomNameVisible; + } + + /** {@inheritDoc} */ + @Override + public void setCustomNameVisible(boolean visible) { + this.isCustomNameVisible = visible; + } + + /** {@inheritDoc} */ + @Override + public boolean isInvulnerable() { + return isInvulnerable; + } + + /** {@inheritDoc} */ + @Override + public void setInvulnerable(boolean invulnerable) { + this.isInvulnerable = invulnerable; + } + + /** {@inheritDoc} */ + @Override + public boolean isSilent() { + return isSilent; + } + + /** {@inheritDoc} */ + @Override + public void setSilent(boolean silent) { + this.isSilent = silent; + } + + /** {@inheritDoc} */ + @Override + public boolean isGlowing() { + return isGlowing; + } + + /** {@inheritDoc} */ + @Override + public void setGlowing(boolean glowing) { + this.isGlowing = glowing; + } + + /** {@inheritDoc} */ + @Override + public boolean hasNoGravity() { + return noGravity; + } + + /** {@inheritDoc} */ + @Override + public void setNoGravity(boolean noGravity) { + this.noGravity = noGravity; + } + + /** {@inheritDoc} */ + @Override + public boolean isOnGround() { + return this.isOnGround; + } + + /** {@inheritDoc} */ + @Override + public void setOnGround(boolean onGround) { + this.isOnGround = onGround; + } + + /** {@inheritDoc} */ + @Override + public boolean hasVisualFire() { + return hasVisualFire; + } + + /** {@inheritDoc} */ + @Override + public void setHasVisualFire(boolean hasVisualFire) { + this.hasVisualFire = hasVisualFire; + } + + /** + * {@inheritDoc} + * May be {@link Double#NaN} if unset. + * @see #isPositionValid() + */ + @Override + public double getX() { + return x; + } + + /** {@inheritDoc} */ + @Override + public void setX(final double x) { + if (passengers != null) { + if (!Double.isFinite(this.x) || !Double.isFinite(x)) { + for (Entity passenger : passengers) { + passenger.setX(x); + } + } else { + final double dx = x - this.x; + for (Entity passenger : passengers) { + double px = passenger.getX(); + if (Double.isFinite(px)) { + passenger.setX(px + dx); + } else { + passenger.setX(x); + } + } + } + } + this.x = x; + } + + /** + * {@inheritDoc} + * May be {@link Double#NaN} if unset. + * @see #isPositionValid() + */ + @Override + public double getY() { + return y; + } + + /** {@inheritDoc} */ + @Override + public void setY(final double y) { + if (passengers != null) { + if (!Double.isFinite(this.y) || !Double.isFinite(y)) { + for (Entity passenger : passengers) { + passenger.setY(y); + } + } else { + final double dy = y - this.y; + for (Entity passenger : passengers) { + double py = passenger.getY(); + if (Double.isFinite(py)) { + passenger.setY(py + dy); + } else { + passenger.setY(y); + } + } + } + } + this.y = y; + } + + /** + * {@inheritDoc} + * May be {@link Double#NaN} if unset. + * @see #isPositionValid() + */ + @Override + public double getZ() { + return z; + } + + /** {@inheritDoc} */ + @Override + public void setZ(final double z) { + if (passengers != null) { + if (!Double.isFinite(this.z) || !Double.isFinite(z)) { + for (Entity passenger : passengers) { + passenger.setZ(z); + } + } else { + final double dz = z - this.z; + for (Entity passenger : passengers) { + double pz = passenger.getZ(); + if (Double.isFinite(pz)) { + passenger.setZ(pz + dz); + } else { + passenger.setZ(z); + } + } + } + } + this.z = z; + } + + /** {@inheritDoc} */ + @Override + public float getRotationYaw() { + return yaw; + } + + /** + * Sets entity yaw (rotation about the y-axis) in degrees, with 0 being due south. The caller does not need + * to worry about passing a {@code yaw} value in the range [0..360), the given value will be normalized + * into the valid range. + * @see #getRotationYaw() + * @see #rotate(float) + */ + @Override + public void setRotationYaw(float yaw) { + this.yaw = EntityUtil.normalizeYaw(yaw); + } + + /** {@inheritDoc} */ + @Override + public float getRotationPitch() { + return pitch; + } + + /** {@inheritDoc} */ + @Override + public void setRotationPitch(float pitch) { + this.pitch = EntityUtil.clampPitch(pitch); + } + + /** {@inheritDoc} */ + @Override + public void setRotation(float yaw, float pitch) { + this.yaw = EntityUtil.normalizeYaw(yaw); + this.pitch = EntityUtil.clampPitch(pitch); + } + + /** {@inheritDoc} */ + @Override + public void setPosition(double x, double y, double z) { + if (!hasPassengers()) { + this.x = x; + this.y = y; + this.z = z; + } else { + // it's just easier to handle the passenger move logic this way + setX(x); + setY(y); + setZ(z); + } + } + /** {@inheritDoc} */ + @Override + public double getMotionDX() { + return dx; + } + + /** {@inheritDoc} */ + @Override + public void setMotionDX(double dx) { + this.dx = dx; + if (passengers != null) { + for (Entity passenger : passengers) { + passenger.setMotionDX(dx); + } + } + } + + /** {@inheritDoc} */ + @Override + public double getMotionDY() { + return dy; + } + + /** {@inheritDoc} */ + @Override + public void setMotionDY(double dy) { + this.dy = dy; + if (passengers != null) { + for (Entity passenger : passengers) { + passenger.setMotionDY(dy); + } + } + } + + /** {@inheritDoc} */ + @Override + public double getMotionDZ() { + return dz; + } + + /** {@inheritDoc} */ + @Override + public void setMotionDZ(double dz) { + this.dz = dz; + if (passengers != null) { + for (Entity passenger : passengers) { + passenger.setMotionDZ(dz); + } + } + } + + /** {@inheritDoc} */ + @Override + public void setMotion(double dx, double dy, double dz) { + this.dx = dx; + this.dy = dy; + this.dz = dz; + if (passengers != null) { + for (Entity passenger : passengers) { + passenger.setMotion(dx, dy, dz); + } + } + } + + /** {@inheritDoc} */ + @Override + public List getPassengers() { + return passengers; + } + + /** {@inheritDoc} */ + @Override + public void setPassengers(List passengers) { + if (passengers != null && !passengers.isEmpty()) { + for (Entity passenger : passengers) { + if (passenger != null) { + passenger.setMotion(dx, dy, dz); + if (!passenger.isPositionValid()) { + passenger.setPosition(x, y, z); + } + } + } + this.passengers = passengers; + } else { + clearPassengers(); + } + } + + /** + * {@inheritDoc} + *

The caller is generally responsible for ensuring that the positions of passengers make sense or can be + * corrected by the game. However, if the given passenger does not satisfy {@link #isPositionValid()} + * then its position will be set to be the same as this entities position (which only helps if this entity + * satisfies {@link #isPositionValid()}).

+ *

Also sets passenger motion to match this entities motion.

+ * @throws IndexOutOfBoundsException if {@link #setPassengers(List)} was given a list wrapped array + * @throws UnsupportedOperationException if {@link #setPassengers(List)} was given an unmodifiable list + */ + @Override + public void addPassenger(Entity passenger) { + ArgValidator.requireValue(passenger); + ArgValidator.check(passenger != this); // at least prevent direct recursion + if (passengers == null) { + passengers = new ArrayList<>(); + } + if (!passenger.isPositionValid()) { + passenger.setPosition(x, y, z); + } + passenger.setMotion(dx, dy, dz); + passengers.add(passenger); + } + + /** {@inheritDoc} */ + @Override + public void clearPassengers() { + passengers = null; + } + + /** {@inheritDoc} */ + @Override + public List getScoreboardTags() { + return scoreboardTags; + } + + /** {@inheritDoc} */ + @Override + public void setScoreboardTags(List scoreboardTags) { + this.scoreboardTags = scoreboardTags; + } + + //
+ + /** {@inheritDoc} */ + @Override + public CompoundTag getHandle() { + return data; + } + + /** + * {@inheritDoc} + *

If this tag is being saved into a chunk the caller is responsible for checking the + * result of {@link #isPositionValid()} - this method will simply leave out the "Pos" + * tag if {@link #isPositionValid()} would return false.

+ *

If the uuid is not defined on this entity when this method is called, a random uuid + * will be generated and set.

+ */ + @Override + public CompoundTag updateHandle() { + // TODO: restrict field outputs to be version appropriate - there's no harm in extra fields but might as well + // be clean about it. + data.putString("id", ArgValidator.requireNotEmpty(id, "id")); + if (uuid == null || EntityUtil.ZERO_UUID.equals(uuid)) { + uuid = UUID.randomUUID(); + } + EntityUtil.setUuid(dataVersion, data, uuid); + + if (isPositionValid()) { + data.putDoubleArrayAsTagList("Pos", x, y, z); + } else { + // For passengers... it's probably OK to not require a position and for sake of this being an + // abstraction / wrapper layer - we'll allow it to provide wider use case support and make + // caller responsible ensuring valid usage. + data.remove("Pos"); + } + + if (isRotationValid()) { + data.putFloatArrayAsTagList("Rotation", yaw, pitch); + } else { + data.remove("Rotation"); + } + + if (isMotionValid()) { + data.putDoubleArrayAsTagList("Motion", dx, dy, dz); + } else { + data.remove("Motion"); + } + + if (air != AIR_UNSET) { + data.putShort("Air", air); + } else { + data.remove("Air"); + } + + if (customName != null && !customName.isEmpty()) { + data.putString("CustomName", customName); + } else { + data.remove("CustomName"); + } + + if (isCustomNameVisible || data.containsKey("CustomNameVisible")) { + data.putBoolean("CustomNameVisible", isCustomNameVisible); + } + + data.putFloat("FallDistance", fallDistance); + data.putShort("Fire", fireTicks); + + if (isGlowing || data.containsKey("Glowing")) { + data.putBoolean("Glowing", isGlowing); + } + if (hasVisualFire || data.containsKey("HasVisualFire")) { + data.putBoolean("HasVisualFire", hasVisualFire); + } + if (isInvulnerable || data.containsKey("Invulnerable")) { + data.putBoolean("Invulnerable", isInvulnerable); + } + if (noGravity || data.containsKey("NoGravity")) { + data.putBoolean("NoGravity", noGravity); + } + + data.putBoolean("OnGround", isOnGround); + data.putInt("PortalCooldown", portalCooldown); + + if (isSilent || data.containsKey("Silent")) { + data.putBoolean("Silent", isSilent); + } + + data.putStringsAsTagList("Tags", scoreboardTags); + data.putInt("TicksFrozen", ticksFrozen); + + if (passengers != null && !passengers.isEmpty()) { + ListTag passengersTag = new ListTag<>(CompoundTag.class, passengers.size()); + for (Entity passenger : passengers) { + if (passenger != null) { + passengersTag.add(passenger.updateHandle()); + } + } + data.put("Passengers", passengersTag); + } else { + data.remove("Passengers"); + } + return data; + } + + /** + * Calls the copy constructor. + * @return Deep clone of this entity. + * @see EntityBase#EntityBase(EntityBase) + */ + @Override + public EntityBase clone() { + return new EntityBase(this); + } + + @Override + public String toString() { + return String.format("%s %.2f %.2f %.2f", id, x, y, z); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/entities/EntityCreator.java b/src/main/java/io/github/ensgijs/nbt/mca/entities/EntityCreator.java new file mode 100644 index 00000000..9a77cf86 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/entities/EntityCreator.java @@ -0,0 +1,23 @@ +package io.github.ensgijs.nbt.mca.entities; + + +import io.github.ensgijs.nbt.tag.CompoundTag; + +/** + * Intended for use by {@link EntityFactory} to create custom entity classes when parsing NBT tags. + */ +@FunctionalInterface +public interface EntityCreator { + + /** + * @param normalizedId normalized entity id, with no "minecraft:" prefix and all UPPER CASE. + * Note this is always the result of {@link EntityFactory#normalizeAndRemapId(String)} and may + * not match the id tag found in the nbt data when reading from old chunks. + * @param tag tag containing entity data + * @param dataVersion data version of chunk / tag + * @return NOT NULL, if an implementer cannot process the given tag it should throw an + * {@link IllegalEntityTagException} + * @throws IllegalEntityTagException Thrown if this creator cannot return a result. + */ + T create(String normalizedId, CompoundTag tag, int dataVersion); +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/entities/EntityFactory.java b/src/main/java/io/github/ensgijs/nbt/mca/entities/EntityFactory.java new file mode 100644 index 00000000..201115e8 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/entities/EntityFactory.java @@ -0,0 +1,336 @@ +package io.github.ensgijs.nbt.mca.entities; + +import io.github.ensgijs.nbt.mca.EntitiesChunkBase; +import io.github.ensgijs.nbt.mca.io.McaFileHelpers; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.util.ArgValidator; +import io.github.ensgijs.nbt.util.IdentityHelper; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Provides a way to customize entity data deserialization. Used by {@link EntitiesChunkBase} + * to provide users with {@link Entity} objects to work with instead of having to work with raw NBT data. + * This factory can be configured to produce any custom class that implements {@link Entity}. + *

Further customization is possible by also configuring the mca file creator factory lambda functions found in + * {@link McaFileHelpers}, specifically the {@link McaFileHelpers#MCA_CREATORS} map

+ */ +public final class EntityFactory { + private EntityFactory() { } + + // TODO: Implement "creator ai" solution to learn which creators make the "best" entity instances for + // any given entity id. + // Strategy Idea: + // Creator Nomination - given a data tag and returns null if uninterested, or a creator instance which + // can be used (doesn't have to be "the best" creator - just one capable). + // Creator Selection - each nominated creator is asked to create an entity from the tag. + // It is expected that all entity types have a common inheritance ancestor and that all + // created entity types exist on a linear inheritance path such that there is only one entity E + // which is the most distant from Object and no entity falls outside the direct line of inheritance + // between E and Object. + // Association - register the highest quality creator for the entity id that started it all. + // If no creators were nominated then the default creator is registered for that id, + // for performance reasons. + // Variation: + // If Creator Selection produces entity types which do not all fall on a single inheritance line, or if + // this variation is simply preferred, consider making CreatorNomination objects themselves comparable + // - though probably not via the Comparable interface - but perhaps via a Comparator which can be user + // supplied. In fact the Creator Selection phase described previously could simply be one such Comparator + // and the registered default creator can simply "always" be in the running. + + /** + * This map controls the factory creation behavior, keys are entity ID's (such as "PIG"). + * ID names in this map should not contain the "minecraft:" prefix and should be all UPPER CASE. + * @see #normalizeId(String) + */ + private static final Map> ENTITY_CREATORS_BY_ID = new HashMap<>(); + private static EntityCreator DEFAULT_ENTITY_CREATOR = new DefaultEntityCreator(); + + /** Provides a mapping table to translate "old id" names to new ones to simplify creator registration. */ + private static final Map ID_REMAP; + + static { + ID_REMAP = new HashMap<>(); + resetEntityIdRemap(); + } + + /** + * Clears the entity id remapping table and removes any creators registered to one of the "old id's". + * This should be generally safe, but if you have explicitly associated a creator with an old name, know that + * you need to re-associate it after making this call. + *

The remapping table to translate "old id" names to new ones to simplify creator registration.

+ * @see #registerIdRemap(String, String) + */ + public static void clearEntityIdRemap() { + // contract of supporting functions ensure that no key is also a value in this map, therefore + // it is not possible that we remove an explicit "value" mapping. + ENTITY_CREATORS_BY_ID.keySet().removeAll(ID_REMAP.keySet()); + ID_REMAP.clear(); + } + + /** + * Resets the entity id remapping table to hard-coded defaults. + * @see #clearCreators() + * @see #registerIdRemap(String, String) + */ + public static void resetEntityIdRemap() { + clearEntityIdRemap(); + // sources: + // https://technical-minecraft.fandom.com/wiki/Entity + // https://minecraft.fandom.com/wiki/Java_Edition_data_values#Entities + registerIdRemap("ArmorStand", "armor_stand"); + registerIdRemap("CaveSpider", "cave_spider"); + registerIdRemap("Dragon", "ender_dragon"); + registerIdRemap("EnderCrystal", "end_crystal"); + registerIdRemap("ender_crystal", "end_crystal"); + registerIdRemap("EnderEye", "eye_of_ender"); + registerIdRemap("EnderPearl", "ender_pearl"); + registerIdRemap("ExpBottle", "experience_bottle"); + registerIdRemap("FallingBlock", "falling_block"); + registerIdRemap("FireworkRocket", "firework_rocket"); + registerIdRemap("GiantZombie", "giant"); + registerIdRemap("IronGolem", "iron_golem"); + registerIdRemap("ItemFrame", "item_frame"); + registerIdRemap("LargeFireball", "fireball"); + registerIdRemap("LeashKnot", "leash_knot"); + registerIdRemap("LightningBolt", "lightning_bolt"); + registerIdRemap("MagmaCube", "magma_cube"); + // TODO: find old name for command_block_minecart - not that anyone is likely to notice + registerIdRemap("MinecartChest", "chest_minecart"); + registerIdRemap("MinecartEmpty", "minecart"); + registerIdRemap("MinecartFurnace", "furnace_minecart"); + registerIdRemap("MinecartHopper", "hopper_minecart"); + registerIdRemap("MinecartMobSpawner", "spawner_minecart"); + registerIdRemap("MinecartTNT", "tnt_minecart"); + registerIdRemap("PigZombie", "zombified_piglin"); + registerIdRemap("zombie_pigman", "zombified_piglin"); + registerIdRemap("SmallFireball", "small_fireball"); + registerIdRemap("Snowman", "snow_golem"); + registerIdRemap("TNTPrimed", "tnt"); + registerIdRemap("WitherSkull", "wither_skull"); + registerIdRemap("XPOrb", "experience_orb"); + } + + /** + * Registers a mapping from an old id name to a new one. + * Chaining of mappings is not supported and is guarded against. + * Maintains ENTITY_CREATORS_BY_ID map to ensure any creator registered for the preferredId is fired when the oldId + * is encountered IFF there is not already a creator registered for the oldId. + *

Note that creators are ALWAYS passed the preferredId even if the data source used an old id.

+ * @param oldId ID found in entity nbt data for versions of minecraft prior to, or even later than, the preferred version. + * @param preferredId Preferred ID found in the entity nbt data for the most current supported minecraft version. + */ + public static void registerIdRemap(String oldId, String preferredId) { + String oldIdNorm = normalizeId(oldId); + String newIdNorm = normalizeId(preferredId); + ArgValidator.check(!ID_REMAP.containsKey(newIdNorm) && !ID_REMAP.containsValue(oldIdNorm), + String.format("Chaining of mappings not supported. While adding %s -> %s", oldIdNorm, newIdNorm)); + ID_REMAP.put(oldIdNorm, newIdNorm); + if (ENTITY_CREATORS_BY_ID.containsKey(newIdNorm)) { + ENTITY_CREATORS_BY_ID.putIfAbsent(oldIdNorm, ENTITY_CREATORS_BY_ID.get(newIdNorm)); + } + } + + /** + * Performs a reverse lookup in the remap table to find all of the old id's which are mapped to the given one. + * Use infrequently - this is not an optimized operation. + * @param currentId Current ID (will be passed through {@link #normalizeId(String)}) + * @return not null; list of old id's (in normalized form) that are mapped to the given currentID + */ + public static List reverseIdRemap(String currentId) { + final String idNorm = normalizeId(currentId); + return ID_REMAP.entrySet().stream() + .filter(e -> idNorm.equals(e.getValue())) + .map(Map.Entry::getKey) + .collect(Collectors.toList()); + } + + /** + * Sets the default creator to use when one has not been registered for a particular entity id. + * @see #registerCreator(EntityCreator, String...) + */ + public static void setDefaultEntityCreator(EntityCreator creator) { + if (creator == null) throw new IllegalArgumentException(); + DEFAULT_ENTITY_CREATOR = creator; + } + + /** Gets the current default entity creator instance. */ + public static EntityCreator getDefaultEntityCreator() { + return DEFAULT_ENTITY_CREATOR; + } + + /** + * Gets the set of NORMALIZED id's which have creators registered. + * @see #normalizeId(String) + * @return Key set from underlying map, modifications to this map will affect the set of registered creators. + */ + public static Set getRegisteredCreatorIdKeys() { + return ENTITY_CREATORS_BY_ID.keySet(); + } + + /** + * Exposed for advanced usage only, most use cases should not need to call this function. + * @param id NORMALIZED ID. You can normalize an id by calling {@link #normalizeId(String)} + * @return Registered creator or null if there is none (does not fall back to the default creator). + */ + public static EntityCreator getCreatorById(String id) { + return ENTITY_CREATORS_BY_ID.get(id); + } + + /** + * Clears this factory's creators map and restores the {@link DefaultEntityCreator} as the default. + * Does NOT reset the entity id remap - call {@link #resetEntityIdRemap()} to do that. + */ + public static void clearCreators() { + ENTITY_CREATORS_BY_ID.clear(); + DEFAULT_ENTITY_CREATOR = new DefaultEntityCreator(); + } + + /** + * Checks that the given id has a value then normalizes it by removing any "minecraft:" + * prefix and making them ALL CAPS for ease of use with custom enum lookups by name. + * + *

This function DOES NOT perform any name remapping for id's from old versions. + * If that is what you are looking for, use {@link #normalizeAndRemapId(String)} instead.

+ * + * @param id Entity ID + * @return Normalized entity ID - DO NOT set this value as the value of an "id" nbt tag, + * that's not what this function is for. + * @throws IllegalArgumentException Thrown when ID is null or empty (after removing any "minecraft:" prefix). + */ + public static String normalizeId(String id) { + ArgValidator.requireValue(id); + id = id.toUpperCase(); + if (id.startsWith("MINECRAFT:")) id = id.substring(10); + ArgValidator.requireNotEmpty(id); + return id; + } + + /** + * @param id Entity ID such as "pig" or "minecraft:pig" + * @return Remapped normalized id if there is one, otherwise same as calling {@link #normalizeId(String)} + */ + public static String normalizeAndRemapId(String id) { + final String idNorm = normalizeId(id); + return ID_REMAP.getOrDefault(idNorm, idNorm); + } + + /** + * Registers a creator for one or more entity id's. If there is already a creator registered for the id + * it is silently replaced. There is no need to list all current and legacy id's, only the current ones. + * This function will also register the given creator for all mapped legacy id's which do not already + * have a creator associated. + * @param creator Entity creator + * @param entityId One or more entity id's. ID matching is performed case-insensitive and any "minecraft:" + * prefixes are stripped (therefore do not need to be included). + * @see #registerIdRemap(String, String) + */ + public static void registerCreator(EntityCreator creator, String... entityId) { + if (creator == null) throw new IllegalArgumentException("creator must not be null"); + for (String id : entityId) { + String idNorm = normalizeId(id); + ENTITY_CREATORS_BY_ID.put(idNorm, creator); + for (String legacyId : reverseIdRemap(idNorm)) { + ENTITY_CREATORS_BY_ID.putIfAbsent(legacyId, creator); + } + } + } + + /** + * Creates and initializes an entity from the given information. + * @param tag must not be null; must contain an "id" tag representation of the entity's ID + * @param dataVersion chunk data version to pass along to the creator + * @return new entity object; never null + * @throws IllegalEntityTagException if the creator failed to create an instance (returned null) + * or threw any other type of exception. Note that the offending tag is captured by this exception type. + * @throws IllegalStateException if the entity produced by the creator does not have an ID + */ + public static Entity create(CompoundTag tag, int dataVersion) { + if (tag == null) throw new IllegalArgumentException("tag must not be null"); + String idRaw = tag.getString("id", null); + String idNorm = normalizeId(idRaw); + String idPreferredNorm = ID_REMAP.getOrDefault(idNorm, idNorm); + EntityCreator creator = ENTITY_CREATORS_BY_ID.getOrDefault(idNorm, DEFAULT_ENTITY_CREATOR); + Entity entity; + try { + entity = creator.create(idPreferredNorm, tag, dataVersion); + } catch (IllegalEntityTagException ex) { + throw ex; + } catch (Exception ex) { + throw new IllegalEntityTagException(tag, ex); + } + if (entity == null) { + throw new IllegalEntityTagException(tag, String.format( + "creator %s for %s returned null (it should throw IllegalEntityTagException itself, but didn't)", + creator.getClass().getSimpleName(), + idNorm)); + } + if (entity.getId() == null) { + throw new IllegalStateException(String.format( + "Creator '%s' messed up! Normalized ID used was '%s' created from id '%s' read from nbt " + + "but entity produced by creator did not set an id!", + creator.getClass().getSimpleName(), + idNorm, + idRaw)); + } + return entity; + } + + /** + * Use this method when you know the return type - for example if you have your own base class and have + * reconfigured this factory with creators which always return that base. + * Any casting exceptions which result will be thrown from the call site - not from within this function. + * @see #create(CompoundTag, int) + */ + @SuppressWarnings("unchecked") + public static T createAutoCast(CompoundTag tag, int dataVersion) { + return (T) create(tag, dataVersion); + } + + /** + * Convenience function to populate, or create then populate, a {@code ListTag<CompoundTag>} given a + * {@code List<Entity>}. + *

Calls {@link Entity#updateHandle()} and adds the result to the returned {@link CompoundTag}.

+ * + * @param entities List of entities. This list must not contain the same entity instance multiple times. + * @param entitiesTag Optional, if provided this ListTag is cleared then filled with Compound tags from the + * given entities. If this argument is null, then a new ListTag will be created and populated. + * @return The given entitiesTag or a new ListTag if entitiesTag was null + * @throws IllegalArgumentException Thrown if entities contains the same Entity instance more than once. + */ + public static ListTag toListTag(List entities, ListTag entitiesTag) { + Set> seen = new HashSet<>(); + if (entitiesTag == null) entitiesTag = new ListTag<>(CompoundTag.class, entities.size()); + else entitiesTag.clear(); + for (T entity : entities) { + if (seen.add(IdentityHelper.of(entity))) { + entitiesTag.add(entity.updateHandle()); + } else { + throw new IllegalArgumentException("entities list contained the same entity multiple times"); + } + } + return entitiesTag; + } + + /** + * Convenience function to create then populate a {@code ListTag<CompoundTag>} given a {@code List<Entity>}. + * @see #toListTag(List, ListTag) + */ + public static ListTag toListTag(List entities) { + return toListTag(entities, null); + } + + /** + * Convenience function to create then populate a {@code List<Entity>} given a {@code ListTag<CompoundTag>}. + */ + @SuppressWarnings("unchecked") + public static List fromListTag(ListTag entitiesTag, int dataVersion) { + List entities = new ArrayList<>(); + for (CompoundTag entityTag : entitiesTag) { + entities.add((T) EntityFactory.create(entityTag, dataVersion)); + } + return entities; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/entities/EntityUtil.java b/src/main/java/io/github/ensgijs/nbt/mca/entities/EntityUtil.java new file mode 100644 index 00000000..5b1f1f95 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/entities/EntityUtil.java @@ -0,0 +1,103 @@ +package io.github.ensgijs.nbt.mca.entities; + +import io.github.ensgijs.nbt.mca.DataVersion; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.util.ArgValidator; + +import java.util.*; + +/** + * Collection of helper methods for working with entity NBT data. + */ +public final class EntityUtil { + private EntityUtil() {} + + public static final UUID ZERO_UUID = new UUID(0, 0); + + /** + * May return null if tag does not contain expected UUID fields or contains ZERO UUID value. + */ + public static UUID getUuid(int dataVersion, CompoundTag tag) { + ArgValidator.requireValue(tag); + long most; + long least; + if (dataVersion >= DataVersion.JAVA_1_16_20W12A.id()) { + int[] bits = tag.getIntArray("UUID"); + if (bits == null || bits.length != 4) return null; + most = ((long)bits[0] << 32) | ((long)bits[1] & 0xFFFF_FFFFL); + least = ((long)bits[2] << 32) | ((long)bits[3] & 0xFFFF_FFFFL); + } else { + most = tag.getLong("UUIDMost"); + least = tag.getLong("UUIDLeast"); + } + if (most != 0 || least != 0) { + return new UUID(most, least); + } else { + return null; + } + } + + /** + * @param dataVersion controls tag format where 1.16+ stores an int array + * and lesser versions store "most" and "least" longs + * @param tag not null + * @param uuid not null, not ZERO_UUID value + */ + public static void setUuid(int dataVersion, CompoundTag tag, UUID uuid) { + ArgValidator.requireValue(tag, "tag"); + ArgValidator.requireValue(uuid, "uuid"); + ArgValidator.check(!ZERO_UUID.equals(uuid), "zero uuid"); + long most = uuid.getMostSignificantBits(); + long least = uuid.getLeastSignificantBits(); + if (dataVersion >= DataVersion.JAVA_1_16_20W12A.id()) { + int[] bits = new int[4]; + bits[0] = (int) (most >> 32); + bits[1] = (int) (most & 0xFFFF_FFFFL); + bits[2] = (int) (least >> 32); + bits[3] = (int) (least & 0xFFFF_FFFFL); + tag.putIntArray("UUID", bits); + } else { + tag.putLong("UUIDMost", most); + tag.putLong("UUIDLeast", least); + } + } + + /** + * Removes all UUID value tags from the given tag (for all possible tag names across all version). + * @param tag Tag to modify. + * @return Given tag. + */ + public static CompoundTag removeUuid(CompoundTag tag) { + tag.remove("UUID"); + tag.remove("UUIDMost"); + tag.remove("UUIDLeast"); + return tag; + } + + /** + * Normalizes the given yaw to be in the range [0..360) by removing excessive rotations. + */ + public static float normalizeYaw(float yaw) { + yaw = yaw % 360; + if (yaw < 0) yaw += 360; + return yaw; + } + + /** + * Normalizes the given yaw to be in the range [0..360) by removing excessive rotations. + */ + public static float normalizeYaw(double yaw) { + yaw = yaw % 360; + if (yaw < 0) yaw += 360; + return (float) yaw; + } + + /** + * Clamps the given pitch to to [-90..90] + */ + public static float clampPitch(float pitch) { + if (pitch < -90f) return -90f; + if (pitch > 90f) return 90f; + return pitch; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/entities/IllegalEntityTagException.java b/src/main/java/io/github/ensgijs/nbt/mca/entities/IllegalEntityTagException.java new file mode 100644 index 00000000..322b3ce1 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/entities/IllegalEntityTagException.java @@ -0,0 +1,32 @@ +package io.github.ensgijs.nbt.mca.entities; + +import io.github.ensgijs.nbt.tag.Tag; + +public class IllegalEntityTagException extends IllegalArgumentException { + private final Tag tag; + + public IllegalEntityTagException(Tag tag) { + super(); + this.tag = tag; + } + + public IllegalEntityTagException(Tag tag, String message) { + super(message); + this.tag = tag; + } + + public IllegalEntityTagException(Tag tag, String message, Throwable cause) { + super(message, cause); + this.tag = tag; + } + + public IllegalEntityTagException(Tag tag, Throwable cause) { + super(cause); + this.tag = tag; + } + + /** Gets the tag which caused this exception to be raised. */ + public Tag getTag() { + return tag; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/io/CorruptMcaFileException.java b/src/main/java/io/github/ensgijs/nbt/mca/io/CorruptMcaFileException.java new file mode 100644 index 00000000..0de9d07e --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/io/CorruptMcaFileException.java @@ -0,0 +1,21 @@ +package io.github.ensgijs.nbt.mca.io; + +import java.io.IOException; + +public class CorruptMcaFileException extends IOException { + public CorruptMcaFileException() { + super(); + } + + public CorruptMcaFileException(String message) { + super(message); + } + + public CorruptMcaFileException(String message, Throwable cause) { + super(message, cause); + } + + public CorruptMcaFileException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/io/LoadFlags.java b/src/main/java/io/github/ensgijs/nbt/mca/io/LoadFlags.java new file mode 100644 index 00000000..9d48da1b --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/io/LoadFlags.java @@ -0,0 +1,78 @@ +package io.github.ensgijs.nbt.mca.io; + +import io.github.ensgijs.nbt.mca.TerrainSectionBase; +import io.github.ensgijs.nbt.mca.ChunkBase; + +/** + * Bitfield flags used to control mca data loading. Use logical OR to combine values such as + *
{@code long loadFlags = BIOMES | HEIGHTMAPS | RELEASE_CHUNK_DATA_TAG;}
+ *

If you define your own {@link ChunkBase} implementations and wish to use custom flags + * define them in the range of the reserved byte masks {@link #RESERVE_MASK_FOR_USER_LOAD_FLAGS_DEFAULT_ON} + * and {@link #RESERVE_MASK_FOR_USER_LOAD_FLAGS_DEFAULT_OFF}

+ */ +public final class LoadFlags { + private LoadFlags() {} + + public static final long BIOMES = 0x0000_0001; + public static final long HEIGHTMAPS = 0x0000_0002; + public static final long CARVING_MASKS = 0x0000_0004; + public static final long ENTITIES = 0x0000_0008; + public static final long TILE_ENTITIES = 0x0000_0010; + public static final long TILE_TICKS = 0x0000_0040; + public static final long LIQUID_TICKS = 0x0000_0080; + public static final long TO_BE_TICKED = 0x0000_0100; + public static final long POST_PROCESSING = 0x0000_0200; + public static final long STRUCTURES = 0x0000_0400; + public static final long BLOCK_LIGHTS = 0x0000_0800; + public static final long BLOCK_STATES = 0x0000_1000; + public static final long SKY_LIGHT = 0x0000_2000; + public static final long LIGHTS = 0x0000_4000; + public static final long LIQUIDS_TO_BE_TICKED = 0x0000_8000; + public static final long POI_RECORDS = 0x0002_0000; + // For fields such as below_zero_retrogen and blending_data which were added to support chunk migration to 1.18 + public static final long WORLD_UPGRADE_HINTS = 0x0004_0000; + + /** Flags within this byte mask are reserved for custom flags which can be defined by users of this NBT library. + *

This mask has space for 15 flags.

+ *

Note that flags in this range are DEFAULT ENABLED by {@link #LOAD_ALL_DATA}

*/ + public static final long RESERVE_MASK_FOR_USER_LOAD_FLAGS_DEFAULT_ON = 0x0000_FFFF_0000_0000L; + /** Flags within this byte mask are reserved for custom flags which can be defined by users of this NBT library. + *

This mask has space for 7 flags.

+ *

Note that flags in this range are DEFAULT DISABLED by {@link #LOAD_ALL_DATA}.

*/ + public static final long RESERVE_MASK_FOR_USER_LOAD_FLAGS_DEFAULT_OFF = 0x00FF_0000_0000_0000L; + + // high byte reserved for behavioral flags that follow + public static final long LOAD_ALL_DATA = 0x0000_FFFF_FFFF_FFFFL; + + /** + * When set {@link ChunkBase#data} will be nulled out after {@link ChunkBase#initReferences} has completed. + * This will allow garbage collection the chance to free memory you're only interested in biome data for example. + *

However, even if you have specified {@link #LOAD_ALL_DATA}, this will also cause non-vanilla tags + * (or very new vanilla tags this library doesn't yet support) to also be discarded should you wish to write + * the data back out. Also if you did not specify {@link #LOAD_ALL_DATA} and you release the chunk data tag + * and you write the chunk back out you will get a very reduced, incomplete, output containing only data + * as specified by the given load flags.

+ *

{@link TerrainSectionBase} also honors this flag.

+ *

Note that if {@link #RAW} is specified setting this flag has no effect!

+ *

Note if you set this flag you will not be able to call {@link ChunkBase#updateHandle()}. + * This behavior may change in the future but for now it's the safe option to prevent overwriting + * an mca file with partial contents.

+ */ + public static final long RELEASE_CHUNK_DATA_TAG = 0x4000_0000_0000_0000L; + + /** + * Setting the RAW bit causes all other flag settings to be ignored and for only {@link ChunkBase#data} + * and {@link ChunkBase#dataVersion} to be populated. {@link ChunkBase#initReferences(long)} will NOT be + * called and therefore any child classes of {@link ChunkBase} will also not have a chance to perform + * any tag processing. + */ + public static final long RAW = 0x8000_0000_0000_0000L; + + public static String toHexString(long flags) { + return String.format("0x%04X_%04X_%04X_%04X", + flags >>> 48, + (flags >>> 32) & 0xFFFF, + (flags >>> 16) & 0xFFFF, + flags & 0xFFFF); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/io/McaFileChunkIterator.java b/src/main/java/io/github/ensgijs/nbt/mca/io/McaFileChunkIterator.java new file mode 100644 index 00000000..f42c128e --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/io/McaFileChunkIterator.java @@ -0,0 +1,215 @@ +package io.github.ensgijs.nbt.mca.io; + +import io.github.ensgijs.nbt.mca.ChunkBase; +import io.github.ensgijs.nbt.mca.EntitiesChunk; +import io.github.ensgijs.nbt.mca.PoiChunk; +import io.github.ensgijs.nbt.mca.TerrainChunk; +import io.github.ensgijs.nbt.io.PositionTrackingInputStream; +import io.github.ensgijs.nbt.mca.util.ChunkIterator; +import io.github.ensgijs.nbt.mca.util.IntPointXZ; + +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.IntBuffer; +import java.util.*; +import java.util.function.Supplier; + +/** + * Iterates over the chunks in an MCA file. Note iteration is in file-order, not index-order! + * Chunks which do not exist in the file are skipped - {@link #next()} will never return null. + *

Remember to call {@link #close()}

+ * @see McaFileHelpers + * @see McaFileStreamingWriter + */ +public class McaFileChunkIterator implements ChunkIterator, Closeable { + private final Supplier chunkCreator; + private final IntPointXZ chunkAbsXzOffset; + private final PositionTrackingInputStream in; + private final long loadFlags; + private final List chunkMetaInfos; + private final Iterator iter; + private final IntPointXZ regionXZ; + private ChunkMetaInfo current; + + /** + * This map controls the factory creation behavior of creating new chunk instances which then have their + * {@link ChunkBase#deserialize(InputStream, long, int, int, int)} method called to initialize the chunk data. + *

By manipulating this map you can control the factory behavior to support new chunk types not natively + * supported by this library or to specify that a custom creation method should be called which could even + * return a custom {@link ChunkBase} implementation.

+ *

The default mapping routes "region", "poi", and "entities" to {@link TerrainChunk#TerrainChunk()}, + * {@link PoiChunk#PoiChunk()}, and {@link EntitiesChunk#EntitiesChunk()}.

+ */ + public static final Map> DEFAULT_CHUNK_CREATORS = new HashMap<>(); + + static { + DEFAULT_CHUNK_CREATORS.put("region", TerrainChunk::new); + DEFAULT_CHUNK_CREATORS.put("poi", PoiChunk::new); + DEFAULT_CHUNK_CREATORS.put("entities", EntitiesChunk::new); + } + + @SuppressWarnings("unchecked") + public static McaFileChunkIterator iterate(File file, long loadFlags) throws IOException { + Supplier chunkCreator = (Supplier) DEFAULT_CHUNK_CREATORS.get(file.getParentFile().getName()); + return new McaFileChunkIterator<>( + chunkCreator, + McaFileHelpers.regionXZFromFileName(file.getName()), + new BufferedInputStream(new FileInputStream(file)), + loadFlags + ); + } + + public static McaFileChunkIterator iterate(File file, long loadFlags, Supplier chunkCreator) throws IOException { + return new McaFileChunkIterator<>( + chunkCreator, + McaFileHelpers.regionXZFromFileName(file.getName()), + new BufferedInputStream(new FileInputStream(file)), + loadFlags + ); + } + + + @SuppressWarnings("unchecked") + public static McaFileChunkIterator iterate(InputStream stream, String fileName, long loadFlags) throws IOException { + Supplier chunkCreator = (Supplier) DEFAULT_CHUNK_CREATORS.get(new File(fileName).getParentFile().getName()); + return new McaFileChunkIterator<>( + chunkCreator, + McaFileHelpers.regionXZFromFileName(fileName), + stream, + loadFlags + ); + } + + /** + * @param chunkCreator Supplies new instances of type {@link T} + * @param regionXZ Region XZ location, in region coordinates such as found a filename such as r.1.2.mca + * @param stream Stream to read MCA data from. The stream should be positioned at the start of the mca file. + * @param loadFlags {@link LoadFlags} to use when loading the chunks. + * @throws IOException upon any read errors. + * @see McaFileHelpers#regionXZFromFileName(String) + */ + public McaFileChunkIterator(Supplier chunkCreator, IntPointXZ regionXZ, InputStream stream, long loadFlags) throws IOException { + this.chunkCreator = Objects.requireNonNull(chunkCreator); + this.regionXZ = regionXZ; + this.chunkAbsXzOffset = regionXZ.transformRegionToChunk(); + this.in = new PositionTrackingInputStream(stream); + this.loadFlags = loadFlags; + final int[] offsets = new int[1024]; + final int[] sectors = new int[1024]; + + ByteBuffer byteBuffer = ByteBuffer.allocate(4096); + byteBuffer.order(ByteOrder.BIG_ENDIAN); + IntBuffer intBuffer = byteBuffer.asIntBuffer(); + + // read offsets - but if the file is empty don't throw + if (4096 != in.read(byteBuffer.array()) && this.in.pos() != 0) { + throw new EOFException(); + } + int populatedChunks = 0; + for (int i = 0; i < 1024; i++) { + int glob = intBuffer.get(i); + offsets[i] = glob >>> 8; + sectors[i] = glob & 0xFF; + populatedChunks++; + } + chunkMetaInfos = new ArrayList<>(populatedChunks); + + // read timestamps - but if the file is empty don't throw + if (4096 != in.read(byteBuffer.array()) && this.in.pos() != 0) { + throw new EOFException(); + } + for (int i = 0; i < 1024; i++) { + if (offsets[i] > 0) { + chunkMetaInfos.add(new ChunkMetaInfo(i, offsets[i], sectors[i], intBuffer.get(i))); + } + } + chunkMetaInfos.sort(Comparator.comparingInt(e -> e.offset)); + iter = chunkMetaInfos.iterator(); + } + + public IntPointXZ chunkAbsXzOffset() { + return chunkAbsXzOffset; + } + + public IntPointXZ regionXZ() { + return regionXZ; + } + + @Override + public boolean hasNext() { + return iter.hasNext(); + } + + @Override + public T next() { + current = iter.next(); + try { + in.setSoftEof(0); + in.skipTo(4096L * current.offset + 4); //+4 skip chunk byte count + in.setSoftEof(4096L * (current.offset + current.sectors)); + T currentChunk = chunkCreator.get(); + currentChunk.deserialize(in, loadFlags, current.timestamp, currentAbsoluteX(), currentAbsoluteZ()); + return currentChunk; + } catch (IOException ex) { + throw new RuntimeException("Error processing " + current, ex); + } + } + + @Override + public void set(T chunk) { + throw new UnsupportedOperationException(); + } + + @Override + public int currentIndex() { + if (current == null) throw new NoSuchElementException(); + return current.index; + } + + @Override + public int currentAbsoluteX() { + if (current == null) throw new NoSuchElementException(); + return chunkAbsXzOffset.getX() + currentX(); + } + + @Override + public int currentAbsoluteZ() { + if (current == null) throw new NoSuchElementException(); + return chunkAbsXzOffset.getZ() + currentZ(); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + for (ChunkMetaInfo cmi : chunkMetaInfos) { + if (sb.length() > 0) sb.append('\n'); + sb.append(cmi); + } + return sb.toString(); + } + + @Override + public void close() throws IOException { + in.close(); + } + + private static class ChunkMetaInfo { + public final int index; + public final int offset; + public final int sectors; + public final int timestamp; + + public ChunkMetaInfo(int index, int offset, int sectors, int timestamp) { + this.index = index; + this.offset = offset; + this.sectors = sectors; + this.timestamp = timestamp; + } + + @Override + public String toString() { + return "index: " + index + "; offset: " + offset + "; sectors: " + sectors + "; timestamp: " + timestamp; + } + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/io/McaFileHelpers.java b/src/main/java/io/github/ensgijs/nbt/mca/io/McaFileHelpers.java new file mode 100644 index 00000000..2e19275a --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/io/McaFileHelpers.java @@ -0,0 +1,574 @@ +package io.github.ensgijs.nbt.mca.io; + +import io.github.ensgijs.nbt.io.CompressionType; +import io.github.ensgijs.nbt.mca.McaEntitiesFile; +import io.github.ensgijs.nbt.mca.McaFileBase; +import io.github.ensgijs.nbt.mca.McaPoiFile; +import io.github.ensgijs.nbt.mca.McaRegionFile; +import io.github.ensgijs.nbt.mca.util.IntPointXZ; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Provides utility functions to read and write .mca files and to convert block, chunk, and region coordinates. + * @see McaFileChunkIterator + * @see McaFileStreamingWriter + */ +public final class McaFileHelpers { + + private McaFileHelpers() {} + + private static final Pattern MCA_FILE_PATTERN = Pattern.compile("^.*\\.(?-?\\d+)\\.(?-?\\d+)\\.mca$"); + private static final Predicate IS_VALID_MCA_FILE_NAME_TESTER = MCA_FILE_PATTERN.asPredicate(); + + /** + * This map controls the factory creation behavior of the various "auto" functions. When an auto function is + * given an mca file location the folder name which contains the .mca files is used to lookup the correct + * mca file creator from this map. For example, if an auto method were passed the path + * "foo/bar/creator_name/r.4.2.mca" it would call {@code MCA_CREATORS.get("creator_name").apply(4, 2)}. + *

By manipulating this map you can control the factory behavior to support new mca types or to specify + * that a custom creation method should be called which could even return a custom {@link McaFileBase} + * implementation.

+ */ + public static final Map>> MCA_CREATORS = new HashMap<>(); + + static { + MCA_CREATORS.put("region", McaRegionFile::new); + MCA_CREATORS.put("poi", McaPoiFile::new); + MCA_CREATORS.put("entities", McaEntitiesFile::new); + } + + // + /** + * @see McaFileHelpers#read(File) + * @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. + * @deprecated switch to {@link #readAuto(String)} + */ + @Deprecated + public static McaRegionFile read(String file) throws IOException { + return read(new File(file), LoadFlags.LOAD_ALL_DATA); + } + + /** + * Reads an MCA file and loads all of its chunks. + * @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. + * @deprecated switch to {@link #readAuto(File)} + */ + @Deprecated + public static McaRegionFile read(File file) throws IOException { + return read(file, LoadFlags.LOAD_ALL_DATA); + } + + /** + * @see McaFileHelpers#read(File, long) + * @param file The file to read the data from. + * @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. + * @deprecated switch to {@link #readAuto(String, long)} + */ + @Deprecated + public static McaRegionFile read(String file, long loadFlags) throws IOException { + return read(new File(file), loadFlags); + } + + /** + * Reads an MCA file and loads all of its chunks. + * @param file The file to read the data from. + * @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. + * @deprecated switch to {@link #readAuto(File, long)} + */ + @Deprecated + public static McaRegionFile read(File file, long loadFlags) throws IOException { + IntPointXZ xz = regionXZFromFileName(file.getName()); + McaRegionFile mcaFile = new McaRegionFile(xz.getX(), xz.getZ()); + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + mcaFile.deserialize(raf, loadFlags); + return mcaFile; + } + } + + // + + // + + public static McaPoiFile readPoi(String file) throws IOException { + return readPoi(new File(file), LoadFlags.LOAD_ALL_DATA); + } + + public static McaPoiFile readPoi(Path file) throws IOException { + return readPoi(file.toFile(), LoadFlags.LOAD_ALL_DATA); + } + + public static McaPoiFile readPoi(File file) throws IOException { + return readPoi(file, LoadFlags.LOAD_ALL_DATA); + } + + public static McaPoiFile readPoi(String file, long loadFlags) throws IOException { + return readPoi(new File(file), loadFlags); + } + + public static McaPoiFile readPoi(Path file, long loadFlags) throws IOException { + return readPoi(file.toFile(), loadFlags); + } + + public static McaPoiFile readPoi(File file, long loadFlags) throws IOException { + IntPointXZ xz = regionXZFromFileName(file.getName()); + McaPoiFile mcaFile = new McaPoiFile(xz.getX(), xz.getZ()); + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + mcaFile.deserialize(raf, loadFlags); + return mcaFile; + } + } + + // + + // + + public static McaEntitiesFile readEntities(String file) throws IOException { + return readEntities(new File(file), LoadFlags.LOAD_ALL_DATA); + } + + public static McaEntitiesFile readEntities(Path file) throws IOException { + return readEntities(file.toFile(), LoadFlags.LOAD_ALL_DATA); + } + + public static McaEntitiesFile readEntities(File file) throws IOException { + return readEntities(file, LoadFlags.LOAD_ALL_DATA); + } + + public static McaEntitiesFile readEntities(String file, long loadFlags) throws IOException { + return readEntities(new File(file), loadFlags); + } + + public static McaEntitiesFile readEntities(Path file, long loadFlags) throws IOException { + return readEntities(file.toFile(), loadFlags); + } + + public static McaEntitiesFile readEntities(File file, long loadFlags) throws IOException { + IntPointXZ xz = regionXZFromFileName(file.getName()); + McaEntitiesFile mcaFile = new McaEntitiesFile(xz.getX(), xz.getZ()); + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + mcaFile.deserialize(raf, loadFlags); + return mcaFile; + } + } + + // + + // + + /** + * @see McaFileHelpers#readAuto(File) + * @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 > T readAuto(String file) throws IOException { + return readAuto(new File(file), LoadFlags.LOAD_ALL_DATA); + } + + /** + * Reads an MCA file and loads all of its chunks. + * @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 > T readAuto(File file) throws IOException { + return readAuto(file, LoadFlags.LOAD_ALL_DATA); + } + + /** + * Reads an MCA file and loads all of its chunks. + * @param path The path 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 > T readAuto(Path path) throws IOException { + return readAuto(path, LoadFlags.LOAD_ALL_DATA); + } + + /** + * @see McaFileHelpers#readAuto(File, long) + * @param file The file to read the data from. + * @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 > T readAuto(String file, long loadFlags) throws IOException { + return readAuto(new File(file), loadFlags); + } + + /** + * @see McaFileHelpers#readAuto(File, long) + * @param path The path to read the data from. + * @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 > T readAuto(Path path, long loadFlags) throws IOException { + return readAuto(path.toFile(), loadFlags); + } + + /** + * Reads an MCA file and loads all of its chunks. + * @param file The file to read the data from. + * @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 > T readAuto(File file, long loadFlags) throws IOException { + T mcaFile = autoMCAFile(file); + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + mcaFile.deserialize(raf, loadFlags); + return mcaFile; + } + } + + // + + // + + /** + * Calls {@link McaFileHelpers#write(McaFileBase, File, boolean)} without changing the timestamps. + * @see McaFileHelpers#write(McaFileBase, File, boolean) + * @param mcaFile The data of the MCA file to write. + * @param file The file to write to. + * @return The amount of chunks written to the file. + * @throws IOException If something goes wrong during serialization. + */ + public static int write(McaFileBase mcaFile, String file) throws IOException { + return write(mcaFile, new File(file), false); + } + + /** + * Calls {@link McaFileHelpers#write(McaFileBase, File, boolean)} without changing the timestamps. + * @see McaFileHelpers#write(McaFileBase, File, boolean) + * @param mcaFile The data of the MCA file to write. + * @param file The file to write to. + * @return The amount of chunks written to the file. + * @throws IOException If something goes wrong during serialization. + */ + public static int write(McaFileBase mcaFile, File file) throws IOException { + return write(mcaFile, file, false); + } + + /** + * Calls {@link McaFileHelpers#write(McaFileBase, File, boolean)} without changing the timestamps. + * @see McaFileHelpers#write(McaFileBase, File, boolean) + * @param mcaFile The data of the MCA file to write. + * @param path The file to write to. + * @return The amount of chunks written to the file. + * @throws IOException If something goes wrong during serialization. + */ + public static int write(McaFileBase mcaFile, Path path) throws IOException { + return write(mcaFile, path.toFile(), false); + } + + /** + * @see McaFileHelpers#write(McaFileBase, File, boolean) + * @param mcaFile The data of the MCA file to write. + * @param path The file to write to. + * @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(McaFileBase mcaFile, Path path, boolean changeLastUpdate) throws IOException { + return write(mcaFile, path.toFile(), changeLastUpdate); + } + + /** + * @see McaFileHelpers#write(McaFileBase, File, boolean) + * @param mcaFile The data of the MCA file to write. + * @param file The file to write to. + * @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(McaFileBase mcaFile, String file, boolean changeLastUpdate) throws IOException { + return write(mcaFile, new File(file), changeLastUpdate); + } + + /** + * Writes an {@code McaFileBase} object to disk. It optionally adjusts the timestamps + * when the file was last saved to the current date and time or leaves them at + * the value set by either loading an already existing MCA file or setting them manually.
+ * If the file already exists, it is completely overwritten by the new file (no modification). + * @param mcaFile The data of the MCA file to write. + * @param file The file to write to. + * @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(McaFileBase mcaFile, File file, boolean changeLastUpdate) throws IOException { + File to = file; + if (file.exists()) { + to = File.createTempFile(to.getName(), null); + to.deleteOnExit(); // attempt to make sure the temp file is cleaned up if we fail before we move it + } + int chunks; + try (RandomAccessFile raf = new RandomAccessFile(to, "rw")) { + chunks = mcaFile.serialize(raf, CompressionType.ZLIB, changeLastUpdate); + } + + // TODO(bug): This logic is flawed - why would we ever want an empty region file? + // Why only produce an empty region file if it didn't exist before? + // Shouldn't trying to write an empty region file be an error case the caller should know about? + // Should the (existing) region file be DELETED if we try to write an empty one? + // Proposal: always write to a temp file, if chunks is empty we throw a custom NoChunksWrittenIOException + // and leave the original alone. NoChunksWrittenIOException could be fully file aware and provide + // a utility function to delete the destination file - but this is a non-standard offering for exceptions, + // though it might make things cleaner for the caller. + if (chunks > 0 && to != file) { + Files.move(to.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + return chunks; + } + + //
+ + // + + /** + * Turns the chunks coordinates into region coordinates and calls + * {@link McaFileHelpers#createNameFromRegionLocation(int, int)} + * @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)); + } + + /** + * Turns the block coordinates into region coordinates and calls + * {@link McaFileHelpers#createNameFromRegionLocation(int, int)} + * @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 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"; + } + + public static String createNameFromRegionLocation(IntPointXZ regionXZ) { + return "r." + regionXZ.getX() + "." + regionXZ.getZ() + ".mca"; + } + + // + + // + + /** + * 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; + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * Turns an absolute block coordinate into a chunk relative one [0..15] + * @param block The absolute block coordinate. + * @return Block coordinate relative to its chunk. + */ + public static int blockAbsoluteToChunkRelative(int block) { + return block & 0xF; + } + + /** + * Turns an absolute block coordinate into a chunk relative one [0..16) + * @param block The absolute block coordinate. + * @return Block coordinate relative to its chunk. + */ + public static double blockAbsoluteToChunkRelative(double block) { + double bin = block % 16; + return bin >= 0 ? bin : 16 + bin; + } + + // + + /** + * Creates a {@link IntPointXZ} initialized with its X and Z extracted from the given file name. + * Ex. "r.1.-4.mca" returns XZ(1, -4) + */ + public static IntPointXZ regionXZFromFileName(String name) { + final Matcher m = MCA_FILE_PATTERN.matcher(name); + if (!m.find()) { + throw new IllegalArgumentException("invalid mca file name (expect name match '*...mca'): " + + name); + } + return new IntPointXZ(Integer.parseInt(m.group("regionX")), Integer.parseInt(m.group("regionZ"))); + } + + private static void throwCannotDetermineMcaType(Exception cause) { + throw new IllegalArgumentException( + "Unable to determine mca file type. Expect the mca file to have a parent folder with one of the following names: " + + MCA_CREATORS.keySet().stream().sorted().collect(Collectors.joining(", ")), cause); + } + + /** + * @see #autoMCAFile(Path) + * @see #autoMCAFile(String, Path) + */ + public static > T autoMCAFile(File file) { + return autoMCAFile(file.toPath()); + } + + /** + * Detects and creates a concretion (implementer) of {@link McaFileBase}. The actual type returned is determined by + * the name of the folder containing the .mca file. + *

Usage suggestion when the caller fully controls passed file name: + *

{@code McaEntitiesFile entitiesMca = McaFileHelpers.autoMCAFile(Paths.get("entities/r.0.0.mca"));}
+ *

Usage suggestion when the caller expects a specific return type but does not control the passed file name: + *

{@code try {
+	 *   McaEntitiesFile entitiesMca = McaFileHelpers.autoMCAFile(filename);
+	 * } catch (ClassCastException expected) {
+	 *   // got an unexpected type
+	 * }}
+ *

Usage suggestion when the caller may not know what return type to expect: + *

{@code McaFileBase mcaFile = McaFileHelpers.autoMCAFile(filename);
+	 * if (mcaFile instanceof McaRegionFile) {
+	 *   // process region mca file
+	 *   McaRegionFile regionMca = (McaRegionFile) mcaFile;
+	 * } else if (mcaFile instanceof McaPoiFile) {
+	 *   // process poi mca file
+	 *   McaPoiFile poiMca = (McaPoiFile) mcaFile;
+	 * } else if (mcaFile instanceof McaEntitiesFile) {
+	 *   // process entities mca file
+	 *   McaEntitiesFile entitiesMca = (McaEntitiesFile) mcaFile;
+	 * } else {
+	 *   // unsupported type / don't care about this type, etc.
+	 * }}
+ * + * @see #autoMCAFile(String, Path) + */ + public static > T autoMCAFile(Path path) { + return autoMCAFile(path.getParent().getFileName().toString(), path); + } + + /** + * Creates and initializes a new {@link McaFileBase} implementation. The actual type returned is determined by + * the value of useCreatorName, {@link #autoMCAFile(Path)} determines this value from the file path. + * However it is sometimes useful to force which creator to used, for example when reading an old region mca file + * with the new feature rich {@link McaEntitiesFile}. For a list of valid creator names query the keys of + * {@link #MCA_CREATORS} - the default list contains "region", "poi", and "entities". + * + * @param useCreatorName creator name to use to read the given file + * @param path The file does not need to exist but the given path must have at least 2 parts. + * Required parts: "mca_type/mca_file" + * where mca_type (such as "region", "poi", "entities") is used to determine which + * {@link #MCA_CREATORS} to call and mca_file is the .mca file such as "r.0.0.mca". + * @param {@link McaFileBase} type - do note that any {@link ClassCastException} errors will be thrown + * at the location of assignment, not from within this call. + * @return Instantiated and initialized concretion of {@link McaFileBase}. Never Null. + * @throws IllegalArgumentException Thrown when the mca type could not be determined from the path, when there + * is no {@link #MCA_CREATORS} mapped to the mca type, or when the regions X and Z locations could not be + * extracted from the filename. + * @throws NullPointerException Thrown when a custom creator did not produce a result. + * @see #autoMCAFile(Path) + */ + @SuppressWarnings("unchecked") + public static > T autoMCAFile(String useCreatorName, Path path) { + BiFunction> creator = null; + try { + creator = MCA_CREATORS.get(useCreatorName); + if (creator == null) throwCannotDetermineMcaType(null); + } catch (Exception ex) { + throwCannotDetermineMcaType(ex); + } + final Matcher m = MCA_FILE_PATTERN.matcher(path.getFileName().toString()); + if (!m.find()) { + throw new IllegalArgumentException("invalid mca file name (expect name match '*...mca'): " + path); + } + final int x = Integer.parseInt(m.group("regionX")); + final int z = Integer.parseInt(m.group("regionZ")); + T mcaFile = (T) creator.apply(x, z); + if (mcaFile == null) { + throw new NullPointerException("Creator for " + useCreatorName + " did not produce a result for " + path); + } + return mcaFile; + } + + public static boolean isValidMcaFileName(String fileName) { + return IS_VALID_MCA_FILE_NAME_TESTER.test(fileName); + } + + public static boolean isValidMcaFileName(File file) { + return IS_VALID_MCA_FILE_NAME_TESTER.test(file.getName()); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/io/McaFileStreamingWriter.java b/src/main/java/io/github/ensgijs/nbt/mca/io/McaFileStreamingWriter.java new file mode 100644 index 00000000..4822c783 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/io/McaFileStreamingWriter.java @@ -0,0 +1,129 @@ +package io.github.ensgijs.nbt.mca.io; + +import io.github.ensgijs.nbt.io.CompressionType; +import io.github.ensgijs.nbt.mca.ChunkBase; +import io.github.ensgijs.nbt.util.ArgValidator; +import io.github.ensgijs.nbt.util.Stopwatch; + +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.IntBuffer; +import java.nio.file.Path; + +/** + * Provides a streaming data sink for writing a region file. Chunks can be written in any order. + * Attempting to write a chunk (XZ) that has already been written will throw {@link IOException}. + *

You must remember to call {@link McaFileStreamingWriter#close()}! Close writes the file + * index and without this index the region will appear to contain no chunk data.

+ * @see McaFileHelpers + * @see McaFileChunkIterator + */ +public class McaFileStreamingWriter implements Closeable { + private static final byte[] ZERO_FILL_BUFFER = new byte[4096]; + private final int[] chunkSectors = new int[1024]; + private final int[] chunkTimestamps = new int[1024]; + private final RandomAccessFile raf; + private final Stopwatch fileInitializationStopwatch = Stopwatch.createUnstarted(); + private final Stopwatch totalWriteStopwatch = Stopwatch.createUnstarted(); + private final Stopwatch chunkSerializationStopwatch = Stopwatch.createUnstarted(); + private final Stopwatch fileCloseStopwatch = Stopwatch.createUnstarted(); + private int chunksWritten = 0; + private boolean fileInitialized = false; + private boolean fileFinalized = false; + + public McaFileStreamingWriter(RandomAccessFile raf) { + ArgValidator.requireValue(raf); + this.raf = raf; + } + public McaFileStreamingWriter(File file) throws IOException { + this(new RandomAccessFile(file, "rw")); + } + public McaFileStreamingWriter(String file) throws IOException { + this(new File(file)); + } + public McaFileStreamingWriter(Path path) throws IOException { + this(path.toFile()); + } + + public void write(ChunkBase chunk) throws IOException { + ArgValidator.requireValue(chunk); + if (!fileInitialized) { + try (Stopwatch.LapToken lap = fileInitializationStopwatch.startLap()) { + raf.setLength(0); + raf.seek(0); + // zero out the chunk sector and timestamp tables + raf.write(ZERO_FILL_BUFFER); + raf.write(ZERO_FILL_BUFFER); + fileInitialized = true; + } + } + try (Stopwatch.LapToken lap1 = totalWriteStopwatch.startLap()) { + if (chunk.getChunkX() == ChunkBase.NO_CHUNK_COORD_SENTINEL || chunk.getChunkZ() == ChunkBase.NO_CHUNK_COORD_SENTINEL) { + throw new IllegalArgumentException("Chunk XZ must be set!"); + } + final int index = chunk.getIndex(); + if (chunkSectors[index] != 0) + throw new IOException("Chunk " + chunk.getChunkXZ() + " (index: " + index + ") has already been written!"); + + if (raf.getFilePointer() % 4096 != 0) + throw new IllegalStateException(); + final int startSector = (int) (raf.getFilePointer() >> 12); + + int bytesWritten; + try (Stopwatch.LapToken lap2 = chunkSerializationStopwatch.startLap()) { + bytesWritten = chunk.serialize(raf, chunk.getChunkX(), chunk.getChunkZ(), CompressionType.ZLIB, true); + } + + // compute the count of 4kb sectors the chunk data occupies + int sectors = (bytesWritten >> 12) + (bytesWritten % 4096 == 0 ? 0 : 1); + if (sectors > 255) throw new IOException("Chunk " + chunk.getChunkXZ() + " to large! 1MB maximum"); + long roundedEof = ((long) (startSector + sectors) << 12); + while (roundedEof > raf.getFilePointer()) { + int gap = (int) Math.min(roundedEof - raf.getFilePointer(), ZERO_FILL_BUFFER.length); + raf.write(ZERO_FILL_BUFFER, 0, gap); + } + if (raf.getFilePointer() % 4096 != 0) + throw new IllegalStateException(); + chunkSectors[index] = (startSector << 8) | sectors; + chunkTimestamps[index] = chunk.getLastMCAUpdate(); + chunksWritten++; + } + } + + @Override + public void close() throws IOException { + if (fileFinalized) return; + try (Stopwatch.LapToken lap = fileCloseStopwatch.startLap()) { + raf.seek(0); + ByteBuffer byteBuffer = ByteBuffer.allocate(4096); + byteBuffer.order(ByteOrder.BIG_ENDIAN); + IntBuffer intBuffer = byteBuffer.asIntBuffer(); + intBuffer.put(chunkSectors); + raf.write(byteBuffer.array()); + intBuffer.clear(); + intBuffer.put(chunkTimestamps); + raf.write(byteBuffer.array()); + raf.close(); + fileFinalized = true; + } + } + + @Override + public String toString() { + return String.format("chunks written: %4d; timing[total %s; serialize %s; write %s; init %s; finalize %s]", + chunksWritten, + elapsed(), + chunkSerializationStopwatch, + totalWriteStopwatch.subtract(chunkSerializationStopwatch), + fileInitialized ? fileInitializationStopwatch : "n/a", + fileFinalized ? fileCloseStopwatch : "n/a"); + } + + /** + * @return A copy of a Stopwatch which represents the total time taken so far. + */ + public Stopwatch elapsed() { + return totalWriteStopwatch.add(fileInitializationStopwatch, fileCloseStopwatch); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/io/MoveChunkFlags.java b/src/main/java/io/github/ensgijs/nbt/mca/io/MoveChunkFlags.java new file mode 100644 index 00000000..560dcadd --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/io/MoveChunkFlags.java @@ -0,0 +1,35 @@ +package io.github.ensgijs.nbt.mca.io; + +import io.github.ensgijs.nbt.mca.ChunkBase; + +/** + *
{@code
+ * import static MoveChunkFlags.*;
+ * }
+ */ +public final class MoveChunkFlags { + private MoveChunkFlags() {} + + public static final long MOVE_CHUNK_NO_FLAGS = 0; + + /** When true, and if move chunk made any data changes, {@link ChunkBase#updateHandle()} will be called. */ + public static final long AUTOMATICALLY_UPDATE_HANDLE = 0x0000_0001; + /** Entity UUID's will be randomized to avoid UUID collisions. */ + public static final long RANDOMIZE_ENTITY_UUID = 0x0000_0002; + /** If the (terrain) chunk contains references to structures outside the new chunk's region file + * those references will be removed. If the chunk contains structure 'start' data, all bounding + * box's and other volumes will be restricted to values which fall inside the new region file. */ + public static final long DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION = 0x0000_0004; + + public static final long MOVE_CHUNK_DEFAULT_FLAGS = 0x0000_0000_FFFF_FFFFL; + + /** When set all structure references and start tags will be discarded. Any structures which have already + * been generated will still exist, but they won't behave any differently than if they were player built. + * E.g. structure spawns won't happen. */ + public static final long DISCARD_STRUCTURE_DATA = 0x0000_0004_0000_0000L; + + /** The "UpgradeData" tag often contains redundant tile-tick information and can cause spamming of + * errors such as {@code [WARN]: Neighbour tick .. serialized in chunk (..) is too far (..)} */ + public static final long DISCARD_UPGRADE_DATA = 0x0000_0008_0000_0000L; + +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/io/RandomAccessMcaFile.java b/src/main/java/io/github/ensgijs/nbt/mca/io/RandomAccessMcaFile.java new file mode 100644 index 00000000..f670e196 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/io/RandomAccessMcaFile.java @@ -0,0 +1,1031 @@ +package io.github.ensgijs.nbt.mca.io; + +import io.github.ensgijs.nbt.io.BinaryNbtSerializer; +import io.github.ensgijs.nbt.io.CompressionType; +import io.github.ensgijs.nbt.io.NamedTag; +import io.github.ensgijs.nbt.io.SilentIOException; +import io.github.ensgijs.nbt.mca.*; +import io.github.ensgijs.nbt.mca.util.ChunkIterator; +import io.github.ensgijs.nbt.mca.util.IntPointXZ; +import io.github.ensgijs.nbt.mca.util.RegionBoundingRectangle; +import io.github.ensgijs.nbt.util.ArgValidator; +import io.github.ensgijs.nbt.util.Stopwatch; + +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.IntBuffer; +import java.nio.file.Path; +import java.util.*; + +/** + * Provides random access read and write operations for working with MCA files. + * + *

This class can be used to modify exist or create new mca files.

+ * + *

Using this class instead of {@link io.github.ensgijs.nbt.mca.McaFileBase} saves memory at the cost of increased IO. + * This can be a good tradeoff when working with a large number of region files as each instance of + * {@link RandomAccessMcaFile} has a memory footprint of a bit over 8KB while {@link io.github.ensgijs.nbt.mca.McaFileBase} + * may use tens of MB per region file to load and keep all 1024 chunks in memory.

+ * + *

You must remember to call {@link RandomAccessMcaFile#close()}! This is especially true if you have written any + * chunk data - failing to call close may result in the mca file appearing to be empty to Minecraft and subsequent + * mca file reads (though will clearly be non-empty on disk) or more likely will make the mca file appear to + * have been corrupted. An mca file corrupted in this way could be recovered by skipping the file header (8kb) + * and scanning the file sections directly - this library does not provide such a recovery mechanism at this time.

+ * + *

Suggested usage pattern to ensure the file is always closed.

+ *
{@code
+ *      try(RandomAccessMcaFile ra = new RandomAccessMcaFile(file)){
+ *          ...
+ *      }
+ * }
+ * @param In truth, this class doesn't care what type of chunk it reads and writes - but being strict about + * which type of chunk is stored keeps users from shooting themselves in the foot. + */ +public class RandomAccessMcaFile implements Closeable, Iterable { + private static final byte[] ZERO_FILL_BUFFER = new byte[4096]; + private final Class chunkClass; + protected final int[] chunkSectors = new int[1024]; + protected final int[] chunkTimestamps = new int[1024]; + private final RegionBoundingRectangle regionBounds; + private final IntPointXZ regionXZ; + private final IntPointXZ regionChunkOffsetXZ; + private int chunksWritten; + private int chunksRead; + protected final RandomAccessFile raf; + protected final SectorManager sectorManager = new SectorManager(); + protected boolean fileInitialized = false; + protected boolean fileFinalized = false; + + protected long loadFlags = LoadFlags.LOAD_ALL_DATA; + protected boolean autoOptimizeOnClose = false; + protected boolean autoUpdateHandelOnWrite = true; + protected boolean alwaysUpdateChunkLastUpdatedTimestamp = true; + // TODO: use this flag to short-circuit file write operations if they are not necessary. + // Currently this flag is only ever set, never cleared. + protected boolean isDirty = false; // set true if any chunks were written or removed + protected final boolean isReadOnly; + + private final Stopwatch fileInitializationStopwatch = Stopwatch.createUnstarted(); + private final Stopwatch totalReadStopwatch = Stopwatch.createUnstarted(); + private final Stopwatch totalWriteStopwatch = Stopwatch.createUnstarted(); + private final Stopwatch chunkSerializationStopwatch = Stopwatch.createUnstarted(); + private final Stopwatch fileFlushStopwatch = Stopwatch.createUnstarted(); + private final Stopwatch fileOptimizationStopwatch = Stopwatch.createUnstarted(); + + + /** + * @param chunkClass class type to operate upon + * @param raf random access file object + * @param regionXZ XZ coords of the region data - usually as extracted from the file name such as [1 -2] from r.1.-2.mca + * @param mode one of the modes accepted by RandomAccessFile ("r", "rw" are the most common). This mode can be + * more restrictive than the mode the RandomAccessFile was actually opened with. Specifying a mode of + * "r" will cause all calls to {@link #write}, {@link #removeChunk}, and {@link #optimizeFile()} to + * fail with an exception and all calls to {@link #flush()} to silently do nothing. + */ + public RandomAccessMcaFile(Class chunkClass, RandomAccessFile raf, IntPointXZ regionXZ, String mode) { + this.chunkClass = ArgValidator.requireValue(chunkClass, "chunkClass"); + this.raf = ArgValidator.requireValue(raf, "RandomAccessFile"); + this.regionXZ = ArgValidator.requireValue(regionXZ, "regionXZ"); + this.regionChunkOffsetXZ = regionXZ.transformRegionToChunk(); + this.regionBounds = new RegionBoundingRectangle(regionXZ.getX(), regionXZ.getZ()); + this.isReadOnly = !mode.startsWith("rw"); + } + + /** + * @param chunkClass class type to operate upon + * @param file Mca file to open. The file name must follow the standard naming of "r.X.Z.mca". + * @param mode one of the modes accepted by RandomAccessFile ("r", "rw" are the most common). This mode can be + * more restrictive than the mode the RandomAccessFile was actually opened with. Specifying a mode of + * "r" will cause all calls to {@link #write} and {@link #optimizeFile()} to fail with an exception + * and all calls to {@link #flush()} to silently do nothing. + */ + public RandomAccessMcaFile(Class chunkClass, File file, String mode) throws IOException { + this(chunkClass, new RandomAccessFile(file, mode), McaFileHelpers.regionXZFromFileName(file.getName()), mode); + } + + /** + * @param chunkClass class type to operate upon + * @param file Mca file to open. The file name must follow the standard naming of "r.X.Z.mca". + * @param mode one of the modes accepted by RandomAccessFile ("r", "rw" are the most common). This mode can be + * more restrictive than the mode the RandomAccessFile was actually opened with. Specifying a mode of + * "r" will cause all calls to {@link #write} and {@link #optimizeFile()} to fail with an exception + * and all calls to {@link #flush()} to silently do nothing. + */ + public RandomAccessMcaFile(Class chunkClass, String file, String mode) throws IOException { + this(chunkClass, new File(file), mode); + } + + /** + * @param chunkClass class type to operate upon + * @param path Mca file to open. The file name must follow the standard naming of "r.X.Z.mca". + * @param mode one of the modes accepted by RandomAccessFile ("r", "rw" are the most common). This mode can be + * more restrictive than the mode the RandomAccessFile was actually opened with. Specifying a mode of + * "r" will cause all calls to {@link #write} and {@link #optimizeFile()} to fail with an exception + * and all calls to {@link #flush()} to silently do nothing. + */ + public RandomAccessMcaFile(Class chunkClass, Path path, String mode) throws IOException { + this(chunkClass, path.toFile(), mode); + } + + /** True if opened in read only mode. */ + public boolean isReadOnly() { + return isReadOnly; + } + + /** + * @return XZ coords of the region, in region coordinates. + */ + public IntPointXZ getRegionXZ() { + return regionXZ; + } + + /** LoadFlags which are passed to the chunk deserialization method. */ + public long getLoadFlags() { + return loadFlags; + } + + /** LoadFlags which are passed to the chunk deserialization method. */ + public RandomAccessMcaFile setLoadFlags(long loadFlags) { + this.loadFlags = loadFlags; + return this; + } + + /** + * Automatically call {@link #optimizeFile()} when {@link #close()} is called. + *

When set the mca file will be auto optimized (compacted) when {@link #close()} is called IFF any chunks + * were written or removed.

+ */ + public boolean isAutoOptimizeOnClose() { + return autoOptimizeOnClose; + } + + /** + * Automatically call {@link #optimizeFile()} when {@link #close()} is called. + *

When set the mca file will be auto optimized (compacted) when {@link #close()} is called IFF any chunks + * were written or removed.

+ */ + public RandomAccessMcaFile setAutoOptimizeOnClose(boolean autoOptimizeOnClose) { + this.autoOptimizeOnClose = autoOptimizeOnClose; + return this; + } + + /** + * When set calls to {@link #write} will automatically call {@link ChunkBase#updateHandle()} before writing to + * disk. If unset the library user is responsible for ensuring that the chunk handel is updated prior to calling + * {@link #write}. Note that mca last updated timestamp data is NOT stored in the chunk handle, it is part of the + * mca file header. + */ + public boolean isAutoUpdateHandelOnWrite() { + return autoUpdateHandelOnWrite; + } + + + /** + * When set calls to {@link #write} will automatically call {@link ChunkBase#updateHandle()} before writing to + * disk. If unset the library user is responsible for ensuring that the chunk handel is updated prior to calling + * {@link #write}. Note that mca last updated timestamp data is NOT stored in the chunk handle, it is part of the + * mca file header. + */ + public RandomAccessMcaFile setAutoUpdateHandelOnWrite(boolean autoUpdateHandelOnWrite) { + this.autoUpdateHandelOnWrite = autoUpdateHandelOnWrite; + return this; + } + + /** + * When set any call to {@link #write} will set the given chunks last modified timestamp by calling + * {@link ChunkBase#setLastMCAUpdate(int)} with the current system timestamp. + *

Note that if the chunk passed to write currently has no timestamp, one will be set irregardless of + * this setting.

+ */ + public boolean isAlwaysUpdateChunkLastUpdatedTimestamp() { + return alwaysUpdateChunkLastUpdatedTimestamp; + } + + /** + * When set any call to {@link #write} will set the given chunks last modified timestamp by calling + * {@link ChunkBase#setLastMCAUpdate(int)} with the current system timestamp. + *

Note that if the chunk passed to write currently has no timestamp, one will be set irregardless of + * this setting.

+ */ + public RandomAccessMcaFile setAlwaysUpdateChunkLastUpdatedTimestamp(boolean alwaysUpdateChunkLastUpdatedTimestamp) { + this.alwaysUpdateChunkLastUpdatedTimestamp = alwaysUpdateChunkLastUpdatedTimestamp; + return this; + } + + /** + * @return A diagnostic information string. + * @see #chunkSectorTableToString() + */ + @Override + public String toString() { + return String.format( + "region %s; %s; %s; initialized %s; finalized %s; chunks[written %d; read %d]; " + + "timing[init %s; read %s; serialize %s; write %s; optimize %s; flush %s]; " + + "settings[flags %s; auto-optimize %s; auto-update-handel %s; always-update-timestamp %s]; " + + "sector-manager[%s]", + regionXZ, + regionBounds.asChunkBounds(), + regionBounds.asBlockBounds(), + fileInitialized, + fileFinalized, + chunksWritten, + chunksRead, + fileInitialized ? fileInitializationStopwatch : "n/a", + fileInitialized ? totalReadStopwatch : "n/a", + fileInitialized ? chunkSerializationStopwatch : "n/a", + fileInitialized ? totalWriteStopwatch.subtract(chunkSerializationStopwatch) : "n/a", + fileInitialized ? fileOptimizationStopwatch : "n/a", + fileInitialized ? fileFlushStopwatch : "n/a", + LoadFlags.toHexString(loadFlags), + isAutoOptimizeOnClose(), + isAutoOptimizeOnClose(), + isAlwaysUpdateChunkLastUpdatedTimestamp(), + sectorManager); + } + + /** + * Creates a 32x32 textual table of chunk sector information. Useful for debugging this library as well as + * user code. Includes free-sector information. + */ + public String chunkSectorTableToString() throws IOException { + ensureFileInitialized(); + StringBuilder sb = new StringBuilder(); + // defaults set minimum lengths + int maxOffsetStrLen = 2; + int maxSizeStrLen = 1; + for (int i = 0; i < 1024; i++) { + SectorManager.SectorBlock sectorBlock = SectorManager.SectorBlock.unpack(chunkSectors[i]); + int offsetStrLen = String.format("%X", sectorBlock.start).length(); + int sizeStrLen = String.format("%X", sectorBlock.size).length(); + if (maxOffsetStrLen < offsetStrLen) maxOffsetStrLen = offsetStrLen; + if (maxSizeStrLen < sizeStrLen) maxSizeStrLen = sizeStrLen; + } + String sectorFormat = "%0" + maxOffsetStrLen + "X+%0" + maxSizeStrLen + "X "; + String noSector = "-".repeat(maxOffsetStrLen + maxSizeStrLen + 1) + " "; + String headerFormat = "%" + (maxOffsetStrLen + maxSizeStrLen) + "d "; + + sb.append(String.format( + " region %s; %s; %s\n", + regionXZ, + regionBounds.asChunkBounds(), + regionBounds.asBlockBounds())); + sb.append(" Z\\X"); + for (int i = 0; i < 32; i++) { + if (i != 0 && i % 8 == 0) { + sb.append(' '); + } + sb.append(String.format(headerFormat, i)); + } + sb.append('\n'); + + for (int i = 0; i < 1024; i++) { + if (i % 32 == 0) { + if (i > 0) sb.append('\n'); + sb.append(String.format("%2d: ", i / 32)); + } else if (i % 8 == 0) { + sb.append(' '); + } + SectorManager.SectorBlock sectorBlock = SectorManager.SectorBlock.unpack(chunkSectors[i]); + if (sectorBlock.size == 0) { + sb.append(noSector); + } else { + sb.append(sectorBlock.toString(sectorFormat)); + } + } + sb.append('\n'); + sb.append(sectorManager); + return sb.toString(); + } + + /** + * Forces initialization of chunk data. This class lazily loads the mca file header tables, touching the + * instance triggers this loading and is a no-op if already loaded. This method is useful mostly for debugging + * or for ensuring all on-close automatic actions are taken without needing to perform a chunk read/write. + * @return self for chaining. + */ + public RandomAccessMcaFile touch() throws IOException { + ensureFileInitialized(); + return this; + } + + /** Causes the mca file header tables to be read if they have not yet been read. */ + protected void ensureFileInitialized() throws IOException { + if (fileFinalized) throw new IOException("File closed!"); + if (!fileInitialized) { + try (Stopwatch.LapToken lap = fileInitializationStopwatch.startLap()) { + raf.seek(0); + final byte[] buffer = new byte[4096]; + if (raf.length() >= 4096 * 2) { // existing file + ByteBuffer bb = ByteBuffer.wrap(buffer); + raf.read(buffer); + bb.position(0); + bb.asIntBuffer().get(chunkSectors); + raf.read(buffer); + bb.position(0); + bb.asIntBuffer().get(chunkTimestamps); + } else if (!isReadOnly) { // new file, or it existed but was empty - MC seems to do that + // zero out the chunk sector and timestamp tables + raf.setLength(0); + raf.write(buffer); + raf.write(buffer); + } + sectorManager.sync(chunkSectors); + fileInitialized = true; + } + } + } + + /** + * Finalizes and closes this mca file. It's safe to call this method multiple times. + * @see #setAutoOptimizeOnClose(boolean) + * @see #optimizeFile() + */ + @Override + public void close() throws IOException { + if (fileFinalized) return; + try { + if (!isReadOnly && fileInitialized) { + if (isAutoOptimizeOnClose()) + optimizeFile(); + flush(); + } + } finally { + raf.close(); + sectorManager.freeSectors.clear(); + fileFinalized = true; + } + } + + /** + * Forces immediate write of the chunk index and timestamp tables (file header information). + * @see #touch() + */ + public void flush() throws IOException { + if (!fileInitialized || isReadOnly) + return; + if (fileFinalized) + throw new IOException("File closed!"); + try (Stopwatch.LapToken lap = fileFlushStopwatch.startLap()) { + raf.seek(0); + ByteBuffer byteBuffer = ByteBuffer.allocate(4096); + byteBuffer.order(ByteOrder.BIG_ENDIAN); + IntBuffer intBuffer = byteBuffer.asIntBuffer(); + intBuffer.put(chunkSectors); + raf.write(byteBuffer.array()); + intBuffer.clear(); + intBuffer.put(chunkTimestamps); + raf.write(byteBuffer.array()); + } + } + + /** + * Compacts the chunk data in the mca file by removing unused file sectors. This class will attempt to reuse any + * free space within the chunk data as you write chunks, there's no need to call this method except before/during + * close. + *

If you called {@link #removeChunk(int)} this is the method that will actually remove that chunk data from the + * file and cause the file to shrink in size. + * Note that there are other actions which can introduce unused sectors in the mca file - for example if you + * read, modify, and write a chunk in such a way that it takes more or less sectors to store this will also + * introduce unused space.

+ * @return Number of unused bytes that were removed from the file. The file is now this much smaller. + * @see #setAutoOptimizeOnClose(boolean) + */ + public int optimizeFile() throws IOException { + ensureFileInitialized(); + if (isReadOnly) + throw new IOException("File was opened in read-only mode."); + int bytesRemoved = 0; + try (Stopwatch.LapToken lap = fileOptimizationStopwatch.startLap()) { + bytesRemoved = sectorManager.optimizeFile(raf, chunkSectors); + } + return bytesRemoved; + } + + /** + * Marks the specified chunk for removal and makes its file sectors available for saving other chunks into. + *

Does not actually erase the chunk data in the mca file during this call - this is a very lightweight call.

+ * @return True if the chunk previously existed. + * @see #optimizeFile() + * @see #setAutoOptimizeOnClose(boolean) + */ + public boolean removeChunk(int chunkIndex) throws IOException { + if (isReadOnly) + throw new IOException("File was opened in read-only mode."); + if (hasChunk(chunkIndex)) { + isDirty = true; + sectorManager.release(SectorManager.SectorBlock.unpack(chunkSectors[chunkIndex])); + chunkSectors[chunkIndex] = 0; + chunkTimestamps[chunkIndex] = 0; + return true; + } + return false; + } + + /** + * Marks the specified chunk for removal and makes its file sectors available for saving other chunks into. + *

Does not actually erase the chunk data in the mca file during this call - this is a very lightweight call.

+ * @return True if the chunk previously existed. + * @see #optimizeFile() + * @see #setAutoOptimizeOnClose(boolean) + */ + public boolean removeChunkRelative(int x, int z) throws IOException { + if (x < 0 || x >= 32 || z < 0 || z >= 32) + throw new IndexOutOfBoundsException(); + return removeChunk(McaRegionFile.getChunkIndex(x, z)); + } + + /** + * Marks the specified chunk for removal and makes its file sectors available for saving other chunks into. + *

Does not actually erase the chunk data in the mca file during this call - this is a very lightweight call.

+ * @return True if the chunk previously existed. + * @see #optimizeFile() + * @see #setAutoOptimizeOnClose(boolean) + */ + public boolean removeChunkRelative(IntPointXZ xz) throws IOException { + return removeChunkRelative(xz.getX(), xz.getZ()); + } + + /** + * Marks the specified chunk for removal and makes its file sectors available for saving other chunks into. + *

Does not actually erase the chunk data in the mca file during this call - this is a very lightweight call.

+ * @return True if the chunk previously existed. + * @see #optimizeFile() + * @see #setAutoOptimizeOnClose(boolean) + */ + public boolean removeChunkAbsolute(int x, int z) throws IOException { + return this.regionBounds.containsChunk(x, z) && removeChunk(McaRegionFile.getChunkIndex(x, z)); + } + + /** + * Marks the specified chunk for removal and makes its file sectors available for saving other chunks into. + *

Does not actually erase the chunk data in the mca file during this call - this is a very lightweight call.

+ * @return True if the chunk previously existed. + * @see #optimizeFile() + * @see #setAutoOptimizeOnClose(boolean) + */ + public boolean removeChunkAbsolute(IntPointXZ xz) throws IOException { + return removeChunkAbsolute(xz.getX(), xz.getZ()); + } + + + /** @return True if the chunk exists. */ + public boolean hasChunk(int chunkIndex) throws IOException { + ensureFileInitialized(); + return (chunkSectors[chunkIndex] & 0xFF) > 0; + } + + /** @return True if the chunk exists. */ + public boolean hasChunkRelative(int x, int z) throws IOException { + if (x < 0 || x >= 32 || z < 0 || z >= 32) + throw new IndexOutOfBoundsException(); + return hasChunk(McaRegionFile.getChunkIndex(x, z)); + } + + /** @return True if the chunk exists. */ + public boolean hasChunkRelative(IntPointXZ xz) throws IOException { + return hasChunkRelative(xz.getX(), xz.getZ()); + } + + /** @return True if the chunk exists. */ + public boolean hasChunkAbsolute(int x, int z) throws IOException { + return this.regionBounds.containsChunk(x, z) && hasChunk(McaRegionFile.getChunkIndex(x, z)); + } + + /** @return True if the chunk exists. */ + public boolean hasChunkAbsolute(IntPointXZ xz) throws IOException { + return hasChunkAbsolute(xz.getX(), xz.getZ()); + } + + + /** @return Chunk timestamp, in epoch seconds, if chunk exists else -1. */ + public int getChunkTimestamp(int chunkIndex) throws IOException { + ensureFileInitialized(); + return hasChunk(chunkIndex) ? chunkTimestamps[chunkIndex] : -1; + } + + /** @return Chunk timestamp, in epoch seconds, if chunk exists else -1. */ + public int getChunkTimestampRelative(int x, int z) throws IOException { + if (x < 0 || x >= 32 || z < 0 || z >= 32) + throw new IndexOutOfBoundsException(); + return getChunkTimestamp(McaRegionFile.getChunkIndex(x, z)); + } + + /** @return Chunk timestamp, in epoch seconds, if chunk exists else -1. */ + public int getChunkTimestampRelative(IntPointXZ xz) throws IOException { + return getChunkTimestampRelative(xz.getX(), xz.getZ()); + } + + /** @return Chunk timestamp, in epoch seconds, if chunk exists else -1. */ + public int getChunkTimestampAbsolute(int x, int z) throws IOException { + return this.regionBounds.containsChunk(x, z) ? getChunkTimestamp(McaRegionFile.getChunkIndex(x, z)) : -1; + } + + /** @return Chunk timestamp, in epoch seconds, if chunk exists else -1. */ + public int getChunkTimestampAbsolute(IntPointXZ xz) throws IOException { + return getChunkTimestampAbsolute(xz.getX(), xz.getZ()); + } + + + /** + * Reads the specified chunk if it exists. + * @return The chunk if it exists, else null. + */ + public T read(int chunkIndex) throws IOException { + return read(chunkIndex, loadFlags); + } + + /** + * Reads the specified chunk if it exists. + * @return The chunk if it exists, else null. + */ + public T read(int chunkIndex, long loadFlags) throws IOException { + if (chunkIndex < 0 || chunkIndex >= 1024) + throw new IndexOutOfBoundsException(); + ensureFileInitialized(); + try (var lap = totalReadStopwatch.startLap()) { + int sectorOffset = chunkSectors[chunkIndex] >>> 8; + int sectorSize = chunkSectors[chunkIndex] & 0xFF; + if (sectorSize == 0) return null; + if (raf.length() < (sectorOffset + sectorSize) * 4096L) { + throw new EOFException(); + } + raf.seek(sectorOffset * 4096L); // +2 for the file header + int chunkByteSize = raf.readInt(); + if (chunkByteSize > (sectorSize * 4096) - 4) { + throw new CorruptMcaFileException(String.format( + "MCA file header sector size %d (%d bytes) for chunk %04d (at 0x%X) is too small to hold %d bytes!", + sectorSize, sectorSize * 4096, chunkIndex, sectorOffset * 4096L, chunkByteSize)); + } + + T chunk; + try { + chunk = chunkClass.getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException ex) { + // TODO should wrap with a custom chunk creation exception... + // given that this error is something exclusively under the control of the library user I'm OK(ish) with this hacky wrap and throw + throw new RuntimeException(ex); + } + IntPointXZ chunkXZ = McaRegionFile.getRelativeChunkXZ(chunkIndex).add(regionChunkOffsetXZ); + chunksRead ++; + chunk.deserialize(raf, loadFlags, chunkTimestamps[chunkIndex], chunkXZ.getX(), chunkXZ.getZ()); + return chunk; + } + } + + /** + * Reads the specified chunk if it exists. + * @return The chunk if it exists, else null. + */ + public T readRelative(int x, int z) throws IOException { + if (x < 0 || x >= 32 || z < 0 || z >= 32) + throw new IndexOutOfBoundsException(); + return read(McaFileBase.getChunkIndex(x, z)); + } + + /** + * Reads the specified chunk if it exists. + * @return The chunk if it exists, else null. + */ + public T readRelative(IntPointXZ xz) throws IOException { + return readRelative(xz.getX(), xz.getZ()); + } + + /** + * Reads the specified chunk if it exists. + * @return The chunk if it exists, else null. + */ + public T readAbsolute(int x, int z) throws IOException { + if (!this.regionBounds.containsChunk(x, z)) + throw new IndexOutOfBoundsException(); + return read(McaFileBase.getChunkIndex(x, z)); + } + + /** + * Reads the specified chunk if it exists. + * @return The chunk if it exists, else null. + */ + public T readAbsolute(IntPointXZ xz) throws IOException { + return readAbsolute(xz.getX(), xz.getZ()); + } + + /** + * Writes the given chunks. + * @param chunks not null and all chunks must exist within bounds of this region file. + * @see #removeChunk + */ + @SafeVarargs + public final void write(T... chunks) throws IOException { + for (T chunk : chunks) { + write(chunk); + } + } + + /** + * Writes the given chunk. + * @param chunk not null and chunk must exist within bounds of this region file. + * @see #removeChunk + */ + public void write(T chunk) throws IOException { + ArgValidator.requireValue(chunk); + if (isReadOnly) + throw new IOException("File was opened in read-only mode."); + if (chunk.getChunkX() == ChunkBase.NO_CHUNK_COORD_SENTINEL || chunk.getChunkZ() == ChunkBase.NO_CHUNK_COORD_SENTINEL) { + throw new IllegalArgumentException("Chunk XZ must be set!"); + } + if (!this.regionBounds.containsChunk(chunk.getChunkX(), chunk.getChunkZ())) + throw new IndexOutOfBoundsException(String.format( + "ChunkXZ(%s) does not exist within regionXZ(%s) inclusive bounds %s!", + chunk.getChunkXZ(), + regionXZ, + regionBounds.asChunkBounds())); + ensureFileInitialized(); + isDirty = true; + if (isAlwaysUpdateChunkLastUpdatedTimestamp() || chunk.getLastMCAUpdate() <= 0) { + chunk.setLastMCAUpdate((int) (System.currentTimeMillis() / 1000)); + } + + try (Stopwatch.LapToken lap1 = totalWriteStopwatch.startLap()) { + final int index = chunk.getIndex(); + final int oldSectorOffset = chunkSectors[index] >>> 8; + final int oldSectorSize = chunkSectors[index] & 0xFF; + ByteArrayOutputStream baos; + SectorManager.SectorBlock writeToSector; + int totalBytes; + final int newSectorSize; + chunksWritten ++; + + try (Stopwatch.LapToken lap2 = chunkSerializationStopwatch.startLap()) { + baos = new ByteArrayOutputStream(Math.min(2, oldSectorSize) * 4096); + new BinaryNbtSerializer(CompressionType.ZLIB).toStream( + new NamedTag(null, isAutoUpdateHandelOnWrite() ? chunk.updateHandle() : chunk.getHandle()), baos); + } + // Note 'totalBytes' is count 4 larger than the value written at the chunk sector offset because it includes the byte size data too + totalBytes = baos.size() + 4 /*size*/ + 1 /*compression sig*/; + newSectorSize = (totalBytes >> 12) + (totalBytes % 4096 == 0 ? 0 : 1); + if (newSectorSize > 255) throw new IOException("Chunk " + chunk.getChunkXZ() + " to large! 1MB maximum"); + + if (oldSectorSize == 0) { // chunk has never been written to file + writeToSector = sectorManager.allocate(newSectorSize); + } else if (newSectorSize == oldSectorSize) { // new chunk data fits in the old slot like a glove + writeToSector = new SectorManager.SectorBlock(oldSectorOffset, newSectorSize); + } else if (newSectorSize < oldSectorSize) { // new chunk data still fits but there's extra room now + writeToSector = new SectorManager.SectorBlock(oldSectorOffset, newSectorSize); + sectorManager.release(oldSectorOffset + newSectorSize, oldSectorSize - newSectorSize); + } else { // new chunk data is too large to fit in the old slot so alloc a new one + writeToSector = sectorManager.allocate(newSectorSize); + sectorManager.release(oldSectorOffset, oldSectorSize); + } + writeToSector.seekTo(raf); + raf.writeInt(totalBytes - 4); // don't count the int we are writing here in the byte size + raf.write(CompressionType.ZLIB.getID()); + raf.write(baos.toByteArray()); + chunkSectors[index] = writeToSector.pack(); + chunkTimestamps[index] = chunk.getLastMCAUpdate(); + + long roundedEos = writeToSector.end() * 4096L; + while (roundedEos > raf.getFilePointer()) { + int gap = (int) Math.min(roundedEos - raf.getFilePointer(), ZERO_FILL_BUFFER.length); + raf.write(ZERO_FILL_BUFFER, 0, gap); + } + if (raf.getFilePointer() % 4096 != 0) + throw new IllegalStateException(); + } + } + + /** + * @return the chunk XZ coords of the minimum chunk (north-west corner) in this region. + */ + public IntPointXZ getRegionChunkOffsetXZ() { + return regionChunkOffsetXZ; + } + + // TODO: find a common code location for these helpers + public IntPointXZ indexToRelativeXZ(int index) { + if (index < 0 || index >= 1024) + throw new IndexOutOfBoundsException(); + return new IntPointXZ(index & 0x1F, (index >> 5) & 0x1F); + } + + public IntPointXZ indexToAbsoluteXZ(int index) { + return regionChunkOffsetXZ.add(indexToRelativeXZ(index)); + } + + @Override + public Iterator iterator() { + return chunkIterator(); + } + + // This class assumes known usage patterns and does not defend against doing stupid things. + // Making this class safe for general use would require tracking allocs to make sure releases + // are valid and complete/correct - but that would add unneeded bloat and memory use for this + // application. + static class SectorManager { + + static class SectorBlock { + int start; + int size; + SectorBlock(SectorBlock other) { + this.start = other.start; + this.size = other.size; + } + SectorBlock(int start, int size) { + this.start = start; + this.size = size; + } + int end() { + return start + size; + } + void expandToInclude(int value) { + if (value < start) { + size += start - value; + start = value; + } else if (value > end()) { + size = value - start; + } + } + boolean merge(SectorBlock other) { + final int thisEnd = this.end(); + final int otherEnd = other.end(); + if (this.start > otherEnd || other.start > thisEnd) + return false; + this.expandToInclude(other.start); + this.expandToInclude(otherEnd); + return true; + } + public int pack() throws IOException { + if (size < 0 || size > 255) + throw new IOException("Invalid chunk data sector size!"); + return start << 8 | size; + } + public static SectorBlock unpack(int packed) { + return new SectorBlock(packed >> 8, packed & 0xFF); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof SectorBlock other)) { + return false; + } + return other.start == this.start && other.size == this.size; + } + + @Override + public String toString() { + return toString("0x%X+%X"); + } + + /** @param format must have two %d placeholders, first is start, second is size */ + public String toString(String format) { + return String.format(format, start, size); + } + + public void seekTo(RandomAccessFile raf) throws IOException { + raf.seek(start * 4096L); + } + } + final LinkedList freeSectors = new LinkedList<>(); + int appendAtSector = 2; + + void sync(int[] sectorTable) throws CorruptMcaFileException { + if (sectorTable.length != 1024) throw new IllegalArgumentException(); + freeSectors.clear(); + appendAtSector = 2; + List usedSectorBlocks = new ArrayList<>(1024); + for (int i = 0; i < 1024; i++) { + int sectorStart = sectorTable[i] >> 8; + int sectorSize = sectorTable[i] & 0xFF; + if (sectorSize > 0) { + if (sectorStart < 2) + throw new CorruptMcaFileException(); + usedSectorBlocks.add(new SectorBlock(sectorStart, sectorSize)); + } + } + if (!usedSectorBlocks.isEmpty()) { + usedSectorBlocks.sort(Comparator.comparingInt(a -> a.start)); + SectorBlock previous = usedSectorBlocks.get(0); + if (previous.start > 2) { + freeSectors.add(new SectorBlock(2, previous.start - 2)); + } + for (int i = 1; i < usedSectorBlocks.size(); i++) { + SectorBlock current = usedSectorBlocks.get(i); + if (previous.end() != current.start) { + freeSectors.add(new SectorBlock(previous.end(), current.start - previous.end())); + } + previous = current; + } + appendAtSector = Math.max(appendAtSector, previous.end()); + } + } + + SectorBlock allocate(int requestedSectorSize) { + // does not attempt to find a best-fit, simply the first fit. + ListIterator iter = freeSectors.listIterator(); + SectorBlock found = null; + while (iter.hasNext()) { + SectorBlock sb = iter.next(); + if (sb.size == requestedSectorSize) { + found = sb; + iter.remove(); + break; + } else if (sb.size > requestedSectorSize) { + found = new SectorBlock(sb.start, requestedSectorSize); + sb.size -= requestedSectorSize; + sb.start += requestedSectorSize; + break; + } + } + if (found == null) { + found = new SectorBlock(appendAtSector, requestedSectorSize); + appendAtSector += requestedSectorSize; + } + return found; + } + + void release(int start, int size) { + release(new SectorBlock(start, size)); + } + + public void release(SectorBlock sectorBlock) { + if (sectorBlock.size == 0) return; + ListIterator iter = freeSectors.listIterator(); + boolean released = false; + while (iter.hasNext()) { + SectorBlock sb = iter.next(); + if (sb.merge(sectorBlock)) { + released = true; + break; + } + if (sectorBlock.end() < sb.start) { + iter.previous(); + iter.add(new SectorBlock(sectorBlock)); + released = true; + break; + } + } + if (!released) { + freeSectors.addLast(new SectorBlock(sectorBlock)); + } + if (freeSectors.getLast().end() == appendAtSector) { + SectorBlock sb = freeSectors.removeLast(); + appendAtSector = sb.start; + } + } + + /** @return Number of unused bytes that were removed from the file. The file is now this much smaller. */ + public int optimizeFile(RandomAccessFile raf, int[] chunkSectors) throws IOException { + if (freeSectors.isEmpty()) { + return truncate(raf); + } + List sectorsToMove = new ArrayList<>(1024); + SectorBlock[] sectors = new SectorBlock[1024]; + final int firstFreeSector = freeSectors.getFirst().start; + int largestChunkInSectors = 0; + for (int i = 0; i < 1024; i++) { + SectorBlock sectorBlock = SectorBlock.unpack(chunkSectors[i]); + sectors[i] = sectorBlock; + if (sectorBlock.size > 0) { + if (sectorBlock.start < 2) + throw new CorruptMcaFileException(); + if (sectorBlock.start > firstFreeSector) { + sectorsToMove.add(sectorBlock); + if (largestChunkInSectors < sectorBlock.size) + largestChunkInSectors = sectorBlock.size; + } + } + } + if (largestChunkInSectors == 0) { + appendAtSector = 2; + return truncate(raf); + } + + // by here all of these blocks have to be moved + // sort them in ascending location order so the relocation logic can be simplified + sectorsToMove.sort(Comparator.comparingInt(a -> a.start)); + + int nextSectorStart = firstFreeSector; + // This will never be GT 1MB and is usually LE 16KB + byte[] buffer = new byte[largestChunkInSectors * 4096]; + for (SectorBlock sb : sectorsToMove) { + sb.seekTo(raf); + int sectorSizeBytes = sb.size * 4096; + int read = raf.read(buffer, 0, sectorSizeBytes); + if (read < 0) + throw new EOFException(); + if (read != sectorSizeBytes) + throw new IOException("Failed to read the required number of bytes!"); + sb.start = nextSectorStart; + sb.seekTo(raf); + raf.write(buffer, 0, sectorSizeBytes); + nextSectorStart += sb.size; + } + + // refresh the chunk sectors table + for (int i = 0; i < 1024; i++) { + chunkSectors[i] = sectors[i].pack(); + } + + // sync sector manager state + freeSectors.clear(); + appendAtSector = sectorsToMove.get(sectorsToMove.size() - 1).end(); + return truncate(raf); + } + + /** @return Number of unused bytes that were removed from the file. The file is now this much smaller. */ + int truncate(RandomAccessFile raf) throws IOException { + final long oldLength = raf.length(); + final long newLength = appendAtSector * 4096L; + if (newLength > raf.length()) + throw new IOException("File is already smaller than " + newLength); + raf.setLength(newLength); + return (int) (oldLength - newLength); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("eof-sector "); + sb.append(String.format("0x%X", appendAtSector)); + sb.append("; free-sectors"); + if (!freeSectors.isEmpty()) { + sb.append("(count ").append(freeSectors.size()); + sb.append("; sum ").append(freeSectors.stream().mapToInt(s -> s.size).sum()); + sb.append(')'); + } + sb.append('['); + boolean first = true; + for (SectorBlock fs : freeSectors) { + if (!first) sb.append(", "); + else first = false; + sb.append(fs); + } + sb.append("]"); + return sb.toString(); + } + } + + public ChunkIterator chunkIterator() { + return new ChunkIter<>(this, loadFlags); + } + + public ChunkIterator chunkIterator(long loadFlags) { + return new ChunkIter<>(this, loadFlags); + } + + protected static class ChunkIter implements ChunkIterator { + private final RandomAccessMcaFile ramf; + private final long loadFlags; + private int nextIndex = 0; + + public ChunkIter(RandomAccessMcaFile ramf, long loadFlags) { + this.ramf = ramf; + this.loadFlags = loadFlags; + } + + @Override + public void set(T chunk) { + try { + ramf.write(chunk); + } catch (IOException ex) { + throw new SilentIOException(ex); + } + } + + @Override + public int currentIndex() { + if (nextIndex == 0) throw new NoSuchElementException(); + return nextIndex - 1; + } + + @Override + public IntPointXZ currentAbsoluteXZ() { + return ramf.indexToAbsoluteXZ(nextIndex - 1); + } + + @Override + public int currentAbsoluteX() { + return currentAbsoluteXZ().getX(); + } + + @Override + public int currentAbsoluteZ() { + return currentAbsoluteXZ().getZ(); + } + + @Override + public boolean hasNext() { + return nextIndex < 1024; + } + + @Override + public T next() { + if (!hasNext()) throw new NoSuchElementException(); + try { + return ramf.read(nextIndex++, loadFlags); + } catch (IOException ex) { + throw new SilentIOException(ex); + } + } + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/io/RegionFileRelocator.java b/src/main/java/io/github/ensgijs/nbt/mca/io/RegionFileRelocator.java new file mode 100644 index 00000000..f07c0286 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/io/RegionFileRelocator.java @@ -0,0 +1,381 @@ +package io.github.ensgijs.nbt.mca.io; + +import io.github.ensgijs.nbt.mca.ChunkBase; +import io.github.ensgijs.nbt.mca.util.IntPointXZ; +import io.github.ensgijs.nbt.util.Stopwatch; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.*; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +// TODO: add class docs including basic usage +public class RegionFileRelocator implements Closeable { + private McaStreamSupplier mcaStreamSupplier; + private String destinationRoot; + private long moveChunkFlags = MoveChunkFlags.MOVE_CHUNK_DEFAULT_FLAGS; + private final Stopwatch stopwatch = Stopwatch.createUnstarted(); + private int regionFilesRelocated = 0; + private int poiFilesRelocated = 0; + private int entitiesFilesRelocated = 0; + + @Override + public String toString() { + return String.format("relocations[region %d; entities %d; poi %d]; total time %s", + regionFilesRelocated, entitiesFilesRelocated, poiFilesRelocated, stopwatch); + } + + private void check() { + if (mcaStreamSupplier == null) { + throw new IllegalStateException("Must set a sourceRoot first!"); + } + if (destinationRoot == null) { + throw new IllegalStateException("Must set a destinationRoot first!"); + } + } + + /** + * @param sourceRoot A path which contains region/poi/entities folders as immediate children. + * This value may include .zip or .jar files as part of the path. + */ + public RegionFileRelocator sourceRoot(String sourceRoot) throws IOException { + SourcePathParser spp = new SourcePathParser(sourceRoot); + + mcaStreamSupplier = spp.archiveName != null ? + new ArchiveMcaStreamSupplier(spp.archiveName, spp.path) : + new FileMcaStreamSupplier(spp.path); + return this; + } + + /** + * @param destinationRoot A path which contains region/poi/entities folders that relocated regions will be written to. + * Note: this path does not support writing into .zip or .jar files. + */ + public RegionFileRelocator destinationRoot(String destinationRoot) { + this.destinationRoot = destinationRoot; + return this; + } + + public long getMoveChunkFlags() { + return moveChunkFlags; + } + + public RegionFileRelocator setMoveChunkFlags(long moveChunkFlags) { + this.moveChunkFlags = moveChunkFlags; + return this; + } + + public RegionFileRelocator addMoveChunkFlags(long moveChunkFlags) { + this.moveChunkFlags |= moveChunkFlags; + return this; + } + + public RegionFileRelocator removeMoveChunkFlags(long moveChunkFlags) { + this.moveChunkFlags &= ~moveChunkFlags; + return this; + } + + /** + * Resets the time elapsed stopwatch and resets all relocation counters. + */ + public RegionFileRelocator resetPerformanceMetrics() { + stopwatch.reset(); + regionFilesRelocated = 0; + poiFilesRelocated = 0; + entitiesFilesRelocated = 0; + return this; + } + + /** Gets a copy of the stopwatch populated with the current total relocate elapsed time. */ + public Stopwatch elapsed() { + return Stopwatch.createUnstarted().add(stopwatch); + } + + public int regionFilesRelocated() { + return regionFilesRelocated; + } + + public int poiFilesRelocated() { + return poiFilesRelocated; + } + + public int entitiesFilesRelocated() { + return entitiesFilesRelocated; + } + + /** + * @param source A file name such as r.0.0.mca which will be used to read region/poi/entities from {@link #sourceRoot(String)}. + * @param destination A file name such as r.1.1.mca which will be used to write region/poi/entities into {@link #destinationRoot(String)}. + * @return True if any mca files were written, false otherwise. + */ + public boolean relocate(String source, String destination) throws IOException { + check(); + try (Stopwatch.LapToken lap = stopwatch.startLap()) { + boolean didSomething = false; + if (relocate("region", source, destination)) { + didSomething = true; + regionFilesRelocated++; + } + if (relocate("entities", source, destination)) { + didSomething = true; + entitiesFilesRelocated++; + } + if (relocate("poi", source, destination)) { + didSomething = true; + poiFilesRelocated++; + } + return didSomething; + } + } + + public boolean relocate(int sourceX, int sourceZ, int destinationX, int destinationZ) throws IOException { + return relocate( + McaFileHelpers.createNameFromRegionLocation(sourceX, sourceZ), + McaFileHelpers.createNameFromRegionLocation(destinationX, destinationZ)); + } + + public boolean relocate(IntPointXZ sourceXZ, IntPointXZ destinationXZ) throws IOException { + return relocate( + McaFileHelpers.createNameFromRegionLocation(sourceXZ), + McaFileHelpers.createNameFromRegionLocation(destinationXZ)); + } + + private boolean relocate(String mcaType, String source, String destination) throws IOException { + Stopwatch totalStopwatch = Stopwatch.createStarted(); + Stopwatch supplierGetStopwatch = Stopwatch.createStarted(); + try (InputStream in = mcaStreamSupplier.get(mcaType, source)) { + supplierGetStopwatch.stop(); + if (in != null) { + Stopwatch iterNextStopwatch = Stopwatch.createUnstarted(); + Stopwatch moveChunkStopwatch = Stopwatch.createUnstarted(); + File destFolder = Paths.get(destinationRoot, mcaType).toFile(); + if (!destFolder.exists()) destFolder.mkdirs(); + McaFileChunkIterator iter = McaFileChunkIterator.iterate(in, mcaType + "/" + source, LoadFlags.RAW); + try (McaFileStreamingWriter writer = new McaFileStreamingWriter(Paths.get(destinationRoot, mcaType, destination))) { + final IntPointXZ sourceAnchorXZ = iter.chunkAbsXzOffset(); + final IntPointXZ destAnchorXZ = McaFileHelpers.regionXZFromFileName(destination).transformRegionToChunk(); + final IntPointXZ deltaXZ = destAnchorXZ.subtract(sourceAnchorXZ); + + while (iter.hasNext()) { + iterNextStopwatch.start(); + ChunkBase chunk = iter.next(); + iterNextStopwatch.stop(); + if (!deltaXZ.isZero()) { + moveChunkStopwatch.start(); + chunk.moveChunk(chunk.getChunkX() + deltaXZ.getX(), chunk.getChunkZ() + deltaXZ.getZ(), moveChunkFlags); + chunk.updateHandle(); // not necessary when loaded in RAW, but also a very low cost call when in raw so leave it in to avoid bugs when not loading raw. + moveChunkStopwatch.stop(); + } + writer.write(chunk); + } + writer.close(); + totalStopwatch.stop(); +// System.out.println( +// "RELOCATE " + writer + "; relocate[total " + totalStopwatch + +// "; stream supplier " + supplierGetStopwatch + +// "; iter.next " + iterNextStopwatch + "; moveChunk " + moveChunkStopwatch + +// "]; " + mcaType + "/" + source + " -> " + destination ); + } + return true; + } + return false; + } catch (IOException ex) { + throw new IOException("Error while relocating " + mcaType + "/" + source, ex); + } + } + + public List listSourceRegions() throws IOException { + check(); + return mcaStreamSupplier.list(); + } + + @Override + public void close() throws IOException { + if (mcaStreamSupplier != null) + mcaStreamSupplier.close(); + } + + public int relocateAll(int deltaXRegions, int deltaZRegions) throws IOException { + final IntPointXZ deltaXZ = new IntPointXZ(deltaXRegions, deltaZRegions); + int relocated = 0; + for (String source : listSourceRegions()) { +// System.out.println("RELOCATING " + source); + IntPointXZ newXZ = McaFileHelpers.regionXZFromFileName(source).add(deltaXZ); + if (relocate(source, McaFileHelpers.createNameFromRegionLocation(newXZ))) { + relocated++; + } + } + return relocated; + } + + public int relocateAll(IntPointXZ deltaXZRegions) throws IOException { + return relocateAll(deltaXZRegions.getX(), deltaXZRegions.getZ()); + } + + /** All methods may return null if the specified mca file does not exist. */ + private interface McaStreamSupplier extends Closeable { + InputStream get(String mcaType, String mcaName) throws IOException; + List list() throws IOException; + } + + + private static final Predicate IS_MCA_FILE = Pattern.compile("^r[.]-?\\d+[.]-?\\d+[.]mca$", Pattern.CASE_INSENSITIVE).asPredicate(); + private static final Pattern ZIP_PATH_SPLITTER = Pattern.compile("(?:[.]zip|[.]jar)(?:/|$)", Pattern.CASE_INSENSITIVE); + + + private static String normalizeSlashes(String s) { + return s.replaceAll("[\\\\/]+", "/"); + } + + private static String trimTrailingSlash(String s) { + if (s.endsWith("/")) { + return s.substring(0, s.length() - 1); + } + return s; + } + static class SourcePathParser { + public final String archiveName; + public final String path; + + /** + * Normalizes all slashes in name to forward slash. + * Trims trailing slashes from all returned parts. + * Splits name around .zip or .jar extensions. + */ + public SourcePathParser(String name) throws FileNotFoundException { + name = normalizeSlashes(name); + List parts = new ArrayList<>(); + Matcher m = ZIP_PATH_SPLITTER.matcher(name); + if (m.find()) { + int lastEnd = 0; + do { + parts.add(trimTrailingSlash(name.substring(lastEnd, m.end()))); + lastEnd = m.end(); + } while (m.find()); + if (lastEnd < name.length()) { + parts.add(trimTrailingSlash(name.substring(lastEnd))); + } + } else { + parts.add(trimTrailingSlash(name)); + } + + String sofar = ""; + String archiveName = null; + int i; + for (i = 0; i < parts.size(); i++) { + File f = Paths.get(sofar, parts.get(i)).toFile(); + sofar = f.getPath(); + if (!f.exists()) + throw new FileNotFoundException(sofar); + if (f.isFile()) { + archiveName = sofar; + break; + } + } + this.archiveName = archiveName; + if (archiveName == null) { + path = trimTrailingSlash(name); + } else { + sofar = ""; + for (i++; i < parts.size(); i++) { + sofar = Paths.get(sofar, parts.get(i)).toString(); + } + path = sofar; + } + } + } + + /** + * Supplier of mca InputStreams sourced from the filesystem. + */ + private static class FileMcaStreamSupplier implements McaStreamSupplier { + private final String root; + + public FileMcaStreamSupplier(String root) throws FileNotFoundException { + this.root = root; + File file = new File(root); + if (!file.exists()) throw new FileNotFoundException(); + if (!file.isDirectory()) throw new FileNotFoundException("location exists but is not a directory"); + } + + @Override + public InputStream get(String mcaType, String mcaName) throws IOException { + File file = Paths.get(root, mcaType, mcaName).toFile(); + if (file.exists() && file.isFile() && Files.size(file.toPath()) >= 0x2000) { + return new BufferedInputStream(new FileInputStream(file)); + } + return null; + } + + @Override + public List list() { + File regionDir = Paths.get(root, "region").toFile(); + if (!regionDir.exists() || !regionDir.isDirectory()) { + return Collections.emptyList(); + } + String[] files = regionDir.list(); + if (files == null) return Collections.emptyList(); + return Arrays.stream(files) + .filter(IS_MCA_FILE) + .collect(Collectors.toList()); + } + + @Override + public void close() throws IOException { + // no-op + } + } + + /** + * Supplier of mca InputStreams sourced from a zip or jar archive file. + */ + static class ArchiveMcaStreamSupplier implements McaStreamSupplier { + final ZipFile zip; + final String pathPrefix; + + public ArchiveMcaStreamSupplier(String archive, String path) throws IOException { + zip = new ZipFile(archive); + pathPrefix = path; + + String regionPath = Paths.get(pathPrefix, "region").toString(); + ZipEntry ze = zip.getEntry(regionPath); + if (ze == null || !ze.isDirectory()) { + throw new FileNotFoundException("Expected `" + regionPath + "` folder within archive " + zip.getName()); + } + } + + @Override + public InputStream get(String mcaType, String mcaName) throws IOException { + String p = normalizeSlashes(Paths.get(pathPrefix, mcaType, mcaName).toString()); + ZipEntry ze = zip.getEntry(p); + return ze != null && ze.getSize() >= 0x2000 ? zip.getInputStream(ze) : null; + } + + @Override + public List list() throws IOException { + List list = new ArrayList<>(); + final String regionPath = Paths.get(pathPrefix, "region") + "/"; + for (Enumeration e = zip.entries(); e.hasMoreElements(); ) { + ZipEntry ze = e.nextElement(); + if (!ze.isDirectory() && ze.getName().startsWith(regionPath)) { + String[] parts = ze.getName().split("/", 2); + if (parts.length == 2 && IS_MCA_FILE.test(parts[1])) { + list.add(parts[1]); + } + } + } + return list; + } + + @Override + public void close() throws IOException { + zip.close(); + } + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/BlockAlignedBoundingRectangle.java b/src/main/java/io/github/ensgijs/nbt/mca/util/BlockAlignedBoundingRectangle.java new file mode 100644 index 00000000..187ea606 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/BlockAlignedBoundingRectangle.java @@ -0,0 +1,194 @@ +package io.github.ensgijs.nbt.mca.util; + +import java.util.Collection; +import java.util.function.ToIntFunction; + +/** + * An XZ aligned bounding box that conceptually represents block coordinates. + * @see ChunkBoundingRectangle + * @see RegionBoundingRectangle + */ +public class BlockAlignedBoundingRectangle { + + protected final int widthBlockXZ; + + protected final int minBlockX; + protected final int minBlockZ; + protected final int maxBlockX; // exclusive + protected final int maxBlockZ; // exclusive + + protected final double minXd; + protected final double minZd; + protected final double maxXd; // exclusive + protected final double maxZd; // exclusive + + public BlockAlignedBoundingRectangle translate(int x, int z) { + return new BlockAlignedBoundingRectangle( + minBlockX + x, + minBlockZ + z, + widthBlockXZ + ); + } + + /** + * Computes a new region which shares the same center but is {@code size} larger in each direction. + * Total width/height grows by 2x this value. + * @param size amount to shrink by, must be GE0. + * @return new larger rectangle + */ + public BlockAlignedBoundingRectangle grow(int size) { + if (size == 0) return this; + if (size < 0) throw new IllegalArgumentException(); + return new BlockAlignedBoundingRectangle(minBlockX - size, minBlockZ - size, widthBlockXZ + 2 * size); + } + + /** + * Computes a new region which shares the same center but is {@code size} smaller in each direction. + * Total width/height reduces by 2x this value. + * @param size amount to shrink by, must be GE0. + * @return new rectangle if resulting width/height > 1, else null. + */ + public BlockAlignedBoundingRectangle shrink(int size) { + if (size == 0) return this; + if (size < 0) throw new IllegalArgumentException("size must be GE 0"); + int newSize = widthBlockXZ - 2 * size; + if (newSize <= 0) return null; + return new BlockAlignedBoundingRectangle(minBlockX + size, minBlockZ + size, newSize); + } + + public int getMinBlockX() { + return minBlockX; + } + + public int getMinBlockZ() { + return minBlockZ; + } + + /** exclusive */ + public int getMaxBlockX() { + return maxBlockX; + } + + /** exclusive */ + public int getMaxBlockZ() { + return maxBlockZ; + } + + public int getWidthBlockXZ() { + return widthBlockXZ; + } + + public int getCenterBlockX() { + return minBlockX + widthBlockXZ / 2; + } + + public int getCenterBlockZ() { + return minBlockZ + widthBlockXZ / 2; + } + + public double getCenterX() { + return minBlockX + widthBlockXZ / 2d; + } + + public double getCenterZ() { + return minBlockZ + widthBlockXZ / 2d; + } + + public BlockAlignedBoundingRectangle(int minBlockX, int minBlockZ, int widthBlockXZ) { + this.minXd = this.minBlockX = minBlockX; + this.minZd = this.minBlockZ = minBlockZ; + this.maxXd = this.maxBlockX = minBlockX + widthBlockXZ; + this.maxZd = this.maxBlockZ = minBlockZ + widthBlockXZ; + this.widthBlockXZ = widthBlockXZ; + } + + public boolean containsBlock(int blockX, int blockZ) { + return minBlockX <= blockX && blockX < maxBlockX && minBlockZ <= blockZ && blockZ < maxBlockZ; + } + + public boolean containsBlock(IntPointXZ blockXZ) { + return containsBlock(blockXZ.getX(), blockXZ.getZ()); + } + + public boolean containsBlock(double blockX, double blockZ) { + return minXd <= blockX && blockX < maxXd && minZd <= blockZ && blockZ < maxZd; + } + + /** + * Constrains the given 3d bounding cuboid to this rectangle. Note the given bounds (min and max) are both inclusive. + * @return If all corners are outside this rectangle, or if the given bounds are not valid, false is returned. + */ + public final boolean constrain(int[] bounds) { + if (bounds == null || bounds.length != 6) return false; + if (bounds[0] > bounds[3] || bounds[2] > bounds[5]) + throw new IllegalArgumentException("Bounds not in min/max order!"); + boolean c1ok = containsBlock(bounds[0], bounds[2]); + boolean c2ok = containsBlock(bounds[3], bounds[5]); + if (c1ok && c2ok) { + return true; + } + if (!c1ok && !c2ok) { + if (bounds[0] >= maxBlockX || bounds[2] >= maxBlockZ || bounds[3] < minBlockX || bounds[5] < minBlockZ) + return false; + } + + if (!c1ok) { + if (bounds[0] < minBlockX) bounds[0] = minBlockX; + else if (bounds[0] >= maxBlockX) bounds[0] = maxBlockX - 1; + if (bounds[2] < minBlockZ) bounds[2] = minBlockZ; + else if (bounds[2] >= maxBlockZ) bounds[2] = maxBlockZ - 1; + } + + if (!c2ok) { + if (bounds[3] < minBlockX) bounds[3] = minBlockX; + else if (bounds[3] >= maxBlockX) bounds[3] = maxBlockX - 1; + if (bounds[5] < minBlockZ) bounds[5] = minBlockZ; + else if (bounds[5] >= maxBlockZ) bounds[5] = maxBlockZ - 1; + } + + return true; + } + + @Override + public String toString() { + return String.format("blocks[%d..%d, %d..%d]", + minBlockX, maxBlockX - 1, minBlockZ, maxBlockZ - 1); + } + + @Override + public int hashCode() { + return 31 * (31 * minBlockX + minBlockZ) + widthBlockXZ; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof BlockAlignedBoundingRectangle other) { + return this.minBlockX == other.minBlockX && + this.minBlockZ == other.minBlockZ && + this.widthBlockXZ == other.widthBlockXZ; + } + return false; + } + + public static BlockAlignedBoundingRectangle of(Collection blocks) { + return of(blocks, IntPointXZ::getX, IntPointXZ::getZ); + } + + public static BlockAlignedBoundingRectangle of(Collection blocks, ToIntFunction xGetter, ToIntFunction zGetter) { + if (blocks == null || blocks.isEmpty()) + return null; + int minX = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int minZ = Integer.MAX_VALUE; + int maxZ = Integer.MIN_VALUE; + for (T xz : blocks) { + int x = xGetter.applyAsInt(xz); + int z = zGetter.applyAsInt(xz); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + } + return new BlockAlignedBoundingRectangle(minX, minZ, Math.max(maxX - minX, maxZ - minZ) + 1); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/BlockStateIterator.java b/src/main/java/io/github/ensgijs/nbt/mca/util/BlockStateIterator.java new file mode 100644 index 00000000..313c80df --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/BlockStateIterator.java @@ -0,0 +1,37 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.mca.TerrainSection; +import io.github.ensgijs.nbt.tag.CompoundTag; + +import java.util.Iterator; + +// TODO: use or remove +/** + * Enhanced iterable/iterator for iterating over {@link TerrainSection} block data. + */ +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/io/github/ensgijs/nbt/mca/util/BlockStateTag.java b/src/main/java/io/github/ensgijs/nbt/mca/util/BlockStateTag.java new file mode 100644 index 00000000..59d0501e --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/BlockStateTag.java @@ -0,0 +1,368 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.io.TextNbtHelpers; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.StringTag; +import io.github.ensgijs.nbt.tag.Tag; +import io.github.ensgijs.nbt.util.ArgValidator; + +import java.util.*; +import java.util.function.BiFunction; +import java.util.function.Function; + +/** + * Helper to create, modify, and interact with block state nbt data. + */ +public class BlockStateTag implements TagWrapper { + private CompoundTag root; + private StringTag name; + private CompoundTag properties; + + /** + * @param name block name, should include "minecraft:" prefix - one will not be added. + */ + public BlockStateTag(String name) { + Objects.requireNonNull(name); + root = new CompoundTag(); + this.name = new StringTag(name); + properties = new CompoundTag(); + root.put("Name", this.name); + } + + /** + * @param name block name, should include "minecraft:" prefix - one will not be added. + * @param properties may be map of primitives or Tag's. If map of tags then map is shallow copied. + * If map of primitives then all are wrapped as Tags. + */ + public BlockStateTag(String name, Map properties) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(properties, "properties"); + root = new CompoundTag(); + this.name = new StringTag(name); + root.put("Name", this.name); + this.properties = new CompoundTag(); + setProperties(properties); + } + + public BlockStateTag(CompoundTag tag) { + Objects.requireNonNull(tag); + root = tag; + this.name = tag.getStringTag("Name"); + ArgValidator.check(this.name != null, "Missing required tag 'Name'"); + properties = tag.getCompoundTag("Properties"); + if (properties == null) this.properties = new CompoundTag(); + } + + public void setProperties(Map newProperties) { + properties.clear(); + putAll(newProperties); + } + + /** value is taken by reference (not copied) */ + public void setProperties(CompoundTag newProperties) { + root.remove("Properties"); + if (properties != newProperties) { + if (newProperties != null) { + properties = newProperties; + if (!properties.isEmpty()) { + root.put("Properties", properties); + } + } else { + properties = new CompoundTag(); + } + } + } + + private StringTag wrap(Object value) { + if (value == null) return null; + if (value instanceof StringTag tag) { + return tag; + } + if (value instanceof Tag t) { + return new StringTag(t.valueToString()); + } + return new StringTag(value.toString()); + } + + + /** + * @return block name, including any "minecraft:" prefix + */ + public String getName() { + return name.getValue(); + } + + /** + * @param name block name, should include "minecraft:" prefix - one will not be added. + */ + public void setName(String name) { + this.name.setValue(name); + } + + @Override + public CompoundTag getHandle() { + return root; + } + + @Override + public CompoundTag updateHandle() { + if (properties.isEmpty()) { + root.remove("Properties"); + } else { + root.put("Properties", properties); + } + return root; + } + + public int size() { + return properties.size(); + } + + public boolean isEmpty() { + return properties.isEmpty(); + } + + public boolean hasProperty(String key) { + return properties.containsKey(key); + } + + public String get(String key) { + StringTag tag = (StringTag) properties.get(key); + return tag != null ? tag.getValue() : null; + } + + public String put(String key, Object value) { + if (value != null) { + StringTag old = (StringTag) properties.put(key, wrap(value)); + return old != null ? old.getValue() : null; + } + return remove(key); + } + + public String remove(String key) { + StringTag old = (StringTag) properties.remove(key); + return old != null ? old.getValue() : null; + } + + public void putAll(Map m) { + for (var e : m.entrySet()) { + put(e.getKey(), e.getValue()); + } + if (!properties.isEmpty()) { + root.put("Properties", this.properties); + } + } + + public void clear() { + properties.clear(); + root.remove("Properties"); + } + + public Set keySet() { + return properties.keySet(); + } + + public Collection> values() { + return properties.values(); + } + + public Set>> entrySet() { + return properties.entrySet(); + } + + /** + * Returns the value to which the specified property key is mapped, or + * {@code defaultValue} if no property mapping for the key. + * + * @param key the key whose associated value is to be returned + * @param defaultValue the default mapping of the key + * @return the value to which the specified key is mapped, or + * {@code defaultValue} if this map contains no mapping for the key + */ + public String getOrDefault(String key, Object defaultValue) { + StringTag tag = (StringTag) properties.get(key); + if (tag != null) + return tag.getValue(); + return defaultValue != null ? wrap(defaultValue).getValue() : null; + } + + /** + * If the specified key is not already associated with a value + * associates it with the given value and returns null, else returns the current value. + * + * @param key key with which the specified value is to be associated + * @param value value to be associated with the specified key + * @return the previous value associated with the specified key, or + * {@code null} if there was no mapping for the key. + */ + public String putIfAbsent(String key, Object value) { + Objects.requireNonNull(key); + Objects.requireNonNull(value); + StringTag v = (StringTag) properties.get(key); + if (v == null) { + v = wrap(value); + properties.put(key, v); + return null; + } + return v.getValue(); + } + + /** + * Removes the entry for the specified key only if it is currently mapped to the specified value. + */ + public boolean remove(String key, Object value) { + StringTag curValue = (StringTag) properties.get(key); + StringTag v = wrap(value); + if (!Objects.equals(curValue, v) || (curValue == null && !hasProperty(key))) { + return false; + } + remove(key); + return true; + } + + /** + * Replaces the entry for the specified key only if currently + * mapped to the specified value. + * + * @param key key with which the specified value is associated + * @param oldValue value expected to be associated with the specified key + * @param newValue value to be associated with the specified key + * @return {@code true} if the value was replaced + */ + public boolean replace(String key, Object oldValue, Object newValue) { + String curValue = get(key); + if (curValue == null) + return false; + String oldValueStr = wrap(oldValue).getValue(); + + if (!Objects.equals(curValue, oldValue)) { + return false; + } + put(key, wrap(newValue)); + return true; + } + + /** + * Replaces the entry for the specified key only if it is + * currently mapped to some value. + * + * @param key key with which the specified value is associated + * @param value value to be associated with the specified key + * @return the previous value associated with the specified key, or + * {@code null} if there was no mapping for the key. + */ + public String replace(String key, Object value) { + String curValue; + if (((curValue = get(key)) != null) || hasProperty(key)) { + curValue = put(key, wrap(value)); + } + return curValue; + } + + /** + * If the specified key is not already associated with a value (or is mapped + * to {@code null}), attempts to compute its value using the given mapping + * function and enters it into this map unless {@code null}. + * + *

If the mapping function returns {@code null}, no mapping is recorded. + * If the mapping function itself throws an (unchecked) exception, the + * exception is rethrown, and no mapping is recorded. + * + * @param key key with which the specified value is to be associated + * @param mappingFunction the mapping function to compute a value + * @return the current (existing or computed) value associated with + * the specified key, or null if the computed value is null + */ + public String computeIfAbsent(String key, Function mappingFunction) { + Objects.requireNonNull(mappingFunction); + String v; + if ((v = get(key)) == null) { + StringTag newValue; + if ((newValue = wrap(mappingFunction.apply(key))) != null) { + put(key, newValue); + return newValue.getValue(); + } + } + + return v; + } + + /** + * If the value for the specified key is present and non-null, attempts to + * compute a new mapping given the key and its current mapped value. + * + *

If the remapping function returns {@code null}, the mapping is removed. + * If the remapping function itself throws an (unchecked) exception, the + * exception is rethrown, and the current mapping is left unchanged. + * + *

The remapping function should not modify this map during computation. + * + * @param key key with which the specified value is to be associated + * @param remappingFunction the remapping function to compute a value + * @return the new value associated with the specified key, or null if none + */ + public String computeIfPresent(String key, BiFunction remappingFunction) { + Objects.requireNonNull(remappingFunction); + String oldValue; + if ((oldValue = get(key)) != null) { + StringTag newValue = wrap(remappingFunction.apply(key, oldValue)); + if (newValue != null) { + put(key, newValue); + return newValue.getValue(); + } else { + remove(key); + return null; + } + } else { + return null; + } + } + + /** + * Attempts to compute a mapping for the specified key and its current + * mapped value (or {@code null} if there is no current mapping). + * + * @param key key with which the specified value is to be associated + * @param remappingFunction the remapping function to compute a value + * @return the new value associated with the specified key, or null if none + */ + public String compute(String key, BiFunction remappingFunction) { + Objects.requireNonNull(remappingFunction); + String oldValue = get(key); + StringTag newValue = wrap(remappingFunction.apply(key, oldValue)); + if (newValue == null) { + // delete mapping + if (oldValue != null || hasProperty(key)) { + // something to remove + remove(key); + return null; + } else { + // nothing to do. Leave things as they were. + return null; + } + } else { + // add or replace old mapping + put(key, newValue); + return newValue.getValue(); + } + } + + @Override + public int hashCode() { + return name.hashCode() * 31 ^ properties.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof BlockStateTag other) { + return Objects.equals(this.name, other.name) && + Objects.equals(this.properties, other.properties); + } + return false; + } + + @Override + public String toString() { + return TextNbtHelpers.toTextNbt(updateHandle(), false); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/ChunkBoundingRectangle.java b/src/main/java/io/github/ensgijs/nbt/mca/util/ChunkBoundingRectangle.java new file mode 100644 index 00000000..fc110f0d --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/ChunkBoundingRectangle.java @@ -0,0 +1,173 @@ +package io.github.ensgijs.nbt.mca.util; + +import java.util.Collection; +import java.util.function.ToIntFunction; + +public class ChunkBoundingRectangle extends BlockAlignedBoundingRectangle { + /** + * Maximum size of the world border. + * Note that there are 368 XZ blocks (23 XZ chunks) outside the world border that can possibly be generated. + *

Remember that the max bound is exclusive (don't treat getMaxChunkXZ as in bounds).

+ * @see RegionBoundingRectangle#MAX_WORLD_REGION_BOUNDS + */ + public static final ChunkBoundingRectangle MAX_WORLD_BORDER_BOUNDS = + new ChunkBoundingRectangle(-1874999, -1874999, 1874999 * 2); + + public ChunkBoundingRectangle(int chunkX, int chunkZ) { + this(chunkX, chunkZ, 1); + } + + public ChunkBoundingRectangle(int chunkX, int chunkZ, int chunkWidthXZ) { + super(chunkX << 4, chunkZ << 4, chunkWidthXZ << 4); + } + + public ChunkBoundingRectangle translateChunks(int x, int z) { + return new ChunkBoundingRectangle( + getMinChunkX() + x, + getMinChunkZ() + z, + getWidthChunkXZ() + ); + } + + /** + * Computes a new region which shares the same center but is {@code size} larger in each direction. + * Total width/height grows by 2x this value. + * @param size amount to shrink by, must be GE0. + * @return new larger rectangle + */ + public ChunkBoundingRectangle growChunks(int size) { + if (size == 0) return this; + if (size < 0) throw new IllegalArgumentException(); + return new ChunkBoundingRectangle(getMinChunkX() - size, getMinChunkZ() - size, getWidthChunkXZ() + 2 * size); + } + + /** + * Computes a new region which shares the same center but is {@code size} smaller in each direction. + * Total width/height reduces by 2x this value. + * @param size amount to shrink by, must be GE0. + * @return new rectangle if resulting width/height > 1, else null. + */ + public ChunkBoundingRectangle shrinkChunks(int size) { + if (size == 0) return this; + if (size < 0) throw new IllegalArgumentException("size must be GE 0"); + int newSize = getWidthChunkXZ() - 2 * size; + if (newSize <= 0) return null; + return new ChunkBoundingRectangle(getMinChunkX() + size, getMinChunkZ() + size, newSize); + } + + public int getMinChunkX() { + return minBlockX >> 4; + } + + public int getMinChunkZ() { + return minBlockZ >> 4; + } + + /** exclusive */ + public int getMaxChunkX() { + return maxBlockX >> 4; + } + + /** exclusive */ + public int getMaxChunkZ() { + return maxBlockZ >> 4; + } + + public int getWidthChunkXZ() { + return widthBlockXZ >> 4; + } + + public boolean containsChunk(int chunkX, int chunkZ) { + return containsBlock(chunkX << 4, chunkZ << 4); + } + + public boolean containsChunk(IntPointXZ chunkXZ) { + return containsBlock(chunkXZ.getX() << 4, chunkXZ.getZ() << 4); + } + + /** + * Calculates a new absolute X such that the given absolute X is inside this chunk + * at the same relative location as the given X was relative to its source chunk. + * + * @param blockX in block coordinates + * @return an X within the bounds of this chunk + */ + public final int relocateX(int blockX) { + return minBlockX | (blockX & 0xF); + } + + /** + * Calculates a new absolute Z such that the given absolute Z is inside this chunk + * at the same relative location as the given Z was relative to its source chunk. + * + * @param blockZ in block coordinates + * @return an Z within the bounds of this chunk + */ + public final int relocateZ(int blockZ) { + return minBlockZ | (blockZ & 0xF); + } + + public final IntPointXZ relocate(IntPointXZ blockXZ) { + return new IntPointXZ(minBlockX | (blockXZ.getX() & 0xF), minBlockZ | (blockXZ.getZ() & 0xF)); + } + + public final IntPointXZ relocate(int blockX, int blockZ) { + return new IntPointXZ(minBlockX | (blockX & 0xF), minBlockZ | (blockZ & 0xF)); + } + + /** + * Calculates a new absolute X such that the given absolute X is inside this chunk + * at the same relative location as the given X was relative to its source chunk. + * + * @param blockX in block coordinates + * @return an X within the bounds of this chunk + */ + public final double relocateX(double blockX) { + double bin = blockX % 16; + return (bin >= 0 ? minBlockX : maxBlockX) + bin; + } + + /** + * Calculates a new absolute Z such that the given absolute Z is inside this chunk + * at the same relative location as the given Z was relative to its source chunk. + * + * @param blockZ in block coordinates + * @return an Z within the bounds of this chunk + */ + public final double relocateZ(double blockZ) { + double bin = blockZ % 16; + return (bin >= 0 ? minBlockZ : maxBlockZ) + bin; + } + + public BlockAlignedBoundingRectangle asBlockBounds() { + return new BlockAlignedBoundingRectangle(getMinBlockX(), getMinBlockZ(), getWidthBlockXZ()); + } + + @Override + public String toString() { + return String.format("chunks[%d..%d, %d..%d]", + getMinChunkX(), getMaxChunkX() - 1, getMinChunkZ(), getMaxChunkZ() - 1); + } + + public static ChunkBoundingRectangle of(Collection chunks) { + return of(chunks, IntPointXZ::getX, IntPointXZ::getZ); + } + + public static ChunkBoundingRectangle of(Collection chunks, ToIntFunction xGetter, ToIntFunction zGetter) { + if (chunks == null || chunks.isEmpty()) + return null; + int minX = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int minZ = Integer.MAX_VALUE; + int maxZ = Integer.MIN_VALUE; + for (T xz : chunks) { + int x = xGetter.applyAsInt(xz); + int z = zGetter.applyAsInt(xz); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + } + return new ChunkBoundingRectangle(minX, minZ, Math.max(maxX - minX, maxZ - minZ) + 1); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/ChunkIterator.java b/src/main/java/io/github/ensgijs/nbt/mca/util/ChunkIterator.java new file mode 100644 index 00000000..6f865927 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/ChunkIterator.java @@ -0,0 +1,79 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.mca.ChunkBase; +import io.github.ensgijs.nbt.mca.McaFileBase; + +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(); + + /** + * Note this value is calculated from the iterators position and is therefore known even if the chunk is null. + * + * @return Current chunk x within region in range [0-31] + */ + default int currentX(){ + return currentIndex() & 0x1F; + } + + /** + * Note this value is calculated from the iterators position and is therefore known even if the chunk is null. + * + * @return Current chunk z within region in range [0-31] + */ + default int currentZ() { + return (currentIndex() >> 5) & 0x1F; + } + + /** + * Note this value is calculated from the iterators position and is therefore known even if the chunk is null. + * + * @return Current chunk xz within region, both x and z will be in range [0-31] + */ + default IntPointXZ currentXZ() { + return new IntPointXZ(currentX(), currentZ()); + } + + /** + * Note this value is calculated from the iterators position not read from {@link ChunkBase#getChunkX()} and is + * therefore known even if the chunk is null. If the chunk is not null and there is a mismatch between this + * value and that returned by {@link ChunkBase#getChunkX()} then the chunk is "out of place" and should be + * moved / corrected. + * + * @return Current chunk x in absolute coordinates (not block coordinates). + * @see ChunkBase#moveChunk(int, int, long) + */ + int currentAbsoluteX(); + + /** + * Note this value is calculated from the iterators position not read from {@link ChunkBase#getChunkZ()} and is + * therefore known even if the chunk is null. If the chunk is not null and there is a mismatch between this + * value and that returned by {@link ChunkBase#getChunkZ()} then the chunk is "out of place" and should be + * moved / corrected. + * + * @return Current chunk z in absolute coordinates (not block coordinates) + * @see ChunkBase#moveChunk(int, int, long) + */ + int currentAbsoluteZ(); + + default IntPointXZ currentAbsoluteXZ() { + return new IntPointXZ(currentAbsoluteX(), currentAbsoluteZ()); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/IntPointXYZ.java b/src/main/java/io/github/ensgijs/nbt/mca/util/IntPointXYZ.java new file mode 100644 index 00000000..38109514 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/IntPointXYZ.java @@ -0,0 +1,125 @@ +package io.github.ensgijs.nbt.mca.util; + +/** + * An immutable Minecraft style 3D point. + *

Because this class is immutable (x, y, and z cannot be changed), you don't need to worry about + * cloning it or copying it - if you need the same value somewhere else, just use the same instance!

+ *

Use the shorthand way of creating an instance by staticly importing the {@link #XYZ(int, int, int)} method.
+ *

{@code import static IntPointXYZ.XYZ;
+ * IntPointXYZ xyz = XYZ(1, 2, 3);}
+ */ +public class IntPointXYZ extends IntPointXZ { + public static final IntPointXYZ ZERO_XYZ = new IntPointXYZ(0, 0, 0); + protected final int y; + + /** Shorthand way of constructing a new {@link IntPointXYZ}. */ + public static IntPointXYZ XYZ(int x, int y, int z) { + return new IntPointXYZ(x, y, z); + } + + public IntPointXYZ(int x, int y, int z) { + super(x, z); + this.y = y; + } + + public int getY() { + return y; + } + + public IntPointXYZ multiply(int multiplier) { + return new IntPointXYZ(x * multiplier, y * multiplier, z * multiplier); + } + + public IntPointXYZ multiply(IntPointXZ multiplier) { + return new IntPointXYZ(x * multiplier.x, y, z * multiplier.z); + } + + public IntPointXYZ multiply(IntPointXYZ multiplier) { + return new IntPointXYZ(x * multiplier.x, y * multiplier.y, z * multiplier.z); + } + + public IntPointXYZ divide(int denominator) { + return new IntPointXYZ(x / denominator, y / denominator, z / denominator); + } + + public IntPointXYZ divide(IntPointXZ denominator) { + return new IntPointXYZ(x / denominator.x, y, z / denominator.z); + } + + public IntPointXYZ divide(IntPointXYZ denominator) { + return new IntPointXYZ(x / denominator.x, y / denominator.y, z / denominator.z); + } + + public IntPointXYZ add(IntPointXZ other) { + return new IntPointXYZ(x + other.x, y, z + other.z); + } + + public IntPointXYZ add(IntPointXYZ other) { + return new IntPointXYZ(x + other.x, y + other.y, z + other.z); + } + + public IntPointXYZ add(int x, int z) { + return new IntPointXYZ(this.x + x, y, this.z + z); + } + + public IntPointXYZ add(int x, int y, int z) { + return new IntPointXYZ(this.x + x, this.y + y, this.z + z); + } + + public IntPointXYZ subtract(IntPointXZ other) { + return new IntPointXYZ(x - other.x, y, z - other.z); + } + + public IntPointXYZ subtract(IntPointXYZ other) { + return new IntPointXYZ(x - other.x, y - other.y, z - other.z); + } + + public IntPointXYZ subtract(int x, int z) { + return new IntPointXYZ(this.x - x, y, this.z - z); + } + + public IntPointXYZ subtract(int x, int y, int z) { + return new IntPointXYZ(this.x - x, this.y - y, this.z - z); + } + + public IntPointXYZ transformBlockToChunkSection() { + return new IntPointXYZ(x >> 4, y >> 4, z >> 4); + } + + public IntPointXYZ transformChunkSectionToBlock() { + return new IntPointXYZ(x << 4, y << 4, z << 4); + } + + @Override + public int hashCode() { + return (y * 31 + x) * 31 + z; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof IntPointXYZ)) return false; + final IntPointXYZ o = (IntPointXYZ) other; + return this.x == o.x && this.y == o.y && this.z == o.z; + } + + public boolean equals(int x, int y, int z) { + return this.x == x && this.y == y && this.z == z; + } + + @Override + public String toString() { + return x + " " + y + " " + z; + } + + /** + * @param format must contain exactly 3 {@code %d} placeholders. + */ + public String toString(String format) { + return String.format(format, x, y, z); + } + + @Override + public boolean isZero() { + return super.isZero() && y == 0; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/IntPointXZ.java b/src/main/java/io/github/ensgijs/nbt/mca/util/IntPointXZ.java new file mode 100644 index 00000000..b474f5ec --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/IntPointXZ.java @@ -0,0 +1,149 @@ +package io.github.ensgijs.nbt.mca.util; + +/** + * An immutable Minecraft style 2D point where the dimensions are X and Z. + *

Because this class is immutable (x and z cannot be changed), you don't need to worry about + * cloning it or copying it - if you need the same value somewhere else, just use the same instance!

+ */ +public class IntPointXZ { + public static final IntPointXZ ZERO_XZ = new IntPointXZ(0, 0); + + protected final int x; + protected final int z; + + /** Shorthand way of constructing a new {@link IntPointXZ}. */ + public static IntPointXZ XZ(int x, int z) { + return new IntPointXZ(x, z); + } + + public IntPointXZ(int x, int z) { + this.x = x; + this.z = z; + } + + public int getX() { + return x; + } + + public int getZ() { + return z; + } + + public IntPointXZ multiply(int multiplier) { + return new IntPointXZ(x * multiplier, z * multiplier); + } + + public IntPointXZ multiply(IntPointXZ multiplier) { + return new IntPointXZ(x * multiplier.x, z * multiplier.z); + } + + public IntPointXZ multiply(IntPointXYZ multiplier) { + return new IntPointXZ(x * multiplier.x, z * multiplier.z); + } + + public IntPointXZ divide(int denominator) { + return new IntPointXZ(x / denominator, z / denominator); + } + + public IntPointXZ divide(IntPointXZ denominator) { + return new IntPointXZ(x / denominator.x, z / denominator.z); + } + + public IntPointXZ divide(IntPointXYZ denominator) { + return new IntPointXZ(x / denominator.x, z / denominator.z); + } + + public IntPointXZ add(IntPointXZ other) { + return new IntPointXZ(x + other.x, z + other.z); + } + + public IntPointXZ add(IntPointXYZ other) { + return new IntPointXZ(x + other.x, z + other.z); + } + + public IntPointXZ add(int x, int z) { + return new IntPointXZ(this.x + x, this.z + z); + } + + public IntPointXZ subtract(IntPointXZ other) { + return new IntPointXZ(x - other.x, z - other.z); + } + + public IntPointXZ subtract(IntPointXYZ other) { + return new IntPointXZ(x - other.x, z - other.z); + } + + public IntPointXZ subtract(int x, int z) { + return new IntPointXZ(this.x - x, this.z - z); + } + + public IntPointXZ transformBlockToChunk() { + return new IntPointXZ(x >> 4, z >> 4); + } + + public IntPointXZ transformChunkToRegion() { + return new IntPointXZ(x >> 5, z >> 5); + } + + public IntPointXZ transformBlockToRegion() { + return new IntPointXZ(x >> 9, z >> 9); + } + + public IntPointXZ transformRegionToBlock() { + return new IntPointXZ(x << 9, z << 9); + } + + public IntPointXZ transformRegionToChunk() { + return new IntPointXZ(x << 5, z << 5); + } + + public IntPointXZ transformChunkToBlock() { + return new IntPointXZ(x << 4, z << 4); + } + + @Override + public int hashCode() { + return x * 31 + z; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof IntPointXZ)) return false; + final IntPointXZ o = (IntPointXZ) other; + return this.x == o.x && this.z == o.z; + } + + public boolean equals(int x, int z) { + return this.x == x && this.z == z; + } + + @Override + public String toString() { + return x + " " + z; + } + + /** + * @param format must contain exactly 2 {@code %d} placeholders. + */ + public String toString(String format) { + return String.format(format, x, z); + } + + public static IntPointXZ unpack(long xzLong) { + int x = (int) (xzLong & 0xFFFFFFFFL); + int z = (int) ((xzLong >>> 32) & 0xFFFFFFFFL); + return new IntPointXZ(x, z); + } + + public static long pack(int x, int z) { + return (((long) z) << 32) | ((long) x & 0xFFFFFFFFL); + } + + public static long pack(IntPointXZ xz) { + return pack(xz.x, xz.z); + } + + public boolean isZero() { + return x == 0 && z == 0; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/LegacyBiomes.java b/src/main/java/io/github/ensgijs/nbt/mca/util/LegacyBiomes.java new file mode 100644 index 00000000..1a0fc81b --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/LegacyBiomes.java @@ -0,0 +1,238 @@ +package io.github.ensgijs.nbt.mca.util; + + +import java.util.Arrays; +import java.util.Locale; + +import static io.github.ensgijs.nbt.mca.DataVersion.JAVA_1_13_18W06A; +import static io.github.ensgijs.nbt.mca.DataVersion.JAVA_1_18_21W37A; + +/** + * Provides data version aware integer to/from string conversion up to {@link io.github.ensgijs.nbt.mca.DataVersion#JAVA_1_18_21W37A} (exclusive). + */ +public final class LegacyBiomes { + private LegacyBiomes() {} + + public static boolean versionHasLegacyBiomes(int dataVersion) { + return dataVersion < JAVA_1_18_21W37A.id(); + } + + // TODO: add minimum data version + private record Biome(int id, String name, String keyedName) { + static Biome of(int id, String name) { + return new Biome(id, name, "minecraft:" + name); + } + } + + // Mappings copied from MC source. + // Note that these exact version id's (1506 & 2832) were never publicly released so the question of + // inclusive vs exclusive is irrelevant. + + /** Mappings for data-version range (..JAVA_1_13_PRE4] */ + private static final Biome[] before1506; // up to data-version 1506 (between JAVA_1_13_PRE4 & JAVA_1_13_PRE5). + /** Mappings for data-version range [JAVA_1_13_PRE5..JAVA_1_18_21W37A) */ + private static final Biome[] after1506; // up to data-version 2832 (between JAVA_1_17_1/JAVA_1_18_XS7 & JAVA_1_18_21W37A). + + static { + + // up to data-version 1506 (between JAVA_1_13_PRE4 & JAVA_1_13_PRE5). + before1506 = new Biome[168]; + before1506[0] = Biome.of(0, "ocean"); + before1506[1] = Biome.of(1, "plains"); + before1506[2] = Biome.of(2, "desert"); + before1506[3] = Biome.of(3, "mountains"); + before1506[4] = Biome.of(4, "forest"); + before1506[5] = Biome.of(5, "taiga"); + before1506[6] = Biome.of(6, "swamp"); + before1506[7] = Biome.of(7, "river"); + before1506[8] = Biome.of(8, "nether"); + before1506[9] = Biome.of(9, "the_end"); + before1506[10] = Biome.of(10, "frozen_ocean"); + before1506[11] = Biome.of(11, "frozen_river"); + before1506[12] = Biome.of(12, "snowy_tundra"); + before1506[13] = Biome.of(13, "snowy_mountains"); + before1506[14] = Biome.of(14, "mushroom_fields"); + before1506[15] = Biome.of(15, "mushroom_field_shore"); + before1506[16] = Biome.of(16, "beach"); + before1506[17] = Biome.of(17, "desert_hills"); + before1506[18] = Biome.of(18, "wooded_hills"); + before1506[19] = Biome.of(19, "taiga_hills"); + before1506[20] = Biome.of(20, "mountain_edge"); + before1506[21] = Biome.of(21, "jungle"); + before1506[22] = Biome.of(22, "jungle_hills"); + before1506[23] = Biome.of(23, "jungle_edge"); + before1506[24] = Biome.of(24, "deep_ocean"); + before1506[25] = Biome.of(25, "stone_shore"); + before1506[26] = Biome.of(26, "snowy_beach"); + before1506[27] = Biome.of(27, "birch_forest"); + before1506[28] = Biome.of(28, "birch_forest_hills"); + before1506[29] = Biome.of(29, "dark_forest"); + before1506[30] = Biome.of(30, "snowy_taiga"); + before1506[31] = Biome.of(31, "snowy_taiga_hills"); + before1506[32] = Biome.of(32, "giant_tree_taiga"); + before1506[33] = Biome.of(33, "giant_tree_taiga_hills"); + before1506[34] = Biome.of(34, "wooded_mountains"); + before1506[35] = Biome.of(35, "savanna"); + before1506[36] = Biome.of(36, "savanna_plateau"); + before1506[37] = Biome.of(37, "badlands"); + before1506[38] = Biome.of(38, "wooded_badlands_plateau"); + before1506[39] = Biome.of(39, "badlands_plateau"); + before1506[40] = Biome.of(40, "small_end_islands"); + before1506[41] = Biome.of(41, "end_midlands"); + before1506[42] = Biome.of(42, "end_highlands"); + before1506[43] = Biome.of(43, "end_barrens"); + before1506[44] = Biome.of(44, "warm_ocean"); + before1506[45] = Biome.of(45, "lukewarm_ocean"); + before1506[46] = Biome.of(46, "cold_ocean"); + before1506[47] = Biome.of(47, "deep_warm_ocean"); + before1506[48] = Biome.of(48, "deep_lukewarm_ocean"); + before1506[49] = Biome.of(49, "deep_cold_ocean"); + before1506[50] = Biome.of(50, "deep_frozen_ocean"); + before1506[127] = Biome.of(127, "the_void"); + before1506[129] = Biome.of(129, "sunflower_plains"); + before1506[130] = Biome.of(130, "desert_lakes"); + before1506[131] = Biome.of(131, "gravelly_mountains"); + before1506[132] = Biome.of(132, "flower_forest"); + before1506[133] = Biome.of(133, "taiga_mountains"); + before1506[134] = Biome.of(134, "swamp_hills"); + before1506[140] = Biome.of(140, "ice_spikes"); + before1506[149] = Biome.of(149, "modified_jungle"); + before1506[151] = Biome.of(151, "modified_jungle_edge"); + before1506[155] = Biome.of(155, "tall_birch_forest"); + before1506[156] = Biome.of(156, "tall_birch_hills"); + before1506[157] = Biome.of(157, "dark_forest_hills"); + before1506[158] = Biome.of(158, "snowy_taiga_mountains"); + before1506[160] = Biome.of(160, "giant_spruce_taiga"); + before1506[161] = Biome.of(161, "giant_spruce_taiga_hills"); + before1506[162] = Biome.of(162, "modified_gravelly_mountains"); + before1506[163] = Biome.of(163, "shattered_savanna"); + before1506[164] = Biome.of(164, "shattered_savanna_plateau"); + before1506[165] = Biome.of(165, "eroded_badlands"); + before1506[166] = Biome.of(166, "modified_wooded_badlands_plateau"); + before1506[167] = Biome.of(167, "modified_badlands_plateau"); + + // up to data-version 2832 (between JAVA_1_17_1/JAVA_1_18_XS7 & JAVA_1_18_21W37A) - after this MC went to palette biomes + after1506 = new Biome[183]; + after1506[0] = Biome.of(0, "ocean"); + after1506[1] = Biome.of(1, "plains"); + after1506[2] = Biome.of(2, "desert"); + after1506[3] = Biome.of(3, "mountains"); + after1506[4] = Biome.of(4, "forest"); + after1506[5] = Biome.of(5, "taiga"); + after1506[6] = Biome.of(6, "swamp"); + after1506[7] = Biome.of(7, "river"); + after1506[8] = Biome.of(8, "nether_wastes"); + after1506[9] = Biome.of(9, "the_end"); + after1506[10] = Biome.of(10, "frozen_ocean"); + after1506[11] = Biome.of(11, "frozen_river"); + after1506[12] = Biome.of(12, "snowy_tundra"); + after1506[13] = Biome.of(13, "snowy_mountains"); + after1506[14] = Biome.of(14, "mushroom_fields"); + after1506[15] = Biome.of(15, "mushroom_field_shore"); + after1506[16] = Biome.of(16, "beach"); + after1506[17] = Biome.of(17, "desert_hills"); + after1506[18] = Biome.of(18, "wooded_hills"); + after1506[19] = Biome.of(19, "taiga_hills"); + after1506[20] = Biome.of(20, "mountain_edge"); + after1506[21] = Biome.of(21, "jungle"); + after1506[22] = Biome.of(22, "jungle_hills"); + after1506[23] = Biome.of(23, "jungle_edge"); + after1506[24] = Biome.of(24, "deep_ocean"); + after1506[25] = Biome.of(25, "stone_shore"); + after1506[26] = Biome.of(26, "snowy_beach"); + after1506[27] = Biome.of(27, "birch_forest"); + after1506[28] = Biome.of(28, "birch_forest_hills"); + after1506[29] = Biome.of(29, "dark_forest"); + after1506[30] = Biome.of(30, "snowy_taiga"); + after1506[31] = Biome.of(31, "snowy_taiga_hills"); + after1506[32] = Biome.of(32, "giant_tree_taiga"); + after1506[33] = Biome.of(33, "giant_tree_taiga_hills"); + after1506[34] = Biome.of(34, "wooded_mountains"); + after1506[35] = Biome.of(35, "savanna"); + after1506[36] = Biome.of(36, "savanna_plateau"); + after1506[37] = Biome.of(37, "badlands"); + after1506[38] = Biome.of(38, "wooded_badlands_plateau"); + after1506[39] = Biome.of(39, "badlands_plateau"); + after1506[40] = Biome.of(40, "small_end_islands"); + after1506[41] = Biome.of(41, "end_midlands"); + after1506[42] = Biome.of(42, "end_highlands"); + after1506[43] = Biome.of(43, "end_barrens"); + after1506[44] = Biome.of(44, "warm_ocean"); + after1506[45] = Biome.of(45, "lukewarm_ocean"); + after1506[46] = Biome.of(46, "cold_ocean"); + after1506[47] = Biome.of(47, "deep_warm_ocean"); + after1506[48] = Biome.of(48, "deep_lukewarm_ocean"); + after1506[49] = Biome.of(49, "deep_cold_ocean"); + after1506[50] = Biome.of(50, "deep_frozen_ocean"); + after1506[127] = Biome.of(127, "the_void"); + after1506[129] = Biome.of(129, "sunflower_plains"); + after1506[130] = Biome.of(130, "desert_lakes"); + after1506[131] = Biome.of(131, "gravelly_mountains"); + after1506[132] = Biome.of(132, "flower_forest"); + after1506[133] = Biome.of(133, "taiga_mountains"); + after1506[134] = Biome.of(134, "swamp_hills"); + after1506[140] = Biome.of(140, "ice_spikes"); + after1506[149] = Biome.of(149, "modified_jungle"); + after1506[151] = Biome.of(151, "modified_jungle_edge"); + after1506[155] = Biome.of(155, "tall_birch_forest"); + after1506[156] = Biome.of(156, "tall_birch_hills"); + after1506[157] = Biome.of(157, "dark_forest_hills"); + after1506[158] = Biome.of(158, "snowy_taiga_mountains"); + after1506[160] = Biome.of(160, "giant_spruce_taiga"); + after1506[161] = Biome.of(161, "giant_spruce_taiga_hills"); + after1506[162] = Biome.of(162, "modified_gravelly_mountains"); + after1506[163] = Biome.of(163, "shattered_savanna"); + after1506[164] = Biome.of(164, "shattered_savanna_plateau"); + after1506[165] = Biome.of(165, "eroded_badlands"); + after1506[166] = Biome.of(166, "modified_wooded_badlands_plateau"); + after1506[167] = Biome.of(167, "modified_badlands_plateau"); + after1506[168] = Biome.of(168, "bamboo_jungle"); + after1506[169] = Biome.of(169, "bamboo_jungle_hills"); + after1506[170] = Biome.of(170, "soul_sand_valley"); + after1506[171] = Biome.of(171, "crimson_forest"); + after1506[172] = Biome.of(172, "warped_forest"); + after1506[173] = Biome.of(173, "basalt_deltas"); + after1506[174] = Biome.of(174, "dripstone_caves"); + after1506[175] = Biome.of(175, "lush_caves"); + after1506[177] = Biome.of(177, "meadow"); + after1506[178] = Biome.of(178, "grove"); + after1506[179] = Biome.of(179, "snowy_slopes"); + after1506[180] = Biome.of(180, "snowcapped_peaks"); + after1506[181] = Biome.of(181, "lofty_peaks"); + after1506[182] = Biome.of(182, "stony_peaks"); + } + + + public static String name(int dataVersion, int id) { + if (id < 0) return null; + Biome b; + if (dataVersion > 1506) { + b = after1506[id]; + } else { + b = before1506[id]; + } + return b != null ? b.name : null; + } + + public static String keyedName(int dataVersion, int id) { + if (id < 0) return null; + Biome b; + if (dataVersion > 1506) { + b = after1506[id]; + } else { + b = before1506[id]; + } + return b != null ? b.keyedName : null; + } + + public static int id(int dataVersion, String name) { + if (name == null) return -1; + name = name.toLowerCase(Locale.ENGLISH); + final String searchFor = !name.startsWith("minecraft:") ? name : name.substring(10); + return Arrays.stream(dataVersion >= JAVA_1_13_18W06A.id() ? after1506 : before1506) + .filter(b -> b.name.equals(searchFor)) + .findFirst() + .map(Biome::id) + .orElse(-1); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/LongArrayTagPackedIntegers.java b/src/main/java/io/github/ensgijs/nbt/mca/util/LongArrayTagPackedIntegers.java new file mode 100644 index 00000000..9d08ead0 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/LongArrayTagPackedIntegers.java @@ -0,0 +1,1049 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.mca.DataVersion; +import io.github.ensgijs.nbt.mca.TerrainChunk; +import io.github.ensgijs.nbt.tag.LongArrayTag; +import io.github.ensgijs.nbt.util.ArgValidator; + +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.ListIterator; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.function.IntPredicate; + +import static io.github.ensgijs.nbt.mca.DataVersion.JAVA_1_16_20W17A; +import static io.github.ensgijs.nbt.mca.DataVersion.UNKNOWN; + +/** + * A packed array of numeric values stored in an array of 64-bit integers. Each value + * occupies a fixed number of bits, that number of bits is not necessarily a constant, it can be resized as-needed, + * but each value occupies the same number of bits as all others. + * + *

This class maintains its values in the packed long[] in the {@link LongArrayTag} given to / created at + * construction time. This can result in significant memory savings over when working with a large number of + * packed value arrays but will, of course, use more CPU to get and set values than an int[] would. + * If you would prefer to work with int[]'s you can use the family of {@link #toArray} and {@link #setFromArray} + * functions.

+ * + *

Negative values are supported by the use of {@link #setValueOffset(int)}, the actual stored data + * is always GE 0, valueOffset is for your convenience when accessing and modifying the data. + * See below about heightmaps.

+ * + *

This class supports automatic increasing resizes as-needed on any calls which add or modify values + * and provides {@link #compact()} to compact and shrink the long[] buffer to the minimum required size + * while respecting {@link #getMinBitsPerValue()}.

+ * + *

WARNING: The user is responsible for calling {@link #compact()} before storing the {@link LongArrayTag} + * in MCA data! Failing to do so may cause Minecraft to fail to properly interpret the values. Getting the + * tag via {@link #updateHandle()} does this for you.

+ * + *

The packing strategy may be changed to facilitate upgrade/downgrade operations by calling + * {@link #setPackingStrategy(PackingStrategy)}

+ * + *

Tip: If you are using this class to create packed ints from scratch it is recommended to add values in + * descending order (or at least start with larger values rather than smaller ones) to minimize the number + * of resizes that occur. OR to take advantage of the {@link Builder#initializeForStoring(int)} which will + * initialize the long[] with sufficient capacity to store values of at least the specified magnitude.

+ * + *

Even if you are not creating a new packed buffer from scratch you still should specify a correct + * {@link Builder#initializeForStoring(int)}. This value can usually be computed from context outside the + * packed buffer itself such as the palette size minus 1. + * + *

About Heightmaps

+ * In the case of heightmap data {@link Builder#initializeForStoring(int)} should be set + * to the world build height {@code chunk. * 16 + 15} and also set + * {@link Builder#valueOffset(int)} to {@code chunk.getChunkY() * 16 - 1} + * + *

In some cases it is sufficient to only specify {@link Builder#minBitsPerValue(int)}, really there is + * a single case where this is valid and that is for Heightmaps which should use a minBitsPerValue of 9 and + * so long as the assumption that the world build height will remain unchanged from about -64 to 320, and + * that no world height modifying datapacks are used, it will work. Note that 9 bits gives room for the + * value range of [0..511] - but you'll still want to set {@link Builder#valueOffset(int)} to abstract away + * the world bottom offset.

+ * + *

Tip: since we're on the subject of heightmap data - it's not stored as blockY, + * instead it's stored as number of blocks from world bottom which means that for worlds which bottom out + * at Y=0 you'll want to set valueOffset = -1 to account for this. The above formula will always work and + * should be preferred over setting a hard coded -1.

+ * + * @see #builder() + */ +public class LongArrayTagPackedIntegers implements TagWrapper, Iterable, Cloneable { + + public static final VersionAware MOJANG_PACKING_STRATEGY = new VersionAware() + // technically, I don't believe long packing was used in any form until JAVA_1_12_2... or was it JAVA_1_13_17W47A + .register(UNKNOWN.next().id(), PackingStrategy.SPLIT_VALUES_ACROSS_LONGS) + .register(JAVA_1_16_20W17A.id(), PackingStrategy.NO_SPLIT_VALUES_ACROSS_LONGS); + + // + @FunctionalInterface + public interface RemapFunction { + int remap(int value); + } + + public enum PackingStrategy { + /** + * Values are never split across longs resulting in unused bits in every backing long if 64 is not + * evenly divisible by the bits per value + *

While this strategy wastes more bits, it is computationally + * simpler, uses no floating point math, and is therefore faster.

+ * @since {@link io.github.ensgijs.nbt.mca.DataVersion#JAVA_1_16_20W17A} + */ + NO_SPLIT_VALUES_ACROSS_LONGS, + // Packing Math (SPLIT_VALUES_ACROSS_LONGS): + // L := number of longs required + // C := capacity + // B := bits per value + // 64:= bits per long + // L = ceil(C * B / 64d) + // B = floor(L * 64d / C) + // C = floor(L * 64d / B) + /** + * Values are tightly packed and are split across backing longs if 64 is not evenly divisible by the bits + * per value. This results in a minimum number of unused bits. In fact the only place unused bits may exist + * when using this strategy is in the last long in the array. + */ + SPLIT_VALUES_ACROSS_LONGS + } + + + /** + * The only way to construct a {@link LongArrayTagPackedIntegers} because its constructor is too gnarly to + * expose directly and would require a lot of "hey those look confusingly similar" overloads. + */ + public static class Builder { + private int capacity; + private int valueOffset = 0; + private int minBitsPerValue; + private int initializeForStoring = Integer.MIN_VALUE; + private PackingStrategy packingStrategy = MOJANG_PACKING_STRATEGY.get(DataVersion.latest().id()); + + /** Use {@link #builder()} instead. */ + private Builder() {} + + /** + * Count of values to be stored, this value cannot be changed once built and + * is usually one of: 64, 256, 4096. + */ + public Builder length(int length) { + ArgValidator.check(length > 0); + this.capacity = length; + return this; + } + + /** + * Value offset which is applied to all values as they pass through the various store and retrieve functions. + *

In practice this is (currently) only applicable to Heightmap data which always has a negative component.

+ */ + public Builder valueOffset(int valueOffset) { + this.valueOffset = valueOffset; + return this; + } + + /** + * The minimum bits per value used to store data. {@link #compact()} will respect this setting. + *

For all MC versions to date: + *

    + *
  • Block palettes must specify 4
  • + *
  • Biome palettes must specify 1
  • + *
  • Heightmaps must specify 9
  • + *
+ */ + public Builder minBitsPerValue(int minBitsPerValue) { + ArgValidator.check(minBitsPerValue >= 0); + this.minBitsPerValue = minBitsPerValue; + return this; + } + + /** + * Computes an initial bits per value sufficient to hold the specified largestValue (inclusive). + * Therefor making it possible to add values from {@link #valueOffset} up to this value (both inclusive) + * without triggering a long[] resize. + * + *

It is important to set this value! This value can usually be computed from context outside the + * packed buffer itself such as the palette size minus 1. For details about heightmaps see + * {@link LongArrayTagPackedIntegers} class docs.

+ * + *

This value is taken relative to {@link #valueOffset} to compute the number of bits per value required.

+ */ + public Builder initializeForStoring(int largestValue) { + ArgValidator.check(largestValue >= 0); + this.initializeForStoring = largestValue; + return this; + } + + /** + * The packing strategy controls how bits are packed into the long[]. + *

{@link #dataVersion} is generally more useful.

+ * Use a static import to keep code lines with calls to this method from being overly long: + *
{@code
+         * import static io.github.ensgijs.nbt.mca.util.LongArrayTagPackedIntegers.PackingStrategy.NO_SPLIT_VALUES_ACROSS_LONGS;
+         * import static io.github.ensgijs.nbt.mca.util.LongArrayTagPackedIntegers.PackingStrategy.SPLIT_VALUES_ACROSS_LONGS;
+         * }
+ * @see #dataVersion + */ + public Builder packingStrategy(PackingStrategy packingStrategy) { + this.packingStrategy = packingStrategy; + return this; + } + + /** + * Sets the packing strategy based on data version. + * @param dataVersion if EQ 0 then {@link DataVersion#latest()}.id() is used. + */ + public Builder dataVersion(int dataVersion) { + ArgValidator.check(dataVersion >= 0); + this.packingStrategy = MOJANG_PACKING_STRATEGY.get( + dataVersion > 0 ? dataVersion : DataVersion.latest().id()); + return this; + } + + /** + * Sets the packing strategy based on data version. + * @param dataVersion if EQ {@link DataVersion#UNKNOWN} then {@link DataVersion#latest()} is used. + */ + public Builder dataVersion(DataVersion dataVersion) { + ArgValidator.check(dataVersion != null); + return dataVersion(dataVersion.id()); + } + + /** + * Sets the capacity to match the length of the given values and sets an appropriate initialBitsPerValue. + * + *

Note: while {@link #initializeForStoring(int)} and {@link #length(int)} will be set for you, + * it's still important to properly set {@link #minBitsPerValue(int)} and {@link #valueOffset(int)}.

+ */ + public LongArrayTagPackedIntegers build(int[] values) { + capacity = values.length; + LongArrayTagPackedIntegers packed = build(new LongArrayTag()); + packed.setFromArray(values); + return packed; + } + + /** Builds using a new {@link LongArrayTag} as the long[] buffer. */ + public LongArrayTagPackedIntegers build() { + return build(new LongArrayTag()); + } + + /** + * Builds using the given {@link LongArrayTag} as the long[] buffer. + *

It's not possible to reliably compute the required packing parameters given a long[] alone. + * You must specify at least {@link #dataVersion} and ({@link #initializeForStoring(int)} or + * {@link #minBitsPerValue(int)}).

+ */ + public LongArrayTagPackedIntegers build(LongArrayTag tag) { + ArgValidator.requireValue(tag); + if (capacity == 0) + throw new IllegalArgumentException("capacity is required"); + if (packingStrategy == null) + throw new IllegalArgumentException("packingStrategy or dataVersion is required"); + int initialBitsPerValue = 0; + if (initializeForStoring != Integer.MIN_VALUE) { + initialBitsPerValue = calculateBitsRequired(initializeForStoring - valueOffset); + } + return new LongArrayTagPackedIntegers( + tag, packingStrategy, capacity, minBitsPerValue, initialBitsPerValue, valueOffset); + } + } + + /** Creates a new builder. */ + public static Builder builder() { + return new Builder(); + } + //
+ + /** Number of values stored - not the length of the long array. */ + public final int length; + /** + * DO NOT ACCESS DIRECTLY + * @see #cubeEdgeLength() + */ + private int cubeEdgeLength; + /** + * DO NOT ACCESS DIRECTLY + * @see #squareEdgeLength() + */ + private int squareEdgeLength; + private final LongArrayTag packedBitsTag; + private int valueOffset; + + private PackingStrategy packingStrategy; + private long[] packedBits; + private int minBitsPerValue; + private int bitsPerValue; + /** Inclusive bound, does NOT include valueOffset */ + private int currentMaxPackableValue; + private int noSplitIndicesPerLong; + private double splitIndicesPerLong; + + /** set to -1 if length does not have an integer cube root */ + public int cubeEdgeLength() { + if (cubeEdgeLength > 0) { + return cubeEdgeLength; + } + int tmp = (int) Math.round(Math.pow(length, 1/3d)); + return this.cubeEdgeLength = tmp * tmp * tmp == length ? tmp : -1; + } + + /** set to -1 if length does not have an integer square root */ + public int squareEdgeLength() { + if (squareEdgeLength > 0) { + return squareEdgeLength; + } + int tmp = (int) Math.round(Math.sqrt(length)); + return this.squareEdgeLength = tmp * tmp == length ? tmp : -1; + } + + /** + * @param tag Tag with existing longs. If this tag's {@link LongArrayTag#getValue()#length} is 0 it is initialized + * with an appropriately sized long[], otherwise this length is validated based on the other provided arguments. + * @param packingStrategy Controls how values are packed into the long array. + * Prior to {@link io.github.ensgijs.nbt.mca.DataVersion#JAVA_1_16_20W17A} + * {@link PackingStrategy#SPLIT_VALUES_ACROSS_LONGS} was used, from this version on + * {@link PackingStrategy#NO_SPLIT_VALUES_ACROSS_LONGS} is used. + * @param length Count of values to be stored, this value cannot be changed and is usually one of: 64, 256, 4096 + * @param minBitsPerValue Minimum bits per value used to store values. Must be GE 1. + * In practice for unchanging things (such as biomes) this is always 1 and for things which + * may change often (such as block palettes) this is always 4. Presumably setting this value + * above 1 reduces frequent resizing. + * @param initialBitsPerValue Specifies the number of bits-per-value to initialize to / use to decode the initial + * long[] buffer. If minBitsPerValue is GT this value, this value has no effect. + * @param valueOffset Translates the values passed to / returned from {@link #set(int, int)} and {@link #get(int)} + * by this fixed amount. + *

The stored values are always GE 0, however, since height map data is packed and those + * values may represent negative values you can set a valueOffset of (-65 - or more precisely + * {@link TerrainChunk#getChunkY()} * 64 - 1) to interact with this LongArrayTagPackedIntegers + * instance in world coordinates instead of having to calculate those offsets yourself.

+ */ + private LongArrayTagPackedIntegers(LongArrayTag tag, PackingStrategy packingStrategy, int length, int minBitsPerValue, int initialBitsPerValue, int valueOffset) { + ArgValidator.requireValue(tag, "tag"); + ArgValidator.requireValue(packingStrategy, "packingStrategy"); + ArgValidator.check(minBitsPerValue > 0 && minBitsPerValue < 32, "minBitsPerValue must be in range [1..31]"); + this.packingStrategy = packingStrategy; + this.length = length; + this.minBitsPerValue = minBitsPerValue; + this.bitsPerValue = Math.max(minBitsPerValue, initialBitsPerValue); + this.valueOffset = valueOffset; + this.packedBitsTag = tag; + int expectLongCount; + if (packingStrategy == PackingStrategy.NO_SPLIT_VALUES_ACROSS_LONGS) { + expectLongCount = (int) Math.ceil(length / (double) (64 / bitsPerValue)); + this.noSplitIndicesPerLong = 64 / bitsPerValue; + } else { + expectLongCount = (int) Math.ceil(bitsPerValue * length / 64d); + this.splitIndicesPerLong = 64D / bitsPerValue; + } + if (tag.getValue().length == 0) { + tag.setValue(new long[expectLongCount]); + } else { + if (expectLongCount != tag.getValue().length) { + throw new IllegalArgumentException(String.format( + "long array tag has %d longs, but expected %d longs", + tag.getValue().length, expectLongCount)); + } + } + packedBits = tag.getValue(); + this.currentMaxPackableValue = (1 << bitsPerValue) - 1; + } + + protected LongArrayTagPackedIntegers(LongArrayTagPackedIntegers other) { + this.length = other.length; + this.valueOffset = other.valueOffset; + this.minBitsPerValue = other.minBitsPerValue; + this.bitsPerValue = other.bitsPerValue; + this.currentMaxPackableValue = other.currentMaxPackableValue; + this.noSplitIndicesPerLong = other.noSplitIndicesPerLong; + this.splitIndicesPerLong = other.splitIndicesPerLong; + this.packingStrategy = other.packingStrategy; + this.packedBitsTag = other.packedBitsTag.clone(); + this.packedBits = this.packedBitsTag.getValue(); + } + + @Override + public LongArrayTagPackedIntegers clone() { + return new LongArrayTagPackedIntegers(this); + } + + /** + * The long array tag which is being used to store the packed values. + *

Note: this is the same tag instance that was passed to the constructor and will contain the current + * longs[]. However, the user is responsible for calling {@link #compact()} before storing this tag in + * MCA data!

+ *

Generally, if you are getting the tag to store it use {@link #updateHandle()} instead.

+ */ + @Override + public LongArrayTag getHandle() { + return packedBitsTag; + } + + /** + * Calls {@link #compact()} and returns the {@link LongArrayTag}. + *

Note this is the same {@link LongArrayTag} instance given to, or created by, {@link Builder#build}.

+ */ + @Override + public LongArrayTag updateHandle() { + return compact(); + } + + /** Returns the actual longs array - modifying the values in this array will modify the stored values. */ + public long[] longs() { + return packedBits; + } + + /** + * The packing strategy controls how bits are packed into longs. Changing the packing strategy results + * in the backing long array being recomputed to use the new strategy. + */ + public PackingStrategy getPackingStrategy() { + return packingStrategy; + } + + /** + * The packing strategy controls how bits are packed into longs. Changing the packing strategy results + * in the backing long array being recomputed to use the new strategy. + */ + public void setPackingStrategy(PackingStrategy packingStrategy) { + ArgValidator.requireValue(packingStrategy); + resize(bitsPerValue, packingStrategy); + } + + /** + * Gets the current number of bits per value actually used. This may be less than {@link #getMinBitsPerValue()}. + *

Note, returns 0 iff all stored raw values are zero (which means they are all EQ {@link #getValueOffset()}).

+ * @see #shouldCompact() + */ + public int getActualUsedBitsPerValue() { + int maxValue = 0; + for (int i = 0; i < length; i++) { + maxValue = Math.max(maxValue, getRaw(i)); + } + return calculateBitsRequired(maxValue); + } + + /** The minimum bits per value used to store data. {@link #compact()} will respect this setting. */ + public int getMinBitsPerValue() { + return minBitsPerValue; + } + + /** + * The minimum bits per value used to store data. {@link #compact()} will respect this setting. + *

If the provided value is larger than the current bits per value the longs array is resized to accommodate.

+ *

This method never calls {@link #compact()} - but if you are decreasing this value you probably should.

+ */ + public void setMinBitsPerValue(int minBitsPerValue) { + ArgValidator.check(minBitsPerValue > 0 && minBitsPerValue < 32, "minBitsPerValue must be in range [1..31]"); + this.minBitsPerValue = minBitsPerValue; + if (minBitsPerValue > bitsPerValue) { + resize(minBitsPerValue, packingStrategy); + } + } + + /** Value offset which is applied to all values as they pass through the various store and retrieve functions. */ + public int getValueOffset() { + return valueOffset; + } + + /** Value offset which is applied to all values as they pass through the various store and retrieve functions. */ + public void setValueOffset(int valueOffset) { + this.valueOffset = valueOffset; + } + + /** The current bits per value used to store data. */ + public int getBitsPerValue() { + return bitsPerValue; + } + + /** + * The maximum value, inclusive, which can be stored without triggering a resize. + * The returned value has any offset applied. + * @see #getValueOffset() + */ + public int getCurrentMaxPackableValue() { + return currentMaxPackableValue + valueOffset; + } + + /** Synonym for {@link #getValueOffset()}. */ + public int getCurrentMinPackableValue() { + return valueOffset; + } + + /** Gets the value at the specified index. */ + public int get(int index) { + return getRaw(index) + valueOffset; + } + + private int indexOf(int x, int z) { + int xzSize = squareEdgeLength(); + if (xzSize <= 0) + throw new IllegalStateException(); + if (x < 0 || z < 0 || x >= xzSize || z >= xzSize) + throw new IndexOutOfBoundsException(); + return z * xzSize + x; + } + + private int indexOf(int x, int y, int z) { + int xyzSize = cubeEdgeLength(); + if (xyzSize <= 0) + throw new IllegalStateException(); + if (x < 0 || y < 0 ||z < 0 || x >= xyzSize || y >= xyzSize || z >= xyzSize) + throw new IndexOutOfBoundsException(); + return y * xyzSize * xyzSize + z * xyzSize + x; + } + + public int get2d(int x, int z) { + return get(indexOf(x, z)); + } + + public int get3d(int x, int y, int z) { + return get(indexOf(x, y, z)); + } + + /** Does not apply valueOffset */ + private int getRaw(int index) { + if (index < 0 || index >= length) + throw new IndexOutOfBoundsException(); + if (packingStrategy == PackingStrategy.NO_SPLIT_VALUES_ACROSS_LONGS) { + int longIndex = index / noSplitIndicesPerLong; + int startBit = (index % noSplitIndicesPerLong) * bitsPerValue; + return (int) bitRange(packedBits[longIndex], startBit, startBit + bitsPerValue); + } else { + double floatingIndex = index / splitIndicesPerLong; + int longIndex = (int) floatingIndex; + int startBit = (int) ((floatingIndex - longIndex) * 64D); + if (startBit + bitsPerValue > 64) { + long prev = bitRange(packedBits[longIndex], startBit, 64); + long next = bitRange(packedBits[longIndex + 1], 0, startBit + bitsPerValue - 64); + return (int) ((next << 64 - startBit) + prev); + } else { + return (int) bitRange(packedBits[longIndex], startBit, startBit + bitsPerValue); + } + } + } + + /** Sets the value at the specified index. */ + public void set(int index, int value) { + if (index < 0 || index >= length) + throw new IndexOutOfBoundsException(); + setRaw(index, value - valueOffset); + } + + public void set2d(int x, int z, int value) { + set(indexOf(x, z), value); + } + + public void set3d(int x, int y, int z, int value) { + set(indexOf(x, y, z), value); + } + + /** valueOffset should already be removed from the supplied value */ + private void setRaw(int index, int rawValue) { + if (rawValue < 0) + throw new IllegalArgumentException("value must be GE " + valueOffset); + if (rawValue > currentMaxPackableValue) { + resize(calculateBitsRequired(rawValue), packingStrategy); + } + if (packingStrategy == PackingStrategy.NO_SPLIT_VALUES_ACROSS_LONGS) { + setNoSplitIndices(index, rawValue, noSplitIndicesPerLong, bitsPerValue, packedBits); + } else { + setSplitIndices(index, rawValue, splitIndicesPerLong, bitsPerValue, packedBits); + } + } + + /** Sets all values to the zero value and shrinks the longs array if appropriate and if autoShrink is true. */ + public void clear(boolean autoShrink) { + if (!autoShrink || bitsPerValue == minBitsPerValue) { + Arrays.fill(packedBits, 0L); + } else { + reallocateCapacity(minBitsPerValue); + } + } + + /** + * if bitsPerValue == requiredBitsPerValue does nothing, otherwise creates a new long array and updates + * tracking fields. + *

WARNING: does NOT enforce minBitsPerValue!

+ */ + private void reallocateCapacity(int requiredBitsPerValue) { + if (bitsPerValue == requiredBitsPerValue) + return; + bitsPerValue = requiredBitsPerValue; + if (packingStrategy == PackingStrategy.NO_SPLIT_VALUES_ACROSS_LONGS) { + final int newLength = (int) Math.ceil(length / (double) (64 / bitsPerValue)); + packedBitsTag.setValue(packedBits = new long[newLength]); + noSplitIndicesPerLong = 64 / bitsPerValue; + splitIndicesPerLong = 0; + } else { + final int newLength = (int) Math.ceil((bitsPerValue * length) / 64d); + packedBitsTag.setValue(packedBits = new long[newLength]); + splitIndicesPerLong = 64D / bitsPerValue; + noSplitIndicesPerLong = 0; + } + currentMaxPackableValue = (1 << bitsPerValue) - 1; + } + + /** True if the given value is found in the current set of values. */ + public boolean contains(int value) { + value -= valueOffset; + if (value < 0 || value > currentMaxPackableValue) + return false; + + for (int i = 0; i < length; i++) { + if (value == getRaw(i)) { + return true; + } + } + return false; + } + + /** Counts the number of occurrences of the given value. */ + public int count(int value) { + value -= valueOffset; + if (value < 0 || value > currentMaxPackableValue) + return 0; + int count = 0; + for (int i = 0; i < length; i++) { + if (value == getRaw(i)) { + count ++; + } + } + return count; + } + + /** Counts the number of times the given tester returns true while being passed the entire set of values. */ + public int count(IntPredicate tester) { + int count = 0; + for (int i = 0; i < length; i++) { + if (tester.test(get(i))) { + count ++; + } + } + return count; + } + + /** + * Returns whether all elements of this packed array match the provided value. + * May not evaluate the predicate on all elements if not necessary for + * determining the result. + */ + public boolean allMatch(int value) { + return length == count(value); + } + + /** + * Returns whether all elements of this packed array match the provided predicate. + * May not evaluate the predicate on all elements if not necessary for + * determining the result. + */ + public boolean allMatch(IntPredicate tester) { + return length == count(tester); + } + + /** + * Replaces all occurrences of oldValue with newValue. + */ + public void replaceAll(int oldValue, int newValue) { + if (oldValue == newValue) return; + oldValue -= valueOffset; + newValue -= valueOffset; + if (oldValue < 0) + throw new IllegalArgumentException("oldValue must be GE " + valueOffset); + if (newValue < 0) + throw new IllegalArgumentException("newValue must be GE " + valueOffset); + for (int i = 0; i < length; i++) { + int v = getRaw(i); + if (v == oldValue) { + setRaw(i, newValue); + } + } + } + + /** + * Remaps all values. The remapping function is given each value and its return value is assigned to the current + * index. Return the same value passed if you don't wish to remap it. + * @param remapFunction remapping function. + */ + public void remap(RemapFunction remapFunction) { + ArgValidator.requireValue(remapFunction); + for (int i = 0; i < length; i++) { + int oldOffsetValue = get(i); + int newOffsetValue = remapFunction.remap(oldOffsetValue); + if (newOffsetValue < valueOffset) + throw new IllegalArgumentException("remapped value must be GE " + valueOffset); + if (oldOffsetValue != newOffsetValue) { + set(i, newOffsetValue); + } + } + } + + /** + * Remaps all indicated values. + * @param remapping remapping map. + */ + public void remap(Map remapping) { + if (remapping.isEmpty()) + return; + remap(v -> remapping.getOrDefault(v, v)); + } + + /** + * Calculates if {@link #compact()} should be called. + *

Note that it is MORE efficient to just call {@link #compact()} than to call this method first.

+ */ + public boolean shouldCompact() { + return bitsPerValue > Math.max(minBitsPerValue, getActualUsedBitsPerValue()); + } + + /** + * Compacts the long array data to use the minimum number of bits per value required. + * Obeys {@link #getMinBitsPerValue()} + * @return tag containing long[] + */ + public LongArrayTag compact() { + resize(getActualUsedBitsPerValue(), packingStrategy); + return packedBitsTag; + } + + /** Creates a new int[] and populates it by calling {@link #get(int)} successively. */ + public int[] toArray() { + int[] values = new int[length]; + for (int i = 0; i < length; i++) { + values[i] = get(i); + } + return values; + } + + /** + * Populates the given array by calling {@link #get(int)} successively. + * @param array must be exactly {@link #length} in size. + * @return the same array that was passed as an argument. + */ + public int[] toArray(int[] array) { + ArgValidator.check(array.length == length, + String.format("Expected array to be of length %d but it was %d", length, array.length)); + for (int i = 0; i < length; i++) { + array[i] = get(i); + } + return array; + } + + /** + * Populates the given array from startIndex by calling {@link #get(int)} successively. + * @param array receives values from startIndex to startIndex + capacity - 1 + * @param startIndex the index to start copying values into. + * @return the same array that was passed as an argument. + */ + public int[] toArray(int[] array, int startIndex) { + ArgValidator.check(startIndex >= 0 && (startIndex + length) <= array.length); + for (int i = 0; i < length; i++) { + array[i + startIndex] = get(i); + } + return array; + } + + /** + * Resizes the long[] to exactly hold the range of values given, respecting {@link #getMinBitsPerValue()}, + * checks that all values are in the allowed range (GE {@link #getValueOffset()}), then calls {@link #set} + * successively for each value. + *

There is never a need to call {@link #compact()} immediately following this call.

+ * @param values must be exactly {@link #length} in size. + * @throws IllegalArgumentException if any value is LT {@link #getValueOffset()}. + */ + public void setFromArray(int[] values) { + ArgValidator.check(values.length == length, + String.format("Expected array to be of length %d but it was %d", length, values.length)); + setFromArray(values, 0); + } + + /** + * Resizes the long[] to exactly hold the range of values given, respecting {@link #getMinBitsPerValue()}, + * checks that all values are in the allowed range (GE {@link #getValueOffset()}), then calls {@link #set} + * successively for each value. + *

There is never a need to call {@link #compact()} immediately following this call.

+ * @param values must be at least {@link #length} in size. + * @param startIndex the index to start copying values from. + * @throws IllegalArgumentException if any value is LT {@link #getValueOffset()}. + */ + public void setFromArray(int[] values, int startIndex) { + ArgValidator.check(startIndex >= 0 && (startIndex + length) <= values.length); + int maxVal = valueOffset; + for (int i = 0; i < length; i++) { + int v = values[i + startIndex]; + if (v < valueOffset) + throw new IllegalArgumentException( + String.format("Values array contained %d which is smaller than the current value offset " + + "(minimum allowed value) of %d", v, valueOffset)); + maxVal = Math.max(maxVal, v); + } + reallocateCapacity(Math.max(minBitsPerValue, calculateBitsRequired(maxVal - valueOffset))); + for (int i = 0; i < length; i++) { + set(i, values[i + startIndex]); + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("length=").append(length).append("; "); + sb.append("packing-strategy=").append(packingStrategy.name()).append("; "); + sb.append("min-bits-per-value=").append(minBitsPerValue).append("; "); + sb.append("bits-per-value=").append(bitsPerValue).append("; "); + sb.append("value-offset=").append(valueOffset).append("; "); + sb.append("values=["); + boolean notFirst = false; + for (int v : this) { + if (notFirst) { + sb.append(", "); + } else { + notFirst = true; + } + sb.append(v); + } + sb.append("]"); + return sb.toString(); + } + + /** + * Renders the values as a 2D grid where 0,0 is in the top left. + * @throws UnsupportedOperationException if {@link #length} doesn't have an integer square root + * (isn't the product of n^2) + */ + public String toString2dGrid() { + final int rectangleEdgeLength = squareEdgeLength(); + if (rectangleEdgeLength < 0) + throw new UnsupportedOperationException( + "Attempted to format as a 2D grid, but sqrt(count of values) is not an integer!"); + int maxStrLen = 0; + for (int i = 0; i < length; i++) { + maxStrLen = Math.max(maxStrLen, Integer.toString(get(i)).length()); + } + String format = "%" + maxStrLen + "d"; + StringBuilder sb = new StringBuilder(); + int wrap = rectangleEdgeLength - 1; + for (int i = 0; i < length; i++) { + sb.append(String.format(format, get(i))); + if (i % rectangleEdgeLength != wrap) { + sb.append(' '); + } else { + sb.append('\n'); + } + } + return sb.toString().stripTrailing(); + } + + /** + * Renders the values as a 3D grid where each 2D slice is rendered with 0,0 in the top left. + * @throws UnsupportedOperationException if {@link #length} doesn't have an integer cubic root + * (isn't the product of n^3) + */ + public String toString3dGrid() { + final int cubeEdgeLength = cubeEdgeLength(); + if (cubeEdgeLength < 0) + throw new UnsupportedOperationException( + "Attempted to format as a 3D grid, but cube-root(count of values) is not an integer!"); + int maxStrLen = 0; + for (int i = 0; i < length; i++) { + maxStrLen = Math.max(maxStrLen, Integer.toString(get(i)).length()); + } + String format = "%" + maxStrLen + "d"; + StringBuilder sb = new StringBuilder(); + for (int y = 0; y < cubeEdgeLength; y++) { + int yi = y * cubeEdgeLength * cubeEdgeLength; + if (y > 0) sb.append('\n'); + sb.append("Y=").append(y); + for (int z = 0; z < cubeEdgeLength; z++) { + sb.append('\n'); + int zyi = yi + z * cubeEdgeLength; + for (int x = 0; x < cubeEdgeLength; x++) { + if (x > 0) sb.append(' '); + sb.append(String.format(format, get(zyi + x))); + } + } + } + return sb.toString(); + } + + /** + * Calls {@link MessageDigest#update} on the given digest with the current long[] data to accumulate a checksum + * across one or more {@link LongArrayTagPackedIntegers}/. + * @param digest to be modified with current long[] data. + * @return given digest. + */ + public MessageDigest accumulateChecksum(MessageDigest digest) { + ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES * longs().length); + for (long l : longs()) { + buffer.putLong(l); + } + buffer.position(0); + digest.update(buffer); + return digest; + } + + static void setNoSplitIndices(int index, int value, int noSplitIndicesPerLong, int bitsPerValue, long[] packedBits) { + int blockStatesIndex = index / noSplitIndicesPerLong; + int startBit = (index % noSplitIndicesPerLong) * bitsPerValue; + packedBits[blockStatesIndex] = updateBits(packedBits[blockStatesIndex], value, startBit, startBit + bitsPerValue); + } + + static void setSplitIndices(int index, int value, double splitIndicesPerLong, int bitsPerValue, long[] packedBits) { + double floatingIndex = index / splitIndicesPerLong; + int longIndex = (int) floatingIndex; + int startBit = (int) ((floatingIndex - longIndex) * 64D); + if (startBit + bitsPerValue > 64) { + packedBits[longIndex] = updateBits(packedBits[longIndex], value, startBit, 64); + packedBits[longIndex + 1] = updateBits(packedBits[longIndex + 1], value, startBit - 64, startBit + bitsPerValue - 64); + } else { + packedBits[longIndex] = updateBits(packedBits[longIndex], value, startBit, startBit + bitsPerValue); + } + } + + /** + * Increases or decreases the amount of bits used per value based on the size of the palette. + * Can also be used to repack the longs with a new packing strategy. + *

Obeys minBitsPerValue

+ */ + private void resize(int newBitsPerValue, final PackingStrategy newPackingStrategy) { + newBitsPerValue = Math.max(minBitsPerValue, newBitsPerValue); + if (newBitsPerValue == bitsPerValue && newPackingStrategy == packingStrategy) + return; + + final int newMaxValidValue = (int) Math.pow(2, newBitsPerValue) - 1; + if (newPackingStrategy == PackingStrategy.NO_SPLIT_VALUES_ACROSS_LONGS) { + final int newLength = (int) Math.ceil(length / (double) (64 / newBitsPerValue)); + final long[] newLongs = new long[newLength]; + final int newNoSplitIndicesPerLong = 64 / newBitsPerValue; + + for (int i = 0; i < length; i++) { + int value = getRaw(i); + if (value > newMaxValidValue) { + throw new IllegalArgumentException( + "newBitsPerValue is too small to hold existing value " + value + valueOffset); + } + setNoSplitIndices(i, value, newNoSplitIndicesPerLong, newBitsPerValue, newLongs); + } + noSplitIndicesPerLong = newNoSplitIndicesPerLong; + splitIndicesPerLong = 0; + packedBits = newLongs; + } else { + final int newLength = (int) Math.ceil((newBitsPerValue * length) / 64d); + final long[] newLongs = new long[newLength]; + final double newSplitIndicesPerLong = 64D / newBitsPerValue; + for (int i = 0; i < length; i++) { + int value = getRaw(i); + if (value > newMaxValidValue) { + throw new IllegalArgumentException( + "newBitsPerValue is too small to hold existing value " + value + valueOffset); + } + setSplitIndices(i, value, newSplitIndicesPerLong, newBitsPerValue, newLongs); + } + noSplitIndicesPerLong = 0; + splitIndicesPerLong = newSplitIndicesPerLong; + packedBits = newLongs; + } + bitsPerValue = newBitsPerValue; + packingStrategy = newPackingStrategy; + packedBitsTag.setValue(packedBits); + currentMaxPackableValue = (1 << newBitsPerValue) - 1; + } + + /** replace i to j bits in n with j - i bits of m */ + static long updateBits(long n, long m, int i, int j) { + // updateBits(longs[longIndex], value, startBit, startBit + bits) + long mShifted = i > 0 ? (m & ((1L << j - i) - 1)) << i : (m & ((1L << j - i) - 1)) >>> -i; + return ((n & ((j > 63 ? 0 : (~0L << j)) | (i < 0 ? 0 : ((1L << i) - 1L)))) | mShifted); + } + + static long bitRange(long value, int from, int to) { + // bitRange(longs[longIndex], startBit, startBit + bits) + int waste = 64 - to; + return (value << waste) >>> (waste + from); + } + + /** + * Calculates the number of bits required to store the given num. + *
    + *
  • Ex. num = 7 -> 3 (2^3 = 8)
  • + *
  • Ex. num = 2 -> 2
  • + *
+ * + * If num is 0 then 0 is returned. + */ + public static int calculateBitsRequired(int num) { + if (num < 0) throw new IllegalArgumentException(); + return 32 - Integer.numberOfLeadingZeros(num); + } + + @Override + public ListIterator iterator() { + return new IteratorImpl(); + } + + private class IteratorImpl implements ListIterator { + int lastYieldedIndex = -1; + int i = 0; + + /** {@inheritDoc} */ + @Override + public boolean hasNext() { + return i < length; + } + + /** {@inheritDoc} */ + @Override + public Integer next() { + if (!hasNext()) + throw new NoSuchElementException(); + return get(lastYieldedIndex = i++); + } + + /** {@inheritDoc} */ + @Override + public boolean hasPrevious() { + return i != 0; + } + + /** {@inheritDoc} */ + @Override + public Integer previous() { + if (!hasPrevious()) + throw new NoSuchElementException(); + return get(lastYieldedIndex = --i); + } + + /** {@inheritDoc} */ + @Override + public int nextIndex() { + return i; + } + + /** {@inheritDoc} */ + @Override + public int previousIndex() { + return i - 1; + } + + /** + * Replaces the last element returned by {@link #next} or + * {@link #previous} with the specified element. + */ + @Override + public void set(Integer value) { + if (lastYieldedIndex < 0) + throw new IllegalStateException(); + LongArrayTagPackedIntegers.this.set(lastYieldedIndex, value); + } + + /** Unsupported */ + @Override + public void add(Integer unsupported) { + throw new UnsupportedOperationException(); + } + + /** Unsupported */ + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/McaDumper.java b/src/main/java/io/github/ensgijs/nbt/mca/util/McaDumper.java new file mode 100644 index 00000000..f71c7b85 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/McaDumper.java @@ -0,0 +1,84 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.io.TextNbtHelpers; +import io.github.ensgijs.nbt.mca.ChunkBase; +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.mca.io.McaFileChunkIterator; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class McaDumper { + private McaDumper() {} + + /** + * @param mcaFile MCA file to dump the chunk data for. + * @param outputRoot Root directory to dump into. The output will be written to + * "<mca type>/rX.Z/<chunk index>.X.Z.snbt;" + * Example: "region/r.0.0/0042.168.-475.snbt" + * @return The path containing the dumped chunk text nbt (.snbt) files. + * @throws IOException + */ + public static Path dumpChunksAsTextNbt(File mcaFile, Path outputRoot) throws IOException { + try (McaFileChunkIterator iter = McaFileChunkIterator.iterate(mcaFile, LoadFlags.RAW)) { + File dir = Paths.get( + outputRoot.toString(), + mcaFile.getAbsoluteFile().getParentFile().getName(), + iter.regionXZ().toString("r.%d.%d")).toFile(); + if (!dir.exists()) { + dir.mkdirs(); + } + final String dirName = dir.getPath(); + while (iter.hasNext()) { + ChunkBase chunk = iter.next(); + TextNbtHelpers.writeTextNbtFile( + Paths.get( + dirName, + String.format("%04d.%d.%d.snbt", + chunk.getIndex(), + chunk.getChunkX(), + chunk.getChunkZ()) + ), chunk.getHandle(), /*pretty print*/ true, /*sorted*/ true); + + } + return dir.toPath(); + } + } + + /** Writes the given chunk to the specified destinationFile. */ + public static Path dumpChunkAsTextNbtToFile(ChunkBase chunk, File destinationFile) throws IOException { + Path path = destinationFile.toPath(); + TextNbtHelpers.writeTextNbtFile(path, chunk.getHandle(), /*pretty print*/ true, /*sorted*/ true); + return path; + } + + /** + * Writes the given chunk to a file within the given outputRoot. + * @param chunk Chunk to dump. + * @param outputRoot Root directory to dump into. The output will be written to + * "<mca type>/r.X.Z/<chunk index>.X.Z.snbt;" + * Example: "region/r0.0/0042.168.-475.snbt" + * @return The full path of the output snbt file. + * @throws IOException + */ + public static Path dumpChunkAsTextNbtAutoFilename(ChunkBase chunk, Path outputRoot) throws IOException { + IntPointXZ regionXZ = chunk.getRegionXZ(); + File dir = Paths.get( + outputRoot.toString(), + chunk.getMcaType(), + "r." + regionXZ.getX() + "." + regionXZ.getZ()).toFile(); + if (!dir.exists()) { + dir.mkdirs(); + } + Path outFilePath = Paths.get( + dir.getPath(), + String.format("%04d.%d.%d.snbt", + chunk.getIndex(), + chunk.getChunkX(), + chunk.getChunkZ())); + TextNbtHelpers.writeTextNbtFile(outFilePath, chunk.getHandle(), /*pretty print*/ true, /*sorted*/ true); + return outFilePath; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/McaWorld.java b/src/main/java/io/github/ensgijs/nbt/mca/util/McaWorld.java new file mode 100644 index 00000000..998bff36 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/McaWorld.java @@ -0,0 +1,336 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.mca.TerrainChunk; +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.mca.io.McaFileHelpers; +import io.github.ensgijs.nbt.mca.io.RandomAccessMcaFile; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.StringTag; +import io.github.ensgijs.nbt.util.ArgValidator; + +import java.io.Closeable; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +import static io.github.ensgijs.nbt.mca.DataVersion.JAVA_1_18_21W37A; + +/** + * Abstracts away the interactions with individual chunks and mca files. + *

Early impl limitations:

+ *
    + *
  • Only operates on {@link TerrainChunk}
  • + *
  • 'rw' mode does not support creating new mca files or new chunks.
  • + *
+ */ +public class McaWorld implements Closeable { + static final int DEFAULT_CHUNK_CACHE_CAPACITY = 1024; + private final boolean isReadonly; + private final String mode; + private final String worldRootDir; + + // TODO: use region/poi/entities abstraction chunk type (once one exists) + private final Map> regionCache = new HashMap<>(); + private final Map chunkCache; + private long loadFlags = LoadFlags.LOAD_ALL_DATA; + + public McaWorld(String worldRootDir, String mode, int chunkCacheSize) throws FileNotFoundException { + ArgValidator.check(mode != null && mode.length() >= 1 && mode.charAt(0) == 'r'); + if (!new File(worldRootDir).exists()) { // TODO: && mode == "r" - else create directory?? + throw new FileNotFoundException("World root directory does not exist! " + worldRootDir); + } + this.worldRootDir = worldRootDir; + this.mode = mode; + isReadonly = mode.equals("r"); + chunkCache = new LinkedHashMap<>(16, 0.75F, true) { + // This method is called just after a new entry has been added + // Note access order = true is specified to the map ctor so this is an LRU + public boolean removeEldestEntry(Map.Entry eldest) { + return size() > chunkCacheSize; + } + }; + } + public McaWorld(File worldRootDir, String mode, int chunkCacheSize) throws FileNotFoundException { + this(worldRootDir.getAbsolutePath(), mode, chunkCacheSize); + } + public McaWorld(Path worldRootDir, String mode, int chunkCacheSize) throws FileNotFoundException { + this(worldRootDir.toAbsolutePath().toString(), mode, chunkCacheSize); + } + public McaWorld(File worldRootDir, String mode) throws FileNotFoundException { + this(worldRootDir.getAbsolutePath(), mode, DEFAULT_CHUNK_CACHE_CAPACITY); + } + public McaWorld(Path worldRootDir, String mode) throws FileNotFoundException { + this(worldRootDir.toAbsolutePath().toString(), mode, DEFAULT_CHUNK_CACHE_CAPACITY); + } + public McaWorld(String worldRootDir, String mode) throws FileNotFoundException { + this(worldRootDir, mode, DEFAULT_CHUNK_CACHE_CAPACITY); + } + + public Set touchedRegions() { + return Collections.unmodifiableSet(regionCache.keySet()); + } + + /** LoadFlags which are passed to the chunk deserialization method. */ + public void setLoadFlags(long loadFlags) { + // TODO: detect if bits have been added and if so clear the chunk cache + this.loadFlags = loadFlags; + } + + public boolean isReadonly() { + return isReadonly; + } + + public String mode() { + return mode; + } + + public String worldRootDir() { + return worldRootDir; + } + + public long loadFlags() { + return loadFlags; + } + + /** + * Closing causes all currently opened mca files to be closed and for all cached chunk data to be released. + *

May be called more than once. Object may continue to be used to access chunk data after calling close().

+ * @throws IOException one or more mca files threw when closing + */ + @Override + public void close() throws IOException { + chunkCache.clear(); + List closeExceptions = new ArrayList<>(); + for (RandomAccessMcaFile ramf : regionCache.values()) { + try { + if (ramf != null) + ramf.close(); + } catch (IOException ex) { + // TODO: improve this - don't printStackTrace and make throw below contain more context. + ex.printStackTrace(); + closeExceptions.add(ex); + } + } + int openCount = regionCache.size(); + regionCache.clear(); + if (!closeExceptions.isEmpty()) { + throw new IOException("Error closing " + closeExceptions.size() + " of " + openCount + " MCA files!"); + } + } + + public RandomAccessMcaFile getRegion(int regionX, int regionZ) throws IOException { + return getRegion(new IntPointXZ(regionX, regionZ)); + } + + public RandomAccessMcaFile getRegion(IntPointXZ regionXZ) throws IOException { + if (!regionCache.containsKey(regionXZ)) { + String fileName = McaFileHelpers.createNameFromRegionLocation(regionXZ); + File mcaFile = Path.of(worldRootDir, "region", fileName).toFile(); + RandomAccessMcaFile ramf = null; + // TODO: mode != "r" - create directory?? + if (mcaFile.exists() && Files.size(mcaFile.toPath()) > 0) { // TODO: || !mode.equals("r") + ramf = new RandomAccessMcaFile<>(TerrainChunk.class, mcaFile, mode); + ramf.setLoadFlags(loadFlags); + } + regionCache.put(regionXZ, ramf); + } + + return regionCache.get(regionXZ); + } + + public TerrainChunk getChunk(int chunkX, int chunkZ) throws IOException { + return getChunk(new IntPointXZ(chunkX, chunkZ)); + } + + public TerrainChunk getChunk(IntPointXZ chunkXZ) throws IOException { + if (chunkCache.containsKey(chunkXZ)) // strategy allows caching of nulls + return chunkCache.get(chunkXZ); + + var region = getRegion(chunkXZ.transformChunkToRegion()); + + TerrainChunk chunk = null; + if (region != null) { + chunk = region.readAbsolute(chunkXZ); + } + // TODO: mode != "r" - create new chunk + chunkCache.put(chunkXZ, chunk); + return chunk; + } + + /** + * @param heightmap typically one of + *
    + *
  • MOTION_BLOCKING
  • + *
  • MOTION_BLOCKING_NO_LEAVES
  • + *
  • OCEAN_FLOOR
  • + *
  • OCEAN_FLOOR_WG
  • + *
  • WORLD_SURFACE
  • + *
  • WORLD_SURFACE_WG
  • + *
+ * @param xz block XZ location + * @return top block Y location - or Integer.MIN_VALUE if chunk or heightmap name does not exist. + * @throws IOException read error + */ + public int getHeightAt(String heightmap, IntPointXZ xz) throws IOException { + var chunk = getChunk(xz.transformBlockToChunk()); + // TODO: this can be more lenient || !chunk.getStatus().endsWith("full") + if (chunk == null) return Integer.MIN_VALUE; + var hm = chunk.getHeightMap(heightmap); + if (hm == null) return Integer.MIN_VALUE; + return hm.get2d(xz.x & 0xF, xz.z & 0xF); + } + + /** + * @param heightmap typically one of + *
    + *
  • MOTION_BLOCKING
  • + *
  • MOTION_BLOCKING_NO_LEAVES
  • + *
  • OCEAN_FLOOR
  • + *
  • OCEAN_FLOOR_WG
  • + *
  • WORLD_SURFACE
  • + *
  • WORLD_SURFACE_WG
  • + *
+ * @param x block X location + * @param z block Z location + * @return top block Y location - or Integer.MIN_VALUE if chunk or heightmap name does not exist. + * @throws IOException read error + */ + public int getHeightAt(String heightmap, int x, int z) throws IOException { + return getHeightAt(heightmap, new IntPointXZ(x, z)); + } + + public String getBiomeAt(IntPointXYZ xyz) throws IOException { + var chunk = getChunk(xyz.transformBlockToChunk()); + if (chunk == null) return null; + + if (!LegacyBiomes.versionHasLegacyBiomes(chunk.getDataVersion())) { + var biomeTag = chunk.getBiomeAtByRef(xyz.x, xyz.y, xyz.z); + return biomeTag != null ? biomeTag.getValue() : null; + } else { + return LegacyBiomes.keyedName(chunk.getDataVersion(), chunk.getLegacyBiomeAt(xyz.x, xyz.y, xyz.z)); + } + } + + public String getBiomeAt(int x, int y, int z) throws IOException { + var chunk = getChunk(x >> 4, z >> 4); + if (chunk == null) return null; + if (!LegacyBiomes.versionHasLegacyBiomes(chunk.getDataVersion())) { + var biomeTag = chunk.getBiomeAtByRef(x, y, z); + return biomeTag != null ? biomeTag.getValue() : null; + } else { + return LegacyBiomes.keyedName(chunk.getDataVersion(), chunk.getLegacyBiomeAt(x, y, z)); + } + } + + /** + * @return true if the chunk and section existed and the biome was set (true even if the value was unchanged) + */ + public boolean setBiomeAt(IntPointXYZ xyz, String biome) throws IOException { + if (isReadonly) throw new IOException("opened in readonly mode"); + var chunk = getChunk(xyz.x >> 4, xyz.z >> 4); + if (chunk == null) return false; + if (!LegacyBiomes.versionHasLegacyBiomes(chunk.getDataVersion())) { + return chunk.setBiomeAt(xyz.x, xyz.y, xyz.z, new StringTag(biome)); + } else { + if (xyz.y < 0 || xyz.y > 255) return false; + int id = LegacyBiomes.id(chunk.getDataVersion(), biome); + if (id < 0) return false; + chunk.setLegacyBiomeAt(xyz.x, xyz.y, xyz.z, id); + return true; + } + } + + /** + * @return true if the chunk and section existed and the biome was set (true even if the value was unchanged) + */ + public boolean setBiomeAt(int x, int y, int z, String biome) throws IOException { + if (isReadonly) throw new IOException("opened in readonly mode"); + var chunk = getChunk(x >> 4, z >> 4); + if (chunk == null) return false; + if (!LegacyBiomes.versionHasLegacyBiomes(chunk.getDataVersion())) { + return chunk.setBiomeAt(x, y, z, new StringTag(biome)); + } else { + if (y < 0 || y > 255) return false; + int id = LegacyBiomes.id(chunk.getDataVersion(), biome); + if (id < 0) return false; + chunk.setLegacyBiomeAt(x, y, z, id); + return true; + } + } + + /** + * @see BlockStateTag + */ + public CompoundTag getBlockAt(IntPointXYZ xyz) throws IOException { + var chunk = getChunk(xyz.transformBlockToChunk()); + return chunk != null ? chunk.getBlockAt(xyz.x, xyz.y, xyz.z) : null; + } + + /** + * @see BlockStateTag + */ + public CompoundTag getBlockAt(int x, int y, int z) throws IOException { + var chunk = getChunk(x >> 4, z >> 4); + return chunk != null ? chunk.getBlockAt(x, y, z) : null; + } + + /** + * @see BlockStateTag + */ + public CompoundTag getBlockAtByRef(IntPointXYZ xyz) throws IOException { + var chunk = getChunk(xyz.transformBlockToChunk()); + return chunk != null ? chunk.getBlockAtByRef(xyz.x, xyz.y, xyz.z) : null; + } + + /** + * @see BlockStateTag + */ + public CompoundTag getBlockAtByRef(int x, int y, int z) throws IOException { + var chunk = getChunk(x >> 4, z >> 4); + return chunk != null ? chunk.getBlockAtByRef(x, y, z) : null; + } + + public String getBlockNameAt(IntPointXYZ xyz) throws IOException { + var chunk = getChunk(xyz.transformBlockToChunk()); + if (chunk == null) return null; + var blockTag = chunk.getBlockAtByRef(xyz.x, xyz.y, xyz.z); + return blockTag != null ? blockTag.getString("Name") : null; + } + + public String getBlockNameAt(int x, int y, int z) throws IOException { + var chunk = getChunk(x >> 4, z >> 4); + if (chunk == null) return null; + var blockTag = chunk.getBlockAtByRef(x, y, z); + return blockTag != null ? blockTag.getString("Name") : null; + } + + + /** + * @param xyz block XYZ location + * @param tag block palette tag, must contain a 'Name' StringTag + * @return true if the chunk and section existed and the block was set (true even if the value was unchanged) + * @see BlockStateTag + */ + public boolean setBlockAt(IntPointXYZ xyz, CompoundTag tag) throws IOException { + if (isReadonly) throw new IOException("opened in readonly mode"); + var chunk = getChunk(xyz.transformBlockToChunk()); + return chunk != null && chunk.setBlockAt(xyz.x, xyz.y, xyz.z, tag); + } + + /** + * @param x block X location + * @param y block Y location + * @param z block Z location + * @param tag block palette tag, must contain a 'Name' StringTag + * @return true if the chunk and section existed and the block was set (true even if the value was unchanged) + * @see BlockStateTag + */ + public boolean setBlockAt(int x, int y, int z, CompoundTag tag) throws IOException { + if (isReadonly) throw new IOException("opened in readonly mode"); + var chunk = getChunk(x >> 4, z >> 4); + return chunk != null && chunk.setBlockAt(x, y, z, tag); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/PalettizedCuboid.java b/src/main/java/io/github/ensgijs/nbt/mca/util/PalettizedCuboid.java new file mode 100644 index 00000000..ff3ddb2b --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/PalettizedCuboid.java @@ -0,0 +1,1007 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.io.TextNbtHelpers; +import io.github.ensgijs.nbt.tag.*; + +import java.util.*; +import java.util.function.Predicate; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import static io.github.ensgijs.nbt.util.ArgValidator.*; + +/** + * A PalettizedCuboid is serialized as a {@link CompoundTag} containing {@code data} + * and {@code palette} entries. {@code data} is always of type {@link LongArrayTag} + * while {@code palette} is a {@link ListTag} of some type {@link E}. If {@code palette} + * contains only a single value then {@code data} is omitted and all data values are assumed to be the same. + * + *

The {@link #size()} is fixed and a cube of a power of two, typically 16 or 4.

+ *

A cuboid is a 3D rectangle, however in MCA chunk's x, y, and z sizes are all equal (currently).

+ * + *

NBT Examples

+ *

block_states

+ *
+ * "block_states": {
+ *   "type": "CompoundTag",
+ *   "value": {
+ *     "data": {
+ *       "type": "LongArrayTag",
+ *       "value": [...]
+ *     },
+ *     "palette": {
+ *       "type": "ListTag",
+ *       "value": {
+ *         "type": "CompoundTag",
+ *         "list": [
+ *           {
+ *             "Name": {
+ *               "type": "StringTag",
+ *               "value": "minecraft:bedrock"
+ *             }
+ *           },
+ *           ...
+ *         ]
+ *       }
+ *     }
+ *   }
+ * 
+ *

biomes

+ *
+ * "biomes": {
+ *   "type": "CompoundTag",
+ *   "value": {
+ *     "data": {
+ *       "type": "LongArrayTag",
+ *       "value": [
+ *         0,
+ *         -7686143365460459264
+ *       ]
+ *     },
+ *     "palette": {
+ *       "type": "ListTag",
+ *       "value": {
+ *         "type": "StringTag",
+ *         "list": [
+ *           "minecraft:deep_dark",
+ *           "minecraft:snowy_taiga",
+ *           "minecraft:grove"
+ *         ]
+ *       }
+ *     }
+ *   }
+ * 
+ * + * @see BlockStateTag + */ +public class PalettizedCuboid> implements TagWrapper, Iterable, Cloneable { + // public static boolean DEBUG = false; + record CubeInfo(int edgeLength, int cordBitMask , int zShift , int yShift) { + public int entryCount() { + return edgeLength * edgeLength * edgeLength; + } + } + + /** + * Flyweight instances - reuse these instead of having an instance for every PalettizedCuboid instance. + * @see #nilSentinelFor(Class) + */ + private static final Map, Tag> EMPTY_VALUE_SENTINEL_CACHE = new HashMap<>(); + /** + * Flyweight instances - reuse these instead of having an instance for every PalettizedCuboid instance. + * @see #cubeInfoFor(int) + */ + private static final Map CUBE_INFO_CACHE = new HashMap<>(); + + protected final CubeInfo cubeInfo; + protected final Class paletteEntryClass; + protected transient int paletteModCount = 0; + protected final CompoundTag paletteContainerTag; + protected final ListTag palette; + protected final LongArrayTagPackedIntegers packedData; + + @SuppressWarnings("unchecked") + protected static > T nilSentinelFor(Class clazz) { + T val = (T) EMPTY_VALUE_SENTINEL_CACHE.get(clazz); + if (val == null) { + try { + val = clazz.getDeclaredConstructor().newInstance(); + EMPTY_VALUE_SENTINEL_CACHE.put(clazz, val); + } catch (ReflectiveOperationException ex) { + throw new IllegalArgumentException("Failed to create a default instance of " + clazz.getName(), ex); + } + } + return val; + } + + protected static CubeInfo cubeInfoFor(final int edgeLength) { + return CUBE_INFO_CACHE.computeIfAbsent(edgeLength, (k) -> { + final int bits = calculatePowerOfTwoExponent(edgeLength, true); + return new CubeInfo(edgeLength, calculateBitMask(bits), bits, bits * 2); + }); + } + + /** + * @param cubeEdgeLength The size of this {@link PalettizedCuboid} will be {@code cubeEdgeLength^3}. + * The value of {@code cubeEdgeLength} is typically 16 or 4. + * Must be a power of 2. + * @param fillWith Required. Value to fill the initial {@link PalettizedCuboid} with. + * The given value is cloned for each entry in the cuboid. + */ + @SuppressWarnings("unchecked") + public PalettizedCuboid(final int cubeEdgeLength, E fillWith) { + this(cubeEdgeLength, (Class) fillWith.getClass(), fillWith, false); + } + + @SuppressWarnings("unchecked") + public PalettizedCuboid(PalettizedCuboid other) { + this.cubeInfo = other.cubeInfo; + this.paletteEntryClass = other.paletteEntryClass; + paletteContainerTag = new CompoundTag(); + palette = new ListTag<>(paletteEntryClass, other.size()); + paletteContainerTag.put("palette", palette); + for (E e : other.palette) { + this.palette.add((E) e.clone()); + } + this.packedData = other.packedData.clone(); + } + + @SuppressWarnings("unchecked") + protected PalettizedCuboid(final int cubeEdgeLength, Class paletteEntryClass, E fillWith, boolean allowNullFill) { + if (!allowNullFill) { + requireValue(fillWith, "fillWith"); + } + cubeInfo = cubeInfoFor(cubeEdgeLength); + this.paletteEntryClass = paletteEntryClass; + paletteContainerTag = new CompoundTag(); + palette = new ListTag<>(paletteEntryClass); + paletteContainerTag.put("palette", palette); + this.packedData = LongArrayTagPackedIntegers.builder() + .length(cubeInfo.entryCount()) + .minBitsPerValue(cubeEdgeLength == 16 ? 4 /*blocks*/: 1 /*biomes*/) + .build(); + if (fillWith != null) { + this.palette.add((E) fillWith.clone()); + } + } + + /** + * Protected access because of the annoyance of needing to know the paletteEntryClass apriori to the call. + * @see #fromCompoundTag + */ + protected PalettizedCuboid(final int cubeEdgeLength, Class paletteEntryClass, CompoundTag paletteContainerTag, int dataVersion) { + requireValue(paletteContainerTag, "paletteContainerTag"); + check(paletteContainerTag.containsKey("palette"), "paletteContainerTag must contain a 'palette' ListTag"); + cubeInfo = cubeInfoFor(cubeEdgeLength); + this.paletteContainerTag = paletteContainerTag; + this.paletteEntryClass = paletteEntryClass; + palette = this.paletteContainerTag.getListTag("palette").asTypedList(paletteEntryClass); + var builder = LongArrayTagPackedIntegers.builder() + .length(cubeInfo.entryCount()) + .dataVersion(dataVersion) + .minBitsPerValue(cubeEdgeLength == 16 ? 4 /*blocks*/: 1 /*biomes*/) + .initializeForStoring(palette.size() - 1); + if (paletteContainerTag.containsKey("data")) { + this.packedData = builder.build(paletteContainerTag.getLongArrayTag("data")); + } else { + this.packedData = builder.build(); + } + } + + /** + * @param values Must be of a length that has a cube root and that root must itself be a power of 2. + * Ex. 4096 is 16^3 and 16 is 4^2; Ex. 64 is 4^3 and 4 is 2^2. The given values are cloned, + * neither the given values array nor its elements are taken by reference. However, any value + * which is repeated is only entered into the palette once. + */ + @SuppressWarnings("unchecked") + public PalettizedCuboid(E[] values) { + this(cubeRoot(values.length), (Class) values.getClass().getComponentType(), null, true); + Map indexLookup = new HashMap<>(); + for (int i = 0; i < packedData.length; i++) { + check(values[i] != null, "values must not contain nulls!"); + int paletteIndex = indexLookup.computeIfAbsent(values[i], k -> { + palette.add((E) k.clone()); + return palette.size() - 1; + }); + this.packedData.set(i, paletteIndex); + } + } + + /** This is a very verbose, multi-line, string that includes the full palette and full data cube. */ + @Override + public String toString() { + StringBuilder sb = new StringBuilder("\n"); + for (int i = 0; i < palette.size(); i++) { + sb.append(i).append(":= ").append(TextNbtHelpers.toTextNbt(palette.get(i), true)).append('\n'); + } + return sb.append("\n").append(packedData.toString3dGrid()).toString(); + } + + static int cubeRoot(int num) { + int k = (int) Math.round(Math.pow(num, 1/3d)); + if (k * k * k != num) { + throw new IllegalArgumentException("the cube root of " + num + " is not an integer!"); + } + return k; + } + + /** + * If strict is true and num is not a power of 2 then IllegalArgumentException is thrown. + *

If strict is false and num is not a power of 2 then the return value is the power of 2 which + * is large enough to include num. Ex. num = 7 -> 3 (2^3 = 8)

+ */ + static int calculatePowerOfTwoExponent(int num, boolean strict) { + int k = 0; + boolean bump = false; + while (num > 1) { + k++; + if (num % 2 != 0) { + if (strict) { + throw new IllegalArgumentException(num + " isn't a power of two!"); + } else { + bump = true; + } + } + num /= 2; + } + return k + (bump ? 1 : 0); + } + + static int calculateBitMask(int numberOfBits) { + if (numberOfBits < 0 || numberOfBits >= 32) { + throw new IllegalArgumentException(Integer.toString(numberOfBits)); + } + return ~(-1 << numberOfBits); + } + + public void setDataVersion(int newDataVersion) { + packedData.setPackingStrategy(LongArrayTagPackedIntegers.MOJANG_PACKING_STRATEGY.get(newDataVersion)); + } + + /** size of data array (ex. 64 for a 4x4x4 cuboid) */ + public int size() { + return packedData.length; + } + + /** + * Length of one edge of the cuboid (ex. 4 for a 4x4x4 cuboid). + */ + public int cubeEdgeLength() { + return cubeInfo.edgeLength; + } + + /** The current palette size. Note for an exact accurate palette count call {@link #optimizePalette()} first. */ + public int paletteSize() { + return palette.size(); + } + + /** + * @see #countIf(Predicate) + */ + public boolean contains(E o) { + return palette.contains(o); + } + + /** + * Counts the number of data entries which match the given filter. + */ + public int countIf(Predicate filter) { + Collection counting = new HashSet<>(); + final int expectPaletteModCount = paletteModCount; + for (int i = 0; i < palette.size(); i++) { + E paletteValue = palette.get(i); + if (paletteValue == null) { + continue; + } + final int hash = paletteValue.hashCode(); + if (filter.test(paletteValue)) { + counting.add(i); + } + if (paletteValue.hashCode() != hash) { + throw new PaletteCorruptedException("Palette element modified during countIf filter call! Consider using replaceIf() instead."); + } + } + if (expectPaletteModCount != paletteModCount) { + throw new ConcurrentModificationException(); + } + if (counting.isEmpty()) { + return 0; + } + if (counting.size() == 1) { + counting = Collections.singleton(counting.iterator().next()); + } else if (counting.size() < 5) { + counting = new ArrayList<>(counting); + } + return packedData.count(counting::contains); + } + + /** + * Returns a copy of the palette values for every position in this cuboid. + *

Modifying the returned value can be done safely, it will have no effect on this cuboid.

+ *

To avoid the overhead of making N copies of palette tags use {@link #toArrayByRef()} instead.

+ */ + @SuppressWarnings("unchecked") + public E[] toArray() { + E[] a = (E[]) java.lang.reflect.Array.newInstance(paletteEntryClass, packedData.length); + for (int i = 0; i < packedData.length; i++) { + a[i] = (E) palette.get(packedData.get(i)).clone(); + } + return a; + } + + /** + * Returns the palette value for every position in this cuboid. + *

WARNING if the returned Tags are modified it modifies every value which references the same palette entry!

+ *

Modifying the returned array itself does not change the cuboid.

+ */ + @SuppressWarnings("unchecked") + public E[] toArrayByRef() { + E[] a = (E[]) java.lang.reflect.Array.newInstance(paletteEntryClass, packedData.length); + for (int i = 0; i < packedData.length; i++) { + a[i] = palette.get(packedData.get(i)); + } + return a; + } + + @SuppressWarnings("unchecked") + private boolean replace(Collection replacing, E replacement) { + requireValue(replacement, "replacement"); + paletteModCount ++; + replacing.remove(-1); // TODO: document semantics + if (replacing.isEmpty()) { + return false; + } + int replacementPaletteIndex = palette.indexOf(replacement); + boolean addReplacementToPaletteIfDataModified; + if (replacementPaletteIndex < 0) { + replacementPaletteIndex = palette.size(); + addReplacementToPaletteIfDataModified = true; + } else { + replacing.remove(replacementPaletteIndex); + if (replacing.isEmpty()) { + return false; + } + addReplacementToPaletteIfDataModified = false; + } + + // TODO: run some simulations to see if this "optimization" matters at all and if it does, tune the list/set threshold + if (replacing.size() == 1) { + replacing = Collections.singleton(replacing.iterator().next()); + } else if (replacing.size() < 5) { + // small lists are (generally) faster than small hashsets + if (!(replacing instanceof List)) + replacing = new ArrayList<>(replacing); + } else if (!(replacing instanceof Set)) { + replacing = new HashSet<>(replacing); + } + + boolean modified = false; + for (int i = 0; i < packedData.length; i++) { + if (replacing.contains(packedData.get(i))) { + modified = true; + packedData.set(i, replacementPaletteIndex); + } + } + if (modified) { + final var nilValue = nilSentinelFor(paletteEntryClass); + for (int i : replacing) { + palette.set(i, nilValue); // paletteModCount incremented at top of method + } + if (addReplacementToPaletteIfDataModified) + palette.add((E) replacement.clone()); // paletteModCount incremented at top of method + } + return modified; + } + + /** + * @return True if any modifications were made, false otherwise. + */ + public boolean replace(E oldValue, E newValue) { + requireValue(oldValue, "oldValue"); + requireValue(newValue, "newValue"); + if (oldValue.equals(newValue)) { + return false; + } + // Don't pass a singleton list/set type - they are immutable and will cause errors. + return replace(new ArrayList<>(Collections.singletonList(palette.indexOf(oldValue))), newValue); + } + + public final boolean replaceAll(E[] a, E replacement) { + return replaceAll(Arrays.asList(a), replacement); + } + + public boolean replaceAll(Collection c, E replacement) { + requireValue(replacement, "replacement"); + if (c.isEmpty()) { + return false; + } + Set replacing = new HashSet<>(); + for (E e : c) { + int i = palette.indexOf(e); + if (i >= 0) { + replacing.add(i); + } + } + return replace(replacing, replacement); + } + + public boolean replaceIf(Predicate filter, E replacement) { + requireValue(replacement, "replacement"); + final int expectPaletteModCount = paletteModCount; + Set replacing = new HashSet<>(); + final var nilValue = nilSentinelFor(paletteEntryClass); + for (int i = 0; i < palette.size(); i++) { + E paletteValue = palette.get(i); + if (paletteValue == nilValue) { + continue; + } + final int hash = paletteValue.hashCode(); + if (filter.test(paletteValue)) { + replacing.add(i); + } + if (paletteValue.hashCode() != hash) { + throw new PaletteCorruptedException("Palette element passed to filter modified unexpectedly!"); + } + if (expectPaletteModCount != paletteModCount) { + throw new ConcurrentModificationException(); + } + } + return replace(replacing, replacement); + } + + public final boolean retainAll(E[] a, E replacement) { + return retainAll(Arrays.asList(a), replacement); + } + + public boolean retainAll(Collection c, E replacement) { + requireValue(replacement, "replacement"); + Set replacing = new HashSet<>(); + final var nilValue = nilSentinelFor(paletteEntryClass); + for (int i = 0; i < palette.size(); i++) { + E paletteValue = palette.get(i); + if (paletteValue != nilValue && !c.contains(paletteValue)) { + replacing.add(i); + } + } + return replace(replacing, replacement); + } + + /** + * Sets the entire volume to the given value. + * @param fillWith value to fill volume with, this value is cloned (not taken by reference). + */ + @SuppressWarnings("unchecked") + public void fill(E fillWith) { + requireValue(fillWith, "fillWith"); + paletteModCount ++; + palette.clear(); + palette.add((E) fillWith.clone()); + packedData.clear(true); + } + + /** + * Computes the wrapped index of XYZ. If, for example, xSize is 16 and a value of 20 is passed + * for X it will behave the same as if a value of 4 were passed instead. + * @param x X index + * @param y Y index + * @param z Z index + * @return wrapped element index + */ + public int indexOf(int x, int y, int z) { + return ((y & cubeInfo.cordBitMask) << cubeInfo.yShift) | + ((z & cubeInfo.cordBitMask) << cubeInfo.zShift) | + (x & cubeInfo.cordBitMask); + } + + public int indexOf(IntPointXYZ xyz) { + return indexOf(xyz.x, xyz.y, xyz.z); + } + + /** + * Calculates the x, y, z (in cuboid space) of the given index. + */ + public IntPointXYZ xyzOf(int index) { + return new IntPointXYZ( + index & cubeInfo.cordBitMask, + (index >> cubeInfo.yShift) & cubeInfo.cordBitMask, + (index >> cubeInfo.zShift) & cubeInfo.cordBitMask + ); + } + + /** + * Wraps the given x, y, z into cuboid space. + */ + public IntPointXYZ xyzOf(int x, int y, int z) { + int index = indexOf(x, y, z); + return new IntPointXYZ( + index & cubeInfo.cordBitMask, + (index >> cubeInfo.yShift) & cubeInfo.cordBitMask, + (index >> cubeInfo.zShift) & cubeInfo.cordBitMask + ); + } + + /** + * Returns a copy of the palette value at the specified position in this cuboid. + *

Modifying the returned value can be done safely, it will have no effect on this cuboid.

+ *

To avoid the overhead of making a copy use {@link #getByRef(int)} instead.

+ * + * @param index index of the element to return + * @return the element at the specified position in this cuboid + * @throws IndexOutOfBoundsException if the index is out of range + * (index < 0 || index >= size()) + */ + @SuppressWarnings("unchecked") + public E get(int index) { + return (E) palette.get(packedData.get(index)).clone(); + } + /** + * Returns a copy of the palette value at the specified position in this cuboid. + *

Modifying the returned value can be done safely, it will have no effect on this cuboid.

+ *

To avoid the overhead of making a copy use {@link #getByRef(int, int, int)} instead.

+ * + *

Never throws IndexOutOfBoundsException. XYZ are always wrapped into bounds.

+ * @return the element at the specified position in this cuboid. + */ + public E get(int x, int y, int z) { + return get(indexOf(x, y, z)); + } + + public E get(IntPointXYZ xyz) { + return get(indexOf(xyz)); + } + + + /** + * Returns the palette value at the specified position in this cuboid. + *

WARNING if the returned value is modified it modifies every value which references the same palette entry!

+ * + * @param index index of the element to return + * @return the element at the specified position in this cuboid + * @throws IndexOutOfBoundsException if the index is out of range + * (index < 0 || index >= size()) + */ + public E getByRef(int index) { + return palette.get(packedData.get(index)); + } + + /** + * Returns the palette value at the specified position in this cuboid. + *

WARNING if the returned value is modified it modifies every value which references the same palette entry!

+ * + *

Never throws IndexOutOfBoundsException. XYZ are always wrapped into bounds.

+ * @return the element at the specified position in this cuboid. + */ + public E getByRef(int x, int y, int z) { + return getByRef(indexOf(x, y, z)); + } + + public E getByRef(IntPointXYZ xyz) { + return getByRef(indexOf(xyz)); + } + + /** + * Replaces the element at the specified position in this cuboid with + * the specified element. + * + * @param index index of the element to replace + * @param element element to be stored at the specified position + * @throws IndexOutOfBoundsException if the index is out of range (index < 0 || index >= size()) + */ + @SuppressWarnings("unchecked") + public void set(int index, E element) { + requireValue(element, "element"); + if (index < 0 || index >= packedData.length) { + throw new IndexOutOfBoundsException(); + } + paletteModCount ++; + int paletteIndex = palette.indexOf(element); + if (paletteIndex < 0) { + paletteIndex = palette.size(); + palette.add((E) element.clone()); // paletteModCount incremented at top of method + } + packedData.set(index, paletteIndex); + } + + /** + * Replaces the element at the specified position in this cuboid with + * the specified element. + * + *

Never throws IndexOutOfBoundsException. XYZ are always wrapped into bounds.

+ * @param x X index of the element to replace + * @param y Y index of the element to replace + * @param z Z index of the element to replace + * @param element element to be stored at the specified position + */ + public void set(int x, int y, int z, E element) { + set(indexOf(x, y, z), element); + } + + public void set(IntPointXYZ xyz, E element) { + set(indexOf(xyz.x, xyz.y, xyz.z), element); + } + + /** + * Sets a range of entries. The given coordinates must be in cuboid space (not absolute) and be contained + * within the bounds of this cuboid as wrapping these bounds would cause strange artifacts. + * @param x1 inclusive bound + * @param y1 inclusive bound + * @param z1 inclusive bound + * @param element fill with + * @param x2 inclusive bound + * @param y2 inclusive bound + * @param z2 inclusive bound + */ + @SuppressWarnings("unchecked") + public void set(int x1, int y1, int z1, E element, int x2, int y2, int z2 ) { + requireValue(element, "element"); + checkBounds(x1, y1, z1); + checkBounds(x2, y2, z2); + if (x1 > x2) { int t = x2; x2 = x1 + 1; x1 = t; } else { x2++; } + if (y1 > y2) { int t = y2; y2 = y1 + 1; y1 = t; } else { y2++; } + if (z1 > z2) { int t = z2; z2 = z1 + 1; z1 = t; } else { z2++; } + if ((x2 - x1) * (y2 - y1) * (z2 - z1) == size()) { + fill(element); + return; + } + + int paletteIndex = palette.indexOf(element); + if (paletteIndex < 0) { + paletteModCount ++; + paletteIndex = palette.size(); + palette.add((E) element.clone()); + } + + // detect and optimize XZ plain fills + if (x1 == 0 && z1 == 0 && x2 == cubeInfo.edgeLength && z2 == cubeInfo.edgeLength) { + final int endIndex = indexOf(x2 - 1, y2 - 1, z2 - 1); + for (int i = indexOf(x1, y1, z1); i <= endIndex; i++) { + packedData.set(i, paletteIndex); + } + return; + } + + // iteration order x, z, y + for (int y = y1; y < y2; y++) { + for (int z = z1; z < z2; z++) { + for (int x = x1; x < x2; x++) { + packedData.set(indexOf(x, y , z), paletteIndex); + } + } + } + } + + /** + * Sets a range of entries. The given coordinates must be in cuboid space (not absolute) and be contained + * within the bounds of this cuboid as wrapping these bounds would cause strange artifacts. + * @param xyz1 inclusive bound + * @param element fill with + * @param xyz2 inclusive bound + */ + public void set(IntPointXYZ xyz1, E element, IntPointXYZ xyz2) { + set(xyz1.x, xyz1.y, xyz1.z, element, xyz2.x, xyz2.y, xyz2.z); + } + + protected void checkBounds(int x, int y, int z) { + if (x < 0 || y < 0 || z < 0 || x >= cubeInfo.edgeLength || y >= cubeInfo.edgeLength || z >= cubeInfo.edgeLength) { + throw new IndexOutOfBoundsException(); + } + } + + /** + * Removes empty value sentinels from the palette and remaps value references as-needed. + * @return true if any modifications were made + */ + protected boolean optimizePalette() { + paletteModCount ++; + // 1. identify unused palette id's & remove them from palette + Set seenIds = new HashSet<>(); + for (int id : packedData) { + seenIds.add(id); + } + int maxId = seenIds.stream().mapToInt(v -> v).max().getAsInt(); + if (maxId >= palette.size()) { +// String dataStr = Arrays.stream(data) +// .mapToObj(String::valueOf) +// .collect(Collectors.joining(", ")); + throw new IllegalStateException("data[" + /*dataStr +*/ "] contained an out of bounds palette id " + maxId + " palette size " + palette.size()); + } + final E nilValue = nilSentinelFor(paletteEntryClass); + for (int i = 0; i < palette.size(); i++) { + if (!seenIds.contains(i)) { + palette.set(i, nilValue); // paletteModCount at top of function + } + } + + // 2. calculate palette defragmentation + int cursor = 0; + Map remapping = new HashMap<>(); + for (int i = 0; i < palette.size(); i++) { + if (palette.get(i) != nilValue) { + if (i != cursor) { + remapping.put(i, cursor); + } + cursor++; + } + } + + // 3. remove nilValue's from palette + palette.removeAll(Collections.singletonList(nilValue)); // paletteModCount at top of function + + // 4. perform id remapping + if (remapping.isEmpty()) { + packedData.compact(); + return false; + } else { + packedData.remap(remapping); + packedData.compact(); + return true; + } + } + + @Override + @SuppressWarnings("unchecked") + public PalettizedCuboid clone() { + optimizePalette(); + return new PalettizedCuboid<>(this); + } + + /** Serializes this cuboid to a {@link CompoundTag} assuming the latest data version (fine if only working with >= JAVA_1_16_20W17A). */ + public CompoundTag toCompoundTag() { + return toCompoundTag(0); + } + + public CompoundTag toCompoundTag(int dataVersion) { + return this.toCompoundTag(dataVersion, -1); + } + + /** + * Serializes this cuboid to a {@link CompoundTag}. + * + * @param dataVersion Optional - data version for formatting / packing. + * If GT 0 then packing strategy is updated to match the given versions behavior. + * @param minimumBitsPerIndex Optional - Minimum bits per index to use for packing. + * If GT 0, the long[] packing is updated to respect this value. + */ + public CompoundTag toCompoundTag(int dataVersion, int minimumBitsPerIndex) { + optimizePalette(); + if (palette.size() > 1) { + if (minimumBitsPerIndex > 0 || dataVersion > 0) { + if (minimumBitsPerIndex > 0) { + packedData.setMinBitsPerValue(minimumBitsPerIndex); + } + if (dataVersion > 0) { + packedData.setPackingStrategy(LongArrayTagPackedIntegers.MOJANG_PACKING_STRATEGY.get(dataVersion)); + } + packedData.compact(); + } + // don't need to call packedData.updateHandle() because we already compacted in optimizePalette() - or above + paletteContainerTag.put("data", packedData.getHandle()); + } else { + paletteContainerTag.remove("data"); + } + return paletteContainerTag; + } + + /** + * @param tag Must contain a 'palette' entry of type ListTag (usually then of type ListTag<StringTag> or + * <CompoundTag>) and MAY contain a 'data' tag of type {@link LongArrayTag}. The palette + * must contain at least one record to be considered valid. + * @param expectedCubeEdgeLength The length of one edge of the cuboid, usually a power of two, typically 16 or 4. + * @param The type of the palette list entries. Usually {@link CompoundTag} or {@link StringTag} + * @return New PalettizedCuboid wrapping the supplied tag. + */ + public static > PalettizedCuboid fromCompoundTag(CompoundTag tag, int expectedCubeEdgeLength) { + return fromCompoundTag(tag, expectedCubeEdgeLength, 0); + } + + /** + * @param tag Must contain a 'palette' entry of type ListTag (usually then of type ListTag<StringTag> or + * <CompoundTag>) and MAY contain a 'data' tag of type {@link LongArrayTag}. The palette + * must contain at least one record to be considered valid. + * @param expectedCubeEdgeLength The length of one edge of the cuboid, usually a power of two, typically 16 or 4. + * @param dataVersion if GT 0, this dataversion is used to determine the long[] packing strategy, + * see {@link LongArrayTagPackedIntegers#MOJANG_PACKING_STRATEGY}. If EQ 0 then the latest + * packing standard is used, {@link LongArrayTagPackedIntegers.Builder#dataVersion(int)}. + * @param The type of the palette list entries. Usually {@link CompoundTag} or {@link StringTag} + * @return New PalettizedCuboid wrapping the supplied tag. + */ + @SuppressWarnings("unchecked") + public static > PalettizedCuboid fromCompoundTag(CompoundTag tag, int expectedCubeEdgeLength, int dataVersion) { + if (tag == null) { + return null; + } + ListTag paletteListTag = tag.getListTagAutoCast("palette"); + check(paletteListTag != null, "Did not find 'palette' ListTag"); + check(!paletteListTag.isEmpty(), "'palette' ListTag exists but it was empty!"); + LongArrayTag dataTag = tag.getLongArrayTag("data"); + if ((dataTag == null || dataTag.getValue().length == 0) && paletteListTag.size() > 1) { + throw new IllegalArgumentException("Did not find 'data' LongArrayTag when expected"); + } + return new PalettizedCuboid<>(expectedCubeEdgeLength, (Class) paletteListTag.get(0).getClass(), tag, dataVersion); + } + + /** + * Creates a by-value iterator that will visit every entry and yield a clone of each entry. + *

Tip: if you are using a java version that supports the {@code var} keyword (java 10+), + * you can avoid using the otherwise unavoidably long typename with

+ *
{@code var iter = cuboid.iterator();}
+ * + * @see CursorIterator + */ + @Override + public CursorIterator iterator() { + return new CursorIterator(null); + } + + /** + * Creates a by-value iterator that will only visit entries which match the provided filter nd yield a clone of + * each entry. + * @see CursorIterator + */ + public CursorIterator iterator(Predicate filter) { + return new CursorIterator(filter); + } + + public Stream stream() { + return StreamSupport.stream(spliterator(), false); + } + + @Override + public CompoundTag getHandle() { + return paletteContainerTag; + } + + @Override + public CompoundTag updateHandle() { + return toCompoundTag(); + } + + /** + * This is both an iterator and a cursor. This cursor knows where it is and can + * be inspected to get additional information about the last item returned by {@link CursorIterator#next()} + * such as its XYZ cuboid location and index in the data array. + * + *

Warning - this iterator yields values by reference

+ * This iterator yields palette values by reference! Do not modify the object returned by {@link #next()} or + * {@link #current()}. If you want to keep a copy of the yielded value {@code .clone()} it. If you want to modify + * the value at the current iteration position {@code .clone()} it first, then call {@link #set(Tag)}. You should + * not modify the cuboid while iterating over it other than through the iterator itself. + * + *

The iterator will make a best-effort to detect palette corruption and throw a + * {@link PaletteCorruptedException} if detected and throw {@link ConcurrentModificationException} if the + * cuboid palette is modified during iteration.

+ * + *

Remember

+ * All {@link Tag}'s support cloning. If you are careful it's always faster / more efficient + * to iterate by reference and clone entries only when needed. But this optimization comes at the risk of + * accidentally corrupting the palette. + * + *

Filtering Mode

+ * When a filter is provided {@link #next()} will only return the next matching entry, skipping as necessary. + * {@link #hasNext()} will behave appropriately and indicate if there is a next matching entry or not. + *

While you could use a {@link Stream} to filter and process entries of interest, using a filter on a cursor + * iterator allows you to get more information about the current entry, such as its xyz cuboid position.

+ * + * @see PalettizedCuboid#iterator(Predicate) + */ + public class CursorIterator implements java.util.Iterator { + private final Predicate filter; // nullable + private int currentIndex = -1; + private int nextIndex = 0; // only used if filter isn't null + int currentPaletteHash; + int expectPaletteModCount = paletteModCount; + + CursorIterator(Predicate filter) { + this.filter = filter; + } + + private void checkNotModified() { + if (expectPaletteModCount != paletteModCount) { + throw new ConcurrentModificationException(); + } + if (currentIndex >= 0 && currentPaletteHash != get(currentIndex).hashCode()) { + throw new PaletteCorruptedException( + "Palette modified during iteration! Be sure to .clone() on the yielded element before " + + "modifying it and use .set() to update the data value at the current position."); + } + } + + private void checkCurrentIndex() { + if (currentIndex < 0) { // note currentIndex will never be GE data.length + throw new NoSuchElementException(); + } + } + + @Override + public boolean hasNext() { + checkNotModified(); + if (filter == null) { + return currentIndex < packedData.length - 1; + } + while (nextIndex < packedData.length) { + if (filter.test(get(nextIndex))) { + return true; + } + nextIndex ++; + } + return false; + } + + @Override + public E next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + E ret; + if (filter == null) { + ret = getByRef(++currentIndex); + } else { + ret = get(currentIndex = nextIndex++); + } + currentPaletteHash = ret.hashCode(); + return ret; + } + + /** Advances to the next entry and returns its XYZ. */ + public IntPointXYZ nextXYZ() { + next(); + return currentXYZ(); + } + + /** + * Updates the cuboid entry at the index belonging to the last value returned by {@link #next()}. + */ + public void set(E replacement) { + checkNotModified(); + checkCurrentIndex(); + PalettizedCuboid.this.set(currentIndex, replacement); + expectPaletteModCount = paletteModCount; + currentPaletteHash = replacement.hashCode(); + } + + /** + * Gets the last entry returned by {@link #next()}. + */ + public E current() { + checkCurrentIndex(); + return getByRef(currentIndex); + } + + /** Gets the cuboid data index of the last entry returned by {@link #next()}. */ + public int currentIndex() { + checkCurrentIndex(); + return currentIndex; + } + + /** Gets the cuboid x position of the last entry returned by {@link #next()}. */ + public int currentX() { + checkCurrentIndex(); + return currentIndex & cubeInfo.cordBitMask; + } + + /** Gets the cuboid y position of the last entry returned by {@link #next()}. */ + public int currentY() { + checkCurrentIndex(); + return (currentIndex >> cubeInfo.yShift) & cubeInfo.cordBitMask; + } + + /** Gets the cuboid z position of the last entry returned by {@link #next()}. */ + public int currentZ() { + checkCurrentIndex(); + return (currentIndex >> cubeInfo.zShift) & cubeInfo.cordBitMask; + } + + /** Gets the cuboid xyz position of the last entry returned by {@link #next()}. */ + public IntPointXYZ currentXYZ() { + checkCurrentIndex(); + return xyzOf(currentIndex); + } + } + + public static class PaletteCorruptedException extends RuntimeException { + public PaletteCorruptedException(String message) { + super(message); + } + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/RegionBoundingRectangle.java b/src/main/java/io/github/ensgijs/nbt/mca/util/RegionBoundingRectangle.java new file mode 100644 index 00000000..bfb999f8 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/RegionBoundingRectangle.java @@ -0,0 +1,127 @@ +package io.github.ensgijs.nbt.mca.util; + +import java.util.Collection; +import java.util.function.ToIntFunction; + +public class RegionBoundingRectangle extends ChunkBoundingRectangle { + /** + * Bounds of the maximum size of a minecraft world. + * Note that there are 368 XZ blocks (23 XZ chunks) outside the world border that are still within these region bounds. + *

Remember that the max bound is exclusive (don't treat getMaxRegionXZ as in bounds).

+ * @see ChunkBoundingRectangle#MAX_WORLD_BORDER_BOUNDS + */ + public static final RegionBoundingRectangle MAX_WORLD_REGION_BOUNDS = + new RegionBoundingRectangle(-58594, -58594, 58594 * 2); + + public RegionBoundingRectangle(int regionX, int regionZ) { + this(regionX, regionZ, 1); + } + + public RegionBoundingRectangle(int regionX, int regionZ, int widthXZ) { + super(regionX << 5, regionZ << 5, widthXZ << 5); + } + + public RegionBoundingRectangle translateRegions(int x, int z) { + return new RegionBoundingRectangle( + getMinRegionX() + x, + getMinRegionZ() + z, + getWidthRegionXZ() + ); + } + + /** + * Computes a new region which shares the same center but is {@code size} larger in each direction. + * Total width/height grows by 2x this value. + * @param size amount to shrink by, must be GE0. + * @return new larger rectangle + */ + public RegionBoundingRectangle growRegions(int size) { + if (size == 0) return this; + if (size < 0) throw new IllegalArgumentException(); + return new RegionBoundingRectangle(getMinRegionX() - size, getMinRegionZ() - size, getWidthRegionXZ() + 2 * size); + } + + /** + * Computes a new region which shares the same center but is {@code size} smaller in each direction. + * Total width/height reduces by 2x this value. + * @param size amount to shrink by, must be GE0. + * @return new rectangle if resulting width/height > 1, else null. + */ + public RegionBoundingRectangle shrinkRegions(int size) { + if (size == 0) return this; + if (size < 0) throw new IllegalArgumentException("size must be GE 0"); + int newSize = getWidthRegionXZ() - 2 * size; + if (newSize <= 0) return null; + return new RegionBoundingRectangle(getMinRegionX() + size, getMinRegionZ() + size, newSize); + } + + public boolean containsRegion(int regionX, int regionZ) { + return containsBlock(regionX << 9, regionZ << 9); + } + + public boolean containsRegion(IntPointXZ regionXZ) { + return containsBlock(regionXZ.getX() << 9, regionXZ.getZ() << 9); + } + + public final int getMinRegionX() { + return minBlockX >> 9; + } + + public final int getMinRegionZ() { + return minBlockZ >> 9; + } + + /** exclusive */ + public final int getMaxRegionX() { + return maxBlockX >> 9; + } + + /** exclusive */ + public final int getMaxRegionZ() { + return maxBlockZ >> 9; + } + + public final int getWidthRegionXZ() { + return widthBlockXZ >> 9; + } + + public static RegionBoundingRectangle forChunk(int x, int z) { + return new RegionBoundingRectangle(x >> 5, z >> 5); + } + + public static RegionBoundingRectangle forBlock(int x, int z) { + return new RegionBoundingRectangle(x >> 9, z >> 9); + } + + public ChunkBoundingRectangle asChunkBounds() { + return new ChunkBoundingRectangle(getMinChunkX(), getMinChunkZ(), getWidthChunkXZ()); + } + + @Override + public String toString() { + return String.format("regions[%d..%d, %d..%d]", + getMinRegionX(), getMaxRegionX() - 1, getMinRegionZ(), getMaxRegionZ() - 1); + } + + public static RegionBoundingRectangle of(Collection regions) { + return of(regions, IntPointXZ::getX, IntPointXZ::getZ); + } + + public static RegionBoundingRectangle of(Collection regions, ToIntFunction xGetter, ToIntFunction zGetter) { + if (regions == null || regions.isEmpty()) + return null; + int minX = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int minZ = Integer.MAX_VALUE; + int maxZ = Integer.MIN_VALUE; + for (T xz : regions) { + int x = xGetter.applyAsInt(xz); + int z = zGetter.applyAsInt(xz); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + } + return new RegionBoundingRectangle(minX, minZ, Math.max(maxX - minX, maxZ - minZ) + 1); + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/SectionIterator.java b/src/main/java/io/github/ensgijs/nbt/mca/util/SectionIterator.java new file mode 100644 index 00000000..835c5563 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/SectionIterator.java @@ -0,0 +1,14 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.mca.SectionBase; + +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. Inclusive. */ + int sectionBlockMinY(); + /** Current block world level y of the top most block in the current section. Inclusive. */ + int sectionBlockMaxY(); +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/TagWrapper.java b/src/main/java/io/github/ensgijs/nbt/mca/util/TagWrapper.java new file mode 100644 index 00000000..da222408 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/TagWrapper.java @@ -0,0 +1,19 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.tag.Tag; + +public interface TagWrapper> { + + /** + * 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 Tag this object is based on. + */ + T getHandle(); + + /** + * Updates the data tag held by this wrapper and returns it. + * @return A reference to the raw Tag this object is based on. + */ + T updateHandle(); +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/TracksUnreadDataTags.java b/src/main/java/io/github/ensgijs/nbt/mca/util/TracksUnreadDataTags.java new file mode 100644 index 00000000..00c1d679 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/TracksUnreadDataTags.java @@ -0,0 +1,25 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.tag.CompoundTag; + +import java.util.*; + +public interface TracksUnreadDataTags { + /** + * Gets the unmodifiable set of data tag keys which were not read during initialization. + * @return Nullable - null if LoadFags contained {@link LoadFlags#RAW} - else the unmodifiable set of unread key names + */ + Set getUnreadDataTagKeys(); + + /** + * Gets a new CompoundTag containing all entries which were not read during initialization. + * Note that the returned tag values are by reference (linked to the values in the {@link CompoundTag} provided + * for initialization) so modifying values in the returned tag will also modify the underlying data, however, + * adding or removing elements directly from the returned tag has no effect. + *

Basically know what you're doing when modifying the returned value - or don't modify it at all.

+ * @return NotNull - if LoadFlags specified {@link LoadFlags#RAW} then the raw data is returned - else a new + * CompoundTag populated, by reference, with values that were not read during initialization. + */ + CompoundTag getUnreadDataTags(); +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/VersionAware.java b/src/main/java/io/github/ensgijs/nbt/mca/util/VersionAware.java new file mode 100644 index 00000000..b4c7c042 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/VersionAware.java @@ -0,0 +1,31 @@ +package io.github.ensgijs.nbt.mca.util; +import java.util.Map; +import java.util.TreeMap; + +/** + * Simple utility class for managing data version support. + */ +public class VersionAware { + private final TreeMap versionedValues = new TreeMap<>(); + + /** + * Registers a value. + * @param minVersion minimum version for which to return the given value (inclusive). + * @param value value to associate with the given version up to the next registered version (exclusive). + * @return self for chaining + */ + public VersionAware register(int minVersion, T value) { + versionedValues.put(minVersion, value); + return this; + } + + /** + * Gets the best value for the given version. + * @param forVersion version of interest. + * @return an entry with the greatest version less than or equal to forVersion, or null if there is no such version registered. + */ + public T get(int forVersion) { + Map.Entry entry = versionedValues.floorEntry(forVersion); + return entry != null ? entry.getValue() : null; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/mca/util/VersionedDataContainer.java b/src/main/java/io/github/ensgijs/nbt/mca/util/VersionedDataContainer.java new file mode 100644 index 00000000..b5a12bd8 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/mca/util/VersionedDataContainer.java @@ -0,0 +1,44 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.mca.DataVersion; + +/** + * 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/io/github/ensgijs/nbt/query/NbtPath.java b/src/main/java/io/github/ensgijs/nbt/query/NbtPath.java new file mode 100644 index 00000000..cd324e49 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/query/NbtPath.java @@ -0,0 +1,488 @@ +package io.github.ensgijs.nbt.query; + +import io.github.ensgijs.nbt.query.evaluator.Evaluator; +import io.github.ensgijs.nbt.query.evaluator.IndexEvaluator; +import io.github.ensgijs.nbt.query.evaluator.NameEvaluator; +import io.github.ensgijs.nbt.tag.*; +import io.github.ensgijs.nbt.util.ArgValidator; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Provides a simple mechanism to retrieve, and store, structured data without + * having to handle the intermediate tags yourself. + *

Use simple dot separated names and bracket indexes such as {@code "Level.Section[0].BlockLight"}

+ *

Colons are allowed in the path such as {@code Brain.memories.minecraft:home.value.pos}

+ */ +public class NbtPath { + private static final NbtPath IDENTITY_PATH = new NbtPath(Collections.emptyList()); + + protected List evalChain; + + protected NbtPath(List evalChain) { + this.evalChain = Collections.unmodifiableList(evalChain); + } + + /** + * Injects "<>" into the string at the specified position optionally wrapping some content as + * specified by width. + * @param str string to decorate + * @param pos position to insert < + * @param width how many characters to skip before inserting > + * @return modified str + */ + static String markLocation(String str, int pos, int width) { + assert width >= 0; + if (pos < 0) return "<>" + str; + if (pos >= str.length()) return str + "<>"; + if (width <= 0) { + return str.substring(0, pos) + "<>" + str.substring(pos); + } + return str.substring(0, pos) + "<" + str.substring(pos, pos + width) + ">" + str.substring(pos + width); + } + + /** + * Creates a new {@link NbtPath} from the given selector string. + * @param selector Use dots to separate names/keys and use array notation for indexing {@link ListTag} + * and {@link ArrayTag}'s. Example: {@code "Level.Section[0].BlockLight"} + * Colons are allowed in the path such as {@code Brain.memories.minecraft:home.value.pos} + * @return new {@link NbtPath} + */ + public static NbtPath of(String selector) { + if (selector == null || selector.isEmpty() || selector.equals(".")) return IDENTITY_PATH; + List evalChain = new ArrayList<>(); + int partPos = 0; + String[] parts = selector.split("[.]", -1); + boolean allowEmpty = true; + + for (String part : parts) { + if (part.isEmpty() && !allowEmpty) throw new IllegalArgumentException("empty name at: " + markLocation(selector, partPos, 0)); + int bracketOpenIdx = part.indexOf('['); + int bracketCloseIdx = part.indexOf(']'); + String name; + if (bracketOpenIdx < 0 || bracketCloseIdx < bracketOpenIdx) { + if (bracketCloseIdx >= 0) throw new IllegalArgumentException("unexpected close bracket at: " + markLocation(selector, bracketCloseIdx + partPos, 1)); + name = part; + } else { + if (bracketCloseIdx < 0) throw new IllegalArgumentException("unclose bracket at: " + markLocation(selector, bracketOpenIdx + partPos, 1)); + name = part.substring(0, bracketOpenIdx); + } + + if (!name.isEmpty()) { + evalChain.add(new NameEvaluator(name)); + } else if (!evalChain.isEmpty() || !allowEmpty) { + throw new IllegalArgumentException("expected map key name at: " + markLocation(selector, partPos, 0)); + } + while (bracketOpenIdx >= 0) { + if (bracketCloseIdx < 0) + throw new IllegalArgumentException("missing close bracket for: " + markLocation(selector, bracketOpenIdx + partPos, 1)); + String valStr = part.substring(bracketOpenIdx + 1, bracketCloseIdx); + if (valStr.isEmpty() || !valStr.chars().allMatch(Character::isDigit)) + throw new IllegalArgumentException("list index must be a positive number at: " + markLocation(selector, bracketOpenIdx + partPos + 1, bracketCloseIdx - bracketOpenIdx - 1)); + + evalChain.add(new IndexEvaluator(Integer.parseInt(valStr))); + bracketOpenIdx = part.indexOf('[', bracketCloseIdx + 1); + if (bracketOpenIdx != -1 && bracketOpenIdx != bracketCloseIdx + 1) { + throw new IllegalArgumentException("invalid path string - error at: " + markLocation(selector, bracketCloseIdx + 1 + partPos, 0)); + } + bracketCloseIdx = part.indexOf(']', bracketCloseIdx + 1); + } + if (bracketCloseIdx > 0) + throw new IllegalArgumentException(); + partPos += name.length() + 1; + allowEmpty = false; + } + return new NbtPath(evalChain); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + boolean canDot = false; + for (Evaluator evaluator : evalChain) { + if (evaluator instanceof NameEvaluator) { + if (canDot) sb.append('.'); + } + sb.append(evaluator); + canDot = true; + } + return sb.toString(); + } + + protected String makeErrorHint(int atIndex) { + return makeErrorHint(evalChain.get(atIndex)); + } + + /** + * Just like toString except it wraps the specified evaluator in < and > + * @param atEvaluator evaluator to wrap + * @return modified toString + */ + protected String makeErrorHint(Evaluator atEvaluator) { + StringBuilder sb = new StringBuilder(); + boolean canDot = false; + for (Evaluator evaluator : evalChain) { + if (evaluator instanceof NameEvaluator) { + if (canDot) sb.append('.'); + } + if (evaluator == atEvaluator) sb.append("<"); + sb.append(evaluator); + if (evaluator == atEvaluator) sb.append(">"); + canDot = true; + } + return sb.toString(); + } + + /** + * Evaluates this {@link NbtPath} against the provided {@link Tag}. Note that this method may return + * {@link Tag}'s, but it may also return a primitive type such as byte/int/long if the leaf in the + * path is an index into one of the {@link ArrayTag} types. + * + *

The caller must know the type which will be returned. Any type errors will result in a + * {@link ClassCastException} being thrown at the call site of this method - not from within this method.

+ * @param root tag to begin traversal from. + * @param return type - note that if this function returns an unexpected type you will see a + * {@link ClassCastException} thrown at the call site of this method - not from within this method. + * @return result or null + * @see #getTag(Tag) + */ + @SuppressWarnings("unchecked") + public R get(Tag root) { + Object node = root; + for (Evaluator evaluator : evalChain) { + if (node == null) return null; + if (!(node instanceof Tag)) throw new IllegalStateException("Expected TAG but was " + node.getClass().getTypeName() + "\n" + makeErrorHint(evaluator)); + node = evaluator.eval((Tag) node); + } + return (R) node; + } + + /** + * Just like {@link #get(Tag)} except auto-castable to a {@link Tag} type. + * + *

The caller must know the type which will be returned. Any type errors will result in a + * {@link ClassCastException} being thrown at the call site of this method - not from within this method.

+ * @see #get(Tag) + */ + public > R getTag(Tag root) { + return get(root); + } + + /** + * Gets the value found by this path as a String. + * @param root tag to begin traversal from. + * @return String value or null if not found (note that a StringTag may be the empty string but never contain null; + * therefore null indicates the value was not set). + * @throws ClassCastException if the tag exists and was not a {@link StringTag} + */ + public String getString(Tag root) { + Object value = get(root); + if (value instanceof StringTag) return ((StringTag) value).getValue(); + if (value instanceof String) return (String) value; + if (value == null) return null; + throw new ClassCastException("expected string but was " + value.getClass().getTypeName()); + } + + /** + * Gets the value found by this path as a boolean. This helper follows the convention that the truthiness of + * a tag is FALSE for all cases except a {@link ByteTag} with a positive value. + * @param root tag to begin traversal from. + * @return truthiness of the tag found by this path (default of false if the tag does not exist or is not a + * {@link ByteTag} with a positive value). + */ + public boolean getBoolean(Tag root) { + Object value = get(root); + return value instanceof ByteTag && ((ByteTag) value).asByte() > 0; + } + + /** + * Gets the value found by this path as a byte. Note that this method can be used on any {@link NumberTag} or + * an index into any {@link ArrayTag}, not just {@link ByteTag}'s. + * @param root tag to begin traversal from. + * @return value cast to a byte + */ + public byte getByte(Tag root) { + Object value = get(root); + if (value instanceof NumberTag) { + return ((NumberTag) value).asByte(); + } + if (value == null) return 0; + return (byte) value; + } + + public byte[] getByteArray(Tag root) { + Object value = get(root); + if (value instanceof ByteArrayTag) { + return ((ByteArrayTag) value).getValue(); + } + return null; + } + + /** + * Gets the value found by this path as a short. Note that this method can be used on any {@link NumberTag} or + * an index into any {@link ArrayTag}, not just {@link ShortTag}'s. + * @param root tag to begin traversal from. + * @return value cast to a short + */ + public short getShort(Tag root) { + Object value = get(root); + if (value instanceof NumberTag) { + return ((NumberTag) value).asShort(); + } + if (value == null) return 0; + return (short) value; + } + + /** + * Gets the value found by this path as an int. Note that this method can be used on any {@link NumberTag} or + * an index into any {@link ArrayTag}, not just {@link IntTag}'s. + * @param root tag to begin traversal from. + * @return value cast to an int + */ + public int getInt(Tag root) { + Object value = get(root); + if (value instanceof NumberTag) { + return ((NumberTag) value).asInt(); + } + if (value == null) return 0; + return (int) value; + } + + public int[] getIntArray(Tag root) { + Object value = get(root); + if (value instanceof IntArrayTag) { + return ((IntArrayTag) value).getValue(); + } + return null; + } + + /** + * Gets the value found by this path as a long. Note that this method can be used on any {@link NumberTag} or + * an index into any {@link ArrayTag}, not just {@link LongTag}'s. + * @param root tag to begin traversal from. + * @return value cast to a long + */ + public long getLong(Tag root) { + Object value = get(root); + if (value instanceof NumberTag) { + return ((NumberTag) value).asLong(); + } + if (value == null) return 0; + return (long) value; + } + + public long[] getLongArray(Tag root) { + Object value = get(root); + if (value instanceof LongArrayTag) { + return ((LongArrayTag) value).getValue(); + } + return null; + } + + + /** + * Gets the value found by this path as a float. Note that this method can be used on any {@link NumberTag} or + * an index into any {@link ArrayTag}, not just {@link FloatTag}'s. + * @param root tag to begin traversal from. + * @return value cast to a float + */ + public float getFloat(Tag root) { + Object value = get(root); + if (value instanceof NumberTag) { + return ((NumberTag) value).asFloat(); + } + if (value == null) return 0; + return (float) value; + } + + /** + * Gets the value found by this path as a double. Note that this method can be used on any {@link NumberTag} or + * an index into any {@link ArrayTag}, not just {@link DoubleTag}'s. + * @param root tag to begin traversal from. + * @return value cast to a double + */ + public double getDouble(Tag root) { + Object value = get(root); + if (value instanceof NumberTag) { + return ((NumberTag) value).asDouble(); + } + if (value == null) return 0; + return (double) value; + } + + /** + * Shorthand for {@code putTag(root, value, createParents=false);} + * @see #putTag(Tag, Tag, boolean) + */ + public > T putTag(Tag root, Tag value) { + return putTag(root, value, false); + } + + /** + * Putting a new list of lists tag can be finicky - if you get an "inference variable has incompatible upper bounds" + * error try this. + *
{@code nbtpath.putTag(tag, new ListTag>(ListTag.class))}
+ * @param root root tag to evaluate this path from + * @param value tag value to set, may be null to remove the tag + * @param createParents when true CompoundTag's will be created as necessary. Will not create ListTag's or ArrayTag's. + * @param value tag type + * @return the previous value associated with key, or null if there was no mapping for key. + */ + @SuppressWarnings("unchecked") + public > T putTag(Tag root, Tag value, boolean createParents) { + ArgValidator.requireValue(root, "root"); + ArgValidator.check(evalChain.size() > 0); + Tag parent; + Tag node = root; + for (int i = 0, l = evalChain.size() - 1; i < l; i ++) { + parent = node; + Evaluator evaluator = evalChain.get(i); + node = (Tag) evaluator.eval(node); + if (node == null && createParents) { + if (parent instanceof CompoundTag && evaluator instanceof NameEvaluator && evalChain.get(i + 1) instanceof NameEvaluator) { + node = new CompoundTag(); + ((CompoundTag) parent).put(((NameEvaluator) evaluator).key(), node); + } else { + throw new UnsupportedOperationException("cannot auto-create child tag at " + makeErrorHint(evaluator)); + } + } + + if (node == null) { + throw new IllegalArgumentException("Tag does not exist: " + makeErrorHint(evaluator)); + } + } + Evaluator last = evalChain.get(evalChain.size() - 1); + if (node instanceof CompoundTag) { + if (!(last instanceof NameEvaluator)) throw new IllegalArgumentException(); + if (value != null) { + node = ((CompoundTag) node).put(((NameEvaluator) last).key(), value); + } else { + node = ((CompoundTag) node).remove(((NameEvaluator) last).key()); + } + } else { + throw new UnsupportedOperationException("expected CompoundTag but was " + node.getClass().getSimpleName() + " at: " + + makeErrorHint(last)); + } + return (T) node; + } + + // + public ByteTag put(Tag root, boolean value) { + return putTag(root, new ByteTag(value), false); + } + + public ByteArrayTag put(Tag root, byte[] value) { + return putTag(root, new ByteArrayTag(value), false); + } + + public ByteTag put(Tag root, byte value) { + return putTag(root, new ByteTag(value), false); + } + + public DoubleTag put(Tag root, double value) { + return putTag(root, new DoubleTag(value), false); + } + + public FloatTag put(Tag root, float value) { + return putTag(root, new FloatTag(value), false); + } + + public IntArrayTag put(Tag root, int[] value) { + return putTag(root, new IntArrayTag(value), false); + } + + public IntTag put(Tag root, int value) { + return putTag(root, new IntTag(value), false); + } + + public LongArrayTag put(Tag root, long[] value) { + return putTag(root, new LongArrayTag(value), false); + } + + public LongTag put(Tag root, long value) { + return putTag(root, new LongTag(value), false); + } + + public ShortTag put(Tag root, short value) { + return putTag(root, new ShortTag(value), false); + } + + public StringTag put(Tag root, String value) { + return putTag(root, new StringTag(value), false); + } + + + public ByteTag put(Tag root, boolean value, boolean createParents) { + return putTag(root, new ByteTag(value), createParents); + } + + public ByteArrayTag put(Tag root, byte[] value, boolean createParents) { + return putTag(root, new ByteArrayTag(value), createParents); + } + + public ByteTag put(Tag root, byte value, boolean createParents) { + return putTag(root, new ByteTag(value), createParents); + } + + public DoubleTag put(Tag root, double value, boolean createParents) { + return putTag(root, new DoubleTag(value), createParents); + } + + public FloatTag put(Tag root, float value, boolean createParents) { + return putTag(root, new FloatTag(value), createParents); + } + + public IntArrayTag put(Tag root, int[] value, boolean createParents) { + return putTag(root, new IntArrayTag(value), createParents); + } + + public IntTag put(Tag root, int value, boolean createParents) { + return putTag(root, new IntTag(value), createParents); + } + + public LongArrayTag put(Tag root, long[] value, boolean createParents) { + return putTag(root, new LongArrayTag(value), createParents); + } + + public LongTag put(Tag root, long value, boolean createParents) { + return putTag(root, new LongTag(value), createParents); + } + + public ShortTag put(Tag root, short value, boolean createParents) { + return putTag(root, new ShortTag(value), createParents); + } + + public StringTag put(Tag root, String value, boolean createParents) { + return putTag(root, new StringTag(value), createParents); + } + // + + /** + * Gets the size, or length, of the tag at this path. + * @param root root tag to evaluate this path from + * @return size/length of tag if exists, 0 if the tag doesn't exist. + * @throws IllegalArgumentException if the type of tag found by this path doesn't have a reasonable size/length + * property - such as a {@link DoubleTag}. + */ + public int size(Tag root) { + Tag tag = getTag(root); + if (tag instanceof CompoundTag) { + return ((CompoundTag) tag).size(); + } else if (tag instanceof ListTag) { + return ((ListTag) tag).size(); + } else if (tag instanceof ArrayTag) { + return ((ArrayTag) tag).length(); + } else if (tag instanceof StringTag) { + String str = ((StringTag) tag).getValue(); + return str != null ? str.length() : 0; + } + if (tag == null) return 0; + throw new IllegalArgumentException("don't know how to get the size of " + tag.getClass().getTypeName()); + } + + public boolean exists(Tag root) { + return root != null && get(root) != null; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/query/evaluator/Evaluator.java b/src/main/java/io/github/ensgijs/nbt/query/evaluator/Evaluator.java new file mode 100644 index 00000000..00b38603 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/query/evaluator/Evaluator.java @@ -0,0 +1,8 @@ +package io.github.ensgijs.nbt.query.evaluator; + +import io.github.ensgijs.nbt.tag.Tag; + +@FunctionalInterface +public interface Evaluator { + Object eval(Tag tag); +} diff --git a/src/main/java/io/github/ensgijs/nbt/query/evaluator/IndexEvaluator.java b/src/main/java/io/github/ensgijs/nbt/query/evaluator/IndexEvaluator.java new file mode 100644 index 00000000..d33b287f --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/query/evaluator/IndexEvaluator.java @@ -0,0 +1,41 @@ +package io.github.ensgijs.nbt.query.evaluator; + +import io.github.ensgijs.nbt.tag.*; + +public class IndexEvaluator implements Evaluator { + private final int index; + + public int index() { + return index; + } + + public IndexEvaluator(int index) { + this.index = index; + } + + public Object eval(Tag tag) { + if (tag instanceof ListTag) { + ListTag listTag = (ListTag) tag; + return index < listTag.size() ? listTag.get(index) : null; + } else if (tag instanceof ArrayTag) { + if (index >= ((ArrayTag) tag).length()) return null; + + if (tag instanceof ByteArrayTag) { + return ((ByteArrayTag) tag).getValue()[index]; + } + if (tag instanceof IntArrayTag) { + return ((IntArrayTag) tag).getValue()[index]; + } + if (tag instanceof LongArrayTag) { + return ((LongArrayTag) tag).getValue()[index]; + } + } + if (tag == null) return null; + throw new IllegalArgumentException("expected ListTag but was " + tag.getClass().getTypeName()); + } + + @Override + public String toString() { + return "[" + index + "]"; + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/query/evaluator/NameEvaluator.java b/src/main/java/io/github/ensgijs/nbt/query/evaluator/NameEvaluator.java new file mode 100644 index 00000000..ebe1dbca --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/query/evaluator/NameEvaluator.java @@ -0,0 +1,29 @@ +package io.github.ensgijs.nbt.query.evaluator; + +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.Tag; + +public class NameEvaluator implements Evaluator { + + private final String key; + + public String key() { + return key; + } + + public NameEvaluator(String key) { + this.key = key; + } + + public Object eval(Tag tag) { + if (tag instanceof CompoundTag) { + return ((CompoundTag) tag).get(key); + } + return null; + } + + @Override + public String toString() { + return key; + } +} diff --git a/src/main/java/net/querz/nbt/tag/ArrayTag.java b/src/main/java/io/github/ensgijs/nbt/tag/ArrayTag.java similarity index 91% rename from src/main/java/net/querz/nbt/tag/ArrayTag.java rename to src/main/java/io/github/ensgijs/nbt/tag/ArrayTag.java index 2842fa63..2a2e2c50 100644 --- a/src/main/java/net/querz/nbt/tag/ArrayTag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/ArrayTag.java @@ -1,4 +1,4 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; import java.lang.reflect.Array; @@ -20,16 +20,19 @@ public int length() { return Array.getLength(getValue()); } + /** {@inheritDoc} */ @Override public T getValue() { return super.getValue(); } + /** {@inheritDoc} */ @Override public void setValue(T value) { super.setValue(value); } + /** {@inheritDoc} */ @Override public String valueToString(int maxDepth) { return arrayToString("", ""); diff --git a/src/main/java/net/querz/nbt/tag/ByteArrayTag.java b/src/main/java/io/github/ensgijs/nbt/tag/ByteArrayTag.java similarity index 69% rename from src/main/java/net/querz/nbt/tag/ByteArrayTag.java rename to src/main/java/io/github/ensgijs/nbt/tag/ByteArrayTag.java index 8fbcf8a3..ac8d3188 100644 --- a/src/main/java/net/querz/nbt/tag/ByteArrayTag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/ByteArrayTag.java @@ -1,4 +1,4 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; import java.util.Arrays; @@ -11,30 +11,37 @@ public ByteArrayTag() { super(ZERO_VALUE); } - public ByteArrayTag(byte[] value) { + public ByteArrayTag(byte... value) { super(value); } + /** {@inheritDoc} */ @Override public byte getID() { return ID; } + /** {@inheritDoc} */ @Override public boolean equals(Object other) { return super.equals(other) && Arrays.equals(getValue(), ((ByteArrayTag) other).getValue()); } + /** {@inheritDoc} */ @Override public int hashCode() { return Arrays.hashCode(getValue()); } + /** {@inheritDoc} */ @Override public int compareTo(ByteArrayTag other) { - return Integer.compare(length(), other.length()); + int k = Integer.compare(length(), other.length()); + if (k != 0) return k; + return Arrays.compare(getValue(), other.getValue()); } + /** {@inheritDoc} */ @Override public ByteArrayTag clone() { return new ByteArrayTag(Arrays.copyOf(getValue(), length())); diff --git a/src/main/java/net/querz/nbt/tag/ByteTag.java b/src/main/java/io/github/ensgijs/nbt/tag/ByteTag.java similarity index 70% rename from src/main/java/net/querz/nbt/tag/ByteTag.java rename to src/main/java/io/github/ensgijs/nbt/tag/ByteTag.java index 207cefd2..c260e314 100644 --- a/src/main/java/net/querz/nbt/tag/ByteTag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/ByteTag.java @@ -1,4 +1,4 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; public class ByteTag extends NumberTag implements Comparable { @@ -17,29 +17,38 @@ public ByteTag(boolean value) { super((byte) (value ? 1 : 0)); } + /** {@inheritDoc} */ @Override public byte getID() { return ID; } public boolean asBoolean() { + // TODO(bug): MC uses `.asByte() != 0` for truthiness - and asBoolean is valid on all NumberTags (even float and double) return getValue() > 0; } + /** + * Sets the value for this Tag directly. + * @param value The value to be set. + */ public void setValue(byte value) { super.setValue(value); } + /** {@inheritDoc} */ @Override public boolean equals(Object other) { return super.equals(other) && asByte() == ((ByteTag) other).asByte(); } + /** {@inheritDoc} */ @Override public int compareTo(ByteTag other) { return getValue().compareTo(other.getValue()); } + /** {@inheritDoc} */ @Override public ByteTag clone() { return new ByteTag(getValue()); diff --git a/src/main/java/io/github/ensgijs/nbt/tag/CompoundTag.java b/src/main/java/io/github/ensgijs/nbt/tag/CompoundTag.java new file mode 100644 index 00000000..b5fe1efa --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/tag/CompoundTag.java @@ -0,0 +1,662 @@ +package io.github.ensgijs.nbt.tag; + +import java.lang.reflect.Array; +import java.util.*; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import io.github.ensgijs.nbt.io.MaxDepthIO; +import io.github.ensgijs.nbt.io.NamedTag; +import io.github.ensgijs.nbt.util.ArgValidator; + +import static io.github.ensgijs.nbt.tag.StringTag.escapeString; + +public class CompoundTag extends Tag>> + implements Iterable, Comparable, MaxDepthIO { + + public static final byte ID = 10; + + public CompoundTag() { + super(createEmptyValue()); + } + + public CompoundTag(int initialCapacity) { + super(new LinkedHashMap<>(initialCapacity)); + } + + /** + * @param data passed by ref + */ + protected CompoundTag(Map> data) { + super(data); + } + + /** {@inheritDoc} */ + @Override + public byte getID() { + return ID; + } + + private static Map> createEmptyValue() { + return new LinkedHashMap<>(8); + } + + public int size() { + return getValue().size(); + } + + public boolean isEmpty() { + return getValue().isEmpty(); + } + + public Tag remove(String key) { + return getValue().remove(key); + } + + public void clear() { + getValue().clear(); + } + + public boolean containsKey(String key) { + return getValue().containsKey(key); + } + + /** + * Tests if the given key is present and if so if it is of the specified type. + * @param key name to require + * @param tagType expected value type, note this can be a super type of the actual value. + * Ex. if the actual type is {@link ByteTag} and the passed tagType is {@link NumberTag}, + * this function will return true. + * @return true if the given key is found and the value is of the expected type. + */ + public boolean containsKey(String key, Class tagType) { + var v = getValue().get(key); + return v != null && tagType.isAssignableFrom(v.getClass()); + } + + public boolean containsValue(Tag value) { + return getValue().containsValue(value); + } + + public Collection> values() { + return getValue().values(); + } + + public Set keySet() { + return getValue().keySet(); + } + + public Set>> entrySet() { + return getValue().entrySet(); + } + + /** {@inheritDoc} */ + @Override + public Iterator iterator() { + return new CompoundTagIterator(getValue().entrySet()); + } + + public Stream stream() { + return getValue().entrySet().stream().map(NamedTag::new); + } + + public void forEach(BiConsumer> action) { + getValue().forEach(action); + } + + public > C get(String key, Class type) { + Tag t = getValue().get(key); + if (t != null) { + return type.cast(t); + } + return null; + } + + public Tag get(String key) { + return getValue().get(key); + } + + /** + * Gets a NamedTag that references this compound tag's values. Setting {@link NamedTag#setTag(Tag)} + * WILL NOT modify the contents of this {@link CompoundTag}, however, modifying the tag itself will. + * @return new {@link NamedTag} or null if the key does not exist. + */ + public NamedTag getNamedTag(String key) { + Tag tag = get(key); + if (tag != null) { + return new NamedTag(key, tag); + } + return null; + } + + public NumberTag getNumberTag(String key) { + return (NumberTag) getValue().get(key); + } + + public Number getNumber(String key) { + return getNumberTag(key).getValue(); + } + + public ByteTag getByteTag(String key) { + return get(key, ByteTag.class); + } + + public ShortTag getShortTag(String key) { + return get(key, ShortTag.class); + } + + public IntTag getIntTag(String key) { + return get(key, IntTag.class); + } + + public LongTag getLongTag(String key) { + return get(key, LongTag.class); + } + + public FloatTag getFloatTag(String key) { + return get(key, FloatTag.class); + } + + public DoubleTag getDoubleTag(String key) { + return get(key, DoubleTag.class); + } + + public StringTag getStringTag(String key) { + return get(key, StringTag.class); + } + + public ByteArrayTag getByteArrayTag(String key) { + return get(key, ByteArrayTag.class); + } + + public IntArrayTag getIntArrayTag(String key) { + return get(key, IntArrayTag.class); + } + + public LongArrayTag getLongArrayTag(String key) { + return get(key, LongArrayTag.class); + } + + /** @see #getCompoundList */ + public ListTag getListTag(String key) { + return get(key, ListTag.class); + } + + /** + * @return ListTag<> of the type requested by the context in which this method was called. + * @throws ClassCastException may be thrown at call site if tag exists but cannot be cast to the necessary type. + * @see #getCompoundList + */ + @SuppressWarnings("unchecked") + public > R getListTagAutoCast(String key) { + return (R) get(key, ListTag.class); + } + + public CompoundTag getCompoundTag(String key) { + return get(key, CompoundTag.class); + } + + public CompoundTag getOrCreateCompoundTag(String key) { + CompoundTag tag = get(key, CompoundTag.class); + if (tag == null) { + tag = new CompoundTag(); + put(key, tag); + } + return tag; + } + + /** + * @return ListTag<CompoundTag> identified by key + * @throws ClassCastException may be thrown at call site if tag exists but is not a ListTag<CompoundTag> + */ + @SuppressWarnings("unchecked") + public ListTag getCompoundList(String key) { + return (ListTag) get(key, ListTag.class); + } + + /** @return the value associated with key or false if there was none or if the tag was not of type {@link ByteTag}. */ + public boolean getBoolean(String key) { + Tag t = get(key); + return t instanceof ByteTag && ((ByteTag) t).asByte() > 0; + } + + /** @return the value associated with key or {@code defaultValue} if there was none. */ + public boolean getBoolean(String key, boolean defaultValue) { + Tag t = get(key); + return t instanceof ByteTag ? ((ByteTag) t).asByte() > 0 : defaultValue; + } + + /** @return the value associated with key or {@link ByteTag#ZERO_VALUE} if there was none. */ + public byte getByte(String key) { + ByteTag t = getByteTag(key); + return t == null ? ByteTag.ZERO_VALUE : t.asByte(); + } + + /** @return the value associated with key or {@code defaultValue} if there was none. */ + public byte getByte(String key, byte defaultValue) { + ByteTag t = getByteTag(key); + return t == null ? defaultValue : t.asByte(); + } + + /** @return the value associated with key or {@link ShortTag#ZERO_VALUE} if there was none. */ + public short getShort(String key) { + ShortTag t = getShortTag(key); + return t == null ? ShortTag.ZERO_VALUE : t.asShort(); + } + + /** @return the value associated with key or {@code defaultValue} if there was none. */ + public short getShort(String key, short defaultValue) { + ShortTag t = getShortTag(key); + return t == null ? defaultValue: t.asShort(); + } + + /** @return the value associated with key or {@link IntTag#ZERO_VALUE} if there was none. */ + public int getInt(String key) { + IntTag t = getIntTag(key); + return t == null ? IntTag.ZERO_VALUE : t.asInt(); + } + + /** @return the value associated with key or {@code defaultValue} if there was none. */ + public int getInt(String key, int defaultValue) { + IntTag t = getIntTag(key); + return t == null ? defaultValue : t.asInt(); + } + + /** @return the value associated with key or {@link LongTag#ZERO_VALUE} if there was none. */ + public long getLong(String key) { + LongTag t = getLongTag(key); + return t == null ? LongTag.ZERO_VALUE : t.asLong(); + } + + /** @return the value associated with key or {@code defaultValue} if there was none. */ + public long getLong(String key, long defaultValue) { + LongTag t = getLongTag(key); + return t == null ? defaultValue : t.asLong(); + } + + /** @return the value associated with key or {@link FloatTag#ZERO_VALUE} if there was none. */ + public float getFloat(String key) { + FloatTag t = getFloatTag(key); + return t == null ? FloatTag.ZERO_VALUE : t.asFloat(); + } + + /** @return the value associated with key or {@code defaultValue} if there was none. */ + public float getFloat(String key, float defaultValue) { + FloatTag t = getFloatTag(key); + return t == null ? defaultValue : t.asFloat(); + } + + /** @return the value associated with key or {@link DoubleTag#ZERO_VALUE} if there was none. */ + public double getDouble(String key) { + DoubleTag t = getDoubleTag(key); + return t == null ? DoubleTag.ZERO_VALUE : t.asDouble(); + } + + /** @return the value associated with key or {@code defaultValue} if there was none. */ + public double getDouble(String key, double defaultValue) { + DoubleTag t = getDoubleTag(key); + return t == null ? defaultValue: t.asDouble(); + } + + /** @return the value associated with key or {@link StringTag#ZERO_VALUE} if there was none. */ + public String getString(String key) { + StringTag t = getStringTag(key); + return t == null ? StringTag.ZERO_VALUE : t.getValue(); + } + + /** @return the value associated with key or {@code defaultValue} if there was none. */ + public String getString(String key, String defaultValue) { + StringTag t = getStringTag(key); + return t == null ? defaultValue: t.getValue(); + } + + /** @return the value associated with key or {@link ByteArrayTag#ZERO_VALUE} if there was none. */ + public byte[] getByteArray(String key) { + ByteArrayTag t = getByteArrayTag(key); + return t == null ? ByteArrayTag.ZERO_VALUE : t.getValue(); + } + + /** @return the value associated with key or {@link IntArrayTag#ZERO_VALUE} if there was none. */ + public int[] getIntArray(String key) { + IntArrayTag t = getIntArrayTag(key); + return t == null ? IntArrayTag.ZERO_VALUE : t.getValue(); + } + + /** @return the value associated with key or {@link LongArrayTag#ZERO_VALUE} if there was none. */ + public long[] getLongArray(String key) { + LongArrayTag t = getLongArrayTag(key); + return t == null ? LongArrayTag.ZERO_VALUE : t.getValue(); + } + + /** + * Convenience function to get the values from a {@code ListTag} as an array of floats. + * @param key name of the ListTag + * @return null if key does not exist; empty array if key exists but list was empty; array of values otherwise + */ + public float[] getFloatTagListAsArray(String key) { + ListTag t = getListTagAutoCast(key); + if (t == null) return null; + List floatTagList = t.getValue(); + float[] floats = new float[floatTagList.size()]; + for (int i = 0; i < floats.length; i++) { + floats[i] = floatTagList.get(i).asFloat(); + } + return floats; + } + + /** + * Convenience function to get the values from a {@code ListTag} as an array of doubles. + * @param key name of the ListTag + * @return null if key does not exist; empty array if key exists but list was empty; array of values otherwise + */ + public double[] getDoubleTagListAsArray(String key) { + ListTag t = getListTagAutoCast(key); + if (t == null) return null; + List doubleTagList = t.getValue(); + double[] doubles = new double[doubleTagList.size()]; + for (int i = 0; i < doubles.length; i++) { + doubles[i] = doubleTagList.get(i).asDouble(); + } + return doubles; + } + + /** + * Convenience function to get the values from a {@code ListTag} as a {@code List} + * @see #putStringsAsTagList(String, List) + * @param key name of the ListTag + * @return null if key does not exist; empty list if key exists but list was empty; list of values otherwise + */ + public List getStringTagListValues(String key) { + ListTag t = getListTagAutoCast(key); + if (t == null) return null; + return t.getValue().stream() + .map(StringTag::getValue) + .collect(Collectors.toList()); + } + + /** @return the previous value associated with namedTag.getName() or null if there was none. */ + public Tag put(NamedTag namedTag) { + return put(namedTag.getName(), namedTag.getTag()); + } + + /** @return the previous value associated with key or null if there was none. */ + public Tag put(String key, Tag tag) { + return getValue().put(Objects.requireNonNull(key), Objects.requireNonNull(tag)); + } + + /** + * Puts the raw value by wrapping it in an appropriate Tag if necessary. + * @param key Non-null key. + * @param value Any value, passing null will call {@link #remove(String)}. + * @return previous value associated with key, or null. + * @throws IllegalArgumentException if the type of value is not supported. + */ + public Tag putValue(String key, Object value) { + ArgValidator.requireValue(key, "key"); + if (value == null) { + return remove(key); + } + return put(key, Tag.asTag(value)); + } + + /** + * Puts the tag iff {@code namedTag != null && namedTag.getTag() != null} + * @return the previous value associated with namedTag.getName() or null if there was none. + */ + public Tag putIfNotNull(NamedTag namedTag) { + if (namedTag == null) return null; + return putIfNotNull(namedTag.getName(), namedTag.getTag()); + } + + /** + * Puts the tag iff {tag != null} + * @return the previous value associated with key or null if there was none. + */ + public Tag putIfNotNull(String key, Tag tag) { + if (tag == null) { + return null; + } + return put(key, tag); + } + + /** @return the previous value associated with key or null if there was none. */ + public Tag putBoolean(String key, boolean value) { + return put(key, new ByteTag(value)); + } + + /** @return the previous value associated with key or null if there was none. */ + public Tag putByte(String key, byte value) { + return put(key, new ByteTag(value)); + } + + /** @return the previous value associated with key or null if there was none. */ + public Tag putShort(String key, short value) { + return put(key, new ShortTag(value)); + } + + /** @return the previous value associated with key or null if there was none. */ + public Tag putInt(String key, int value) { + return put(key, new IntTag(value)); + } + + /** @return the previous value associated with key or null if there was none. */ + public Tag putLong(String key, long value) { + return put(key, new LongTag(value)); + } + + /** @return the previous value associated with key or null if there was none. */ + public Tag putFloat(String key, float value) { + return put(key, new FloatTag(value)); + } + + /** @return the previous value associated with key or null if there was none. */ + public Tag putDouble(String key, double value) { + return put(key, new DoubleTag(value)); + } + + /** @return the previous value associated with key or null if there was none. */ + public Tag putString(String key, String value) { + return put(key, new StringTag(value)); + } + + /** @return the previous value associated with key or null if there was none. */ + public Tag putByteArray(String key, byte[] value) { + return put(key, new ByteArrayTag(value)); + } + + /** @return the previous value associated with key or null if there was none. */ + public Tag putIntArray(String key, int[] value) { + return put(key, new IntArrayTag(value)); + } + + /** @return the previous value associated with key or null if there was none. */ + public Tag putLongArray(String key, long[] value) { + return put(key, new LongArrayTag(value)); + } + + /** + * Convenience function to set a ListTag<FloatTag> from an array. If values is null then + * the specified key is REMOVED. Provide an empty array to indicate that an empty ListTag is desired. + * @param key name of ListTag + * @param values values to set (may be one or more floats, or a float[]) + * @return new ListTag, or null if values was null + */ + public Tag putFloatArrayAsTagList(String key, float... values) { + if (values == null) { + remove(key); + return null; + } + ListTag listTag = new ListTag<>(FloatTag.class, values.length); + for (float v : values) { + listTag.addFloat(v); + } + return put(key, listTag); + } + + /** + * Convenience function to set a ListTag<DoubleTag> from an array. If values is null then + * the specified key is REMOVED. Provide an empty array to indicate that an empty ListTag is desired. + * @param key name of ListTag + * @param values values to set (may be one or more doubles, or a double[]) + * @return new ListTag, or null if values was null + */ + public Tag putDoubleArrayAsTagList(String key, double... values) { + if (values == null) { + remove(key); + return null; + } + ListTag listTag = new ListTag<>(DoubleTag.class, values.length); + for (double v : values) { + listTag.addDouble(v); + } + return put(key, listTag); + } + + /** + * Convenience function to set a {@code ListTag} from a {@code List}. If values is null then + * the specified key is REMOVED. Provide an empty List to indicate that an empty ListTag is desired. + * @see #getStringTagListValues(String) + * @see Arrays#asList(Object[]) + * @param key name of ListTag + * @param values values to set + * @return new ListTag, or null if values was null + */ + public Tag putStringsAsTagList(String key, List values) { + if (values == null) { + remove(key); + return null; + } + ListTag listTag = new ListTag<>(StringTag.class, values.size()); + for (String v : values) { + listTag.addString(v); + } + return put(key, listTag); + } + + /** {@inheritDoc} */ + @Override + public String valueToString(int maxDepth) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + Iterator>> iter = getValue().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .iterator(); + while (iter.hasNext()) { + Map.Entry> e = iter.next(); + sb.append(first ? "" : ",") + .append(escapeString(e.getKey(), false)).append(":") + .append(e.getValue().toString(decrementMaxDepth(maxDepth))); + first = false; + } + sb.append("}"); + return sb.toString(); + } + + /** {@inheritDoc} */ + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!super.equals(other) || size() != ((CompoundTag) other).size()) { + return false; + } + for (Map.Entry> e : getValue().entrySet()) { + Tag v; + if ((v = ((CompoundTag) other).get(e.getKey())) == null || !e.getValue().equals(v)) { + return false; + } + } + return true; + } + + /** + * Compares this compound tag to another one. + *

Comparison sequence:

+ *
    + *
  • key set length
  • + *
  • key names
  • + *
  • tag value compare result (tag type before value comparison)
  • + *
+ */ + @Override + public int compareTo(CompoundTag o) { + final var thisMap = getValue(); + final var otherMap = o.getValue(); + int k = Integer.compare(size(), otherMap.size()); + if (k != 0) return k; + if (!thisMap.keySet().containsAll(otherMap.keySet())) { + ArrayList keys1 = new ArrayList<>(thisMap.keySet()); + ArrayList keys2 = new ArrayList<>(otherMap.keySet()); + keys1.sort(String::compareTo); + keys2.sort(String::compareTo); + k = Arrays.compare(keys1.toArray(new String[0]), keys2.toArray(new String[0])); + if (k != 0) return k; + for (String key: keys1) { + k = Tag.compare(thisMap.get(key), otherMap.get(key)); + if (k != 0) + return k; + } + } + return 0; + } + + /** {@inheritDoc} */ + @Override + public CompoundTag clone() { + // Choose initial capacity based on default load factor (0.75) so all entries fit in map without resizing + CompoundTag copy = new CompoundTag((int) Math.ceil(getValue().size() / 0.75f)); + for (Map.Entry> e : getValue().entrySet()) { + copy.put(e.getKey(), e.getValue().clone()); + } + return copy; + } + + private static class CompoundTagIterator implements Iterator { + private final Iterator>> iterator; + + CompoundTagIterator(Set>> set) { + this.iterator = set.iterator(); + } + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public NamedTag next() { + return new MappedNamedTag(iterator.next()); + } + + @Override + public void remove() { + iterator.remove(); + } + } + + private static class MappedNamedTag extends NamedTag { + private final Map.Entry> entry; + public MappedNamedTag(Map.Entry> entry) { + this.entry = entry; + } + + public void setName(String name) { + throw new UnsupportedOperationException(); + } + + public void setTag(Tag tag) { + ArgValidator.requireValue(tag, "tag"); + entry.setValue(tag); + } + + public String getName() { + return entry.getKey(); + } + + public Tag getTag() { + return entry.getValue(); + } + } +} diff --git a/src/main/java/net/querz/nbt/tag/DoubleTag.java b/src/main/java/io/github/ensgijs/nbt/tag/DoubleTag.java similarity index 76% rename from src/main/java/net/querz/nbt/tag/DoubleTag.java rename to src/main/java/io/github/ensgijs/nbt/tag/DoubleTag.java index 28d08658..768d8e07 100644 --- a/src/main/java/net/querz/nbt/tag/DoubleTag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/DoubleTag.java @@ -1,4 +1,4 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; public class DoubleTag extends NumberTag implements Comparable { @@ -13,25 +13,33 @@ public DoubleTag(double value) { super(value); } + /** {@inheritDoc} */ @Override public byte getID() { return ID; } + /** + * Sets the value for this Tag directly. + * @param value The value to be set. + */ public void setValue(double value) { super.setValue(value); } + /** {@inheritDoc} */ @Override public boolean equals(Object other) { return super.equals(other) && getValue().equals(((DoubleTag) other).getValue()); } + /** {@inheritDoc} */ @Override public int compareTo(DoubleTag other) { return getValue().compareTo(other.getValue()); } + /** {@inheritDoc} */ @Override public DoubleTag clone() { return new DoubleTag(getValue()); diff --git a/src/main/java/net/querz/nbt/tag/EndTag.java b/src/main/java/io/github/ensgijs/nbt/tag/EndTag.java similarity index 92% rename from src/main/java/net/querz/nbt/tag/EndTag.java rename to src/main/java/io/github/ensgijs/nbt/tag/EndTag.java index 30b970b8..eb11eee9 100644 --- a/src/main/java/net/querz/nbt/tag/EndTag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/EndTag.java @@ -1,4 +1,4 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; public final class EndTag extends Tag { diff --git a/src/main/java/net/querz/nbt/tag/FloatTag.java b/src/main/java/io/github/ensgijs/nbt/tag/FloatTag.java similarity index 76% rename from src/main/java/net/querz/nbt/tag/FloatTag.java rename to src/main/java/io/github/ensgijs/nbt/tag/FloatTag.java index 9d79204f..83f52869 100644 --- a/src/main/java/net/querz/nbt/tag/FloatTag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/FloatTag.java @@ -1,4 +1,4 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; public class FloatTag extends NumberTag implements Comparable { @@ -13,25 +13,33 @@ public FloatTag(float value) { super(value); } + /** {@inheritDoc} */ @Override public byte getID() { return ID; } + /** + * Sets the value for this Tag directly. + * @param value The value to be set. + */ public void setValue(float value) { super.setValue(value); } + /** {@inheritDoc} */ @Override public boolean equals(Object other) { return super.equals(other) && getValue().equals(((FloatTag) other).getValue()); } + /** {@inheritDoc} */ @Override public int compareTo(FloatTag other) { return getValue().compareTo(other.getValue()); } + /** {@inheritDoc} */ @Override public FloatTag clone() { return new FloatTag(getValue()); diff --git a/src/main/java/net/querz/nbt/tag/IntArrayTag.java b/src/main/java/io/github/ensgijs/nbt/tag/IntArrayTag.java similarity index 63% rename from src/main/java/net/querz/nbt/tag/IntArrayTag.java rename to src/main/java/io/github/ensgijs/nbt/tag/IntArrayTag.java index 1799c93c..43675d6d 100644 --- a/src/main/java/net/querz/nbt/tag/IntArrayTag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/IntArrayTag.java @@ -1,6 +1,7 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; import java.util.Arrays; +import java.util.stream.IntStream; public class IntArrayTag extends ArrayTag implements Comparable { @@ -11,32 +12,43 @@ public IntArrayTag() { super(ZERO_VALUE); } - public IntArrayTag(int[] value) { + public IntArrayTag(int... value) { super(value); } + /** {@inheritDoc} */ @Override public byte getID() { return ID; } + /** {@inheritDoc} */ @Override public boolean equals(Object other) { return super.equals(other) && Arrays.equals(getValue(), ((IntArrayTag) other).getValue()); } + /** {@inheritDoc} */ @Override public int hashCode() { return Arrays.hashCode(getValue()); } + /** {@inheritDoc} */ @Override public int compareTo(IntArrayTag other) { - return Integer.compare(length(), other.length()); + int k = Integer.compare(length(), other.length()); + if (k != 0) return k; + return Arrays.compare(getValue(), other.getValue()); } + /** {@inheritDoc} */ @Override public IntArrayTag clone() { return new IntArrayTag(Arrays.copyOf(getValue(), length())); } + + public IntStream stream() { + return Arrays.stream(getValue()); + } } diff --git a/src/main/java/net/querz/nbt/tag/IntTag.java b/src/main/java/io/github/ensgijs/nbt/tag/IntTag.java similarity index 75% rename from src/main/java/net/querz/nbt/tag/IntTag.java rename to src/main/java/io/github/ensgijs/nbt/tag/IntTag.java index 57c1f2ba..b0719c2c 100644 --- a/src/main/java/net/querz/nbt/tag/IntTag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/IntTag.java @@ -1,4 +1,4 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; public class IntTag extends NumberTag implements Comparable { @@ -13,25 +13,33 @@ public IntTag(int value) { super(value); } + /** {@inheritDoc} */ @Override public byte getID() { return ID; } + /** + * Sets the value for this Tag directly. + * @param value The value to be set. + */ public void setValue(int value) { super.setValue(value); } + /** {@inheritDoc} */ @Override public boolean equals(Object other) { return super.equals(other) && asInt() == ((IntTag) other).asInt(); } + /** {@inheritDoc} */ @Override public int compareTo(IntTag other) { return getValue().compareTo(other.getValue()); } + /** {@inheritDoc} */ @Override public IntTag clone() { return new IntTag(getValue()); diff --git a/src/main/java/io/github/ensgijs/nbt/tag/ListTag.java b/src/main/java/io/github/ensgijs/nbt/tag/ListTag.java new file mode 100644 index 00000000..6bc23aac --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/tag/ListTag.java @@ -0,0 +1,631 @@ +package io.github.ensgijs.nbt.tag; + +import java.util.*; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import io.github.ensgijs.nbt.io.MaxDepthIO; + +/** + * ListTag represents a typed List in the nbt structure. + * An empty {@link ListTag} will be of type {@link EndTag} (unknown type). + * The type of an empty untyped {@link ListTag} can be set by using any of the {@code add()} + * methods or any of the {@code as...List()} methods. + */ +public class ListTag> extends Tag> implements List, Comparable>, MaxDepthIO { + + public static final byte ID = 9; + + private Class typeClass = null; + + private ListTag(int initialCapacity) { + super(createEmptyValue(initialCapacity)); + } + + /** + * Creates a new ListTag that uses the given list. + * @param usingList List instance to use to back this ListTag. Values are NOT cloned. + * @throws NullPointerException if usingList is null or contains null elements. + */ + @SuppressWarnings("unchecked") + public ListTag(List usingList) { + super(usingList); + validateContainsNoNullsAndTypeOk(usingList); + assignTypeClassIfNeeded(usingList); + } + + protected Collection validateContainsNoNullsAndTypeOk(Collection c) { + Objects.requireNonNull(c); + if (c.isEmpty()) return c; + final Class tagType; + if (this.typeClass == null || this.typeClass == EndTag.class) + // If this throws an NPE - we don't allow null elements anyway! + // This will throw a ClassCastException if the entry doesn't match the erasure. + tagType = c.iterator().next().getClass(); + else + tagType = this.typeClass; + + for (E e : c) { + if (e == null) + throw new NullPointerException(); + if (!tagType.isAssignableFrom(e.getClass())) + throw new ClassCastException("list contained " + e.getClass().getName() + + " which is not a child type of " + tagType.getName()); + } + return c; + } + + protected Collection assignTypeClassIfNeeded(Collection c) { + if (this.typeClass == null || this.typeClass == EndTag.class) { + Class tagType = !c.isEmpty() ? c.iterator().next().getClass() : EndTag.class; + if (!Tag.class.isAssignableFrom(tagType)) + throw new IllegalArgumentException("Type must extend Tag"); + this.typeClass = tagType; + } + return c; + } + + /** {@inheritDoc} */ + @Override + public byte getID() { + return ID; + } + + /** + *

Creates a non-type-safe ListTag. Its element type will be set after the first + * element was added.

+ * + *

This is an internal helper method for cases where the element type is not known + * at construction time. Use {@link #ListTag(Class)} when the type is known.

+ * + * @return A new non-type-safe ListTag + */ + public static ListTag createUnchecked(Class typeClass) { + return createUnchecked(typeClass, 3); + } + + /** + *

Creates a non-type-safe ListTag. Its element type will be set after the first + * element was added.

+ * + *

This is an internal helper method for cases where the element type is not known + * at construction time. Use {@link #ListTag(Class)} when the type is known.

+ * + * @return A new non-type-safe ListTag + */ + public static ListTag createUnchecked(Class typeClass, int initialCapacity) { + ListTag list = new ListTag<>(initialCapacity); + list.typeClass = typeClass; + return list; + } + + /** + *

Creates an empty mutable list to be used as empty value of ListTags.

+ * + * @param Type of the list elements + * @param initialCapacity The initial capacity of the returned List + * @return An instance of {@link java.util.List} with an initial capacity of 3 + */ + private static List createEmptyValue(int initialCapacity) { + return new ArrayList<>(initialCapacity); + } + + /** + * @param typeClass The exact class of the elements + * @throws IllegalArgumentException When {@code typeClass} is {@link EndTag}{@code .class} + * @throws NullPointerException When {@code typeClass} is {@code null} + */ + public ListTag(Class typeClass) throws IllegalArgumentException, NullPointerException { + this(typeClass, 3); + } + + /** + * @param typeClass The exact class of the elements + * @param initialCapacity Initial capacity of list + * @throws IllegalArgumentException When {@code typeClass} is {@link EndTag}{@code .class} + * @throws NullPointerException When {@code typeClass} is {@code null} + */ + public ListTag(Class typeClass, int initialCapacity) throws IllegalArgumentException, NullPointerException { + super(createEmptyValue(initialCapacity)); + if (typeClass == EndTag.class) { + throw new IllegalArgumentException("cannot create ListTag with EndTag elements"); + } + this.typeClass = Objects.requireNonNull(typeClass); + } + + public Class getTypeClass() { + return typeClass == null ? EndTag.class : typeClass; + } + + /** {@inheritDoc} */ + @Override + public int size() { + return getValue().size(); + } + + /** {@inheritDoc} */ + @Override + public boolean isEmpty() { + return getValue().isEmpty(); + } + + /** {@inheritDoc} */ + @Override + public boolean contains(Object o) { + return getValue().contains(o); + } + + /** {@inheritDoc} */ + @Override + public E remove(int index) { + return getValue().remove(index); + } + + /** {@inheritDoc} */ + @Override + public int indexOf(Object o) { + return getValue().indexOf(o); + } + + /** {@inheritDoc} */ + @Override + public int lastIndexOf(Object o) { + return getValue().lastIndexOf(o); + } + + /** {@inheritDoc} */ + @Override + public ListIterator listIterator() { + return new NullRejectingListIterator<>(getValue().listIterator()); + } + + /** {@inheritDoc} */ + @Override + public ListIterator listIterator(int index) { + return new NullRejectingListIterator<>(getValue().listIterator(index)); + } + + /** {@inheritDoc} + * + * @param fromIndex low endpoint (inclusive) of the subList + * @param toIndex high endpoint (exclusive) of the subList + * @return a view of the specified range within this list + * @throws IndexOutOfBoundsException for an illegal endpoint index value + * ({@code fromIndex < 0 || toIndex > size || + * fromIndex > toIndex}) + */ + @Override + public ListTag subList(int fromIndex, int toIndex) { + return new ListTag<>(getValue().subList(fromIndex, toIndex)); + } + + /** {@inheritDoc} */ + @Override + public void clear() { + getValue().clear(); + } + + /** {@inheritDoc} */ + @Override + public boolean containsAll(Collection tags) { + return getValue().containsAll(tags); + } + + /** {@inheritDoc} */ + @Override + public Iterator iterator() { + return listIterator(); + } + + /** {@inheritDoc} */ + @Override + public Object[] toArray() { + return getValue().toArray(); + } + + /** {@inheritDoc} */ + @Override + public T1[] toArray(T1[] a) { + return getValue().toArray(a); + } + + /** {@inheritDoc} */ + @Override + public Spliterator spliterator() { + return getValue().spliterator(); + } + + /** {@inheritDoc} */ + @Override + public Stream stream() { + return getValue().stream(); + } + + /** + *

Value cannot be null.

+ * {@inheritDoc} + */ + @Override + public E set(int index, E element) { + return getValue().set(index, Objects.requireNonNull(element)); + } + + /** + *

Value cannot be null.

+ * {@inheritDoc} + */ + @Override + public boolean add(E element) { + Objects.requireNonNull(element); + if (getTypeClass() == EndTag.class) { + typeClass = checkTypeClass(element.getClass()); + } else if (!typeClass.isAssignableFrom(element.getClass())) { + throw new ClassCastException( + String.format("cannot add %s to ListTag<%s>", + element.getClass().getSimpleName(), + typeClass.getSimpleName())); + } + return getValue().add(element); + } + + /** {@inheritDoc} */ + @Override + public boolean remove(Object o) { + return getValue().remove(o); + } + + /** + *

Value cannot be null.

+ * {@inheritDoc} + */ + @Override + public void add(int index, E element) { + Objects.requireNonNull(element); + if (getTypeClass() == EndTag.class) { + typeClass = checkTypeClass(element.getClass()); + } else if (!typeClass.isAssignableFrom(element.getClass())) { + throw new ClassCastException( + String.format("cannot add %s to ListTag<%s>", + element.getClass().getSimpleName(), + typeClass.getSimpleName())); + } + getValue().add(index, element); + } + + /** + *

Collection must not contain null elements.

+ * {@inheritDoc} + */ + @Override + public boolean addAll(Collection c) { + return getValue().addAll(assignTypeClassIfNeeded(validateContainsNoNullsAndTypeOk(c))); + } + + /** + *

Collection must not contain null elements.

+ * {@inheritDoc} + */ + @Override + public boolean addAll(int index, Collection c) { + return getValue().addAll(index, assignTypeClassIfNeeded(validateContainsNoNullsAndTypeOk(c))); + } + + /** {@inheritDoc} */ + @Override + public boolean removeAll(Collection c) { + return getValue().removeAll(c); + } + + /** {@inheritDoc} */ + @Override + public boolean removeIf(Predicate filter) { + return getValue().removeIf(filter); + } + + /** {@inheritDoc} */ + @Override + public boolean retainAll(Collection c) { + return getValue().retainAll(c); + } + + /** {@inheritDoc} */ + @Override + public void sort(Comparator c) { + getValue().sort(c); + } + + public void addBoolean(boolean value) { + addUnchecked(new ByteTag(value)); + } + + public void addByte(byte value) { + addUnchecked(new ByteTag(value)); + } + + public void addShort(short value) { + addUnchecked(new ShortTag(value)); + } + + public void addInt(int value) { + addUnchecked(new IntTag(value)); + } + + public void addLong(long value) { + addUnchecked(new LongTag(value)); + } + + public void addFloat(float value) { + addUnchecked(new FloatTag(value)); + } + + public void addDouble(double value) { + addUnchecked(new DoubleTag(value)); + } + + public void addString(String value) { + addUnchecked(new StringTag(value)); + } + + public void addByteArray(byte[] value) { + addUnchecked(new ByteArrayTag(value)); + } + + public void addIntArray(int[] value) { + addUnchecked(new IntArrayTag(value)); + } + + public void addLongArray(long[] value) { + addUnchecked(new LongArrayTag(value)); + } + + /** {@inheritDoc} */ + @Override + public E get(int index) { + return getValue().get(index); + } + + @SuppressWarnings("unchecked") + public > ListTag asTypedList(Class type) { + checkTypeClass(type); + return (ListTag) this; + } + + public ListTag asByteTagList() { + return asTypedList(ByteTag.class); + } + + public ListTag asShortTagList() { + return asTypedList(ShortTag.class); + } + + public ListTag asIntTagList() { + return asTypedList(IntTag.class); + } + + public ListTag asLongTagList() { + return asTypedList(LongTag.class); + } + + public ListTag asFloatTagList() { + return asTypedList(FloatTag.class); + } + + public ListTag asDoubleTagList() { + return asTypedList(DoubleTag.class); + } + + public ListTag asStringTagList() { + return asTypedList(StringTag.class); + } + + public ListTag asByteArrayTagList() { + return asTypedList(ByteArrayTag.class); + } + + public ListTag asIntArrayTagList() { + return asTypedList(IntArrayTag.class); + } + + public ListTag asLongArrayTagList() { + return asTypedList(LongArrayTag.class); + } + + @SuppressWarnings("unchecked") + public > ListTag> asListTagList() { + checkTypeClass(ListTag.class); + typeClass = ListTag.class; + return (ListTag>) this; + } + + public ListTag asCompoundTagList() { + return asTypedList(CompoundTag.class); + } + + public static ListTag ofBytes(List values) { + var tag = new ListTag<>(ByteTag.class); + for (var v : values) { + tag.add(new ByteTag(v)); + } + return tag; + } + + public static ListTag ofShorts(List values) { + var tag = new ListTag<>(ShortTag.class); + for (var v : values) { + tag.add(new ShortTag(v)); + } + return tag; + } + + public static ListTag ofInts(List values) { + var tag = new ListTag<>(IntTag.class); + for (var v : values) { + tag.add(new IntTag(v)); + } + return tag; + } + + public static ListTag ofLongs(List values) { + var tag = new ListTag<>(LongTag.class); + for (var v : values) { + tag.add(new LongTag(v)); + } + return tag; + } + + public static ListTag ofFloats(List values) { + var tag = new ListTag<>(FloatTag.class); + for (var v : values) { + tag.add(new FloatTag(v)); + } + return tag; + } + + public static ListTag ofDoubles(List values) { + var tag = new ListTag<>(DoubleTag.class); + for (var v : values) { + tag.add(new DoubleTag(v)); + } + return tag; + } + + public static ListTag ofStrings(List values) { + var tag = new ListTag<>(StringTag.class); + for (var v : values) { + tag.add(new StringTag(v)); + } + return tag; + } + + /** {@inheritDoc} */ + @Override + public String valueToString(int maxDepth) { + StringBuilder sb = new StringBuilder("{\"type\":\"").append(getTypeClass().getSimpleName()).append("\",\"list\":["); + for (int i = 0; i < size(); i++) { + sb.append(i > 0 ? "," : "").append(get(i).valueToString(decrementMaxDepth(maxDepth))); + } + sb.append("]}"); + return sb.toString(); + } + + /** {@inheritDoc} */ + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!super.equals(other) || size() != ((ListTag) other).size() || getTypeClass() != ((ListTag) other) + .getTypeClass()) { + return false; + } + for (int i = 0; i < size(); i++) { + if (!get(i).equals(((ListTag) other).get(i))) { + return false; + } + } + return true; + } + + /** {@inheritDoc} */ + @Override + public int hashCode() { + return Objects.hash(getTypeClass().hashCode(), getValue().hashCode()); + } + + /** {@inheritDoc} */ + @Override + public int compareTo(ListTag o) { + int k = Integer.compare(this.size(), o.size()); + if (k != 0) return k; + k = this.typeClass == o.typeClass ? 0 : this.typeClass.getName().compareTo(o.typeClass.getName()); + for (int i = 0, len = size(); k == 0 && i < len; i++) { + k = Tag.compare(this.get(i), o.get(i)); + } + return k; + } + + /** {@inheritDoc} */ + @SuppressWarnings("unchecked") + @Override + public ListTag clone() { + ListTag copy = new ListTag<>(this.size()); + // assure type safety for clone + copy.typeClass = typeClass; + for (E e : getValue()) { + copy.add((E) e.clone()); + } + return copy; + } + + //TODO: make private + @SuppressWarnings("unchecked") + public void addUnchecked(Tag tag) { + if (getTypeClass() != EndTag.class && typeClass != tag.getClass()) { + throw new IllegalArgumentException(String.format( + "cannot add %s to ListTag<%s>", + tag.getClass().getSimpleName(), typeClass.getSimpleName())); + } + add((E) tag); + } + + private Class checkTypeClass(Class clazz) { + if (getTypeClass() != EndTag.class && typeClass != clazz) { + throw new ClassCastException(String.format( + "cannot cast ListTag<%s> to ListTag<%s>", + typeClass.getSimpleName(), clazz.getSimpleName())); + } + return clazz; + } + + private static class NullRejectingListIterator> implements ListIterator { + private final ListIterator iter; + public NullRejectingListIterator(ListIterator iter) { + this.iter = iter; + } + + @Override + public boolean hasNext() { + return iter.hasNext(); + } + + @Override + public E next() { + return iter.next(); + } + + @Override + public boolean hasPrevious() { + return iter.hasPrevious(); + } + + @Override + public E previous() { + return iter.previous(); + } + + @Override + public int nextIndex() { + return iter.nextIndex(); + } + + @Override + public int previousIndex() { + return iter.previousIndex(); + } + + @Override + public void remove() { + iter.remove(); + } + + @Override + public void set(E e) { + iter.set(Objects.requireNonNull(e)); + } + + @Override + public void add(E e) { + iter.add(Objects.requireNonNull(e)); + } + } +} diff --git a/src/main/java/net/querz/nbt/tag/LongArrayTag.java b/src/main/java/io/github/ensgijs/nbt/tag/LongArrayTag.java similarity index 63% rename from src/main/java/net/querz/nbt/tag/LongArrayTag.java rename to src/main/java/io/github/ensgijs/nbt/tag/LongArrayTag.java index e0528dd1..8e3cbe30 100644 --- a/src/main/java/net/querz/nbt/tag/LongArrayTag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/LongArrayTag.java @@ -1,6 +1,7 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; import java.util.Arrays; +import java.util.stream.LongStream; public class LongArrayTag extends ArrayTag implements Comparable { @@ -11,32 +12,43 @@ public LongArrayTag() { super(ZERO_VALUE); } - public LongArrayTag(long[] value) { + public LongArrayTag(long... value) { super(value); } + /** {@inheritDoc} */ @Override public byte getID() { return ID; } + /** {@inheritDoc} */ @Override public boolean equals(Object other) { return super.equals(other) && Arrays.equals(getValue(), ((LongArrayTag) other).getValue()); } + /** {@inheritDoc} */ @Override public int hashCode() { return Arrays.hashCode(getValue()); } + /** {@inheritDoc} */ @Override public int compareTo(LongArrayTag other) { - return Integer.compare(length(), other.length()); + int k = Integer.compare(length(), other.length()); + if (k != 0) return k; + return Arrays.compare(getValue(), other.getValue()); } + /** {@inheritDoc} */ @Override public LongArrayTag clone() { return new LongArrayTag(Arrays.copyOf(getValue(), length())); } + + public LongStream stream() { + return Arrays.stream(getValue()); + } } diff --git a/src/main/java/net/querz/nbt/tag/LongTag.java b/src/main/java/io/github/ensgijs/nbt/tag/LongTag.java similarity index 75% rename from src/main/java/net/querz/nbt/tag/LongTag.java rename to src/main/java/io/github/ensgijs/nbt/tag/LongTag.java index 8f40a325..cc30b42c 100644 --- a/src/main/java/net/querz/nbt/tag/LongTag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/LongTag.java @@ -1,4 +1,4 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; public class LongTag extends NumberTag implements Comparable { @@ -13,25 +13,33 @@ public LongTag(long value) { super(value); } + /** {@inheritDoc} */ @Override public byte getID() { return ID; } + /** + * Sets the value for this Tag directly. + * @param value The value to be set. + */ public void setValue(long value) { super.setValue(value); } + /** {@inheritDoc} */ @Override public boolean equals(Object other) { return super.equals(other) && asLong() == ((LongTag) other).asLong(); } + /** {@inheritDoc} */ @Override public int compareTo(LongTag other) { return getValue().compareTo(other.getValue()); } + /** {@inheritDoc} */ @Override public LongTag clone() { return new LongTag(getValue()); diff --git a/src/main/java/net/querz/nbt/tag/NumberTag.java b/src/main/java/io/github/ensgijs/nbt/tag/NumberTag.java similarity index 78% rename from src/main/java/net/querz/nbt/tag/NumberTag.java rename to src/main/java/io/github/ensgijs/nbt/tag/NumberTag.java index 48faa024..c5f26f27 100644 --- a/src/main/java/net/querz/nbt/tag/NumberTag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/NumberTag.java @@ -1,5 +1,10 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; +/** + * NumberTag is an abstract representation of any {@link Number} tag. + * + * @param The array type. + */ public abstract class NumberTag> extends Tag { public NumberTag(T value) { @@ -30,6 +35,7 @@ public double asDouble() { return getValue().doubleValue(); } + /** {@inheritDoc} */ @Override public String valueToString(int maxDepth) { return getValue().toString(); diff --git a/src/main/java/net/querz/nbt/tag/ShortTag.java b/src/main/java/io/github/ensgijs/nbt/tag/ShortTag.java similarity index 76% rename from src/main/java/net/querz/nbt/tag/ShortTag.java rename to src/main/java/io/github/ensgijs/nbt/tag/ShortTag.java index 5f434c37..5e2aea74 100644 --- a/src/main/java/net/querz/nbt/tag/ShortTag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/ShortTag.java @@ -1,4 +1,4 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; public class ShortTag extends NumberTag implements Comparable { @@ -13,25 +13,33 @@ public ShortTag(short value) { super(value); } + /** {@inheritDoc} */ @Override public byte getID() { return ID; } + /** + * Sets the value for this Tag directly. + * @param value The value to be set. + */ public void setValue(short value) { super.setValue(value); } + /** {@inheritDoc} */ @Override public boolean equals(Object other) { return super.equals(other) && asShort() == ((ShortTag) other).asShort(); } + /** {@inheritDoc} */ @Override public int compareTo(ShortTag other) { return getValue().compareTo(other.getValue()); } + /** {@inheritDoc} */ @Override public ShortTag clone() { return new ShortTag(getValue()); diff --git a/src/main/java/io/github/ensgijs/nbt/tag/StringTag.java b/src/main/java/io/github/ensgijs/nbt/tag/StringTag.java new file mode 100644 index 00000000..7dd8d72e --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/tag/StringTag.java @@ -0,0 +1,91 @@ +package io.github.ensgijs.nbt.tag; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class StringTag extends Tag implements Comparable { + + private static final Pattern ESCAPE_PATTERN = Pattern.compile("[\\\\\n\t\r\"]"); + private static final Pattern LENIENT_NON_QUOTE_PATTERN = Pattern.compile("(?!true|false)[a-z_][a-z0-9_\\-]*", Pattern.CASE_INSENSITIVE); + + private static final Map ESCAPE_CHARACTERS; + static { + ESCAPE_CHARACTERS = Map.of( + "\\", "\\\\\\\\", + "\n", "\\\\n", + "\t", "\\\\t", + "\r", "\\\\r", + "\"", "\\\\\""); + } + + public static final byte ID = 8; + public static final String ZERO_VALUE = ""; + + public StringTag() { + super(ZERO_VALUE); + } + + public StringTag(String value) { + super(value); + } + + @Override + public byte getID() { + return ID; + } + + @Override + public String getValue() { + return super.getValue(); + } + + @Override + public void setValue(String value) { + super.setValue(value); + } + + @Override + public String valueToString(int maxDepth) { + return escapeString(getValue(), false); + } + + /** + * Escapes a string to fit into a JSON-like string representation for Minecraft + * or to create the JSON string representation of a Tag returned from {@link Tag#toString()} + * @param s The string to be escaped. + * @param lenient {@code false} if it should force double quotes ({@code "}) at the start and + * the end of the string. + * @return The escaped string. + * */ + public static String escapeString(String s, boolean lenient) { + StringBuffer sb = new StringBuffer(); + Matcher m = ESCAPE_PATTERN.matcher(s); + while (m.find()) { + m.appendReplacement(sb, ESCAPE_CHARACTERS.get(m.group())); + } + m.appendTail(sb); + m = LENIENT_NON_QUOTE_PATTERN.matcher(s); + if (!lenient || !m.matches()) { + sb.insert(0, "\"").append("\""); + } + return sb.toString(); + } + + @Override + public boolean equals(Object other) { + return super.equals(other) && getValue().equals(((StringTag) other).getValue()); + } + + @Override + public int compareTo(StringTag o) { + return getValue().compareTo(o.getValue()); + } + + @Override + public StringTag clone() { + return new StringTag(getValue()); + } +} diff --git a/src/main/java/net/querz/nbt/tag/Tag.java b/src/main/java/io/github/ensgijs/nbt/tag/Tag.java similarity index 54% rename from src/main/java/net/querz/nbt/tag/Tag.java rename to src/main/java/io/github/ensgijs/nbt/tag/Tag.java index dd1c8d55..b005b606 100644 --- a/src/main/java/net/querz/nbt/tag/Tag.java +++ b/src/main/java/io/github/ensgijs/nbt/tag/Tag.java @@ -1,17 +1,17 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.io.MaxDepthReachedException; -import java.util.Collections; -import java.util.HashMap; +import io.github.ensgijs.nbt.io.MaxDepthReachedException; +import io.github.ensgijs.nbt.io.NamedTag; + +import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * Base class for all NBT tags. * - *

Nesting

+ *

Nesting

*

All methods serializing instances or deserializing data track the nesting levels to prevent * circular references or malicious data which could, when deserialized, result in thousands * of instances causing a denial of service.

@@ -39,39 +39,25 @@ public abstract class Tag implements Cloneable { * */ public static final int DEFAULT_MAX_DEPTH = 512; - private static final Map ESCAPE_CHARACTERS; - static { - final Map temp = new HashMap<>(); - temp.put("\\", "\\\\\\\\"); - temp.put("\n", "\\\\n"); - temp.put("\t", "\\\\t"); - temp.put("\r", "\\\\r"); - temp.put("\"", "\\\\\""); - ESCAPE_CHARACTERS = Collections.unmodifiableMap(temp); - } - - private static final Pattern ESCAPE_PATTERN = Pattern.compile("[\\\\\n\t\r\"]"); - private static final Pattern NON_QUOTE_PATTERN = Pattern.compile("[a-zA-Z0-9_\\-+]+"); - private T value; /** * Initializes this Tag with some value. If the value is {@code null}, it will * throw a {@code NullPointerException} * @param value The value to be set for this Tag. - * */ + */ public Tag(T value) { setValue(value); } /** - * @return This Tag's ID, usually used for serialization and deserialization. - * */ + * This Tag's ID, usually used for serialization and deserialization. + */ public abstract byte getID(); /** - * @return The value of this Tag. - * */ + * The value of this Tag. + */ protected T getValue() { return value; } @@ -80,7 +66,7 @@ protected T getValue() { * Sets the value for this Tag directly. * @param value The value to be set. * @throws NullPointerException If the value is null - * */ + */ protected void setValue(T value) { this.value = checkValue(value); } @@ -90,7 +76,7 @@ protected void setValue(T value) { * @param value The value to check * @throws NullPointerException If {@code value} was {@code null} * @return The parameter {@code value} - * */ + */ protected T checkValue(T value) { return Objects.requireNonNull(value); } @@ -99,7 +85,7 @@ protected T checkValue(T value) { * Calls {@link Tag#toString(int)} with an initial depth of {@code 0}. * @see Tag#toString(int) * @throws MaxDepthReachedException If the maximum nesting depth is exceeded. - * */ + */ @Override public final String toString() { return toString(DEFAULT_MAX_DEPTH); @@ -110,7 +96,7 @@ public final String toString() { * @param maxDepth The maximum nesting depth. * @return The string representation of this Tag. * @throws MaxDepthReachedException If the maximum nesting depth is exceeded. - * */ + */ public String toString(int maxDepth) { return "{\"type\":\""+ getClass().getSimpleName() + "\"," + "\"value\":" + valueToString(maxDepth) + "}"; @@ -120,8 +106,8 @@ public String toString(int maxDepth) { * Calls {@link Tag#valueToString(int)} with {@link Tag#DEFAULT_MAX_DEPTH}. * @return The string representation of the value of this Tag. * @throws MaxDepthReachedException If the maximum nesting depth is exceeded. - * */ - public String valueToString() { + */ + public final String valueToString() { return valueToString(DEFAULT_MAX_DEPTH); } @@ -130,7 +116,7 @@ public String valueToString() { * @param maxDepth The maximum nesting depth. * @return The string representation of the value of this Tag. * @throws MaxDepthReachedException If the maximum nesting depth is exceeded. - * */ + */ public abstract String valueToString(int maxDepth); /** @@ -140,7 +126,7 @@ public String valueToString() { * of this {@code super}-method while comparing. * @param other The Tag to compare to. * @return {@code true} if they are equal based on the conditions mentioned above. - * */ + */ @Override public boolean equals(Object other) { return other != null && getClass() == other.getClass(); @@ -150,7 +136,7 @@ public boolean equals(Object other) { * Calculates the hash code of this Tag. Tags which are equal according to {@link Tag#equals(Object)} * must return an equal hash code. * @return The hash code of this Tag. - * */ + */ @Override public int hashCode() { return value.hashCode(); @@ -163,25 +149,111 @@ public int hashCode() { @SuppressWarnings("CloneDoesntDeclareCloneNotSupportedException") public abstract Tag clone(); - /** - * Escapes a string to fit into a JSON-like string representation for Minecraft - * or to create the JSON string representation of a Tag returned from {@link Tag#toString()} - * @param s The string to be escaped. - * @param lenient {@code true} if it should force double quotes ({@code "}) at the start and - * the end of the string. - * @return The escaped string. - * */ - protected static String escapeString(String s, boolean lenient) { - StringBuffer sb = new StringBuffer(); - Matcher m = ESCAPE_PATTERN.matcher(s); - while (m.find()) { - m.appendReplacement(sb, ESCAPE_CHARACTERS.get(m.group())); + @SuppressWarnings("unchecked") + public static int compare(Tag tag1, Tag tag2) { + if (tag1 == null && tag2 == null) return 0; + if (tag1 == null) return -1; + if (tag2 == null) return 1; + int k = Integer.compare(tag1.getID(), tag2.getID()); + if (k != 0) return k; + return switch (tag1.getID()) { + case ByteTag.ID -> ((ByteTag) tag1).compareTo((ByteTag) tag2); + case ShortTag.ID -> ((ShortTag) tag1).compareTo((ShortTag) tag2); + case IntTag.ID -> ((IntTag) tag1).compareTo((IntTag) tag2); + case LongTag.ID -> ((LongTag) tag1).compareTo((LongTag) tag2); + case FloatTag.ID -> ((FloatTag) tag1).compareTo((FloatTag) tag2); + case DoubleTag.ID -> ((DoubleTag) tag1).compareTo((DoubleTag) tag2); + case ByteArrayTag.ID -> ((ByteArrayTag) tag1).compareTo((ByteArrayTag) tag2); + case StringTag.ID -> ((StringTag) tag1).compareTo((StringTag) tag2); + case ListTag.ID -> ((ListTag) tag1).compareTo((ListTag) tag2); + case CompoundTag.ID -> ((CompoundTag) tag1).compareTo((CompoundTag) tag2); + case IntArrayTag.ID -> ((IntArrayTag) tag1).compareTo((IntArrayTag) tag2); + case LongArrayTag.ID -> ((LongArrayTag) tag1).compareTo((LongArrayTag) tag2); + default -> throw new UnsupportedOperationException("New tag type? ID: " + tag1.getID()); + }; + } + + public static Tag asTag(Object value) { + if (value == null) { + return null; + } + if (value instanceof Tag v) { + return v; + } + if (value instanceof NamedTag v) { + return v.getTag(); + } + if (value instanceof String v) { + return new StringTag(v); + } + if (value instanceof Boolean v) { + return new ByteTag(v); + } + if (value instanceof Number) { + if (value instanceof Byte v) { + return new ByteTag(v); + } + if (value instanceof Short v) { + return new ShortTag(v); + } + if (value instanceof Integer v) { + return new IntTag(v); + } + if (value instanceof Long v) { + return new LongTag(v); + } + if (value instanceof Float v) { + return new FloatTag(v); + } + if (value instanceof Double v) { + return new DoubleTag(v); + } + } + if (value instanceof byte[] v) { + return new ByteArrayTag(v); + } + if (value instanceof int[] v) { + return new IntArrayTag(v); + } + if (value instanceof long[] v) { + return new LongArrayTag(v); + } + if (value instanceof float[] values) { + ListTag listTag = new ListTag<>(FloatTag.class, values.length); + for (float v : values) { + listTag.addFloat(v); + } + return listTag; + } + if (value instanceof double[] values) { + ListTag listTag = new ListTag<>(DoubleTag.class, values.length); + for (double v : values) { + listTag.addDouble(v); + } + return listTag; + } + if (value instanceof String[] values) { + ListTag listTag = new ListTag<>(StringTag.class, values.length); + for (String v : values) { + listTag.addString(v); + } + return listTag; + } + if (value instanceof Map m) { + CompoundTag tag = new CompoundTag(m.size()); + for (var entry : m.entrySet()) { + tag.putValue((String) entry.getKey(), entry.getValue()); + } + return tag; } - m.appendTail(sb); - m = NON_QUOTE_PATTERN.matcher(s); - if (!lenient || !m.matches()) { - sb.insert(0, "\"").append("\""); + if (value instanceof Collection c) { + ListTag listTag = ListTag.createUnchecked(EndTag.class); + for (Object v : c) { + var t = asTag(v); + listTag.add(t); + } + return listTag; } - return sb.toString(); + throw new IllegalArgumentException("Could not determine Tag type of " + value.getClass().getSimpleName()); } } diff --git a/src/main/java/io/github/ensgijs/nbt/util/ArgValidator.java b/src/main/java/io/github/ensgijs/nbt/util/ArgValidator.java new file mode 100644 index 00000000..90248807 --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/util/ArgValidator.java @@ -0,0 +1,66 @@ +package io.github.ensgijs.nbt.util; + +public class ArgValidator { + private ArgValidator() { } + + public static T requireValue(T value) { + if (value == null) { + throw new IllegalArgumentException(); + } + return value; + } + + public static String requireNotEmpty(String value) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(); + } + return value; + } + + public static T requireValue(T value, String name) { + if (value == null) { + throw new IllegalArgumentException(name + " must not be null"); + } + return value; + } + + public static String requireNotEmpty(String value, String name) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(name + " must not be null or empty"); + } + return value; + } + + public static void check(boolean condition) { + if (!condition) { + throw new IllegalArgumentException(); + } + } + + public static void check(boolean condition, String description) { + if (!condition) { + throw new IllegalArgumentException(description); + } + } + + + public static T check(T value, boolean condition) { + if (!condition) { + throw new IllegalArgumentException(); + } + return value; + } + + public static T check(T value, boolean condition, String description) { + if (!condition) { + throw new IllegalArgumentException(description); + } + return value; + } + + public static void checkState(boolean expression, Object errorMessage) { + if (!expression) { + throw new IllegalStateException(String.valueOf(errorMessage)); + } + } +} diff --git a/src/main/java/io/github/ensgijs/nbt/util/IdentityHelper.java b/src/main/java/io/github/ensgijs/nbt/util/IdentityHelper.java new file mode 100644 index 00000000..e93c3a4d --- /dev/null +++ b/src/main/java/io/github/ensgijs/nbt/util/IdentityHelper.java @@ -0,0 +1,50 @@ +package io.github.ensgijs.nbt.util; + +/** + * Use this in sets/map-keys when you need to track which objects you have visited + * which may evaluate {@code Objects.equals(..) == true} + * + *

Created to track visitation of values attached to nodes in a graph where those values may or may not be + * identical and may or may not be the same instance - but you need to visit each instance only once in + * either case, or in mixed cases. + * + *

Example use case + *

{@code
+ *         Set set = new HashSet<>();
+ *         String s1 = new String("net.Foo");
+ *         String s2 = new String("net.Foo");
+ *
+ *         set.add(new IdentityHelper<>(s1));
+ *         set.add(new IdentityHelper<>(s2));
+ *         set.add(new IdentityHelper<>(s2));
+ *         Assert.assertEquals(2, set.size());
+ * }
+ */
+public class IdentityHelper  {
+    private final T value;
+
+    public T getValue() {
+        return value;
+    }
+
+    public IdentityHelper(T value) {
+        this.value = value;
+    }
+
+    @Override
+    public int hashCode() {
+        return System.identityHashCode(value);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (o instanceof IdentityHelper) {
+            return value == ((IdentityHelper)o).value;
+        }
+        return value == o;
+    }
+
+    public static  IdentityHelper of(T o) {
+        return new IdentityHelper<>(o);
+    }
+}
diff --git a/src/main/java/io/github/ensgijs/nbt/util/JsonPrettyPrinter.java b/src/main/java/io/github/ensgijs/nbt/util/JsonPrettyPrinter.java
new file mode 100644
index 00000000..9e22619d
--- /dev/null
+++ b/src/main/java/io/github/ensgijs/nbt/util/JsonPrettyPrinter.java
@@ -0,0 +1,78 @@
+package io.github.ensgijs.nbt.util;
+
+/**
+ * Very forgiving json parser and formatter.
+ * Logic strongly based on implementation of https://jsonviewer.stack.hu/
+ */
+public final class JsonPrettyPrinter {
+    private JsonPrettyPrinter() {}
+
+    public static String prettyPrintJson(final String jsonText) {
+        StringBuilder sb = new StringBuilder();
+        int indentationLevel = 0;
+        boolean inString = false;
+        char strQuoteChar = '\0';
+        for (int i = 0, jsonLen = jsonText.length(); i < jsonLen; i++) {
+            char c = jsonText.charAt(i);
+            if (inString && c == strQuoteChar) {
+                if (jsonText.charAt(i - 1) != '\\') {
+                    inString = false;
+                }
+                sb.append(c);
+            } else if (!inString && (c == '"' || c == '\'')) {
+                inString = true;
+                strQuoteChar = c;
+                sb.append(c);
+            } else if (!inString && (c == ' ' || c == '\t' || c == '\n' || c == '\r')) {
+                continue;
+            } else if (!inString && c == ':') {
+                sb.append(c).append(' ');
+            } else if (!inString && c == ',') {
+                appendIndent(sb.append(",\n"), indentationLevel);
+            } else if (!inString && (c == '[' || c == '{')) {
+                indentationLevel++;
+                sb.append(c);
+                // keep int/long tag array hint on same line next to open bracket
+                if (i + 2 < jsonLen && jsonText.charAt(i + 2) == ';') {
+                    sb.append(jsonText.charAt(i + 1)).append(';');
+                    i += 2;
+                }
+                if (i + 1 < jsonLen && jsonText.charAt(i + 1) == ']') {
+                    sb.append(']');
+                    i ++;
+                    indentationLevel--;
+                } else {
+                    appendIndent(sb.append('\n'), indentationLevel);
+                }
+            } else if (!inString && c == ']') {
+                indentationLevel--;
+                appendIndent(sb.append('\n'), indentationLevel);
+//                if (jsonText.charAt(i - 1) != '[') {
+//                    appendIndent(sb.append('\n'), indentationLevel);
+//                } else {
+//                    sb.setLength(sb.lastIndexOf("[") + 1);
+//                }
+                sb.append(c);
+            } else if (!inString && c == '}') {
+                indentationLevel--;
+                if (jsonText.charAt(i - 1) != '{') {
+                    appendIndent(sb.append('\n'), indentationLevel);
+                } else {
+                    sb.setLength(sb.lastIndexOf("{") + 1);
+                }
+                sb.append(c);
+            } else if (inString && c == '\n') {
+                sb.append("\\n");
+            } else if (c != '\r') {
+                sb.append(c);
+            }
+        }
+        return sb.toString();
+    }
+
+    private static void appendIndent(final StringBuilder output, final int indentationLevel) {
+        for (int i = 0; i < indentationLevel * 2; i++) {
+            output.append(' ');
+        }
+    }
+}
diff --git a/src/main/java/io/github/ensgijs/nbt/util/Mutable.java b/src/main/java/io/github/ensgijs/nbt/util/Mutable.java
new file mode 100644
index 00000000..d5f53ab6
--- /dev/null
+++ b/src/main/java/io/github/ensgijs/nbt/util/Mutable.java
@@ -0,0 +1,24 @@
+package io.github.ensgijs.nbt.util;
+
+/**
+ * Simple utility for passing mutable objects into/out of lambdas, like {@link java.util.Optional}, but mutable.
+ * @param  Mutable type
+ */
+public class Mutable {
+
+    T value;
+
+    public Mutable() { }
+
+    public Mutable(T value) {
+        this.value = value;
+    }
+
+    public T get() {
+        return value;
+    }
+
+    public void set(T value) {
+        this.value = value;
+    }
+}
diff --git a/src/main/java/io/github/ensgijs/nbt/util/ObservedCompoundTag.java b/src/main/java/io/github/ensgijs/nbt/util/ObservedCompoundTag.java
new file mode 100644
index 00000000..bb41fdaf
--- /dev/null
+++ b/src/main/java/io/github/ensgijs/nbt/util/ObservedCompoundTag.java
@@ -0,0 +1,497 @@
+package io.github.ensgijs.nbt.util;
+
+import io.github.ensgijs.nbt.tag.*;
+import io.github.ensgijs.nbt.io.NamedTag;
+
+import java.util.*;
+import java.util.function.BiConsumer;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Decorates a compound tag to track what keys have been accessed and offers some utility functions like
+ * {@link #readKeys()}, {@link #unreadKeys()} and {@link #unreadTagsByRef()}.
+ */
+public class ObservedCompoundTag extends CompoundTag {
+    private final CompoundTag wrappedTag;
+    protected final Set readKeys = new HashSet<>();
+
+    public ObservedCompoundTag(CompoundTag wrappedTag) {
+        // emptyMap saves byte overhead - we never read/write it anyway
+        super(Collections.emptyMap());
+        this.wrappedTag = wrappedTag;
+    }
+
+    public CompoundTag wrappedTag() {
+        return wrappedTag;
+    }
+
+    /**
+     *  @return unmodifiable set of keys which one of the GET methods has been called with.
+     *  It doesn't matter if the CompoundTag contained the key or not - if get was called with a key it's in this set.
+     */
+    public Set readKeys() {
+        return Collections.unmodifiableSet(readKeys);
+    }
+
+    /**
+     *  @return unmodifiable set of keys which one of the GET methods has NOT been called with.
+     */
+    public Set unreadKeys() {
+        return Collections.unmodifiableSet(wrappedTag.keySet().stream()
+                .filter(k -> !readKeys.contains(k))
+                .collect(Collectors.toSet()));
+    }
+
+    /**
+     * @return A new CompoundTag that contains a reference to all the unread keys/entries.
+     */
+    public CompoundTag unreadTagsByRef() {
+        CompoundTag unread = new CompoundTag(wrappedTag.size());
+        wrappedTag.forEach((k, v) -> {
+            if (!readKeys.contains(k)) {
+                unread.put(k, v);
+            }
+        });
+        return unread;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public byte getID() {
+        return wrappedTag.getID();
+    }
+
+    /** {@inheritDoc} */
+    public int size() {
+        return wrappedTag.size();
+    }
+
+    /** {@inheritDoc} */
+    public boolean isEmpty() {
+        return wrappedTag.isEmpty();
+    }
+
+    /** {@inheritDoc} */
+    public Tag remove(String key) {
+        return wrappedTag.remove(key);
+    }
+
+    /** {@inheritDoc} */
+    public void clear() {
+        wrappedTag.clear();
+    }
+
+    /** {@inheritDoc} */
+    public boolean containsKey(String key) {
+        return wrappedTag.containsKey(key);
+    }
+
+    /** {@inheritDoc} */
+    public boolean containsValue(Tag value) {
+        return wrappedTag.containsValue(value);
+    }
+
+    /** {@inheritDoc} */
+    public Collection> values() {
+        return wrappedTag.values();
+    }
+
+    /** {@inheritDoc} */
+    public Set keySet() {
+        return wrappedTag.keySet();
+    }
+
+    /** {@inheritDoc}
+     * @return*/
+    @Override
+    public Iterator iterator() {
+        return wrappedTag.iterator();
+    }
+
+    /** {@inheritDoc}
+     * @return*/
+    @Override
+    public Spliterator spliterator() {
+        return wrappedTag.spliterator();
+    }
+
+    /** {@inheritDoc}
+     * @return*/
+    public Stream stream() {
+        return wrappedTag.stream();
+    }
+
+    /** {@inheritDoc} */
+    public void forEach(BiConsumer> action) {
+        wrappedTag.forEach(action);
+    }
+
+    /** {@inheritDoc} */
+    public > C get(String key, Class type) {
+        readKeys.add(key);
+        return wrappedTag.get(key, type);
+    }
+
+    /** {@inheritDoc} */
+    public Tag get(String key) {
+        readKeys.add(key);
+        return wrappedTag.get(key);
+    }
+
+    @Override
+    public NamedTag getNamedTag(String key) {
+        readKeys.add(key);
+        return super.getNamedTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public NumberTag getNumberTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getNumberTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public Number getNumber(String key) {
+        readKeys.add(key);
+        return wrappedTag.getNumber(key);
+
+    }
+
+    /** {@inheritDoc} */
+    public ByteTag getByteTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getByteTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public ShortTag getShortTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getShortTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public IntTag getIntTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getIntTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public LongTag getLongTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getLongTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public FloatTag getFloatTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getFloatTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public DoubleTag getDoubleTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getDoubleTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public StringTag getStringTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getStringTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public ByteArrayTag getByteArrayTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getByteArrayTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public IntArrayTag getIntArrayTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getIntArrayTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public LongArrayTag getLongArrayTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getLongArrayTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public ListTag getListTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getListTag(key);
+    }
+
+    /** {@inheritDoc} */
+    public > R getListTagAutoCast(String key) {
+        readKeys.add(key);
+        return wrappedTag.getListTagAutoCast(key);
+    }
+
+    /** {@inheritDoc} */
+    public CompoundTag getCompoundTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getCompoundTag(key);
+    }
+
+
+    public CompoundTag getOrCreateCompoundTag(String key) {
+        readKeys.add(key);
+        return wrappedTag.getOrCreateCompoundTag(key);
+    }
+
+    @Override
+    public ListTag getCompoundList(String key) {
+        readKeys.add(key);
+        return super.getCompoundList(key);
+    }
+
+    /** {@inheritDoc} */
+    public boolean getBoolean(String key) {
+        readKeys.add(key);
+        return wrappedTag.getBoolean(key);
+    }
+
+    /** {@inheritDoc} */
+    public boolean getBoolean(String key, boolean defaultValue) {
+        readKeys.add(key);
+        return wrappedTag.getBoolean(key, defaultValue);
+    }
+
+    /** {@inheritDoc} */
+    public byte getByte(String key) {
+        readKeys.add(key);
+        return wrappedTag.getByte(key);
+    }
+
+    /** {@inheritDoc} */
+    public byte getByte(String key, byte defaultValue) {
+        readKeys.add(key);
+        return wrappedTag.getByte(key, defaultValue);
+    }
+
+    /** {@inheritDoc} */
+    public short getShort(String key) {
+        readKeys.add(key);
+        return wrappedTag.getShort(key);
+    }
+
+    /** {@inheritDoc} */
+    public short getShort(String key, short defaultValue) {
+        readKeys.add(key);
+        return wrappedTag.getShort(key, defaultValue);
+    }
+
+    /** {@inheritDoc} */
+    public int getInt(String key) {
+        readKeys.add(key);
+        return wrappedTag.getInt(key);
+    }
+
+    /** {@inheritDoc} */
+    public int getInt(String key, int defaultValue) {
+        readKeys.add(key);
+        return wrappedTag.getInt(key, defaultValue);
+    }
+
+    /** {@inheritDoc} */
+    public long getLong(String key) {
+        readKeys.add(key);
+        return wrappedTag.getLong(key);
+    }
+
+    /** {@inheritDoc} */
+    public long getLong(String key, long defaultValue) {
+        readKeys.add(key);
+        return wrappedTag.getLong(key, defaultValue);
+    }
+
+    /** {@inheritDoc} */
+    public float getFloat(String key) {
+        readKeys.add(key);
+        return wrappedTag.getFloat(key);
+    }
+
+    /** {@inheritDoc} */
+    public float getFloat(String key, float defaultValue) {
+        readKeys.add(key);
+        return wrappedTag.getFloat(key, defaultValue);
+    }
+
+    /** {@inheritDoc} */
+    public double getDouble(String key) {
+        readKeys.add(key);
+        return wrappedTag.getDouble(key);
+    }
+
+    /** {@inheritDoc} */
+    public double getDouble(String key, double defaultValue) {
+        readKeys.add(key);
+        return wrappedTag.getDouble(key, defaultValue);
+    }
+
+    /** {@inheritDoc} */
+    public String getString(String key) {
+        readKeys.add(key);
+        return wrappedTag.getString(key);
+    }
+
+    /** {@inheritDoc} */
+    public String getString(String key, String defaultValue) {
+        readKeys.add(key);
+        return wrappedTag.getString(key, defaultValue);
+    }
+
+    /** {@inheritDoc} */
+    public byte[] getByteArray(String key) {
+        readKeys.add(key);
+        return wrappedTag.getByteArray(key);
+    }
+
+    /** {@inheritDoc} */
+    public int[] getIntArray(String key) {
+        readKeys.add(key);
+        return wrappedTag.getIntArray(key);
+    }
+
+    /** {@inheritDoc} */
+    public long[] getLongArray(String key) {
+        readKeys.add(key);
+        return wrappedTag.getLongArray(key);
+    }
+
+    /** {@inheritDoc} */
+    public float[] getFloatTagListAsArray(String key) {
+        readKeys.add(key);
+        return wrappedTag.getFloatTagListAsArray(key);
+    }
+
+    /** {@inheritDoc} */
+    public double[] getDoubleTagListAsArray(String key) {
+        readKeys.add(key);
+        return wrappedTag.getDoubleTagListAsArray(key);
+    }
+
+    /** {@inheritDoc} */
+    public List getStringTagListValues(String key) {
+        readKeys.add(key);
+        return wrappedTag.getStringTagListValues(key);
+    }
+
+    @Override
+    public Tag put(NamedTag namedTag) {
+        return super.put(namedTag);
+    }
+
+    /** {@inheritDoc} */
+    public Tag put(String key, Tag tag) {
+        return wrappedTag.put(key, tag);
+    }
+
+    @Override
+    public Tag putIfNotNull(NamedTag namedTag) {
+        return super.putIfNotNull(namedTag);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putIfNotNull(String key, Tag tag) {
+        return wrappedTag.putIfNotNull(key, tag);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putBoolean(String key, boolean value) {
+        return wrappedTag.putBoolean(key, value);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putByte(String key, byte value) {
+        return wrappedTag.putByte(key, value);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putShort(String key, short value) {
+        return wrappedTag.putShort(key, value);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putInt(String key, int value) {
+        return wrappedTag.putInt(key, value);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putLong(String key, long value) {
+        return wrappedTag.putLong(key, value);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putFloat(String key, float value) {
+        return wrappedTag.putFloat(key, value);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putDouble(String key, double value) {
+        return wrappedTag.putDouble(key, value);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putString(String key, String value) {
+        return wrappedTag.putString(key, value);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putByteArray(String key, byte[] value) {
+        return wrappedTag.putByteArray(key, value);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putIntArray(String key, int[] value) {
+        return wrappedTag.putIntArray(key, value);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putLongArray(String key, long[] value) {
+        return wrappedTag.putLongArray(key, value);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putFloatArrayAsTagList(String key, float... values) {
+        return wrappedTag.putFloatArrayAsTagList(key, values);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putDoubleArrayAsTagList(String key, double... values) {
+        return wrappedTag.putDoubleArrayAsTagList(key, values);
+    }
+
+    /** {@inheritDoc} */
+    public Tag putStringsAsTagList(String key, List values) {
+        return wrappedTag.putStringsAsTagList(key, values);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String valueToString(int maxDepth) {
+        return wrappedTag.valueToString(maxDepth);
+
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean equals(Object other) {
+        return wrappedTag.equals(other);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int compareTo(CompoundTag o) {
+        return wrappedTag.compareTo(o);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public CompoundTag clone() {
+        return wrappedTag.clone();
+    }
+}
diff --git a/src/main/java/io/github/ensgijs/nbt/util/Stopwatch.java b/src/main/java/io/github/ensgijs/nbt/util/Stopwatch.java
new file mode 100644
index 00000000..e24bb227
--- /dev/null
+++ b/src/main/java/io/github/ensgijs/nbt/util/Stopwatch.java
@@ -0,0 +1,200 @@
+// Base code copied from com.google.common.base.Stopwatch (Apache V2 licenced source)
+package io.github.ensgijs.nbt.util;
+
+import java.io.Closeable;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import static java.util.concurrent.TimeUnit.*;
+
+public final class Stopwatch {
+    private final Supplier ticker = System::nanoTime;
+    private boolean isRunning;
+    private long elapsedNanos;
+    private long startTick;
+
+    public static Stopwatch createUnstarted() {
+        return new Stopwatch();
+    }
+
+    public static Stopwatch createStarted() {
+        return new Stopwatch().start();
+    }
+
+    public class LapToken implements Closeable {
+        LapToken() {
+            start();
+        }
+
+        @Override
+        public void close() {
+            stop();
+        }
+    }
+
+    Stopwatch() {}
+
+    /**
+     * @return A new Stopwatch instance with an elapsed time equal to the sum of this
+     * and all others elapsed time.
+     */
+    public Stopwatch add(Stopwatch... others) {
+        Stopwatch out = new Stopwatch();
+        out.elapsedNanos = this.elapsedNanos();
+        for (Stopwatch s : others) {
+            out.elapsedNanos += s.elapsedNanos();
+        }
+        return out;
+    }
+
+    /**
+     * @return A new Stopwatch instance with an elapsed time equal to the product of this
+     * instances elapsed time minus all others elapsed time. The result may be negative.
+     */
+    public Stopwatch subtract(Stopwatch... others) {
+        Stopwatch out = new Stopwatch();
+        out.elapsedNanos = this.elapsedNanos();
+        for (Stopwatch s : others) {
+            out.elapsedNanos -= s.elapsedNanos();
+        }
+        return out;
+    }
+
+    /**
+     * Returns {@code true} if {@link #start()} has been called on this stopwatch, and {@link #stop()}
+     * has not been called since the last call to {@code start()}.
+     */
+    public boolean isRunning() {
+        return isRunning;
+    }
+
+    /**
+     * Starts the stopwatch.
+     *
+     * @return this {@code Stopwatch} instance
+     * @throws IllegalStateException if the stopwatch is already running.
+     */
+    public Stopwatch start() {
+        ArgValidator.checkState(!isRunning, "This stopwatch is already running.");
+        isRunning = true;
+        startTick = ticker.get();
+        return this;
+    }
+
+    /**
+     * Use inside {@code try (Stopwatch.LapToken lap1 = totalWriteStopwatch.startLap()){..}} blocks for an auto-closing stopwatch timer.
+     */
+    public LapToken startLap() {
+        return new LapToken();
+    }
+
+    /**
+     * Stops the stopwatch. Future reads will return the fixed duration that had elapsed up to this
+     * point.
+     *
+     * @return this {@code Stopwatch} instance
+     * @throws IllegalStateException if the stopwatch is already stopped.
+     */
+    public Stopwatch stop() {
+        long tick = ticker.get();
+        ArgValidator.checkState(isRunning, "This stopwatch is already stopped.");
+        isRunning = false;
+        elapsedNanos += tick - startTick;
+        return this;
+    }
+
+    /**
+     * Sets the elapsed time for this stopwatch to zero, and places it in a stopped state.
+     *
+     * @return this {@code Stopwatch} instance
+     */
+    public Stopwatch reset() {
+        elapsedNanos = 0;
+        isRunning = false;
+        return this;
+    }
+
+    private long elapsedNanos() {
+        return isRunning ? ticker.get() - startTick + elapsedNanos : elapsedNanos;
+    }
+
+    /**
+     * Returns the current elapsed time shown on this stopwatch, expressed in the desired time unit,
+     * with any fraction rounded down.
+     *
+     * 

Note: the overhead of measurement can be more than a microsecond, so it is generally + * not useful to specify {@link TimeUnit#NANOSECONDS} precision here. + * + *

It is generally not a good idea to use an ambiguous, unitless {@code long} to represent + * elapsed time. Therefore, we recommend using {@link #elapsed()} instead, which returns a + * strongly-typed {@code Duration} instance. + * + * @since 14.0 (since 10.0 as {@code elapsedTime()}) + */ + public long elapsed(TimeUnit desiredUnit) { + return desiredUnit.convert(elapsedNanos(), NANOSECONDS); + } + + /** + * Returns the current elapsed time shown on this stopwatch as a {@link Duration}. Unlike {@link + * #elapsed(TimeUnit)}, this method does not lose any precision due to rounding. + */ + public Duration elapsed() { + return Duration.ofNanos(elapsedNanos()); + } + + /** Returns a string representation of the current elapsed time. */ + @Override + public String toString() { + long nanos = elapsedNanos(); + TimeUnit unit = chooseUnit(nanos); + double value = (double) nanos / NANOSECONDS.convert(1, unit); + return String.format("%.4g", value) + " " + abbreviate(unit); + } + + private static TimeUnit chooseUnit(long nanos) { + if (nanos < 0) nanos = -nanos; + if (DAYS.convert(nanos, NANOSECONDS) > 0) { + return DAYS; + } + if (HOURS.convert(nanos, NANOSECONDS) > 0) { + return HOURS; + } + if (MINUTES.convert(nanos, NANOSECONDS) > 0) { + return MINUTES; + } + if (SECONDS.convert(nanos, NANOSECONDS) > 0) { + return SECONDS; + } + if (MILLISECONDS.convert(nanos, NANOSECONDS) > 0) { + return MILLISECONDS; + } + if (MICROSECONDS.convert(nanos, NANOSECONDS) > 0) { + return MICROSECONDS; + } + return NANOSECONDS; + } + + private static String abbreviate(TimeUnit unit) { + switch (unit) { + case NANOSECONDS: + return "ns"; + case MICROSECONDS: +// return "\u03bcs"; // μs + return "us"; // μs + case MILLISECONDS: + return "ms"; + case SECONDS: + return "s"; + case MINUTES: + return "min"; + case HOURS: + return "h"; + case DAYS: + return "d"; + default: + throw new AssertionError(); + } + } +} \ No newline at end of file diff --git a/src/main/java/net/querz/io/StringDeserializer.java b/src/main/java/net/querz/io/StringDeserializer.java deleted file mode 100644 index 2160e2a8..00000000 --- a/src/main/java/net/querz/io/StringDeserializer.java +++ /dev/null @@ -1,37 +0,0 @@ -package net.querz.io; - -import java.io.File; -import java.io.FileReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.Reader; -import java.io.StringReader; - -public interface StringDeserializer extends Deserializer { - - T fromReader(Reader reader) throws IOException; - - default T fromString(String s) throws IOException { - return fromReader(new StringReader(s)); - } - - @Override - default T fromStream(InputStream stream) throws IOException { - try (Reader reader = new InputStreamReader(stream)) { - return fromReader(reader); - } - } - - @Override - default T fromFile(File file) throws IOException { - try (Reader reader = new FileReader(file)) { - return fromReader(reader); - } - } - - @Override - default T fromBytes(byte[] data) throws IOException { - return fromReader(new StringReader(new String(data))); - } -} diff --git a/src/main/java/net/querz/io/StringSerializer.java b/src/main/java/net/querz/io/StringSerializer.java deleted file mode 100644 index c4da8104..00000000 --- a/src/main/java/net/querz/io/StringSerializer.java +++ /dev/null @@ -1,35 +0,0 @@ -package net.querz.io; - -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.StringWriter; -import java.io.Writer; - -public interface StringSerializer extends Serializer { - - void toWriter(T object, Writer writer) throws IOException; - - default String toString(T object) throws IOException { - Writer writer = new StringWriter(); - toWriter(object, writer); - writer.flush(); - return writer.toString(); - } - - @Override - default void toStream(T object, OutputStream stream) throws IOException { - Writer writer = new OutputStreamWriter(stream); - toWriter(object, writer); - writer.flush(); - } - - @Override - default void toFile(T object, File file) throws IOException { - try (Writer writer = new FileWriter(file)) { - toWriter(object, writer); - } - } -} diff --git a/src/main/java/net/querz/mca/Chunk.java b/src/main/java/net/querz/mca/Chunk.java deleted file mode 100644 index 7c3c620f..00000000 --- a/src/main/java/net/querz/mca/Chunk.java +++ /dev/null @@ -1,726 +0,0 @@ -package net.querz.mca; - -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.LoadFlags.*; - -public class Chunk implements Iterable

{ - - public static final int DEFAULT_DATA_VERSION = 2567; - - private boolean partial; - private boolean raw; - - private int lastMCAUpdate; - - 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; - - Chunk(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 Chunk(CompoundTag data) { - this.data = data; - initReferences(ALL_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; - } - - CompoundTag level; - if ((level = data.getCompoundTag("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 ((loadFlags & HEIGHTMAPS) != 0) { - heightMaps = level.getCompoundTag("Heightmaps"); - } - if ((loadFlags & CARVING_MASKS) != 0) { - carvingMasks = level.getCompoundTag("CarvingMasks"); - } - if ((loadFlags & ENTITIES) != 0) { - entities = level.containsKey("Entities") ? level.getListTag("Entities").asCompoundTagList() : null; - } - if ((loadFlags & TILE_ENTITIES) != 0) { - tileEntities = level.containsKey("TileEntities") ? level.getListTag("TileEntities").asCompoundTagList() : null; - } - if ((loadFlags & TILE_TICKS) != 0) { - tileTicks = level.containsKey("TileTicks") ? level.getListTag("TileTicks").asCompoundTagList() : null; - } - if ((loadFlags & LIQUID_TICKS) != 0) { - liquidTicks = level.containsKey("LiquidTicks") ? level.getListTag("LiquidTicks").asCompoundTagList() : null; - } - if ((loadFlags & LIGHTS) != 0) { - lights = level.containsKey("Lights") ? level.getListTag("Lights").asListTagList() : null; - } - if ((loadFlags & LIQUIDS_TO_BE_TICKED) != 0) { - liquidsToBeTicked = level.containsKey("LiquidsToBeTicked") ? level.getListTag("LiquidsToBeTicked").asListTagList() : null; - } - if ((loadFlags & TO_BE_TICKED) != 0) { - toBeTicked = level.containsKey("ToBeTicked") ? level.getListTag("ToBeTicked").asListTagList() : null; - } - if ((loadFlags & POST_PROCESSING) != 0) { - postProcessing = level.containsKey("PostProcessing") ? level.getListTag("PostProcessing").asListTagList() : null; - } - status = level.getString("Status"); - if ((loadFlags & STRUCTURES) != 0) { - structures = level.getCompoundTag("Structures"); - } - if ((loadFlags & (BLOCK_LIGHTS|BLOCK_STATES|SKY_LIGHT)) != 0 && level.containsKey("Sections")) { - for (CompoundTag section : level.getListTag("Sections").asCompoundTagList()) { - int sectionIndex = section.getNumber("Y").byteValue(); - Section newSection = new Section(section, dataVersion, loadFlags); - sections.put(sectionIndex, newSection); - } - } - - // 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 - */ - @Deprecated - public int getBiomeAt(int blockX, int blockZ) { - if (dataVersion < 2202) { - 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"); - } - } - - /** - * Fetches a biome id at a specific block in this chunk. - * The coordinates can be absolute coordinates or relative to the region or chunk. - * @param blockX The x-coordinate of the block. - * @param blockY The y-coordinate of the block. - * @param blockZ The z-coordinate of the block. - * @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 (biomes == null || biomes.length != 1024) { - return -1; - } - int biomeX = (blockX & 0xF) >> 2; - int biomeY = (blockY & 0xF) >> 2; - int biomeZ = (blockZ & 0xF) >> 2; - - return biomes[getBiomeIndex(biomeX, biomeY, biomeZ)]; - } - } - - @Deprecated - public void setBiomeAt(int blockX, 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 (biomes == null || biomes.length != 1024) { - biomes = new int[1024]; - Arrays.fill(biomes, -1); - } - - int biomeX = (blockX & 0xF) >> 2; - int biomeZ = (blockZ & 0xF) >> 2; - - for (int y = 0; y < 64; y++) { - biomes[getBiomeIndex(biomeX, y, biomeZ)] = biomeID; - } - } - } - - /** - * Sets a biome id at a specific block column. - * The coordinates can be absolute coordinates or relative to the region or chunk. - * @param blockX The x-coordinate of the block column. - * @param blockZ The z-coordinate of the block column. - * @param biomeID The biome id to be set. - * When set to a negative number, Minecraft will replace it with the block column's default biome. - */ - 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 (biomes == null || biomes.length != 1024) { - biomes = new int[1024]; - Arrays.fill(biomes, -1); - } - - int biomeX = (blockX & 0xF) >> 2; - int biomeZ = (blockZ & 0xF) >> 2; - - biomes[getBiomeIndex(biomeX, blockY, biomeZ)] = biomeID; - } - } - - int getBiomeIndex(int biomeX, int biomeY, int biomeZ) { - return biomeY * 16 + biomeZ * 4 + biomeX; - } - - public CompoundTag getBlockStateAt(int blockX, int blockY, int blockZ) { - Section section = sections.get(MCAUtil.blockToChunk(blockY)); - if (section == null) { - return null; - } - return section.getBlockStateAt(blockX, blockY, blockZ); - } - - /** - * Sets a block state at a specific location. - * The block coordinates can be absolute or relative to the region or chunk. - * @param blockX The x-coordinate of the block. - * @param blockY The y-coordinate of the block. - * @param blockZ The z-coordinate of the block. - * @param state The block state to be set. - * @param cleanup When true, it will cleanup all palettes of this chunk. - * 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 Chunk to file. - */ - public void setBlockStateAt(int blockX, int blockY, int blockZ, CompoundTag state, boolean cleanup) { - checkRaw(); - int sectionIndex = MCAUtil.blockToChunk(blockY); - Section section = sections.get(sectionIndex); - if (section == null) { - sections.put(sectionIndex, section = Section.newSection()); - } - section.setBlockStateAt(blockX, blockY, blockZ, state, cleanup); - } - - /** - * @return The DataVersion of this chunk. - */ - public int getDataVersion() { - return 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. - */ - public void setDataVersion(int dataVersion) { - checkRaw(); - this.dataVersion = dataVersion; - for (Section section : sections.values()) { - if (section != null) { - section.dataVersion = 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. - */ - public String getStatus() { - return status; - } - - /** - * Sets the generation status of this chunk. - * @param status The generation status of this chunk. - */ - public void setStatus(String status) { - checkRaw(); - 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. - */ - public long getLastUpdate() { - return lastUpdate; - } - - /** - * Sets the time when this chunk was last updated as a UNIX timestamp. - * @param lastUpdate The UNIX timestamp. - */ - public void setLastUpdate(long lastUpdate) { - checkRaw(); - this.lastUpdate = lastUpdate; - } - - /** - * @return The cumulative amount of time players have spent in this chunk in ticks. - */ - public long getInhabitedTime() { - return inhabitedTime; - } - - /** - * Sets the cumulative amount of time players have spent in this chunk in ticks. - * @param inhabitedTime The time in ticks. - */ - public void setInhabitedTime(long inhabitedTime) { - checkRaw(); - this.inhabitedTime = inhabitedTime; - } - - /** - * @return A matrix of biome IDs for all block columns in this chunk. - */ - public int[] getBiomes() { - return biomes; - } - - /** - * 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 - */ - 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")); - } - } - this.biomes = biomes; - } - - /** - * @return The height maps of this chunk. - */ - public CompoundTag getHeightMaps() { - return heightMaps; - } - - /** - * Sets the height maps of this chunk. - * @param heightMaps The height maps. - */ - public void setHeightMaps(CompoundTag heightMaps) { - checkRaw(); - this.heightMaps = heightMaps; - } - - /** - * @return The carving masks of this chunk. - */ - public CompoundTag getCarvingMasks() { - return carvingMasks; - } - - /** - * Sets the carving masks of this chunk. - * @param carvingMasks The carving masks. - */ - public void setCarvingMasks(CompoundTag carvingMasks) { - checkRaw(); - this.carvingMasks = carvingMasks; - } - - /** - * @return The entities of this chunk. - */ - public ListTag getEntities() { - return entities; - } - - /** - * Sets the entities of this chunk. - * @param entities The entities. - */ - public void setEntities(ListTag entities) { - checkRaw(); - this.entities = entities; - } - - /** - * @return The tile entities of this chunk. - */ - public ListTag getTileEntities() { - return tileEntities; - } - - /** - * Sets the tile entities of this chunk. - * @param tileEntities The tile entities of this chunk. - */ - public void setTileEntities(ListTag tileEntities) { - checkRaw(); - this.tileEntities = tileEntities; - } - - /** - * @return The tile ticks of this chunk. - */ - public ListTag getTileTicks() { - return tileTicks; - } - - /** - * Sets the tile ticks of this chunk. - * @param tileTicks Thee tile ticks. - */ - public void setTileTicks(ListTag tileTicks) { - checkRaw(); - this.tileTicks = tileTicks; - } - - /** - * @return The liquid ticks of this chunk. - */ - public ListTag getLiquidTicks() { - return liquidTicks; - } - - /** - * Sets the liquid ticks of this chunk. - * @param liquidTicks The liquid ticks. - */ - public void setLiquidTicks(ListTag liquidTicks) { - checkRaw(); - this.liquidTicks = liquidTicks; - } - - /** - * @return The light sources in this chunk. - */ - public ListTag> getLights() { - return lights; - } - - /** - * Sets the light sources in this chunk. - * @param lights The light sources. - */ - public void setLights(ListTag> lights) { - checkRaw(); - this.lights = lights; - } - - /** - * @return The liquids to be ticked in this chunk. - */ - public ListTag> getLiquidsToBeTicked() { - return liquidsToBeTicked; - } - - /** - * Sets the liquids to be ticked in this chunk. - * @param liquidsToBeTicked The liquids to be ticked. - */ - public void setLiquidsToBeTicked(ListTag> liquidsToBeTicked) { - checkRaw(); - this.liquidsToBeTicked = liquidsToBeTicked; - } - - /** - * @return Stuff to be ticked in this chunk. - */ - public ListTag> getToBeTicked() { - return toBeTicked; - } - - /** - * Sets stuff to be ticked in this chunk. - * @param toBeTicked The stuff to be ticked. - */ - public void setToBeTicked(ListTag> toBeTicked) { - checkRaw(); - this.toBeTicked = toBeTicked; - } - - /** - * @return Things that are in post processing in this chunk. - */ - public ListTag> getPostProcessing() { - return postProcessing; - } - - /** - * Sets things to be post processed in this chunk. - * @param postProcessing The things to be post processed. - */ - public void setPostProcessing(ListTag> postProcessing) { - checkRaw(); - this.postProcessing = postProcessing; - } - - /** - * @return Data about structures in this chunk. - */ - public CompoundTag getStructures() { - return structures; - } - - /** - * Sets data about structures in this chunk. - * @param structures The data about structures. - */ - public void setStructures(CompoundTag structures) { - checkRaw(); - this.structures = structures; - } - - int getBlockIndex(int blockX, int blockZ) { - return (blockZ & 0xF) * 16 + (blockX & 0xF); - } - - public void cleanupPalettesAndBlockStates() { - checkRaw(); - for (Section section : sections.values()) { - if (section != null) { - section.cleanupPaletteAndBlockStates(); - } - } - } - - private void checkRaw() { - if (raw) { - throw new UnsupportedOperationException("cannot update field when working with raw data"); - } - } - - public static Chunk newChunk() { - return newChunk(DEFAULT_DATA_VERSION); - } - - public static Chunk newChunk(int dataVersion) { - Chunk c = new Chunk(0); - c.dataVersion = dataVersion; - c.data = new CompoundTag(); - c.data.put("Level", new CompoundTag()); - c.status = "mobs_spawned"; - 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; - } - - public CompoundTag updateHandle(int xPos, int zPos) { - if (raw) { - return data; - } - - data.putInt("DataVersion", dataVersion); - 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); - } - level.putString("Status", status); - if (structures != null) { - level.put("Structures", structures); - } - ListTag sections = new ListTag<>(CompoundTag.class); - for (Section section : this.sections.values()) { - if (section != null) { - sections.add(section.updateHandle()); - } - } - level.put("Sections", sections); - return data; - } - - @Override - public Iterator
iterator() { - return sections.values().iterator(); - } -} diff --git a/src/main/java/net/querz/mca/ExceptionFunction.java b/src/main/java/net/querz/mca/ExceptionFunction.java deleted file mode 100644 index 40fe8195..00000000 --- a/src/main/java/net/querz/mca/ExceptionFunction.java +++ /dev/null @@ -1,7 +0,0 @@ -package net.querz.mca; - -@FunctionalInterface -public interface ExceptionFunction { - - R accept(T t) throws E; -} diff --git a/src/main/java/net/querz/mca/LoadFlags.java b/src/main/java/net/querz/mca/LoadFlags.java deleted file mode 100644 index da37a597..00000000 --- a/src/main/java/net/querz/mca/LoadFlags.java +++ /dev/null @@ -1,23 +0,0 @@ -package net.querz.mca; - -public final class LoadFlags { - - public static final long BIOMES = 0x00001; - public static final long HEIGHTMAPS = 0x00002; - public static final long CARVING_MASKS = 0x00004; - public static final long ENTITIES = 0x00008; - public static final long TILE_ENTITIES = 0x00010; - public static final long TILE_TICKS = 0x00040; - public static final long LIQUID_TICKS = 0x00080; - public static final long TO_BE_TICKED = 0x00100; - public static final long POST_PROCESSING = 0x00200; - public static final long STRUCTURES = 0x00400; - public static final long BLOCK_LIGHTS = 0x00800; - public static final long BLOCK_STATES = 0x01000; - public static final long SKY_LIGHT = 0x02000; - public static final long LIGHTS = 0x04000; - public static final long LIQUIDS_TO_BE_TICKED = 0x08000; - public static final long RAW = 0x10000; - - public static final long ALL_DATA = 0xffffffffffffffffL; -} diff --git a/src/main/java/net/querz/mca/MCAFile.java b/src/main/java/net/querz/mca/MCAFile.java deleted file mode 100644 index 0a6e7122..00000000 --- a/src/main/java/net/querz/mca/MCAFile.java +++ /dev/null @@ -1,305 +0,0 @@ -package net.querz.mca; - -import net.querz.nbt.tag.CompoundTag; -import net.querz.nbt.tag.Tag; - -import java.io.IOException; -import java.io.RandomAccessFile; -import java.util.Arrays; -import java.util.Iterator; -import java.util.Map; - -public class MCAFile 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; - - /** - * 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. - * */ - 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); - } - - /** - * 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. - */ - public void setChunk(int index, Chunk chunk) { - checkIndex(index); - if (chunks == null) { - chunks = new Chunk[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, Chunk 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 Chunk 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 Chunk 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 (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); - } - return chunk; - } - - /** - * @deprecated Use {@link #setBiomeAt(int, int, int, int)} instead - */ - @Deprecated - public void setBiomeAt(int blockX, int blockZ, int biomeID) { - createChunkIfMissing(blockX, blockZ).setBiomeAt(blockX, blockZ, biomeID); - } - - public void setBiomeAt(int blockX, int blockY, int blockZ, int biomeID) { - createChunkIfMissing(blockX, blockZ).setBiomeAt(blockX, blockY, blockZ, biomeID); - } - - /** - * @deprecated Use {@link #getBiomeAt(int, int, int)} instead - */ - @Deprecated - public int getBiomeAt(int blockX, int blockZ) { - int chunkX = MCAUtil.blockToChunk(blockX), chunkZ = MCAUtil.blockToChunk(blockZ); - Chunk chunk = getChunk(getChunkIndex(chunkX, chunkZ)); - if (chunk == null) { - return -1; - } - return chunk.getBiomeAt(blockX, blockZ); - } - - /** - * Fetches the biome id at a specific block. - * @param blockX The x-coordinate of the block. - * @param blockY The y-coordinate of the block. - * @param blockZ The z-coordinate of the block. - * @return The biome id if the chunk exists and the chunk has biomes, otherwise -1. - */ - public int getBiomeAt(int blockX, int blockY, int blockZ) { - int chunkX = MCAUtil.blockToChunk(blockX), chunkZ = MCAUtil.blockToChunk(blockZ); - Chunk chunk = getChunk(getChunkIndex(chunkX, chunkZ)); - if (chunk == null) { - return -1; - } - return chunk.getBiomeAt(blockX,blockY, blockZ); - } - - /** - * Set a block state at a specific block location. - * The block coordinates can be absolute coordinates or they can be relative to the region. - * @param blockX The x-coordinate of the block. - * @param blockY The y-coordinate of the block. - * @param blockZ The z-coordinate of the block. - * @param state The block state to be set. - * @param cleanup Whether the Palette and the BLockStates should be recalculated after adding the block state. - */ - public void setBlockStateAt(int blockX, int blockY, int blockZ, CompoundTag state, boolean cleanup) { - createChunkIfMissing(blockX, blockZ).setBlockStateAt(blockX, blockY, blockZ, state, cleanup); - } - - /** - * Fetches a block state at a specific block location. - * The block coordinates can be absolute coordinates or they can be relative to the region. - * @param blockX The x-coordinate of the block. - * @param blockY The y-coordinate of the block. - * @param blockZ The z-coordinate of the block. - * @return The block state or null if the chunk or the section do not exist. - */ - public CompoundTag getBlockStateAt(int blockX, int blockY, int blockZ) { - int chunkX = MCAUtil.blockToChunk(blockX), chunkZ = MCAUtil.blockToChunk(blockZ); - Chunk chunk = getChunk(chunkX, chunkZ); - if (chunk == null) { - return null; - } - return chunk.getBlockStateAt(blockX, blockY, blockZ); - } - - /** - * Recalculates the Palette and the BlockStates of all chunks and sections of this region. - */ - public void cleanupPalettesAndBlockStates() { - for (Chunk chunk : chunks) { - if (chunk != null) { - chunk.cleanupPalettesAndBlockStates(); - } - } - } - - @Override - public Iterator iterator() { - return Arrays.stream(chunks).iterator(); - } -} diff --git a/src/main/java/net/querz/mca/MCAUtil.java b/src/main/java/net/querz/mca/MCAUtil.java deleted file mode 100644 index f5ddecc5..00000000 --- a/src/main/java/net/querz/mca/MCAUtil.java +++ /dev/null @@ -1,223 +0,0 @@ -package net.querz.mca; - -import java.io.File; -import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.file.Files; -import java.nio.file.StandardCopyOption; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * 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() {} - - /** - * @see MCAUtil#read(File) - * @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); - } - - /** - * Reads an MCA file and loads all of its chunks. - * @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); - } - - /** - * @see MCAUtil#read(File) - * @param file The file to read the data from. - * @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); - } - - /** - * Reads an MCA file and loads all of its chunks. - * @param file The file to read the data from. - * @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")) { - mcaFile.deserialize(raf, loadFlags); - return mcaFile; - } - } - - /** - * Calls {@link MCAUtil#write(MCAFile, File, boolean)} without changing the timestamps. - * @see MCAUtil#write(MCAFile, File, boolean) - * @param file The file to write to. - * @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); - } - - /** - * Calls {@link MCAUtil#write(MCAFile, File, boolean)} without changing the timestamps. - * @see MCAUtil#write(MCAFile, File, boolean) - * @param file The file to write to. - * @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); - } - - /** - * @see MCAUtil#write(MCAFile, File, boolean) - * @param file The file to write to. - * @param mcaFile The data of the MCA file to write. - * @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); - } - - /** - * Writes an {@code MCAFile} object to disk. It optionally adjusts the timestamps - * when the file was last saved to the current date and time or leaves them at - * the value set by either loading an already existing MCA file or setting them manually.
- * If the file already exists, it is completely overwritten by the new file (no modification). - * @param file The file to write to. - * @param mcaFile The data of the MCA file to write. - * @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()) { - to = File.createTempFile(to.getName(), null); - } - int chunks; - try (RandomAccessFile raf = new RandomAccessFile(to, "rw")) { - chunks = mcaFile.serialize(raf, changeLastUpdate); - } - - if (chunks > 0 && to != file) { - Files.move(to.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); - } - return chunks; - } - - /** - * Turns the chunks coordinates into region coordinates and calls - * {@link MCAUtil#createNameFromRegionLocation(int, int)} - * @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)); - } - - /** - * Turns the block coordinates into region coordinates and calls - * {@link MCAUtil#createNameFromRegionLocation(int, int)} - * @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. - * @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"; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - private static final Pattern mcaFilePattern = Pattern.compile("^.*r\\.(?-?\\d+)\\.(?-?\\d+)\\.mca$"); - - public static MCAFile newMCAFile(File file) { - Matcher m = mcaFilePattern.matcher(file.getName()); - if (m.find()) { - return new MCAFile(Integer.parseInt(m.group("regionX")), Integer.parseInt(m.group("regionZ"))); - } - throw new IllegalArgumentException("invalid mca file name: " + file.getName()); - } -} diff --git a/src/main/java/net/querz/mca/Section.java b/src/main/java/net/querz/mca/Section.java deleted file mode 100644 index 3b72b969..00000000 --- a/src/main/java/net/querz/mca/Section.java +++ /dev/null @@ -1,473 +0,0 @@ -package net.querz.mca; - -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; - - public Section(CompoundTag sectionRoot, int dataVersion) { - this(sectionRoot, dataVersion, ALL_DATA); - } - - public Section(CompoundTag sectionRoot, int dataVersion, long loadFlags) { - data = sectionRoot; - this.dataVersion = dataVersion; - height = sectionRoot.getNumber("Y").byteValue(); - - ListTag rawPalette = sectionRoot.getListTag("Palette"); - if (rawPalette == null) { - return; - } - palette = rawPalette.asCompoundTagList(); - for (int i = 0; i < palette.size(); i++) { - CompoundTag data = palette.get(i); - 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; - } - if ((loadFlags & BLOCK_STATES) != 0) { - this.blockStates = blockStates != null ? blockStates.getValue() : null; - } - if ((loadFlags & SKY_LIGHT) != 0) { - this.skyLight = skyLight != null ? skyLight.getValue() : null; - } - } - - Section() {} - - void putValueIndexedPalette(CompoundTag data, int index) { - PaletteIndex leaf = new PaletteIndex(data, index); - String name = data.getString("Name"); - List leaves = valueIndexedPalette.get(name); - if (leaves == null) { - leaves = new ArrayList<>(1); - leaves.add(leaf); - valueIndexedPalette.put(name, leaves); - } else { - for (PaletteIndex pal : leaves) { - if (pal.data.equals(data)) { - return; - } - } - leaves.add(leaf); - } - } - - PaletteIndex getValueIndexedPalette(CompoundTag data) { - List leaves = valueIndexedPalette.get(data.getString("Name")); - if (leaves == null) { - return null; - } - for (PaletteIndex leaf : leaves) { - if (leaf.data.equals(data)) { - return leaf; - } - } - 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; - int index; - - PaletteIndex(CompoundTag data, int index) { - this.data = data; - this.index = index; - } - } - - /** - * 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. - * @param blockX The x-coordinate of the block in this Section - * @param blockY The y-coordinate of the block in this Section - * @param blockZ The z-coordinate of the block in this Section - * @return The block state data of this block. - */ - public CompoundTag getBlockStateAt(int blockX, int blockY, int blockZ) { - return getBlockStateAt(getBlockIndex(blockX, blockY, blockZ)); - } - - private CompoundTag getBlockStateAt(int index) { - int paletteIndex = getPaletteIndex(index); - return palette.get(paletteIndex); - } - - /** - * Attempts to add a block state for a specific block location in this Section. - * @param blockX The x-coordinate of the block in this Section - * @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. - * 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. - */ - public void setBlockStateAt(int blockX, int blockY, int blockZ, CompoundTag state, boolean cleanup) { - 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) { - adjustBlockStateBits(null, blockStates); - cleanup = true; - } - - setPaletteIndex(getBlockIndex(blockX, blockY, blockZ), paletteIndex, blockStates); - - if (cleanup) { - cleanupPaletteAndBlockStates(); - } - } - - /** - * 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) { - int bits = blockStates.length >> 6; - - if (dataVersion < 2527) { - double blockStatesIndex = blockStateIndex / (4096D / blockStates.length); - int longIndex = (int) blockStatesIndex; - int startBit = (int) ((blockStatesIndex - Math.floor(blockStatesIndex)) * 64D); - if (startBit + bits > 64) { - long prev = bitRange(blockStates[longIndex], startBit, 64); - long next = bitRange(blockStates[longIndex + 1], 0, startBit + bits - 64); - return (int) ((next << 64 - startBit) + prev); - } else { - return (int) bitRange(blockStates[longIndex], startBit, startBit + bits); - } - } else { - int indicesPerLong = (int) (64D / bits); - int blockStatesIndex = blockStateIndex / indicesPerLong; - int startBit = (blockStateIndex % indicesPerLong) * bits; - return (int) bitRange(blockStates[blockStatesIndex], startBit, startBit + bits); - } - } - - /** - * Sets the index of the block data in the BlockStates. Does not adjust the size of the BlockStates array. - * @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) { - int bits = blockStates.length >> 6; - - if (dataVersion < 2527) { - double blockStatesIndex = blockIndex / (4096D / blockStates.length); - int longIndex = (int) blockStatesIndex; - int startBit = (int) ((blockStatesIndex - Math.floor(longIndex)) * 64D); - if (startBit + bits > 64) { - blockStates[longIndex] = updateBits(blockStates[longIndex], paletteIndex, startBit, 64); - blockStates[longIndex + 1] = updateBits(blockStates[longIndex + 1], paletteIndex, startBit - 64, startBit + bits - 64); - } else { - blockStates[longIndex] = updateBits(blockStates[longIndex], paletteIndex, startBit, startBit + bits); - } - } else { - int indicesPerLong = (int) (64D / bits); - int blockStatesIndex = blockIndex / indicesPerLong; - int startBit = (blockIndex % indicesPerLong) * bits; - blockStates[blockStatesIndex] = updateBits(blockStates[blockStatesIndex], paletteIndex, startBit, startBit + bits); - } - } - - /** - * Fetches the palette of this Section. - * @return The palette of this Section. - */ - public ListTag getPalette() { - return palette; - } - - int addToPalette(CompoundTag data) { - PaletteIndex index; - if ((index = getValueIndexedPalette(data)) != null) { - return index.index; - } - palette.add(data); - putValueIndexedPalette(data, palette.size() - 1); - return palette.size() - 1; - } - - int getBlockIndex(int blockX, int blockY, int blockZ) { - return (blockY & 0xF) * 256 + (blockZ & 0xF) * 16 + (blockX & 0xF); - } - - static long updateBits(long n, long m, int i, int j) { - //replace i to j in n with j - i bits of m - long mShifted = i > 0 ? (m & ((1L << j - i) - 1)) << i : (m & ((1L << j - i) - 1)) >>> -i; - return ((n & ((j > 63 ? 0 : (~0L << j)) | (i < 0 ? 0 : ((1L << i) - 1L)))) | mShifted); - } - - static long bitRange(long value, int from, int to) { - int waste = 64 - to; - return (value << waste) >>> (waste + from); - } - - /** - * This method recalculates the palette and its indices. - * This 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. - */ - public void cleanupPaletteAndBlockStates() { - if (blockStates != null) { - Map oldToNewMapping = cleanupPalette(); - adjustBlockStateBits(oldToNewMapping, blockStates); - } - } - - private Map cleanupPalette() { - //create index - palette mapping - Map allIndices = new HashMap<>(); - for (int i = 0; i < 4096; i++) { - int paletteIndex = getPaletteIndex(i); - allIndices.put(paletteIndex, paletteIndex); - } - //delete unused blocks from palette - //start at index 1 because we need to keep minecraft:air - int index = 1; - valueIndexedPalette = new HashMap<>(valueIndexedPalette.size()); - putValueIndexedPalette(palette.get(0), 0); - for (int i = 1; i < palette.size(); i++) { - if (!allIndices.containsKey(index)) { - palette.remove(i); - i--; - } else { - putValueIndexedPalette(palette.get(i), i); - allIndices.put(index, i); - } - index++; - } - - return allIndices; - } - - void adjustBlockStateBits(Map oldToNewMapping, long[] blockStates) { - //increases or decreases the amount of bits used per BlockState - //based on the size of the palette. oldToNewMapping can be used to update indices - //if the palette had been cleaned up before using MCAFile#cleanupPalette(). - - int newBits = 32 - Integer.numberOfLeadingZeros(palette.size() - 1); - newBits = Math.max(newBits, 4); - - long[] newBlockStates; - - if (dataVersion < 2527) { - newBlockStates = newBits == blockStates.length / 64 ? blockStates : new long[newBits * 64]; - } else { - int newLength = (int) Math.ceil(4096D / (Math.floor(64D / newBits))); - newBlockStates = newBits == blockStates.length / 64 ? blockStates : new long[newLength]; - } - if (oldToNewMapping != null) { - for (int i = 0; i < 4096; i++) { - setPaletteIndex(i, oldToNewMapping.get(getPaletteIndex(i)), newBlockStates); - } - } else { - for (int i = 0; i < 4096; i++) { - setPaletteIndex(i, getPaletteIndex(i), newBlockStates); - } - } - this.blockStates = newBlockStates; - } - - /** - * @return The block light array of this Section - */ - public byte[] getBlockLight() { - return blockLight; - } - - /** - * Sets the block light array for this section. - * @param blockLight The block light array - * @throws IllegalArgumentException When the length of the array is not 2048 - */ - public void setBlockLight(byte[] blockLight) { - if (blockLight != null && blockLight.length != 2048) { - throw new IllegalArgumentException("BlockLight array must have a length of 2048"); - } - this.blockLight = blockLight; - } - - /** - * @return The indices of the block states of this Section. - */ - public long[] getBlockStates() { - return blockStates; - } - - /** - * Sets the block state indices to a custom value. - * @param blockStates The block state indices. - * @throws NullPointerException If blockStates is null - * @throws IllegalArgumentException When blockStates' length is < 256 or > 4096 and is not a multiple of 64 - */ - public void setBlockStates(long[] blockStates) { - if (blockStates == null) { - throw new NullPointerException("BlockStates cannot be null"); - } else if (blockStates.length % 64 != 0 || blockStates.length < 256 || blockStates.length > 4096) { - throw new IllegalArgumentException("BlockStates must have a length > 255 and < 4097 and must be divisible by 64"); - } - this.blockStates = blockStates; - } - - /** - * @return The sky light values of this Section - */ - public byte[] getSkyLight() { - return skyLight; - } - - /** - * Sets the sky light values of this section. - * @param skyLight The custom sky light values - * @throws IllegalArgumentException If the length of the array is not 2048 - */ - public void setSkyLight(byte[] skyLight) { - if (skyLight != null && skyLight.length != 2048) { - throw new IllegalArgumentException("SkyLight array must have a length of 2048"); - } - this.skyLight = skyLight; - } - - /** - * Creates an empty Section with base values. - * @return An empty Section - */ - 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; - } - - /** - * 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 - * @return A reference to the raw CompoundTag this Section is based on - */ - public CompoundTag updateHandle(int y) { - data.putByte("Y", (byte) y); - if (palette != null) { - data.put("Palette", palette); - } - if (blockLight != null) { - data.putByteArray("BlockLight", blockLight); - } - if (blockStates != null) { - data.putLongArray("BlockStates", blockStates); - } - if (skyLight != null) { - data.putByteArray("SkyLight", skyLight); - } - 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: - *
-	 * {@code
-	 * index = (blockY & 0xF) * 256 + (blockZ & 0xF) * 16 + (blockX & 0xF);
-	 * }
-	 * 
- * 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); - } - - private static class BlockIterator implements Iterable, Iterator { - - private Section section; - private int currentIndex; - - public BlockIterator(Section section) { - this.section = section; - currentIndex = 0; - } - - @Override - public boolean hasNext() { - return currentIndex < 4096; - } - - @Override - public CompoundTag next() { - CompoundTag blockState = section.getBlockStateAt(currentIndex); - currentIndex++; - return blockState; - } - - @Override - public Iterator iterator() { - return this; - } - } -} diff --git a/src/main/java/net/querz/nbt/io/NBTDeserializer.java b/src/main/java/net/querz/nbt/io/NBTDeserializer.java deleted file mode 100644 index 085e37be..00000000 --- a/src/main/java/net/querz/nbt/io/NBTDeserializer.java +++ /dev/null @@ -1,43 +0,0 @@ -package net.querz.nbt.io; - -import net.querz.io.Deserializer; -import net.querz.nbt.tag.Tag; -import java.io.IOException; -import java.io.InputStream; -import java.util.zip.GZIPInputStream; - -public class NBTDeserializer implements Deserializer { - - private boolean compressed, littleEndian; - - public NBTDeserializer() { - this(true); - } - - public NBTDeserializer(boolean compressed) { - this.compressed = compressed; - } - - public NBTDeserializer(boolean compressed, boolean littleEndian) { - this.compressed = compressed; - this.littleEndian = littleEndian; - } - - @Override - public NamedTag fromStream(InputStream stream) throws IOException { - NBTInput nbtIn; - InputStream input; - if (compressed) { - input = new GZIPInputStream(stream); - } else { - input = stream; - } - - if (littleEndian) { - nbtIn = new LittleEndianNBTInputStream(input); - } else { - nbtIn = new NBTInputStream(input); - } - return nbtIn.readTag(Tag.DEFAULT_MAX_DEPTH); - } -} diff --git a/src/main/java/net/querz/nbt/io/NBTInput.java b/src/main/java/net/querz/nbt/io/NBTInput.java deleted file mode 100644 index b17c6123..00000000 --- a/src/main/java/net/querz/nbt/io/NBTInput.java +++ /dev/null @@ -1,11 +0,0 @@ -package net.querz.nbt.io; - -import net.querz.nbt.tag.Tag; -import java.io.IOException; - -public interface NBTInput { - - NamedTag readTag(int maxDepth) throws IOException; - - Tag readRawTag(int maxDepth) throws IOException; -} diff --git a/src/main/java/net/querz/nbt/io/NBTSerializer.java b/src/main/java/net/querz/nbt/io/NBTSerializer.java deleted file mode 100644 index 921d8f47..00000000 --- a/src/main/java/net/querz/nbt/io/NBTSerializer.java +++ /dev/null @@ -1,44 +0,0 @@ -package net.querz.nbt.io; - -import net.querz.io.Serializer; -import net.querz.nbt.tag.Tag; -import java.io.IOException; -import java.io.OutputStream; -import java.util.zip.GZIPOutputStream; - -public class NBTSerializer implements Serializer { - - private boolean compressed, littleEndian; - - public NBTSerializer() { - this(true); - } - - public NBTSerializer(boolean compressed) { - this.compressed = compressed; - } - - public NBTSerializer(boolean compressed, boolean littleEndian) { - this.compressed = compressed; - this.littleEndian = littleEndian; - } - - @Override - public void toStream(NamedTag object, OutputStream out) throws IOException { - NBTOutput nbtOut; - OutputStream output; - if (compressed) { - output = new GZIPOutputStream(out, true); - } else { - output = out; - } - - if (littleEndian) { - nbtOut = new LittleEndianNBTOutputStream(output); - } else { - nbtOut = new NBTOutputStream(output); - } - nbtOut.writeTag(object, Tag.DEFAULT_MAX_DEPTH); - nbtOut.flush(); - } -} diff --git a/src/main/java/net/querz/nbt/io/NBTUtil.java b/src/main/java/net/querz/nbt/io/NBTUtil.java deleted file mode 100644 index edd0b869..00000000 --- a/src/main/java/net/querz/nbt/io/NBTUtil.java +++ /dev/null @@ -1,134 +0,0 @@ -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.util.zip.GZIPInputStream; - -public final class NBTUtil { - - private NBTUtil() {} - - public static void write(NamedTag tag, File file, boolean compressed) throws IOException { - try (FileOutputStream fos = new FileOutputStream(file)) { - new NBTSerializer(compressed).toStream(tag, fos); - } - } - - public static void write(NamedTag tag, String file, boolean compressed) throws IOException { - write(tag, new File(file), compressed); - } - - public static void write(NamedTag tag, File file) throws IOException { - write(tag, file, true); - } - - public static void write(NamedTag tag, String file) throws IOException { - write(tag, new File(file), true); - } - - public static void write(Tag tag, File file, boolean compressed) throws IOException { - write(new NamedTag(null, tag), file, compressed); - } - - public static void write(Tag tag, String file, boolean compressed) throws IOException { - write(new NamedTag(null, tag), new File(file), compressed); - } - - public static void write(Tag tag, File file) throws IOException { - write(new NamedTag(null, tag), file, true); - } - - public static void write(Tag tag, String file) throws IOException { - write(new NamedTag(null, tag), new File(file), true); - } - - public static void writeLE(NamedTag tag, File file, boolean compressed) throws IOException { - try (FileOutputStream fos = new FileOutputStream(file)) { - new NBTSerializer(compressed, true).toStream(tag, fos); - } - } - - public static void writeLE(NamedTag tag, String file, boolean compressed) throws IOException { - writeLE(tag, new File(file), compressed); - } - - public static void writeLE(NamedTag tag, File file) throws IOException { - writeLE(tag, file, true); - } - - public static void writeLE(NamedTag tag, String file) throws IOException { - writeLE(tag, new File(file), true); - } - - public static void writeLE(Tag tag, File file, boolean compressed) throws IOException { - writeLE(new NamedTag(null, tag), file, compressed); - } - - public static void writeLE(Tag tag, String file, boolean compressed) throws IOException { - writeLE(new NamedTag(null, tag), new File(file), compressed); - } - - public static void writeLE(Tag tag, File file) throws IOException { - writeLE(new NamedTag(null, tag), file, true); - } - - public static void writeLE(Tag tag, String file) throws IOException { - writeLE(new NamedTag(null, tag), new File(file), true); - } - - public static NamedTag read(File file, boolean compressed) throws IOException { - try (FileInputStream fis = new FileInputStream(file)) { - return new NBTDeserializer(compressed).fromStream(fis); - } - } - - public static NamedTag read(String file, boolean compressed) throws IOException { - return read(new File(file), compressed); - } - - public static NamedTag read(File file) throws IOException { - try (FileInputStream fis = new FileInputStream(file)) { - return new NBTDeserializer(false).fromStream(detectDecompression(fis)); - } - } - - public static NamedTag read(String file) throws IOException { - return read(new File(file)); - } - - public static NamedTag readLE(File file, boolean compressed) throws IOException { - try (FileInputStream fis = new FileInputStream(file)) { - return new NBTDeserializer(compressed, true).fromStream(fis); - } - } - - public static NamedTag readLE(String file, boolean compressed) throws IOException { - return readLE(new File(file), compressed); - } - - public static NamedTag readLE(File file) throws IOException { - try (FileInputStream fis = new FileInputStream(file)) { - return new NBTDeserializer(false, true).fromStream(detectDecompression(fis)); - } - } - - public static NamedTag readLE(String file) throws IOException { - return readLE(new File(file)); - } - - private static InputStream detectDecompression(InputStream is) throws IOException { - PushbackInputStream pbis = new PushbackInputStream(is, 2); - int signature = (pbis.read() & 0xFF) + (pbis.read() << 8); - pbis.unread(signature >> 8); - pbis.unread(signature & 0xFF); - if (signature == GZIPInputStream.GZIP_MAGIC) { - return new GZIPInputStream(pbis); - } - return pbis; - } -} diff --git a/src/main/java/net/querz/nbt/io/NamedTag.java b/src/main/java/net/querz/nbt/io/NamedTag.java deleted file mode 100644 index b1873087..00000000 --- a/src/main/java/net/querz/nbt/io/NamedTag.java +++ /dev/null @@ -1,30 +0,0 @@ -package net.querz.nbt.io; - -import net.querz.nbt.tag.Tag; - -public class NamedTag { - - private String name; - private Tag tag; - - public NamedTag(String name, Tag tag) { - this.name = name; - this.tag = tag; - } - - public void setName(String name) { - this.name = name; - } - - public void setTag(Tag tag) { - this.tag = tag; - } - - public String getName() { - return name; - } - - public Tag getTag() { - return tag; - } -} diff --git a/src/main/java/net/querz/nbt/io/SNBTDeserializer.java b/src/main/java/net/querz/nbt/io/SNBTDeserializer.java deleted file mode 100644 index f45c27e1..00000000 --- a/src/main/java/net/querz/nbt/io/SNBTDeserializer.java +++ /dev/null @@ -1,26 +0,0 @@ -package net.querz.nbt.io; - -import net.querz.io.StringDeserializer; -import net.querz.nbt.tag.Tag; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.Reader; -import java.util.stream.Collectors; - -public class SNBTDeserializer implements StringDeserializer> { - - @Override - public Tag fromReader(Reader reader) throws IOException { - return fromReader(reader, Tag.DEFAULT_MAX_DEPTH); - } - - public Tag fromReader(Reader reader, int maxDepth) throws IOException { - BufferedReader bufferedReader; - if (reader instanceof BufferedReader) { - bufferedReader = (BufferedReader) reader; - } else { - bufferedReader = new BufferedReader(reader); - } - return new SNBTParser(bufferedReader.lines().collect(Collectors.joining())).parse(maxDepth); - } -} diff --git a/src/main/java/net/querz/nbt/io/SNBTSerializer.java b/src/main/java/net/querz/nbt/io/SNBTSerializer.java deleted file mode 100644 index 50ea44a6..00000000 --- a/src/main/java/net/querz/nbt/io/SNBTSerializer.java +++ /dev/null @@ -1,18 +0,0 @@ -package net.querz.nbt.io; - -import net.querz.io.StringSerializer; -import net.querz.nbt.tag.Tag; -import java.io.IOException; -import java.io.Writer; - -public class SNBTSerializer implements StringSerializer> { - - @Override - public void toWriter(Tag tag, Writer writer) throws IOException { - SNBTWriter.write(tag, writer); - } - - public void toWriter(Tag tag, Writer writer, int maxDepth) throws IOException { - SNBTWriter.write(tag, writer, maxDepth); - } -} diff --git a/src/main/java/net/querz/nbt/io/SNBTUtil.java b/src/main/java/net/querz/nbt/io/SNBTUtil.java deleted file mode 100644 index d6f43b26..00000000 --- a/src/main/java/net/querz/nbt/io/SNBTUtil.java +++ /dev/null @@ -1,19 +0,0 @@ -package net.querz.nbt.io; - -import net.querz.nbt.tag.Tag; -import java.io.IOException; - -public class SNBTUtil { - - public static String toSNBT(Tag tag) throws IOException { - return new SNBTSerializer().toString(tag); - } - - public static Tag fromSNBT(String string) throws IOException { - return new SNBTDeserializer().fromString(string); - } - - public static Tag fromSNBT(String string, boolean lenient) throws IOException { - return new SNBTParser(string).parse(Tag.DEFAULT_MAX_DEPTH, lenient); - } -} diff --git a/src/main/java/net/querz/nbt/io/SNBTWriter.java b/src/main/java/net/querz/nbt/io/SNBTWriter.java deleted file mode 100644 index c6cffcb7..00000000 --- a/src/main/java/net/querz/nbt/io/SNBTWriter.java +++ /dev/null @@ -1,129 +0,0 @@ -package net.querz.nbt.io; - -import net.querz.io.MaxDepthIO; -import net.querz.nbt.tag.ByteArrayTag; -import net.querz.nbt.tag.ByteTag; -import net.querz.nbt.tag.CompoundTag; -import net.querz.nbt.tag.DoubleTag; -import net.querz.nbt.tag.EndTag; -import net.querz.nbt.tag.FloatTag; -import net.querz.nbt.tag.IntArrayTag; -import net.querz.nbt.tag.IntTag; -import net.querz.nbt.tag.ListTag; -import net.querz.nbt.tag.LongArrayTag; -import net.querz.nbt.tag.LongTag; -import net.querz.nbt.tag.ShortTag; -import net.querz.nbt.tag.StringTag; -import net.querz.nbt.tag.Tag; -import java.io.IOException; -import java.io.Writer; -import java.lang.reflect.Array; -import java.util.Map; -import java.util.regex.Pattern; - -/** - * SNBTWriter creates an SNBT String. - * - * */ -public final class SNBTWriter implements MaxDepthIO { - - private static final Pattern NON_QUOTE_PATTERN = Pattern.compile("[a-zA-Z_.+\\-]+"); - - private Writer writer; - - private SNBTWriter(Writer writer) { - this.writer = writer; - } - - public static void write(Tag tag, Writer writer, int maxDepth) throws IOException { - new SNBTWriter(writer).writeAnything(tag, maxDepth); - } - - public static void write(Tag tag, Writer writer) throws IOException { - write(tag, writer, Tag.DEFAULT_MAX_DEPTH); - } - - private void writeAnything(Tag tag, int maxDepth) throws IOException { - switch (tag.getID()) { - case EndTag.ID: - //do nothing - break; - case ByteTag.ID: - writer.append(Byte.toString(((ByteTag) tag).asByte())).write('b'); - break; - case ShortTag.ID: - writer.append(Short.toString(((ShortTag) tag).asShort())).write('s'); - break; - case IntTag.ID: - writer.write(Integer.toString(((IntTag) tag).asInt())); - break; - case LongTag.ID: - writer.append(Long.toString(((LongTag) tag).asLong())).write('l'); - break; - case FloatTag.ID: - writer.append(Float.toString(((FloatTag) tag).asFloat())).write('f'); - break; - case DoubleTag.ID: - writer.append(Double.toString(((DoubleTag) tag).asDouble())).write('d'); - break; - case ByteArrayTag.ID: - writeArray(((ByteArrayTag) tag).getValue(), ((ByteArrayTag) tag).length(), "B"); - break; - case StringTag.ID: - writer.write(escapeString(((StringTag) tag).getValue())); - break; - case ListTag.ID: - writer.write('['); - for (int i = 0; i < ((ListTag) tag).size(); i++) { - writer.write(i == 0 ? "" : ","); - writeAnything(((ListTag) tag).get(i), decrementMaxDepth(maxDepth)); - } - writer.write(']'); - break; - case CompoundTag.ID: - writer.write('{'); - boolean first = true; - for (Map.Entry> entry : (CompoundTag) tag) { - writer.write(first ? "" : ","); - writer.append(escapeString(entry.getKey())).write(':'); - writeAnything(entry.getValue(), decrementMaxDepth(maxDepth)); - first = false; - } - writer.write('}'); - break; - case IntArrayTag.ID: - writeArray(((IntArrayTag) tag).getValue(), ((IntArrayTag) tag).length(), "I"); - break; - case LongArrayTag.ID: - writeArray(((LongArrayTag) tag).getValue(), ((LongArrayTag) tag).length(), "L"); - break; - default: - throw new IOException("unknown tag with id \"" + tag.getID() + "\""); - } - } - - private void writeArray(Object array, int length, String prefix) throws IOException { - writer.append('[').append(prefix).write(';'); - for (int i = 0; i < length; i++) { - writer.append(i == 0 ? "" : ",").write(Array.get(array, i).toString()); - } - writer.write(']'); - } - - public static String escapeString(String s) { - if (!NON_QUOTE_PATTERN.matcher(s).matches()) { - StringBuilder sb = new StringBuilder(); - sb.append('"'); - for (int i = 0; i < s.length(); i++) { - char c = s.charAt(i); - if (c == '\\' || c == '"') { - sb.append('\\'); - } - sb.append(c); - } - sb.append('"'); - return sb.toString(); - } - return s; - } -} diff --git a/src/main/java/net/querz/nbt/tag/CompoundTag.java b/src/main/java/net/querz/nbt/tag/CompoundTag.java deleted file mode 100644 index f34db876..00000000 --- a/src/main/java/net/querz/nbt/tag/CompoundTag.java +++ /dev/null @@ -1,299 +0,0 @@ -package net.querz.nbt.tag; - -import java.util.Collection; -import java.util.HashMap; -import java.util.Iterator; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.function.BiConsumer; - -import net.querz.io.MaxDepthIO; - -public class CompoundTag extends Tag>> - implements Iterable>>, Comparable, MaxDepthIO { - - public static final byte ID = 10; - - public CompoundTag() { - super(createEmptyValue()); - } - - public CompoundTag(int initialCapacity) { - super(new HashMap<>(initialCapacity)); - } - - @Override - public byte getID() { - return ID; - } - - private static Map> createEmptyValue() { - return new HashMap<>(8); - } - - public int size() { - return getValue().size(); - } - - public Tag remove(String key) { - return getValue().remove(key); - } - - public void clear() { - getValue().clear(); - } - - public boolean containsKey(String key) { - return getValue().containsKey(key); - } - - public boolean containsValue(Tag value) { - return getValue().containsValue(value); - } - - public Collection> values() { - return getValue().values(); - } - - public Set keySet() { - return getValue().keySet(); - } - - public Set>> entrySet() { - return new NonNullEntrySet<>(getValue().entrySet()); - } - - @Override - public Iterator>> iterator() { - return entrySet().iterator(); - } - - public void forEach(BiConsumer> action) { - getValue().forEach(action); - } - - public > C get(String key, Class type) { - Tag t = getValue().get(key); - if (t != null) { - return type.cast(t); - } - return null; - } - - public Tag get(String key) { - return getValue().get(key); - } - - public NumberTag getNumberTag(String key) { - return (NumberTag) getValue().get(key); - } - - public Number getNumber(String key) { - return getNumberTag(key).getValue(); - } - - public ByteTag getByteTag(String key) { - return get(key, ByteTag.class); - } - - public ShortTag getShortTag(String key) { - return get(key, ShortTag.class); - } - - public IntTag getIntTag(String key) { - return get(key, IntTag.class); - } - - public LongTag getLongTag(String key) { - return get(key, LongTag.class); - } - - public FloatTag getFloatTag(String key) { - return get(key, FloatTag.class); - } - - public DoubleTag getDoubleTag(String key) { - return get(key, DoubleTag.class); - } - - public StringTag getStringTag(String key) { - return get(key, StringTag.class); - } - - public ByteArrayTag getByteArrayTag(String key) { - return get(key, ByteArrayTag.class); - } - - public IntArrayTag getIntArrayTag(String key) { - return get(key, IntArrayTag.class); - } - - public LongArrayTag getLongArrayTag(String key) { - return get(key, LongArrayTag.class); - } - - public ListTag getListTag(String key) { - return get(key, ListTag.class); - } - - public CompoundTag getCompoundTag(String key) { - return get(key, CompoundTag.class); - } - - public boolean getBoolean(String key) { - Tag t = get(key); - return t instanceof ByteTag && ((ByteTag) t).asByte() > 0; - } - - public byte getByte(String key) { - ByteTag t = getByteTag(key); - return t == null ? ByteTag.ZERO_VALUE : t.asByte(); - } - - public short getShort(String key) { - ShortTag t = getShortTag(key); - return t == null ? ShortTag.ZERO_VALUE : t.asShort(); - } - - public int getInt(String key) { - IntTag t = getIntTag(key); - return t == null ? IntTag.ZERO_VALUE : t.asInt(); - } - - public long getLong(String key) { - LongTag t = getLongTag(key); - return t == null ? LongTag.ZERO_VALUE : t.asLong(); - } - - public float getFloat(String key) { - FloatTag t = getFloatTag(key); - return t == null ? FloatTag.ZERO_VALUE : t.asFloat(); - } - - public double getDouble(String key) { - DoubleTag t = getDoubleTag(key); - return t == null ? DoubleTag.ZERO_VALUE : t.asDouble(); - } - - public String getString(String key) { - StringTag t = getStringTag(key); - return t == null ? StringTag.ZERO_VALUE : t.getValue(); - } - - public byte[] getByteArray(String key) { - ByteArrayTag t = getByteArrayTag(key); - return t == null ? ByteArrayTag.ZERO_VALUE : t.getValue(); - } - - public int[] getIntArray(String key) { - IntArrayTag t = getIntArrayTag(key); - return t == null ? IntArrayTag.ZERO_VALUE : t.getValue(); - } - - public long[] getLongArray(String key) { - LongArrayTag t = getLongArrayTag(key); - return t == null ? LongArrayTag.ZERO_VALUE : t.getValue(); - } - - public Tag put(String key, Tag tag) { - return getValue().put(Objects.requireNonNull(key), Objects.requireNonNull(tag)); - } - - public Tag putIfNotNull(String key, Tag tag) { - if (tag == null) { - return this; - } - return put(key, tag); - } - - public Tag putBoolean(String key, boolean value) { - return put(key, new ByteTag(value)); - } - - public Tag putByte(String key, byte value) { - return put(key, new ByteTag(value)); - } - - public Tag putShort(String key, short value) { - return put(key, new ShortTag(value)); - } - - public Tag putInt(String key, int value) { - return put(key, new IntTag(value)); - } - - public Tag putLong(String key, long value) { - return put(key, new LongTag(value)); - } - - public Tag putFloat(String key, float value) { - return put(key, new FloatTag(value)); - } - - public Tag putDouble(String key, double value) { - return put(key, new DoubleTag(value)); - } - - public Tag putString(String key, String value) { - return put(key, new StringTag(value)); - } - - public Tag putByteArray(String key, byte[] value) { - return put(key, new ByteArrayTag(value)); - } - - public Tag putIntArray(String key, int[] value) { - return put(key, new IntArrayTag(value)); - } - - public Tag putLongArray(String key, long[] value) { - return put(key, new LongArrayTag(value)); - } - - @Override - public String valueToString(int maxDepth) { - StringBuilder sb = new StringBuilder("{"); - boolean first = true; - for (Map.Entry> e : getValue().entrySet()) { - sb.append(first ? "" : ",") - .append(escapeString(e.getKey(), false)).append(":") - .append(e.getValue().toString(decrementMaxDepth(maxDepth))); - first = false; - } - sb.append("}"); - return sb.toString(); - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - if (!super.equals(other) || size() != ((CompoundTag) other).size()) { - return false; - } - for (Map.Entry> e : getValue().entrySet()) { - Tag v; - if ((v = ((CompoundTag) other).get(e.getKey())) == null || !e.getValue().equals(v)) { - return false; - } - } - return true; - } - - @Override - public int compareTo(CompoundTag o) { - return Integer.compare(size(), o.getValue().size()); - } - - @Override - public CompoundTag clone() { - // Choose initial capacity based on default load factor (0.75) so all entries fit in map without resizing - CompoundTag copy = new CompoundTag((int) Math.ceil(getValue().size() / 0.75f)); - for (Map.Entry> e : getValue().entrySet()) { - copy.put(e.getKey(), e.getValue().clone()); - } - return copy; - } -} diff --git a/src/main/java/net/querz/nbt/tag/ListTag.java b/src/main/java/net/querz/nbt/tag/ListTag.java deleted file mode 100644 index 955ad1a4..00000000 --- a/src/main/java/net/querz/nbt/tag/ListTag.java +++ /dev/null @@ -1,350 +0,0 @@ -package net.querz.nbt.tag; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Comparator; -import java.util.Iterator; -import java.util.List; -import java.util.Objects; -import java.util.function.Consumer; - -import net.querz.io.MaxDepthIO; - -/** - * ListTag represents a typed List in the nbt structure. - * An empty {@link ListTag} will be of type {@link EndTag} (unknown type). - * The type of an empty untyped {@link ListTag} can be set by using any of the {@code add()} - * methods or any of the {@code as...List()} methods. - */ -public class ListTag> extends Tag> implements Iterable, Comparable>, MaxDepthIO { - - public static final byte ID = 9; - - private Class typeClass = null; - - private ListTag(int initialCapacity) { - super(createEmptyValue(initialCapacity)); - } - - @Override - public byte getID() { - return ID; - } - - /** - *

Creates a non-type-safe ListTag. Its element type will be set after the first - * element was added.

- * - *

This is an internal helper method for cases where the element type is not known - * at construction time. Use {@link #ListTag(Class)} when the type is known.

- * - * @return A new non-type-safe ListTag - */ - public static ListTag createUnchecked(Class typeClass) { - return createUnchecked(typeClass, 3); - } - - /** - *

Creates a non-type-safe ListTag. Its element type will be set after the first - * element was added.

- * - *

This is an internal helper method for cases where the element type is not known - * at construction time. Use {@link #ListTag(Class)} when the type is known.

- * - * @return A new non-type-safe ListTag - */ - public static ListTag createUnchecked(Class typeClass, int initialCapacity) { - ListTag list = new ListTag<>(initialCapacity); - list.typeClass = typeClass; - return list; - } - - /** - *

Creates an empty mutable list to be used as empty value of ListTags.

- * - * @param Type of the list elements - * @param initialCapacity The initial capacity of the returned List - * @return An instance of {@link java.util.List} with an initial capacity of 3 - */ - private static List createEmptyValue(int initialCapacity) { - return new ArrayList<>(initialCapacity); - } - - /** - * @param typeClass The exact class of the elements - * @throws IllegalArgumentException When {@code typeClass} is {@link EndTag}{@code .class} - * @throws NullPointerException When {@code typeClass} is {@code null} - */ - public ListTag(Class typeClass) throws IllegalArgumentException, NullPointerException { - this(typeClass, 3); - } - - /** - * @param typeClass The exact class of the elements - * @param initialCapacity Initial capacity of list - * @throws IllegalArgumentException When {@code typeClass} is {@link EndTag}{@code .class} - * @throws NullPointerException When {@code typeClass} is {@code null} - */ - public ListTag(Class typeClass, int initialCapacity) throws IllegalArgumentException, NullPointerException { - super(createEmptyValue(initialCapacity)); - if (typeClass == EndTag.class) { - throw new IllegalArgumentException("cannot create ListTag with EndTag elements"); - } - this.typeClass = Objects.requireNonNull(typeClass); - } - - public Class getTypeClass() { - return typeClass == null ? EndTag.class : typeClass; - } - - public int size() { - return getValue().size(); - } - - public T remove(int index) { - return getValue().remove(index); - } - - public void clear() { - getValue().clear(); - } - - public boolean contains(T t) { - return getValue().contains(t); - } - - public boolean containsAll(Collection> tags) { - return getValue().containsAll(tags); - } - - public void sort(Comparator comparator) { - getValue().sort(comparator); - } - - @Override - public Iterator iterator() { - return getValue().iterator(); - } - - @Override - public void forEach(Consumer action) { - getValue().forEach(action); - } - - public T set(int index, T t) { - return getValue().set(index, Objects.requireNonNull(t)); - } - - /** - * Adds a Tag to this ListTag after the last index. - * - * @param t The element to be added. - */ - public void add(T t) { - add(size(), t); - } - - public void add(int index, T t) { - Objects.requireNonNull(t); - if (getTypeClass() == EndTag.class) { - typeClass = t.getClass(); - } else if (typeClass != t.getClass()) { - throw new ClassCastException( - String.format("cannot add %s to ListTag<%s>", - t.getClass().getSimpleName(), - typeClass.getSimpleName())); - } - getValue().add(index, t); - } - - public void addAll(Collection t) { - for (T tt : t) { - add(tt); - } - } - - public void addAll(int index, Collection t) { - int i = 0; - for (T tt : t) { - add(index + i, tt); - i++; - } - } - - public void addBoolean(boolean value) { - addUnchecked(new ByteTag(value)); - } - - public void addByte(byte value) { - addUnchecked(new ByteTag(value)); - } - - public void addShort(short value) { - addUnchecked(new ShortTag(value)); - } - - public void addInt(int value) { - addUnchecked(new IntTag(value)); - } - - public void addLong(long value) { - addUnchecked(new LongTag(value)); - } - - public void addFloat(float value) { - addUnchecked(new FloatTag(value)); - } - - public void addDouble(double value) { - addUnchecked(new DoubleTag(value)); - } - - public void addString(String value) { - addUnchecked(new StringTag(value)); - } - - public void addByteArray(byte[] value) { - addUnchecked(new ByteArrayTag(value)); - } - - public void addIntArray(int[] value) { - addUnchecked(new IntArrayTag(value)); - } - - public void addLongArray(long[] value) { - addUnchecked(new LongArrayTag(value)); - } - - public T get(int index) { - return getValue().get(index); - } - - public int indexOf(T t) { - return getValue().indexOf(t); - } - - @SuppressWarnings("unchecked") - public > ListTag asTypedList(Class type) { - checkTypeClass(type); - return (ListTag) this; - } - - public ListTag asByteTagList() { - return asTypedList(ByteTag.class); - } - - public ListTag asShortTagList() { - return asTypedList(ShortTag.class); - } - - public ListTag asIntTagList() { - return asTypedList(IntTag.class); - } - - public ListTag asLongTagList() { - return asTypedList(LongTag.class); - } - - public ListTag asFloatTagList() { - return asTypedList(FloatTag.class); - } - - public ListTag asDoubleTagList() { - return asTypedList(DoubleTag.class); - } - - public ListTag asStringTagList() { - return asTypedList(StringTag.class); - } - - public ListTag asByteArrayTagList() { - return asTypedList(ByteArrayTag.class); - } - - public ListTag asIntArrayTagList() { - return asTypedList(IntArrayTag.class); - } - - public ListTag asLongArrayTagList() { - return asTypedList(LongArrayTag.class); - } - - @SuppressWarnings("unchecked") - public ListTag> asListTagList() { - checkTypeClass(ListTag.class); - typeClass = ListTag.class; - return (ListTag>) this; - } - - public ListTag asCompoundTagList() { - return asTypedList(CompoundTag.class); - } - - @Override - public String valueToString(int maxDepth) { - StringBuilder sb = new StringBuilder("{\"type\":\"").append(getTypeClass().getSimpleName()).append("\",\"list\":["); - for (int i = 0; i < size(); i++) { - sb.append(i > 0 ? "," : "").append(get(i).valueToString(decrementMaxDepth(maxDepth))); - } - sb.append("]}"); - return sb.toString(); - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - if (!super.equals(other) || size() != ((ListTag) other).size() || getTypeClass() != ((ListTag) other) - .getTypeClass()) { - return false; - } - for (int i = 0; i < size(); i++) { - if (!get(i).equals(((ListTag) other).get(i))) { - return false; - } - } - return true; - } - - @Override - public int hashCode() { - return Objects.hash(getTypeClass().hashCode(), getValue().hashCode()); - } - - @Override - public int compareTo(ListTag o) { - return Integer.compare(size(), o.getValue().size()); - } - - @SuppressWarnings("unchecked") - @Override - public ListTag clone() { - ListTag copy = new ListTag<>(this.size()); - // assure type safety for clone - copy.typeClass = typeClass; - for (T t : getValue()) { - copy.add((T) t.clone()); - } - return copy; - } - - //TODO: make private - @SuppressWarnings("unchecked") - public void addUnchecked(Tag tag) { - if (getTypeClass() != EndTag.class && typeClass != tag.getClass()) { - throw new IllegalArgumentException(String.format( - "cannot add %s to ListTag<%s>", - tag.getClass().getSimpleName(), typeClass.getSimpleName())); - } - add(size(), (T) tag); - } - - private void checkTypeClass(Class clazz) { - if (getTypeClass() != EndTag.class && typeClass != clazz) { - throw new ClassCastException(String.format( - "cannot cast ListTag<%s> to ListTag<%s>", - typeClass.getSimpleName(), clazz.getSimpleName())); - } - } -} diff --git a/src/main/java/net/querz/nbt/tag/NonNullEntrySet.java b/src/main/java/net/querz/nbt/tag/NonNullEntrySet.java deleted file mode 100644 index e157ba26..00000000 --- a/src/main/java/net/querz/nbt/tag/NonNullEntrySet.java +++ /dev/null @@ -1,140 +0,0 @@ -package net.querz.nbt.tag; - -import java.util.Collection; -import java.util.Iterator; -import java.util.Map; -import java.util.Set; - -/** - * A decorator for the Set returned by CompoundTag#entrySet() - * that disallows setting null values. - * */ -class NonNullEntrySet implements Set> { - - private Set> set; - - NonNullEntrySet(Set> set) { - this.set = set; - } - - @Override - public int size() { - return set.size(); - } - - @Override - public boolean isEmpty() { - return set.isEmpty(); - } - - @Override - public boolean contains(Object o) { - return set.contains(o); - } - - @Override - public Iterator> iterator() { - return new NonNullEntrySetIterator(set.iterator()); - } - - @Override - public Object[] toArray() { - return set.toArray(); - } - - @Override - public T[] toArray(T[] a) { - return set.toArray(a); - } - - @Override - public boolean add(Map.Entry kvEntry) { - return set.add(kvEntry); - } - - @Override - public boolean remove(Object o) { - return set.remove(o); - } - - @Override - public boolean containsAll(Collection c) { - return set.containsAll(c); - } - - @Override - public boolean addAll(Collection> c) { - return set.addAll(c); - } - - @Override - public boolean retainAll(Collection c) { - return set.retainAll(c); - } - - @Override - public boolean removeAll(Collection c) { - return set.removeAll(c); - } - - @Override - public void clear() { - set.clear(); - } - - class NonNullEntrySetIterator implements Iterator> { - - private Iterator> iterator; - - NonNullEntrySetIterator(Iterator> iterator) { - this.iterator = iterator; - } - - @Override - public boolean hasNext() { - return iterator.hasNext(); - } - - @Override - public Map.Entry next() { - return new NonNullEntry(iterator.next()); - } - } - - class NonNullEntry implements Map.Entry { - - private Map.Entry entry; - - NonNullEntry(Map.Entry entry) { - this.entry = entry; - } - - @Override - public K getKey() { - return entry.getKey(); - } - - @Override - public V getValue() { - return entry.getValue(); - } - - @Override - public V setValue(V value) { - if (value == null) { - throw new NullPointerException(getClass().getSimpleName() + " does not allow setting null"); - } - return entry.setValue(value); - } - - @Override - public boolean equals(Object o) { - return entry.equals(o); - } - - @Override - public int hashCode() { - return entry.hashCode(); - } - } -} \ No newline at end of file diff --git a/src/main/java/net/querz/nbt/tag/StringTag.java b/src/main/java/net/querz/nbt/tag/StringTag.java deleted file mode 100644 index 0d30c4b6..00000000 --- a/src/main/java/net/querz/nbt/tag/StringTag.java +++ /dev/null @@ -1,50 +0,0 @@ -package net.querz.nbt.tag; - -public class StringTag extends Tag implements Comparable { - - public static final byte ID = 8; - public static final String ZERO_VALUE = ""; - - public StringTag() { - super(ZERO_VALUE); - } - - public StringTag(String value) { - super(value); - } - - @Override - public byte getID() { - return ID; - } - - @Override - public String getValue() { - return super.getValue(); - } - - @Override - public void setValue(String value) { - super.setValue(value); - } - - @Override - public String valueToString(int maxDepth) { - return escapeString(getValue(), false); - } - - @Override - public boolean equals(Object other) { - return super.equals(other) && getValue().equals(((StringTag) other).getValue()); - } - - @Override - public int compareTo(StringTag o) { - return getValue().compareTo(o.getValue()); - } - - @Override - public StringTag clone() { - return new StringTag(getValue()); - } -} diff --git a/src/test/java/io/github/ensgijs/nbt/AutoTypingDemoTest.java b/src/test/java/io/github/ensgijs/nbt/AutoTypingDemoTest.java new file mode 100644 index 00000000..1e4313c6 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/AutoTypingDemoTest.java @@ -0,0 +1,48 @@ +package io.github.ensgijs.nbt; + +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/ExceptionRunnable.java b/src/test/java/io/github/ensgijs/nbt/ExceptionRunnable.java similarity index 77% rename from src/test/java/net/querz/ExceptionRunnable.java rename to src/test/java/io/github/ensgijs/nbt/ExceptionRunnable.java index ebf57da8..089e9c7b 100644 --- a/src/test/java/net/querz/ExceptionRunnable.java +++ b/src/test/java/io/github/ensgijs/nbt/ExceptionRunnable.java @@ -1,4 +1,4 @@ -package net.querz; +package io.github.ensgijs.nbt; @FunctionalInterface public interface ExceptionRunnable { diff --git a/src/test/java/net/querz/ExceptionSupplier.java b/src/test/java/io/github/ensgijs/nbt/ExceptionSupplier.java similarity index 77% rename from src/test/java/net/querz/ExceptionSupplier.java rename to src/test/java/io/github/ensgijs/nbt/ExceptionSupplier.java index 98de04ed..2819f3b9 100644 --- a/src/test/java/net/querz/ExceptionSupplier.java +++ b/src/test/java/io/github/ensgijs/nbt/ExceptionSupplier.java @@ -1,4 +1,4 @@ -package net.querz; +package io.github.ensgijs.nbt; @FunctionalInterface public interface ExceptionSupplier { diff --git a/src/test/java/net/querz/NBTTestCase.java b/src/test/java/io/github/ensgijs/nbt/NbtTestCase.java similarity index 53% rename from src/test/java/net/querz/NBTTestCase.java rename to src/test/java/io/github/ensgijs/nbt/NbtTestCase.java index 7d412a59..01b87d82 100644 --- a/src/test/java/net/querz/NBTTestCase.java +++ b/src/test/java/io/github/ensgijs/nbt/NbtTestCase.java @@ -1,30 +1,27 @@ -package net.querz; +package io.github.ensgijs.nbt; +import io.github.ensgijs.nbt.io.*; +import io.github.ensgijs.nbt.tag.Tag; +import junit.framework.AssertionFailedError; +import junit.framework.ComparisonFailure; import junit.framework.TestCase; -import net.querz.nbt.io.NBTDeserializer; -import net.querz.nbt.io.NBTSerializer; -import net.querz.nbt.io.NamedTag; -import net.querz.nbt.tag.Tag; - -import java.io.BufferedInputStream; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.DataInputStream; -import java.io.DataOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; + +import java.io.*; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.math.BigInteger; import java.net.URL; +import java.nio.charset.StandardCharsets; 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; +import java.util.function.Predicate; import java.util.function.Supplier; -public abstract class NBTTestCase extends TestCase { +public abstract class NbtTestCase extends TestCase { @Override public void tearDown() throws Exception { @@ -35,7 +32,7 @@ public void tearDown() throws Exception { protected byte[] serialize(Tag tag) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (DataOutputStream dos = new DataOutputStream(baos)) { - new NBTSerializer(false).toStream(new NamedTag(null, tag), dos); + new BinaryNbtSerializer(CompressionType.NONE).toStream(new NamedTag(null, tag), dos); } catch (IOException ex) { ex.printStackTrace(); fail(ex.getMessage()); @@ -45,7 +42,7 @@ protected byte[] serialize(Tag tag) { protected Tag deserialize(byte[] data) { try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(data))) { - return new NBTDeserializer(false).fromStream(dis).getTag(); + return new BinaryNbtDeserializer(CompressionType.NONE).fromStream(dis).getTag(); } catch (IOException ex) { ex.printStackTrace(); fail(ex.getMessage()); @@ -55,17 +52,34 @@ protected Tag deserialize(byte[] data) { protected File getResourceFile(String name) { URL resource = getClass().getClassLoader().getResource(name); - assertNotNull(resource); - return new File(resource.getFile()); + assertNotNull("resource does not exist: " + name, resource); + String resPath = null; + try { + resPath = java.net.URLDecoder.decode(resource.getFile(), StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + fail(e.getMessage()); + } + return new File(resPath); } - protected Tag deserializeFromFile(String f) { - try (DataInputStream dis = new DataInputStream(new FileInputStream(getResourceFile(f)))) { - return new NBTDeserializer(false).fromStream(dis).getTag(); - } catch (IOException ex) { - ex.printStackTrace(); - fail(ex.getMessage()); - return null; + protected NamedTag deserializeFromFile(String f) { + if (!f.endsWith(".snbt") && !f.endsWith(".snbt.gz")) { + try { + return BinaryNbtHelpers.read(getResourceFile(f), CompressionType.NONE); + } catch (IOException ex) { + ex.printStackTrace(); + fail(ex.getMessage()); + return null; + } + } else { + try { + return TextNbtHelpers.readTextNbtFile(getResourceFile(f)); + } catch (IOException ex) { + ex.printStackTrace(); + fail(ex.getMessage()); + return null; + } } } @@ -110,22 +124,33 @@ protected T invokeGetValue(Tag tag) { return null; } - protected void assertThrowsException(ExceptionRunnable r, Class e) { - assertThrowsException(r, e, false); + protected void assertThrowsIllegalArgumentException(ExceptionRunnable r) { + assertThrowsException(r, IllegalArgumentException.class); } - protected void assertThrowsException(ExceptionRunnable r, Class e, boolean printStackTrace) { + protected void assertThrowsUnsupportedOperationException(ExceptionRunnable r) { + assertThrowsException(r, UnsupportedOperationException.class); + } + + protected void assertThrowsException(ExceptionRunnable r, Class e) { try { r.run(); - TestCase.fail(); + TestCase.fail("Did not throw expected: " + e.getSimpleName()); } catch (Exception ex) { - if (printStackTrace) { - ex.printStackTrace(); + if (!e.equals(ex.getClass())) { + throw new WrongExceptionThrownException(e, ex); } - TestCase.assertEquals(ex.getClass(), e); } } + /** + * @deprecated replaced by improved {@link #assertThrowsException(ExceptionRunnable, Class)} + */ + @Deprecated + protected void assertThrowsException(ExceptionRunnable r, Class e, boolean printStackTrace) { + assertThrowsException(r, e); + } + protected void assertThrowsNoException(ExceptionRunnable r) { try { r.run(); @@ -136,53 +161,86 @@ protected void assertThrowsNoException(ExceptionRunnable void assertThrowsException(ExceptionSupplier r, Class e) { - assertThrowsException(r, e, false); + try { + r.run(); + TestCase.fail("expected exception " + e.getSimpleName() + " to be thrown but it was not"); + } catch (Exception ex) { + if (!e.equals(ex.getClass())) { + throw new WrongExceptionThrownException(e, ex); + } + } } - protected void assertThrowsException(ExceptionSupplier r, Class e, boolean printStackTrace) { + protected void assertThrowsException(ExceptionSupplier r, Class e, Predicate expectedMessageMatcher) { try { r.run(); TestCase.fail(); } catch (Exception ex) { - if (printStackTrace) { - ex.printStackTrace(); + if (!e.equals(ex.getClass())) { + throw new WrongExceptionThrownException(e, ex); + } + if (!expectedMessageMatcher.test(ex.getMessage())) { + throw new WrongExceptionMessageException(ex); } - TestCase.assertEquals(ex.getClass(), e); } } - protected T assertThrowsNoException(ExceptionSupplier r) { - return assertThrowsNoException(r, false); + /** + * @deprecated replaced by improved {@link #assertThrowsException(ExceptionSupplier, Class)} + */ + @Deprecated + protected void assertThrowsException(ExceptionSupplier r, Class e, boolean printStackTrace) { + assertThrowsException(r, e); + } + + private static class WrongExceptionThrownException extends ComparisonFailure { + public WrongExceptionThrownException(Class expectedType, Exception actual) { + super("", expectedType.getTypeName(), actual.getClass().getTypeName()); + this.setStackTrace(actual.getStackTrace()); + } + } + + private static class WrongExceptionMessageException extends ComparisonFailure { + public WrongExceptionMessageException(Exception ex) { + super("", "", ex.getClass().getTypeName() + " " + ex.getMessage()); + this.setStackTrace(ex.getStackTrace()); + } + } + + 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) { - assertThrowsRuntimeException(r, e, false); - } - - protected void assertThrowsRuntimeException(Runnable r, Class e, boolean printStackTrace) { try { r.run(); TestCase.fail(); } catch (Exception ex) { - if (printStackTrace) { - ex.printStackTrace(); + if (!e.equals(ex.getClass())) { + throw new WrongExceptionThrownException(e, ex); } - TestCase.assertEquals(e, ex.getClass()); } } + /** + * @deprecated replaced by improved {@link #assertThrowsRuntimeException(Runnable, Class)} + */ + @Deprecated + protected void assertThrowsRuntimeException(Runnable r, Class e, boolean printStackTrace) { + assertThrowsRuntimeException(r, e); + } + protected void assertThrowsRuntimeException(Runnable r, boolean printStackTrace) { try { r.run(); @@ -212,47 +270,48 @@ protected T assertThrowsNoRuntimeException(Supplier r) { return null; } - 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); + protected File getNewTmpDirectory() { + final String workingDir = System.getProperty("user.dir"); + File dir = Paths.get( + workingDir, + "tmp", + this.getClass().getSimpleName(), + getName(), + UUID.randomUUID().toString()).toFile(); + if (!dir.exists()) { + dir.mkdirs(); } - return tmpFile; + return dir; } - protected File getTmpFile(String name) { - String workingDir = System.getProperty("user.dir"); - File tmpDir = new File(workingDir, "tmp"); - if (!tmpDir.exists()) { - tmpDir.mkdirs(); + protected File getNewTmpFile(String name) { + Path tmpPath = Paths.get(getNewTmpDirectory().getPath(), name); + File dir = tmpPath.getParent().toFile(); + if (!dir.exists()) { + dir.mkdirs(); } - return new File(tmpDir, name); + return tmpPath.toFile(); } protected File copyResourceToTmp(String resource) { - URL resFileURL = getClass().getClassLoader().getResource(resource); - TestCase.assertNotNull(resFileURL); - File resFile = new File(resFileURL.getFile()); + File resFile = getResourceFile(resource); File tmpFile = getNewTmpFile(resource); assertThrowsNoException(() -> Files.copy(resFile.toPath(), tmpFile.toPath())); 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/CompressionTypeTest.java b/src/test/java/io/github/ensgijs/nbt/io/CompressionTypeTest.java similarity index 75% rename from src/test/java/net/querz/mca/CompressionTypeTest.java rename to src/test/java/io/github/ensgijs/nbt/io/CompressionTypeTest.java index 65f87125..5d40a7b6 100644 --- a/src/test/java/net/querz/mca/CompressionTypeTest.java +++ b/src/test/java/io/github/ensgijs/nbt/io/CompressionTypeTest.java @@ -1,6 +1,8 @@ -package net.querz.mca; +package io.github.ensgijs.nbt.io; -public class CompressionTypeTest extends MCATestCase { +import junit.framework.TestCase; + +public class CompressionTypeTest extends TestCase { public void testGetFromID() { assertEquals(CompressionType.NONE, CompressionType.getFromID(CompressionType.NONE.getID())); diff --git a/src/test/java/io/github/ensgijs/nbt/io/NamedTagTest.java b/src/test/java/io/github/ensgijs/nbt/io/NamedTagTest.java new file mode 100644 index 00000000..9efdb65f --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/io/NamedTagTest.java @@ -0,0 +1,38 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.ByteTag; +import io.github.ensgijs.nbt.tag.ShortTag; +import io.github.ensgijs.nbt.NbtTestCase; +import io.github.ensgijs.nbt.tag.StringTag; + +public class NamedTagTest extends NbtTestCase { + + public void testCreate() { + ByteTag t = new ByteTag(); + NamedTag n = new NamedTag("name", t); + assertEquals("name", n.getName()); + assertTrue(n.getTag() == t); + } + + public void testSet() { + ByteTag t = new ByteTag(); + NamedTag n = new NamedTag("name", t); + n.setName("blah"); + assertEquals("blah", n.getName()); + ShortTag s = new ShortTag(); + n.setTag(s); + assertTrue(n.getTag() == s); + } + + public void testGetEscapedName() { + NamedTag n = new NamedTag(".Level.TileTicks", new StringTag()); + assertEquals("\".Level.TileTicks\"", n.getEscapedName()); + } + + public void testCompare_integerOrder() { + NamedTag nt2 = new NamedTag("2", new StringTag()); + NamedTag nt10 = new NamedTag("10", new StringTag()); + assertEquals(-1, NamedTag.compare(nt2, nt10)); + assertEquals(1, NamedTag.compare(nt10, nt2)); + } +} diff --git a/src/test/java/net/querz/nbt/io/StringPointerTest.java b/src/test/java/io/github/ensgijs/nbt/io/StringPointerTest.java similarity index 95% rename from src/test/java/net/querz/nbt/io/StringPointerTest.java rename to src/test/java/io/github/ensgijs/nbt/io/StringPointerTest.java index 1030c114..31686ccd 100644 --- a/src/test/java/net/querz/nbt/io/StringPointerTest.java +++ b/src/test/java/io/github/ensgijs/nbt/io/StringPointerTest.java @@ -1,8 +1,8 @@ -package net.querz.nbt.io; +package io.github.ensgijs.nbt.io; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; -public class StringPointerTest extends NBTTestCase { +public class StringPointerTest extends NbtTestCase { public void testLookAhead() { StringPointer ptr = new StringPointer("abcdefg"); diff --git a/src/test/java/io/github/ensgijs/nbt/io/TextNbtHelpersTest.java b/src/test/java/io/github/ensgijs/nbt/io/TextNbtHelpersTest.java new file mode 100644 index 00000000..777288ae --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/io/TextNbtHelpersTest.java @@ -0,0 +1,67 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.StringTag; +import io.github.ensgijs.nbt.NbtTestCase; +import org.junit.Assert; + +import java.io.File; +import java.io.IOException; + +public class TextNbtHelpersTest extends NbtTestCase { + + private void verify(CompoundTag tag) { + assertEquals(3, tag.size()); + assertTrue(tag.containsKey("question")); + assertEquals("¿why?", tag.getString("question")); + assertTrue(tag.containsKey("answer")); + assertEquals(42, tag.getInt("answer")); + assertTrue(tag.containsKey("when")); + Assert.assertArrayEquals(new int[] {1978, 1981, 1984, 2005}, tag.getIntArray("when")); + } + + public void testReadTextNbt_nakedTag() throws IOException { + NamedTag uncompressedResult = TextNbtHelpers.readTextNbtFile(getResourceFile("text_nbt_samples/unnamed_tag_sample.snbt")); + assertNull(uncompressedResult.getName()); + verify(uncompressedResult.getTagAutoCast()); + + NamedTag compressedResult = TextNbtHelpers.readTextNbtFile(getResourceFile("text_nbt_samples/unnamed_tag_sample.snbt.gz")); + assertEquals(uncompressedResult, compressedResult); + } + + public void testReadTextNbt_namedTag() throws IOException { +// System.out.println("-----------------------------"); +// System.out.println(new String(Files.readAllBytes(getResourceFile("text_nbt_samples/named_tag_sample-with_bom.snbt").toPath()))); +// System.out.println("-----------------------------"); + NamedTag uncompressedResult = TextNbtHelpers.readTextNbtFile(getResourceFile("text_nbt_samples/named_tag_sample-with_bom.snbt")); + assertEquals("HitchhikerGuide", uncompressedResult.getName()); + verify(uncompressedResult.getTagAutoCast()); + + NamedTag compressedResult = TextNbtHelpers.readTextNbtFile(getResourceFile("text_nbt_samples/named_tag_sample-with_bom.snbt.gz")); + assertEquals(uncompressedResult, compressedResult); + } + + public void testReadTextNbt_emptyFile() throws IOException { + NamedTag uncompressedResult = TextNbtHelpers.readTextNbtFile(getResourceFile("text_nbt_samples/empty_file.snbt")); + assertNull(uncompressedResult); + NamedTag compressedResult = TextNbtHelpers.readTextNbtFile(getResourceFile("text_nbt_samples/empty_file.snbt.gz")); + assertNull(compressedResult); + } + + public void testWriteTextNbt_nakedTag() throws IOException { + File tempFile = getNewTmpFile("text_nbt_helpers_test/write_naked_tag.snbt"); + StringTag tag = new StringTag("¿why?"); + assertEquals(tempFile.toPath(), TextNbtHelpers.writeTextNbtFile(tempFile, tag)); + StringTag readTag = TextNbtHelpers.readTextNbtFile(tempFile).getTagAutoCast(); + assertEquals(tag, readTag); + } + + public void testWriteTextNbt_writeGzFile() throws IOException { + NamedTag namedTagGolden = TextNbtHelpers.readTextNbtFile(getResourceFile("text_nbt_samples/named_tag_sample-with_bom.snbt")); + File tempFile = getNewTmpFile("text_nbt_helpers_test/write_gz_file.snbt.gz"); + assertEquals(tempFile.toPath(), TextNbtHelpers.writeTextNbtFile(tempFile, namedTagGolden)); + NamedTag readTag = TextNbtHelpers.readTextNbtFile(tempFile); + assertEquals(namedTagGolden, readTag); + verify(readTag.getTagAutoCast()); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/io/TextNbtParserTest.java b/src/test/java/io/github/ensgijs/nbt/io/TextNbtParserTest.java new file mode 100644 index 00000000..6eaff5ad --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/io/TextNbtParserTest.java @@ -0,0 +1,267 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.NbtTestCase; +import io.github.ensgijs.nbt.tag.*; +import org.junit.Assert; + +import java.util.Arrays; + +public class TextNbtParserTest extends NbtTestCase { + + public void testParseTags() { + Tag t = assertThrowsNoException(() -> new TextNbtParser("{abc: def, blah: 4b, blubb: \"string\", \"foo\": 2s}").parse()); + assertEquals(CompoundTag.class, t.getClass()); + CompoundTag c = (CompoundTag) t; + assertEquals(4, c.size()); + assertEquals("def", c.getString("abc")); + assertEquals((byte) 4, c.getByte("blah")); + assertEquals("string", c.getString("blubb")); + assertEquals((short) 2, c.getShort("foo")); + assertFalse(c.containsKey("invalid")); + + // ------------------------------------------------- number tags + + Tag tb = assertThrowsNoException(() -> new TextNbtParser("16b").parse()); + assertEquals(ByteTag.class, tb.getClass()); + assertEquals((byte) 16, ((ByteTag) tb).asByte()); + + tb = assertThrowsNoException(() -> new TextNbtParser("16B").parse()); + assertEquals(ByteTag.class, tb.getClass()); + assertEquals((byte) 16, ((ByteTag) tb).asByte()); + + assertThrowsException((() -> new TextNbtParser("-129b").parse()), ParseException.class); + + Tag ts = assertThrowsNoException(() -> new TextNbtParser("17s").parse()); + assertEquals(ShortTag.class, ts.getClass()); + assertEquals((short) 17, ((ShortTag) ts).asShort()); + + ts = assertThrowsNoException(() -> new TextNbtParser("17S").parse()); + assertEquals(ShortTag.class, ts.getClass()); + assertEquals((short) 17, ((ShortTag) ts).asShort()); + + assertThrowsException((() -> new TextNbtParser("-32769s").parse()), ParseException.class); + + Tag ti = assertThrowsNoException(() -> new TextNbtParser("18").parse()); + assertEquals(IntTag.class, ti.getClass()); + assertEquals(18, ((IntTag) ti).asInt()); + + assertThrowsException((() -> new TextNbtParser("-2147483649").parse()), ParseException.class); + + Tag tl = assertThrowsNoException(() -> new TextNbtParser("19l").parse()); + assertEquals(LongTag.class, tl.getClass()); + assertEquals(19L, ((LongTag) tl).asLong()); + + tl = assertThrowsNoException(() -> new TextNbtParser("19L").parse()); + assertEquals(LongTag.class, tl.getClass()); + assertEquals(19L, ((LongTag) tl).asLong()); + + assertThrowsException((() -> new TextNbtParser("-9223372036854775809l").parse()), ParseException.class); + + Tag tf = assertThrowsNoException(() -> new TextNbtParser("20.3f").parse()); + assertEquals(FloatTag.class, tf.getClass()); + assertEquals(20.3f, ((FloatTag) tf).asFloat()); + + tf = assertThrowsNoException(() -> new TextNbtParser("20.3F").parse()); + assertEquals(FloatTag.class, tf.getClass()); + assertEquals(20.3f, ((FloatTag) tf).asFloat()); + + Tag td = assertThrowsNoException(() -> new TextNbtParser("21.3d").parse()); + assertEquals(DoubleTag.class, td.getClass()); + assertEquals(21.3d, ((DoubleTag) td).asDouble()); + + td = assertThrowsNoException(() -> new TextNbtParser("21.3D").parse()); + assertEquals(DoubleTag.class, td.getClass()); + assertEquals(21.3d, ((DoubleTag) td).asDouble()); + + td = assertThrowsNoException(() -> new TextNbtParser("21.3").parse()); + assertEquals(DoubleTag.class, td.getClass()); + assertEquals(21.3d, ((DoubleTag) td).asDouble()); + + Tag tbo = assertThrowsNoException(() -> new TextNbtParser("true").parse()); + assertEquals(ByteTag.class, tbo.getClass()); + assertEquals((byte) 1, ((ByteTag) tbo).asByte()); + + tbo = assertThrowsNoException(() -> new TextNbtParser("false").parse()); + assertEquals(ByteTag.class, tbo.getClass()); + assertEquals((byte) 0, ((ByteTag) tbo).asByte()); + + // ------------------------------------------------- arrays + + Tag ba = assertThrowsNoException(() -> new TextNbtParser("[B; -128,0, 127]").parse()); + assertEquals(ByteArrayTag.class, ba.getClass()); + assertEquals(3, ((ByteArrayTag) ba).length()); + assertTrue(Arrays.equals(new byte[]{-128, 0, 127}, ((ByteArrayTag) ba).getValue())); + + Tag ia = assertThrowsNoException(() -> new TextNbtParser("[I; -2147483648, 0,2147483647]").parse()); + assertEquals(IntArrayTag.class, ia.getClass()); + assertEquals(3, ((IntArrayTag) ia).length()); + assertTrue(Arrays.equals(new int[]{-2147483648, 0, 2147483647}, ((IntArrayTag) ia).getValue())); + + Tag la = assertThrowsNoException(() -> new TextNbtParser("[L; -9223372036854775808, 0, 9223372036854775807 ]").parse()); + assertEquals(LongArrayTag.class, la.getClass()); + assertEquals(3, ((LongArrayTag) la).length()); + assertTrue(Arrays.equals(new long[]{-9223372036854775808L, 0, 9223372036854775807L}, ((LongArrayTag) la).getValue())); + + // ------------------------------------------------- invalid arrays + + assertThrowsException((() -> new TextNbtParser("[B; -129]").parse()), ParseException.class); + assertThrowsException((() -> new TextNbtParser("[I; -2147483649]").parse()), ParseException.class); + assertThrowsException((() -> new TextNbtParser("[L; -9223372036854775809]").parse()), ParseException.class); + assertThrowsException((() -> new TextNbtParser("[B; 123b]").parse()), ParseException.class); + assertThrowsException((() -> new TextNbtParser("[I; 123i]").parse()), ParseException.class); + assertThrowsException((() -> new TextNbtParser("[L; 123l]").parse()), ParseException.class); + assertThrowsException((() -> new TextNbtParser("[K; -129]").parse()), ParseException.class); + + // ------------------------------------------------- high level errors + + assertThrowsException(() -> new TextNbtParser("{20:10} {blah:blubb}").parse(), ParseException.class); + + // ------------------------------------------------- string tag + + Tag st = assertThrowsNoException(() -> new TextNbtParser("abc").parse()); + assertEquals(StringTag.class, st.getClass()); + assertEquals("abc", ((StringTag) st).getValue()); + + st = assertThrowsNoException(() -> new TextNbtParser("\"abc\"").parse()); + assertEquals(StringTag.class, st.getClass()); + assertEquals("abc", ((StringTag) st).getValue()); + + st = assertThrowsNoException(() -> new TextNbtParser("123a").parse()); + assertEquals(StringTag.class, st.getClass()); + assertEquals("123a", ((StringTag) st).getValue()); + + // ------------------------------------------------- list tag + + Tag lt = assertThrowsNoException(() -> new TextNbtParser("[abc, \"def\", \"123\" ]").parse()); + assertEquals(ListTag.class, lt.getClass()); + assertEquals(StringTag.class, ((ListTag) lt).getTypeClass()); + assertEquals(3, ((ListTag) lt).size()); + assertEquals("abc", ((ListTag) lt).asStringTagList().get(0).getValue()); + assertEquals("def", ((ListTag) lt).asStringTagList().get(1).getValue()); + assertEquals("123", ((ListTag) lt).asStringTagList().get(2).getValue()); + + assertThrowsException(() -> new TextNbtParser("[123, 456").parse(), ParseException.class); + assertThrowsException(() -> new TextNbtParser("[123, 456d]").parse(), ParseException.class); + + // ------------------------------------------------- compound tag + + Tag ct = assertThrowsNoException(() -> new TextNbtParser("{abc: def,\"key\": 123d, blah: [L;123, 456], blubb: [123, 456]}").parse()); + assertEquals(CompoundTag.class, ct.getClass()); + assertEquals(4, ((CompoundTag) ct).size()); + assertEquals("def", assertThrowsNoException(() -> ((CompoundTag) ct).getString("abc"))); + assertEquals(123D, assertThrowsNoException(() -> ((CompoundTag) ct).getDouble("key"))); + assertTrue(Arrays.equals(new long[]{123, 456}, assertThrowsNoException(() -> ((CompoundTag) ct).getLongArray("blah")))); + assertEquals(2, assertThrowsNoException(() -> ((CompoundTag) ct).getListTag("blubb")).size()); + assertEquals(IntTag.class, ((CompoundTag) ct).getListTag("blubb").getTypeClass()); + + assertThrowsException(() -> new TextNbtParser("{abc: def").parse(), ParseException.class); + assertThrowsException(() -> new TextNbtParser("{\"\":empty}").parse(), ParseException.class); + assertThrowsException(() -> new TextNbtParser("{empty:}").parse(), ParseException.class); + } + + + public void testReadNamedTag() { + // name followed by number + // literal name + NamedTag namedTag = assertThrowsNoException(() -> new TextNbtParser("my-value:16b").readTag(99)); + assertEquals("my-value", namedTag.getName()); + assertEquals(new ByteTag((byte) 16), namedTag.getTag()); + + // numbers can be names + namedTag = assertThrowsNoException(() -> new TextNbtParser("10: ten").readTag(99)); + assertEquals("10", namedTag.getName()); + assertEquals("ten", ((StringTag) namedTag.getTag()).getValue()); + + // double literals can be names + namedTag = assertThrowsNoException(() -> new TextNbtParser("1.6: one-point-six").readTag(99)); + assertEquals("1.6", namedTag.getName()); + assertEquals("one-point-six", ((StringTag) namedTag.getTag()).getValue()); + + // name followed by bool + // quoted name with space + namedTag = assertThrowsNoException(() -> new TextNbtParser("\"some bool\":true").readTag(99)); + assertEquals("some bool", namedTag.getName()); + assertTrue(((ByteTag) namedTag.getTag()).asBoolean()); + + // quoted name followed by quoted string + namedTag = assertThrowsNoException(() -> new TextNbtParser("\"me key\": \"me value\"").readTag(99)); + assertEquals("me key", namedTag.getName()); + assertEquals("me value", ((StringTag) namedTag.getTag()).getValue()); + + // name followed by long array + // literal name containing a dot + // whitespace around ':' + namedTag = assertThrowsNoException(() -> new TextNbtParser("mod.params : [L; -9223372036854775808, 0, 9223372036854775807 ]").readTag(99)); + assertEquals("mod.params", namedTag.getName()); + assertEquals(LongArrayTag.class, namedTag.getTag().getClass()); + assertEquals(3, ((LongArrayTag) namedTag.getTag()).length()); + Assert.assertArrayEquals(new long[]{-9223372036854775808L, 0, 9223372036854775807L}, ((LongArrayTag) namedTag.getTag()).getValue()); + + // name followed by string array + namedTag = assertThrowsNoException(() -> new TextNbtParser("my_array: [abc, \"def\", \"123\" ]").readTag(99)); + assertEquals("my_array", namedTag.getName()); + assertEquals(ListTag.class, namedTag.getTag().getClass()); + assertEquals(StringTag.class, ((ListTag) namedTag.getTag()).getTypeClass()); + assertEquals(3, ((ListTag) namedTag.getTag()).size()); + assertEquals("abc", ((ListTag) namedTag.getTag()).asStringTagList().get(0).getValue()); + assertEquals("def", ((ListTag) namedTag.getTag()).asStringTagList().get(1).getValue()); + assertEquals("123", ((ListTag) namedTag.getTag()).asStringTagList().get(2).getValue()); + + // name followed by empty list tag + namedTag = assertThrowsNoException(() -> new TextNbtParser("my-array:[]").readTag(99)); + assertEquals("my-array", namedTag.getName()); + assertEquals(ListTag.class, namedTag.getTag().getClass()); + assertTrue(((ListTag) namedTag.getTag()).isEmpty()); + + // name followed by compound tag + NamedTag namedTagC = assertThrowsNoException(() -> new TextNbtParser("my-object: {abc: def,\"key\": 123d, blah: [L;123, 456], blubb: [123, 456]}").readTag(99)); + assertEquals(CompoundTag.class, namedTagC.getTag().getClass()); + assertEquals(4, ((CompoundTag) namedTagC.getTag()).size()); + assertEquals("def", assertThrowsNoException(() -> ((CompoundTag) namedTagC.getTag()).getString("abc"))); + assertEquals(123D, assertThrowsNoException(() -> ((CompoundTag) namedTagC.getTag()).getDouble("key"))); + Assert.assertArrayEquals(new long[]{123, 456}, assertThrowsNoException(() -> ((CompoundTag) namedTagC.getTag()).getLongArray("blah"))); + assertEquals(2, assertThrowsNoException(() -> ((CompoundTag) namedTagC.getTag()).getListTag("blubb")).size()); + assertEquals(IntTag.class, ((CompoundTag) namedTagC.getTag()).getListTag("blubb").getTypeClass()); + + // name followed by empty compound tag + namedTag = assertThrowsNoException(() -> new TextNbtParser("my-object:{}").readTag(99)); + assertEquals("my-object", namedTag.getName()); + assertEquals(CompoundTag.class, namedTag.getTag().getClass()); + assertTrue(((CompoundTag) namedTag.getTag()).isEmpty()); + + // unnamed string literal + namedTag = assertThrowsNoException(() -> new TextNbtParser("my-value.xyz").readTag(99)); + assertNull(namedTag.getName()); + assertEquals("my-value.xyz", ((StringTag) namedTag.getTag()).getValue()); + + // unnamed quoted string + namedTag = assertThrowsNoException(() -> new TextNbtParser("\"mystring\"").readTag(99)); + assertNull(namedTag.getName()); + assertEquals("mystring", ((StringTag) namedTag.getTag()).getValue()); + + // unnamed float literal + namedTag = assertThrowsNoException(() -> new TextNbtParser("42.5f").readTag(99)); + assertNull(namedTag.getName()); + assertEquals(42.5f, ((FloatTag) namedTag.getTag()).asFloat()); + + // empty quoted string name + namedTag = assertThrowsNoException(() -> new TextNbtParser("\"\":\nnothing").readTag(99)); + assertEquals("", namedTag.getName()); + assertEquals("nothing", ((StringTag) namedTag.getTag()).getValue()); + + // no value after name + assertThrowsException(() -> new TextNbtParser("oof: \n").readTag(99), ParseException.class); + } + + public void testTrueFalseStrings_shouldRemainStrings() { + NamedTag namedTag = assertThrowsNoException(() -> new TextNbtParser("\"not-a-bool\":\"true\"").readTag(99)); + assertEquals("not-a-bool", namedTag.getName()); + assertEquals(StringTag.ID, namedTag.getTag().getID()); + assertEquals("true", ((StringTag) namedTag.getTag()).getValue()); + + namedTag = assertThrowsNoException(() -> new TextNbtParser("\"not-a-bool\":\"false\"").readTag(99)); + assertEquals("not-a-bool", namedTag.getName()); + assertEquals(StringTag.ID, namedTag.getTag().getID()); + assertEquals("false", ((StringTag) namedTag.getTag()).getValue()); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/io/TextNbtWriterTest.java b/src/test/java/io/github/ensgijs/nbt/io/TextNbtWriterTest.java new file mode 100644 index 00000000..24bc1214 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/io/TextNbtWriterTest.java @@ -0,0 +1,287 @@ +package io.github.ensgijs.nbt.io; + +import io.github.ensgijs.nbt.NbtTestCase; +import io.github.ensgijs.nbt.tag.*; + +import java.util.LinkedHashMap; + +public class TextNbtWriterTest extends NbtTestCase { + + public void testToTextNbt_Tag_noPrettyPrint() { + // write number tags + + assertEquals("127b", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new ByteTag(Byte.MAX_VALUE)))); + assertEquals("-32768s", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new ShortTag(Short.MIN_VALUE)))); + assertEquals("-2147483648", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new IntTag(Integer.MIN_VALUE)))); + assertEquals("-9223372036854775808l", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new LongTag(Long.MIN_VALUE)))); + assertEquals("123.456f", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new FloatTag(123.456F)))); + assertEquals("123.456d", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new DoubleTag(123.456D)))); + + // write array tags + + assertEquals("[B;-128,0,127]", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new ByteArrayTag(new byte[]{Byte.MIN_VALUE, 0, Byte.MAX_VALUE}), false))); + assertEquals("[I;-2147483648,0,2147483647]", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new IntArrayTag(new int[]{Integer.MIN_VALUE, 0, Integer.MAX_VALUE}), false))); + assertEquals("[L;-9223372036854775808,0,9223372036854775807]", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new LongArrayTag(new long[]{Long.MIN_VALUE, 0, Long.MAX_VALUE}), false))); + + // write string tag + +// assertEquals("\"abc\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new StringTag("abc")))); + assertEquals("abc", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new StringTag("abc")))); + assertEquals("\"123\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new StringTag("123")))); + assertEquals("\"123.456\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new StringTag("123.456")))); + assertEquals("\"-123\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new StringTag("-123")))); + assertEquals("\"-1.23e14\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new StringTag("-1.23e14")))); + assertEquals("\"äöü\\\\\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new StringTag("äöü\\")))); + assertEquals("\"\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new StringTag("")))); + + // write list tag + + ListTag lt = new ListTag<>(StringTag.class); + lt.addString("blah"); + lt.addString("blubb"); + lt.addString("123"); + lt.addString(""); + assertEquals("[blah,blubb,\"123\",\"\"]", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(lt, false))); +// assertEquals("[\"blah\",\"blubb\",\"123\",\"\"]", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(lt, false))); + + // write compound tag + CompoundTag ct = new CompoundTag(); + invokeSetValue(ct, new LinkedHashMap<>()); + ct.putString("key", "value"); + ct.putByte("byte", Byte.MAX_VALUE); + ct.putByteArray("array", new byte[]{Byte.MIN_VALUE, 0, Byte.MAX_VALUE}); + ListTag clt = new ListTag<>(StringTag.class); + clt.addString("foo"); + clt.addString("bar"); + ct.put("list", clt); +// String ctExpectedUnsorted = "{key:\"value\",byte:127b,array:[B;-128,0,127],list:[\"foo\",\"bar\"]}"; +// String ctExpectedSorted = "{array:[B;-128,0,127],byte:127b,key:\"value\",list:[\"foo\",\"bar\"]}"; + String ctExpectedUnsorted = "{key:value,byte:127b,array:[B;-128,0,127],list:[foo,bar]}"; + String ctExpectedSorted = "{array:[B;-128,0,127],byte:127b,key:value,list:[foo,bar]}"; + assertEquals(ctExpectedUnsorted, assertThrowsNoException(() -> TextNbtHelpers.toTextNbtUnsorted(ct, false))); + assertEquals(ctExpectedSorted, assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(ct, false))); + + // write compound tag containing an empty string value + CompoundTag ct2 = new CompoundTag(); + ct2.put("empty", new StringTag()); + assertEquals("{empty:\"\"}", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(ct2, false))); + } + + public void testToTextNbt_Tag_prettyPrintIsDefault() { + // write compound tag + CompoundTag ct = new CompoundTag(); + invokeSetValue(ct, new LinkedHashMap<>()); + ct.putString("key", "value"); + ct.putByte("byte", Byte.MAX_VALUE); + ct.putByteArray("array", new byte[]{Byte.MIN_VALUE, 0, Byte.MAX_VALUE}); + ListTag clt = new ListTag<>(StringTag.class); + clt.addString("foo"); + clt.addString("bar"); + ct.put("list", clt); +// String ctExpectedUnsorted = +// "{\n" + +// " key: \"value\",\n" + +// " byte: 127b,\n" + +// " array: [B;\n" + +// " -128,\n" + +// " 0,\n" + +// " 127\n" + +// " ],\n" + +// " list: [\n" + +// " \"foo\",\n" + +// " \"bar\"\n" + +// " ]\n" + +// "}"; +// +// String ctExpectedSorted = +// "{\n" + +// " array: [B;\n" + +// " -128,\n" + +// " 0,\n" + +// " 127\n" + +// " ],\n" + +// " byte: 127b,\n" + +// " key: \"value\",\n" + +// " list: [\n" + +// " \"foo\",\n" + +// " \"bar\"\n" + +// " ]\n" + +// "}"; + + String ctExpectedUnsorted = + """ + { + key: value, + byte: 127b, + array: [B; + -128, + 0, + 127 + ], + list: [ + foo, + bar + ] + }"""; + + String ctExpectedSorted = + """ + { + array: [B; + -128, + 0, + 127 + ], + byte: 127b, + key: value, + list: [ + foo, + bar + ] + }"""; + assertEquals(ctExpectedUnsorted, assertThrowsNoException(() -> TextNbtHelpers.toTextNbtUnsorted(ct))); + assertEquals(ctExpectedSorted, assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(ct))); + } + + + public void testToTextNbt_NamedTag_noPrettyPrint() { + // write number tags + + assertEquals("bb: 127b", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("bb", new ByteTag(Byte.MAX_VALUE))))); + assertEquals("bb: -32768s", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("bb", new ShortTag(Short.MIN_VALUE))))); + assertEquals("bb: -2147483648", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("bb", new IntTag(Integer.MIN_VALUE))))); + assertEquals("bb: -9223372036854775808l", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("bb", new LongTag(Long.MIN_VALUE))))); + assertEquals("bb: 123.456f", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("bb", new FloatTag(123.456F))))); + assertEquals("bb: 123.456d", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("bb", new DoubleTag(123.456D))))); + + // write array tags + a space in name + + assertEquals("\"the thing\":[B;-128,0,127]", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("the thing", new ByteArrayTag(new byte[]{Byte.MIN_VALUE, 0, Byte.MAX_VALUE})), false))); + assertEquals("\"the thing\":[I;-2147483648,0,2147483647]", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("the thing", new IntArrayTag(new int[]{Integer.MIN_VALUE, 0, Integer.MAX_VALUE})), false))); + assertEquals("\"the thing\":[L;-9223372036854775808,0,9223372036854775807]", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("the thing", new LongArrayTag(new long[]{Long.MIN_VALUE, 0, Long.MAX_VALUE})), false))); + + // write string tag + dot in name + +// assertEquals("\"plugin.setting\":\"abc\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("plugin.setting", new StringTag("abc")), false))); + assertEquals("\"plugin.setting\":abc", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("plugin.setting", new StringTag("abc")), false))); + assertEquals("\"plugin.setting\":\"123\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("plugin.setting", new StringTag("123")), false))); + assertEquals("\"plugin.setting\":\"123.456\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("plugin.setting", new StringTag("123.456")), false))); + assertEquals("\"plugin.setting\":\"-123\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("plugin.setting", new StringTag("-123")), false))); + assertEquals("\"plugin.setting\":\"-1.23e14\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("plugin.setting", new StringTag("-1.23e14")), false))); + assertEquals("\"plugin.setting\":\"äöü\\\\\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("plugin.setting", new StringTag("äöü\\")), false))); + assertEquals("\"plugin.setting\":\"\"", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("plugin.setting", new StringTag("")), false))); + + // write list tag + + ListTag lt = new ListTag<>(StringTag.class); + lt.addString("blah"); + lt.addString("blubb"); + lt.addString("123"); + lt.addString(""); +// assertEquals("\"foo bar\":[\"blah\",\"blubb\",\"123\",\"\"]", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("foo bar", lt), false))); + assertEquals("\"foo bar\":[blah,blubb,\"123\",\"\"]", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("foo bar", lt), false))); + + // write compound tag + number as name + CompoundTag ct = new CompoundTag(); + invokeSetValue(ct, new LinkedHashMap<>()); + ct.putString("key", "value"); + ct.putByte("byte", Byte.MAX_VALUE); + ct.putByteArray("array", new byte[]{Byte.MIN_VALUE, 0, Byte.MAX_VALUE}); + ListTag clt = new ListTag<>(StringTag.class); + clt.addString("foo"); + clt.addString("bar"); + ct.put("list", clt); +// String ctExpectedUnsorted = "1:{key:\"value\",byte:127b,array:[B;-128,0,127],list:[\"foo\",\"bar\"]}"; +// String ctExpectedSorted = "1:{array:[B;-128,0,127],byte:127b,key:\"value\",list:[\"foo\",\"bar\"]}"; + String ctExpectedUnsorted = "1:{key:value,byte:127b,array:[B;-128,0,127],list:[foo,bar]}"; + String ctExpectedSorted = "1:{array:[B;-128,0,127],byte:127b,key:value,list:[foo,bar]}"; + assertEquals(ctExpectedUnsorted, assertThrowsNoException(() -> TextNbtHelpers.toTextNbtUnsorted(new NamedTag("1", ct), false))); + assertEquals(ctExpectedSorted, assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("1", ct), false))); + + // write compound tag containing an empty string value + float looking name + CompoundTag ct2 = new CompoundTag(); + ct2.put("empty", new StringTag()); + assertEquals("\"1.5\":{empty:\"\"}", assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("1.5", ct2), false))); + } + + public void testToTextNbt_NamedTag_prettyPrintIsDefault() { + // write compound tag + CompoundTag ct = new CompoundTag(); + invokeSetValue(ct, new LinkedHashMap<>()); + ct.putString("key", "value"); + ct.putByte("byte", Byte.MAX_VALUE); + ct.putByteArray("array", new byte[]{Byte.MIN_VALUE, 0, Byte.MAX_VALUE}); + ListTag clt = new ListTag<>(StringTag.class); + clt.addString("foo"); + clt.addString("bar"); + ct.put("list", clt); +// String ctExpectedUnsorted = +// "my-name-is: {\n" + +// " key: \"value\",\n" + +// " byte: 127b,\n" + +// " array: [B;\n" + +// " -128,\n" + +// " 0,\n" + +// " 127\n" + +// " ],\n" + +// " list: [\n" + +// " \"foo\",\n" + +// " \"bar\"\n" + +// " ]\n" + +// "}"; +// String ctExpectedSorted = +// "my-name-is: {\n" + +// " array: [B;\n" + +// " -128,\n" + +// " 0,\n" + +// " 127\n" + +// " ],\n" + +// " byte: 127b,\n" + +// " key: \"value\",\n" + +// " list: [\n" + +// " \"foo\",\n" + +// " \"bar\"\n" + +// " ]\n" + +// "}"; + + String ctExpectedUnsorted = + """ + my-name-is: { + key: value, + byte: 127b, + array: [B; + -128, + 0, + 127 + ], + list: [ + foo, + bar + ] + }"""; + String ctExpectedSorted = + """ + my-name-is: { + array: [B; + -128, + 0, + 127 + ], + byte: 127b, + key: value, + list: [ + foo, + bar + ] + }"""; + assertEquals(ctExpectedUnsorted, assertThrowsNoException(() -> TextNbtHelpers.toTextNbtUnsorted(new NamedTag("my-name-is", ct)))); + assertEquals(ctExpectedSorted, assertThrowsNoException(() -> TextNbtHelpers.toTextNbt(new NamedTag("my-name-is", ct)))); + } + + public void testTrueFalseStrings_shouldRemainStrings() { + CompoundTag ct = new CompoundTag(); + ct.putString("not-a-bool-T", "true"); + ct.putString("not-a-bool-F", "false"); + assertEquals("{not-a-bool-F:\"false\",not-a-bool-T:\"true\"}", TextNbtHelpers.toTextNbt(ct, false)); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/ChunkBaseTest.java b/src/test/java/io/github/ensgijs/nbt/mca/ChunkBaseTest.java new file mode 100644 index 00000000..b95f61bd --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/ChunkBaseTest.java @@ -0,0 +1,139 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.NbtTestCase; +import io.github.ensgijs.nbt.util.Mutable; + +import java.util.function.Consumer; + +// TODO: test non-CompoundTag ctors and getHandle() / updateHandle() +/** + * All implementors of {@link ChunkBaseTest} should create a test which inherits this one and add + * tests to cover any additional functionality added by that concretion. + */ +public abstract class ChunkBaseTest extends NbtTestCase { + protected abstract T createChunk(DataVersion dataVersion); + protected abstract T createChunk(CompoundTag tag); + protected abstract T createChunk(CompoundTag tag, long loadFlags); + + /** + * helper equivalent to {@code createChunk(createTag(dataVersion.id()))} + */ + final protected T createFilledChunk(int chunkX, int chunkZ, DataVersion dataVersion) { + return createChunk(createTag(dataVersion.id(), chunkX, chunkZ)); + } + + public void testChunkBase_defaultConstructor() { + T chunk = createChunk(DataVersion.latest()); + int now = (int)(System.currentTimeMillis() / 1000); + assertEquals(DataVersion.latest().id(), chunk.getDataVersion()); + assertEquals(DataVersion.latest(), chunk.getDataVersionEnum()); + assertTrue(Math.abs(now - chunk.getLastMCAUpdate()) <= 1); + } + + public void testLastMcaUpdated() { + T chunk = createChunk(DataVersion.latest()); + chunk.setLastMCAUpdate(1747522); + assertEquals(1747522, chunk.getLastMCAUpdate()); + } + + public void testDataVersion() { + T chunk = createChunk(createTag(DataVersion.JAVA_1_16_0.id(), 0, 0)); + assertEquals(DataVersion.JAVA_1_16_0.id(), chunk.getDataVersion()); + assertEquals(DataVersion.JAVA_1_16_0, chunk.getDataVersionEnum()); + chunk.setDataVersion(DataVersion.JAVA_1_16_1.id()); + assertEquals(DataVersion.JAVA_1_16_1.id(), chunk.getDataVersion()); + assertEquals(DataVersion.JAVA_1_16_1, chunk.getDataVersionEnum()); + chunk.setDataVersion(DataVersion.JAVA_1_16_2); + assertEquals(DataVersion.JAVA_1_16_2.id(), chunk.getDataVersion()); + assertEquals(DataVersion.JAVA_1_16_2, chunk.getDataVersionEnum()); + } + + /** + * Must be overridden to support chunkX chunkZ because different chunks store this information differently + * (or not at all). + * @param dataVersion set as "DataVersion" in returned tag IFF GT 0 + */ + protected CompoundTag createTag(int dataVersion, int chunkX, int chunkZ) { + CompoundTag tag = new CompoundTag(); + if (dataVersion > 0) tag.putInt("DataVersion", dataVersion); + return tag; + } + + /** + * Override {@link #createTag} and populate it with interesting stuff then validate that stuff + * is properly loaded when the chunk is instantiated with {@link LoadFlags#LOAD_ALL_DATA} + */ + protected abstract void validateAllDataConstructor(T chunk, int expectedChunkX, int expectedChunkZ); + + protected void validateTagRequired(DataVersion dataVersion, String tagName) { + validateTagRequired(dataVersion, tag -> tag.remove(tagName)); + } + + protected void validateTagRequired(DataVersion dataVersion, Consumer tagRemover) { + assertThrowsException(() -> { + CompoundTag tag = createTag(dataVersion.id(), 0, 0); + assertNotNull(tag); + tagRemover.accept(tag); + createChunk(tag, LoadFlags.LOAD_ALL_DATA); + }, IllegalArgumentException.class); + } + + /** + * @return the resulting chunk + */ + protected T validateTagNotRequired(DataVersion dataVersion, String tagName) { + return validateTagNotRequired(dataVersion, tag -> tag.remove(tagName)); + } + + /** + * @return the resulting chunk + */ + protected T validateTagNotRequired(DataVersion dataVersion, Consumer tagRemover) { + Mutable out = new Mutable<>(); + assertThrowsNoException(() -> { + CompoundTag tag = createTag(dataVersion.id(), 0, 0); + assertNotNull(tag); + tagRemover.accept(tag); + out.set(createChunk(tag, LoadFlags.LOAD_ALL_DATA)); + }); + return out.get(); + } + + final public void testConstructor_allData() { + CompoundTag tag = createTag(DataVersion.JAVA_1_17_1.id(), -4, 7); + assertNotNull(tag); + T chunk = createChunk(tag, LoadFlags.LOAD_ALL_DATA); + assertEquals(DataVersion.JAVA_1_17_1.id(), chunk.getDataVersion()); + assertEquals(DataVersion.JAVA_1_17_1, chunk.getDataVersionEnum()); + assertFalse(chunk.partial); + assertFalse(chunk.raw); + assertSame(tag, chunk.getHandle()); + validateAllDataConstructor(chunk, -4, 7); + } + + final public void testConstructor_raw() { + CompoundTag tag = createTag(DataVersion.JAVA_1_16_5.id(), 0, 0); + assertNotNull(tag); + T chunk = createChunk(tag, LoadFlags.RAW); + assertEquals(DataVersion.JAVA_1_16_5.id(), chunk.getDataVersion()); + assertEquals(DataVersion.JAVA_1_16_5, chunk.getDataVersionEnum()); + assertFalse(chunk.partial); + assertTrue(chunk.raw); + assertSame(tag, chunk.getHandle()); + assertThrowsException(chunk::checkRaw, UnsupportedOperationException.class); + } + + public void testConstructor_allData_throwsIfDataVersionNotFound() { + CompoundTag tag = createTag(-1, 0, 0); + assertNotNull(tag); + assertThrowsException(() -> createChunk(tag, LoadFlags.LOAD_ALL_DATA), IllegalArgumentException.class); + } + + public void testConstructor_raw_noThrowIfDataVersionNotFound() { + CompoundTag tag = createTag(-1, 0, 0); + assertNotNull(tag); + assertThrowsNoException(() -> createChunk(tag, LoadFlags.RAW)); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/DataVersionTest.java b/src/test/java/io/github/ensgijs/nbt/mca/DataVersionTest.java new file mode 100644 index 00000000..2dbe4abe --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/DataVersionTest.java @@ -0,0 +1,261 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.io.NamedTag; +import io.github.ensgijs.nbt.io.TextNbtDeserializer; +import io.github.ensgijs.nbt.io.TextNbtHelpers; +import io.github.ensgijs.nbt.io.TextNbtParser; +import io.github.ensgijs.nbt.query.NbtPath; +import io.github.ensgijs.nbt.tag.CompoundTag; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URL; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.ReadableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public class DataVersionTest extends McaTestCase { + private static final Pattern ALLOWED_ENUM_DESCRIPTION_PATTERN = Pattern.compile("^(?:FINAL|\\d{2}w\\d{2}[a-z]|CT\\d+[a-z]?|(?:XS|PRE|RC)\\d+|)"); + + public void testEnumNamesMatchVersionInformation() { + for (DataVersion dv : DataVersion.values()) { + if (dv.id() != 0) { + StringBuilder sb = new StringBuilder("JAVA_1_"); + sb.append(dv.minor()).append('_'); + if (dv.isFullRelease()) { + sb.append(dv.patch()); + } else { + if (dv.patch() > 0) sb.append(dv.patch()).append('_'); + sb.append(dv.getBuildDescription().toUpperCase()); + } + assertEquals(sb.toString(), dv.name()); + assertTrue("Build description of " + dv.name() + " does not follow convention!", + ALLOWED_ENUM_DESCRIPTION_PATTERN.matcher(dv.getBuildDescription()).matches()); + } + } + } + + public void testEnumDataVersionCollisions() { + Set seen = new HashSet<>(); + for (DataVersion dv : DataVersion.values()) { + if (dv.id() != 0) { + assertTrue("duplicate data version " + dv.id(), seen.add(dv.id())); + } + } + } + + public void testEnumDataVersionsIncreasingOrder() { + int last = 0; + for (DataVersion dv : DataVersion.values()) { + if (dv.id() != 0) { + assertTrue(dv.toString() + " is out of order", dv.id() >= last); + last = dv.id(); + } + } + } + + 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_9_15W32A, DataVersion.bestFor(150)); + } + + 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()); + assertEquals("2864 (1.18.1 RC3)", DataVersion.JAVA_1_18_1_RC3.toString()); + } + + public void testIsCrossedByTransition() { + assertFalse(DataVersion.JAVA_1_15_19W36A.isCrossedByTransition(DataVersion.JAVA_1_15_19W36A.id(), DataVersion.JAVA_1_15_19W36A.id())); + assertFalse(DataVersion.JAVA_1_15_19W36A.isCrossedByTransition(DataVersion.JAVA_1_15_0.id(), DataVersion.JAVA_1_15_1.id())); + assertFalse(DataVersion.JAVA_1_15_19W36A.isCrossedByTransition(DataVersion.JAVA_1_14_3.id(), DataVersion.JAVA_1_14_4.id())); + + assertFalse(DataVersion.JAVA_1_15_19W36A.isCrossedByTransition(DataVersion.JAVA_1_15_19W36A.id(), DataVersion.JAVA_1_15_19W36A.id() + 1)); + assertFalse(DataVersion.JAVA_1_15_19W36A.isCrossedByTransition(DataVersion.JAVA_1_15_19W36A.id() + 1, DataVersion.JAVA_1_15_19W36A.id())); + + assertTrue(DataVersion.JAVA_1_15_19W36A.isCrossedByTransition(DataVersion.JAVA_1_15_19W36A.id() - 1, DataVersion.JAVA_1_15_19W36A.id())); + assertTrue(DataVersion.JAVA_1_15_19W36A.isCrossedByTransition(DataVersion.JAVA_1_15_19W36A.id(), DataVersion.JAVA_1_15_19W36A.id() - 1)); + + assertTrue(DataVersion.JAVA_1_15_19W36A.isCrossedByTransition(DataVersion.JAVA_1_15_19W36A.id() - 1, DataVersion.JAVA_1_15_19W36A.id() + 1)); + assertTrue(DataVersion.JAVA_1_15_19W36A.isCrossedByTransition(DataVersion.JAVA_1_15_19W36A.id() + 1, DataVersion.JAVA_1_15_19W36A.id() - 1)); + + assertTrue(DataVersion.JAVA_1_15_19W36A.isCrossedByTransition(DataVersion.JAVA_1_14_4.id(), DataVersion.JAVA_1_16_0.id())); + assertTrue(DataVersion.JAVA_1_15_19W36A.isCrossedByTransition(DataVersion.JAVA_1_16_0.id(), DataVersion.JAVA_1_14_4.id())); + } + + public void testThrowUnsupportedVersionChangeIfCrossed() { + assertThrowsException(() -> DataVersion.JAVA_1_15_19W36A.throwUnsupportedVersionChangeIfCrossed(DataVersion.JAVA_1_14_4.id(), DataVersion.JAVA_1_16_0.id()), + UnsupportedVersionChangeException.class); + assertThrowsNoException(() -> DataVersion.JAVA_1_15_19W36A.throwUnsupportedVersionChangeIfCrossed(DataVersion.JAVA_1_15_19W36A.id(), DataVersion.JAVA_1_15_19W36A.id())); + } + + public void testPrevious() { + assertSame(DataVersion.JAVA_1_9_1_PRE2, DataVersion.JAVA_1_9_1_PRE3.previous()); + assertNull(DataVersion.values()[0].previous()); + } + + public void testNext() { + assertSame(DataVersion.JAVA_1_9_1, DataVersion.JAVA_1_9_1_PRE3.next()); + assertNull(DataVersion.values()[DataVersion.values().length - 1].next()); + } + + // Note that while this test takes a lot of the work out of keeping DataVersions updated it + // is limited by what Mojang puts into the version manifest. Some versions, it appears, don't + // make it into the manifest such as the combat test builds and other experimental builds. + public void testFetchMissingDataVersionInformation() throws IOException { + Path minecraftVersionsDirectory = Paths.get(System.getenv("APPDATA"), ".minecraft", "versions"); + if (!minecraftVersionsDirectory.toFile().exists()) { + // probably not on Windows + return; + } + // 1: weekly + // 2: minor + // 3: patch? + // 4: descriptor? (pre#, rc#, etc) + final Pattern vanillaVersionPattern = Pattern.compile("^(?:(\\d{2}w\\d{2}[a-z])|1[.](\\d+)(?:[.](\\d+))?(?:-(.+))?)$"); + final var isSaneVersionName = vanillaVersionPattern.asPredicate(); + final String mcVerRootStr = minecraftVersionsDirectory.toFile().getAbsolutePath(); + + // phase 1 - scan version manifest, download version json files for unknown versions + CompoundTag versionManifest = TextNbtParser.parseInline(Files.readString(Paths.get(minecraftVersionsDirectory.toString(), "version_manifest_v2.json"))); +// System.out.println(TextNbtHelpers.toTextNbt(versionManifest, true, false)); + for (CompoundTag versionTag : versionManifest.getCompoundList("versions")) { + String version = versionTag.getString("id"); + if ("18w47b".equals(version)) { // the version.json file was added to the client/server jars here + break; // 1.9 is about as far back as the data version concept exists + } + if (DataVersion.find(version) != null || !isSaneVersionName.test(version)) + continue; // we already know about this version - or it's some crazy thing like "1.RV-Pre1" + + File versionFolder = Paths.get(mcVerRootStr, version).toFile(); + if (!versionFolder.exists()) { + versionFolder.mkdirs(); + URL url = new URL(versionTag.getString("url")); + System.out.println("Downloading " + version + ".json from " + url); + downloadFile(url, Paths.get(mcVerRootStr, version, version + ".json").toFile()); + } + } + + // phase 2 - download client jars, extract version.json info, build missing DataVersion enums. + record NewDataVersion(int dataVersion, String enumDef) {} + List newDataVersionDefs = new ArrayList<>(); + for (String version : minecraftVersionsDirectory.toFile().list()) { + Matcher m = vanillaVersionPattern.matcher(version); + if (!m.matches()) + continue; + if (Paths.get(mcVerRootStr, version).toFile().isDirectory()) { + DataVersion dv = DataVersion.find(version); + if (dv == null) { + File jarFile = Paths.get(mcVerRootStr, version, version + ".jar").toFile(); + if (!jarFile.exists()) { + URL url = getClientJarUrl(Paths.get(mcVerRootStr, version, version + ".json")); + System.out.println("Downloading " + version + ".jar from " + url); + downloadFile(url, Paths.get(mcVerRootStr, version, version + ".jar").toFile()); + } + ZipFile zip = new ZipFile(jarFile); + ZipEntry ze = zip.getEntry("version.json"); + if (ze == null) { + System.err.println("Didn't find version.json file in " + jarFile.toPath()); + continue; + } + NamedTag versionInfo = new TextNbtDeserializer().fromStream(zip.getInputStream(ze)); + int dataVersion = ((CompoundTag) versionInfo.getTag()).getInt("world_version"); + StringBuilder sb = new StringBuilder("JAVA_1_"); + StringBuilder sbArgs = new StringBuilder("(").append(dataVersion); + String comment = ""; + + if (m.group(1) != null) { // weekly + DataVersion nearest = DataVersion.bestFor(dataVersion); + if (nearest != null) { + sb.append(nearest.minor()).append('_').append(nearest.patch()); + sbArgs.append(", ").append(nearest.minor()).append(", ").append(nearest.patch()); + comment += " // TODO: verify minor and patch versions are correct"; + } else { + sb.append("?_?"); + comment += " // TODO: determine minor and patch versions"; + } + sb.append('_').append(m.group(1).toUpperCase()); + sbArgs.append(", ").append('"').append(m.group(1)).append('"'); + } else { + sb.append(m.group(2)); + sbArgs.append(", ").append(m.group(2)); + sb.append('_'); + if (m.group(3) != null) { + sb.append(m.group(3)); + sbArgs.append(", ").append(m.group(3)); + } else { + sb.append(0); + sbArgs.append(", ").append(0); + } + if (m.group(4) != null) { // RC, PRE, etc + sb.append('_').append(m.group(4).toUpperCase()); + sbArgs.append(", ").append('"').append(m.group(4).toUpperCase()).append('"'); + } + } + sbArgs.append("),"); + sb.append(sbArgs).append(comment); + newDataVersionDefs.add(new NewDataVersion(dataVersion, sb.toString())); + } + } + } + if (!newDataVersionDefs.isEmpty()) { + newDataVersionDefs.sort(Comparator.comparingInt(a -> a.dataVersion)); + for (var dv : newDataVersionDefs) { + System.out.println(dv.enumDef); + } + fail("Missing DataVersion's found! Please update DataVersion enums"); + } + } + + private void downloadFile(URL url, File saveToFile) throws IOException { + ReadableByteChannel readableByteChannel = Channels.newChannel(url.openStream()); + FileOutputStream fileOutputStream = new FileOutputStream(saveToFile); + FileChannel fileChannel = fileOutputStream.getChannel(); + fileChannel.transferFrom(readableByteChannel, 0, /* 100MB max */ 100 * (long) Math.pow(2, 20)); + } + + private URL getClientJarUrl(Path versionJsonPath) throws IOException { + String blob = Files.readString(versionJsonPath); + int start = blob.indexOf('{', blob.indexOf("\"downloads\"")); + int brackets = 1; + int end; + for (end = start + 1; end < blob.length() && brackets > 0; end++) { + char c = blob.charAt(end); + if (c == '{') brackets ++; + if (c == '}') brackets --; + } + return new URL(NbtPath.of("client.url").getString(TextNbtParser.parseInline(blob.substring(start, end)))); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/EntitiesChunkBaseTest.java b/src/test/java/io/github/ensgijs/nbt/mca/EntitiesChunkBaseTest.java new file mode 100644 index 00000000..5c6b1648 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/EntitiesChunkBaseTest.java @@ -0,0 +1,265 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.io.TextNbtHelpers; +import io.github.ensgijs.nbt.mca.entities.Entity; +import io.github.ensgijs.nbt.mca.entities.EntityBase; +import io.github.ensgijs.nbt.mca.entities.EntityFactory; +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.mca.io.MoveChunkFlags; +import io.github.ensgijs.nbt.mca.util.ChunkBoundingRectangle; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.ListTag; +import org.junit.Assert; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public abstract class EntitiesChunkBaseTest> extends ChunkBaseTest { + + @Override + protected CompoundTag createTag(int dataVersion, int chunkX, int chunkZ) { + CompoundTag tag = super.createTag(dataVersion, chunkX, chunkZ); + ListTag entitiesTagList; + if (dataVersion >= DataVersion.JAVA_1_17_20W45A.id()) { + tag.putIntArray("Position", new int[]{chunkX, chunkZ}); + tag.put("Entities", entitiesTagList = new ListTag<>(CompoundTag.class)); + } else { + CompoundTag level = new CompoundTag(); + tag.put("Level", level); + level.putInt("xPos", chunkX); + level.putInt("zPos", chunkZ); + level.put("Entities", entitiesTagList = new ListTag<>(CompoundTag.class)); + } + + if (dataVersion > 0) { + // Depend on the efficacy of the EntityBase tests to make life easier here + ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(chunkX, chunkZ); + entitiesTagList.add(new EntityBase(dataVersion, "minecraft:zombie", + cbr.relocateX(5), 68, cbr.relocateZ(7), 42, -5).updateHandle()); + entitiesTagList.add(new EntityBase(dataVersion, "minecraft:skeleton", + cbr.relocateX(14), 64, cbr.relocateZ(2), 297, 2.54f).updateHandle()); + } + return tag; + } + + protected abstract ET createEntity(int dataVersion, String id, double x, double y, double z, float yaw, float pitch); + + @Override + protected void validateAllDataConstructor(T chunk, int expectedChunkX, int expectedChunkZ) { + assertEquals(expectedChunkX, chunk.getChunkX()); + assertEquals(expectedChunkZ, chunk.getChunkZ()); + assertNotNull(chunk.getEntitiesTag()); + ListTag entitiesTagList = chunk.getEntitiesTag(); + List entities = chunk.getEntities(); + assertEquals(2, entitiesTagList.size()); + assertEquals(2, entities.size()); + assertEquals(entitiesTagList.get(0).getString("id"), entities.get(0).getId()); + assertEquals(entitiesTagList.get(1).getString("id"), entities.get(1).getId()); + assertTrue(chunk.moveChunkImplemented()); + assertTrue(chunk.moveChunkHasFullVersionSupport()); + } + + public void testInitReferences_throwsWhenMissingEntitiesTag_MC_GE_1_17() { + CompoundTag tag = createTag(DataVersion.JAVA_1_17_20W45A.id(), 1, 7); + assertThrowsNoException(() -> new EntitiesChunk(tag)); + tag.remove("Entities"); + assertThrowsIllegalArgumentException(() -> new EntitiesChunk(tag)); + } + + public void testInitReferences_throwsWhenMissingPositionTag_MC_GE_1_17() { + CompoundTag tag = createTag(DataVersion.JAVA_1_17_20W45A.id(), 1, 7); + assertThrowsNoException(() -> new EntitiesChunk(tag)); + tag.remove("Position"); + assertThrowsIllegalArgumentException(() -> new EntitiesChunk(tag)); + } + + public void testInitReferences_throwsWhenReadingPre_MC_1_17() { + CompoundTag tag = createTag(DataVersion.JAVA_1_16_5.id(), 1, 7); + assertThrowsUnsupportedOperationException(() -> new EntitiesChunk(tag)); + } + + public void testInitEntities_throwsWhenEntitiesTagIsNull() { + T chunk = createFilledChunk(2, 8, DataVersion.JAVA_1_17_1); + chunk.entitiesTag = null; + assertThrowsException(chunk::initEntities, IllegalStateException.class); + } + + public void testRawLoad() throws IOException { + final int dataVersion = DataVersion.JAVA_1_17_1.id(); + CompoundTag mutableTag = createTag(dataVersion, -4, 2); + CompoundTag originalTag = mutableTag.clone(); + T chunk = createChunk(mutableTag, LoadFlags.RAW); + assertEquals(ChunkBase.NO_CHUNK_COORD_SENTINEL, chunk.getChunkX()); + assertEquals(ChunkBase.NO_CHUNK_COORD_SENTINEL, chunk.getChunkZ()); + assertThrowsUnsupportedOperationException(chunk::getEntities); + assertThrowsUnsupportedOperationException(chunk::getEntitiesTag); + assertThrowsUnsupportedOperationException(chunk::clearEntities); + assertThrowsException(() -> chunk.fixEntityLocations(0), IllegalStateException.class); + assertThrowsUnsupportedOperationException(chunk::iterator); + assertThrowsUnsupportedOperationException(chunk::spliterator); + assertThrowsUnsupportedOperationException(chunk::stream); + assertThrowsUnsupportedOperationException(() -> chunk.forEach((c) -> {throw new RuntimeException();})); + + assertEquals(originalTag, chunk.updateHandle(5, 6)); + assertEquals(ChunkBase.NO_CHUNK_COORD_SENTINEL, chunk.getChunkX()); + assertEquals(ChunkBase.NO_CHUNK_COORD_SENTINEL, chunk.getChunkZ()); + + // moving is supported in raw if we have an entities tag + assertTrue(chunk.moveChunkImplemented()); + assertTrue(chunk.moveChunkHasFullVersionSupport()); + assertThrowsNoException(() -> chunk.moveChunk(1, -2, MoveChunkFlags.MOVE_CHUNK_DEFAULT_FLAGS, true)); + assertEquals(1, chunk.getChunkX()); + assertEquals(-2, chunk.getChunkZ()); + + List entities = new ArrayList<>(); + ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(-4, 2); + entities.add(createEntity(dataVersion, "minecraft:sheep", cbr.relocateX(12), 73, cbr.relocateZ(2.3), 12, 0)); + entities.add(createEntity(dataVersion, "minecraft:pig", cbr.relocateX(9.87), 71, cbr.relocateZ(7.998), 220, 20)); + entities.add(createEntity(dataVersion, "minecraft:pig", cbr.relocateX(7.76), 71, cbr.relocateZ(6.123), 77, 0)); + assertThrowsUnsupportedOperationException(() -> chunk.setEntities(entities)); + + // you can set an entities tag, but it doesn't make getting the tag or wrapped entities valid + ListTag newEntitiesTag = EntityFactory.toListTag(entities); + assertThrowsNoException(() -> chunk.setEntitiesTag(newEntitiesTag)); + assertSame(newEntitiesTag, mutableTag.get("Entities")); + assertThrowsUnsupportedOperationException(chunk::getEntities); + assertThrowsUnsupportedOperationException(chunk::getEntitiesTag); + + // ... but we can now fix entity locations (because we previously moved the chunk so XZ is known) + assertTrue(chunk.fixEntityLocations(MoveChunkFlags.MOVE_CHUNK_DEFAULT_FLAGS)); + // System.out.println(TextNbtHelpers.toTextNbt(mutableTag)); + } + + public void testSetEntities() { + final int dataVersion = DataVersion.JAVA_1_17_1.id(); + T chunk = createFilledChunk(4, 2, DataVersion.JAVA_1_17_1); + List entities = new ArrayList<>(); + ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(-4, 2); + entities.add(createEntity(dataVersion, "minecraft:sheep", cbr.relocateX(12), 73, cbr.relocateZ(2.3), 12, 0)); + entities.add(createEntity(dataVersion, "minecraft:pig", cbr.relocateX(9.87), 71, cbr.relocateZ(7.998), 220, 20)); + entities.add(createEntity(dataVersion, "minecraft:pig", cbr.relocateX(7.76), 71, cbr.relocateZ(6.123), 77, 0)); + chunk.setEntities(entities); + + assertSame(entities, chunk.getEntities()); + // setting entities doesn't overwrite entities tag if one exists + assertEquals(2, chunk.getEntitiesTag().size()); + assertEquals("minecraft:zombie", chunk.getEntitiesTag().get(0).getString("id")); + + chunk.updateHandle(); + assertEquals(3, chunk.getEntitiesTag().size()); + assertEquals("minecraft:sheep", chunk.getEntitiesTag().get(0).getString("id")); + } + + public void testSetEntities_whenLoadFlagsDidNotReadEntities() { + final int dataVersion = DataVersion.JAVA_1_17_1.id(); + CompoundTag tag = createTag(dataVersion, -4, 2); + T chunk = createChunk(tag, LoadFlags.BIOMES); + assertEquals(-4, chunk.getChunkX()); + assertEquals(2, chunk.getChunkZ()); + assertNull(chunk.getEntitiesTag()); + List entities = new ArrayList<>(); + ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(-4, 2); + entities.add(createEntity(dataVersion, "minecraft:sheep", cbr.relocateX(12), 73, cbr.relocateZ(2.3), 12, 0)); + entities.add(createEntity(dataVersion, "minecraft:pig", cbr.relocateX(9.87), 71, cbr.relocateZ(7.998), 220, 20)); + entities.add(createEntity(dataVersion, "minecraft:pig", cbr.relocateX(7.76), 71, cbr.relocateZ(6.123), 77, 0)); + chunk.setEntities(entities); + + assertSame(entities, chunk.getEntities()); + assertNull(chunk.getEntitiesTag()); + } + + public void testAreWrappedEntitiesGenerated() { + T chunk = createFilledChunk(1, 0, DataVersion.latest()); + assertFalse(chunk.areWrappedEntitiesGenerated()); + chunk.getEntities(); // trigger lazy initialization + assertTrue(chunk.areWrappedEntitiesGenerated()); + chunk.clearEntities(); + } + + public void testClearEntities() { + T chunk = createFilledChunk(1, 0, DataVersion.latest()); + ListTag entitiesTag = chunk.getEntitiesTag(); + assertNotNull(entitiesTag); + assertFalse(entitiesTag.isEmpty()); + assertFalse(chunk.getEntities().isEmpty()); // trigger lazy initialization too + chunk.clearEntities(); + assertFalse(chunk.areWrappedEntitiesGenerated()); // wrapped entities were nuked + assertNotSame(entitiesTag, chunk.getEntitiesTag()); // reference changed + assertFalse(entitiesTag.isEmpty()); // held ref didn't get cleared + assertTrue(chunk.getEntities().isEmpty()); + assertTrue(chunk.areWrappedEntitiesGenerated()); + } + + public void testSetEntitiesTagInternal_MC_GE_1_17_20W45A() { + // normal case - chunk data fully loadedv + final ListTag newEntitiesTag = new ListTag<>(CompoundTag.class); + T chunk = createFilledChunk(1, 0, DataVersion.JAVA_1_17_20W45A); + assertFalse(chunk.areWrappedEntitiesGenerated()); + chunk.getEntities(); // trigger lazy initialization + assertTrue(chunk.areWrappedEntitiesGenerated()); + chunk.setEntitiesTagInternal(newEntitiesTag); + assertFalse(chunk.areWrappedEntitiesGenerated()); // wrapped entities nuked + assertSame(newEntitiesTag, chunk.getEntitiesTag()); // reference changed + assertSame(newEntitiesTag, chunk.updateHandle().getListTag("Entities")); // given ref now put into handle tag + } + + public void testSetEntitiesTag_valueCannotBeNull() { + T chunk = createFilledChunk(1, 0, DataVersion.JAVA_1_17_20W45A); + assertThrowsIllegalArgumentException(() -> chunk.setEntitiesTag(null)); + } + + public void testMoveChunk() { + // white box testing - let fixEntityLocations tests cover actual entity relocation validation + T chunk = createFilledChunk(1, 0, DataVersion.JAVA_1_17_1); + chunk.moveChunk(-2, 3, MoveChunkFlags.MOVE_CHUNK_DEFAULT_FLAGS); + assertEquals(-2, chunk.getChunkX()); + assertEquals(3, chunk.getChunkZ()); + } + + public void testMoveChunk_doublePassengers_1_20_4() throws IOException { + validateMoveChunk_doublePassengers_1_20_4(LoadFlags.LOAD_ALL_DATA); + validateMoveChunk_doublePassengers_1_20_4(LoadFlags.RAW); + } + private void validateMoveChunk_doublePassengers_1_20_4(long loadFlags) throws IOException { + EntitiesChunk chunk = new EntitiesChunk( + TextNbtHelpers.readTextNbtFile(getResourceFile("1_20_4/entities/double_passengers.snbt")).getTagAutoCast(), + loadFlags); + assertTrue(chunk.moveChunk(10, 10, MoveChunkFlags.AUTOMATICALLY_UPDATE_HANDLE)); +// System.out.println("WROTE " + McaDumper.dumpChunkAsTextNbtAutoFilename(chunk, Paths.get("TESTDBG")).toAbsolutePath()); + CompoundTag expectedTag = TextNbtHelpers.readTextNbtFile(getResourceFile("1_20_4/entities/double_passengers_moveto_10.10_expected.snbt")).getTagAutoCast(); + assertEquals(expectedTag, chunk.getHandle()); + } + + public void testFixEntityLocations() { + T chunk = createFilledChunk(0, 0, DataVersion.latest()); + // white-box: behavior is different if wrapped entities are generated or not, start with not generated + assertFalse(chunk.getEntitiesTag().isEmpty()); + + final double[] initialPos0 = chunk.getEntitiesTag().get(0).getDoubleTagListAsArray("Pos"); + final double[] initialPos1 = chunk.getEntitiesTag().get(1).getDoubleTagListAsArray("Pos"); + assertFalse(chunk.fixEntityLocations(MoveChunkFlags.MOVE_CHUNK_DEFAULT_FLAGS)); + Assert.assertArrayEquals(initialPos0, chunk.getEntitiesTag().get(0).getDoubleTagListAsArray("Pos"), 1e-6); + Assert.assertArrayEquals(initialPos1, chunk.getEntitiesTag().get(1).getDoubleTagListAsArray("Pos"), 1e-6); + + chunk.getEntitiesTag().get(0).putDoubleArrayAsTagList("Pos", initialPos0[0] + 16, initialPos0[1], initialPos0[2]); + chunk.getEntitiesTag().get(1).putDoubleArrayAsTagList("Pos", initialPos1[0], initialPos1[1], initialPos1[2] - 32); + assertTrue(chunk.fixEntityLocations(MoveChunkFlags.MOVE_CHUNK_DEFAULT_FLAGS)); + Assert.assertArrayEquals(initialPos0, chunk.getEntitiesTag().get(0).getDoubleTagListAsArray("Pos"), 1e-6); + Assert.assertArrayEquals(initialPos1, chunk.getEntitiesTag().get(1).getDoubleTagListAsArray("Pos"), 1e-6); + + // now test with wrapped entities + List entities = chunk.getEntities(); + entities.get(0).setX(initialPos0[0] - 64); + entities.get(1).setZ(initialPos1[2] + 128); + assertTrue(chunk.fixEntityLocations(MoveChunkFlags.MOVE_CHUNK_DEFAULT_FLAGS)); + Assert.assertArrayEquals(initialPos0, new double[]{entities.get(0).getX(), entities.get(0).getY(), entities.get(0).getZ()}, 1e-6); + Assert.assertArrayEquals(initialPos1, new double[]{entities.get(1).getX(), entities.get(1).getY(), entities.get(1).getZ()}, 1e-6); + } + +// public void testSetEntitiesTag_valueCannotBeNull() { +// CompoundTag tag = createTag(1, 0, DataVersion.JAVA_1_17_1.id()); +// T chunk = createChunk(tag, LoadFlags.RAW); +// assertThrowsIllegalArgumentException(() -> chunk.setEntitiesTag(null)); +// } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/EntitiesChunkTest.java b/src/test/java/io/github/ensgijs/nbt/mca/EntitiesChunkTest.java new file mode 100644 index 00000000..879db41d --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/EntitiesChunkTest.java @@ -0,0 +1,48 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.mca.entities.Entity; +import io.github.ensgijs.nbt.mca.entities.EntityBase; +import io.github.ensgijs.nbt.tag.CompoundTag; + +public class EntitiesChunkTest extends EntitiesChunkBaseTest { + + @Override + protected EntitiesChunk createChunk(DataVersion dataVersion) { + return new EntitiesChunk(dataVersion.id()); + } + + @Override + protected EntitiesChunk createChunk(CompoundTag tag) { + return new EntitiesChunk(tag); + } + + @Override + protected EntitiesChunk createChunk(CompoundTag tag, long loadFlags) { + return new EntitiesChunk(tag, loadFlags); + } + + @Override + protected Entity createEntity(int dataVersion, String id, double x, double y, double z, float yaw, float pitch) { + return new EntityBase(dataVersion, id, x, y, z, yaw, pitch); + } + + @Override + public void testDataVersion() { + EntitiesChunk chunk = createChunk(createTag(DataVersion.JAVA_1_17_0.id(), 0, 0)); + assertEquals(DataVersion.JAVA_1_17_0.id(), chunk.getDataVersion()); + assertEquals(DataVersion.JAVA_1_17_0, chunk.getDataVersionEnum()); + chunk.setDataVersion(DataVersion.JAVA_1_17_1.id()); + assertEquals(DataVersion.JAVA_1_17_1.id(), chunk.getDataVersion()); + assertEquals(DataVersion.JAVA_1_17_1, chunk.getDataVersionEnum()); + } + + public void testSetDataVersion_cannotCross_JAVA_1_17_20W45A() { + EntitiesChunk chunk = createChunk(DataVersion.JAVA_1_16_5); + assertThrowsException(() -> chunk.setDataVersion(DataVersion.JAVA_1_17_20W45A.id()), UnsupportedVersionChangeException.class); + assertThrowsNoException(() -> chunk.setDataVersion(DataVersion.JAVA_1_16_0.id())); + + EntitiesChunk chunk2 = createChunk(DataVersion.JAVA_1_17_20W45A); + assertThrowsNoException(() -> chunk2.setDataVersion(DataVersion.JAVA_1_17_0.id())); + } +} + diff --git a/src/test/java/io/github/ensgijs/nbt/mca/McaEntitiesFileTest.java b/src/test/java/io/github/ensgijs/nbt/mca/McaEntitiesFileTest.java new file mode 100644 index 00000000..71350094 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/McaEntitiesFileTest.java @@ -0,0 +1,71 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.mca.entities.Entity; +import io.github.ensgijs.nbt.mca.io.McaFileHelpers; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public class McaEntitiesFileTest extends McaFileBaseTest { + + public void testReadEntities_1_17_1() throws IOException { + McaEntitiesFile mca = McaFileHelpers.readAuto(copyResourceToTmp("1_17_1/entities/r.-3.-2.mca")); + assertNotNull(mca); + EntitiesChunk chunk = mca.stream().filter(Objects::nonNull).findFirst().orElse(null); + assertNotNull(chunk); + List entities = chunk.getEntities(); + assertNotNull(entities); + assertFalse(entities.isEmpty()); + + // mca specific checks (will need to be changed if mca file changes in meaningful ways) + assertEquals(1, entities.size()); + Map> entitiesByType = chunk.stream().collect(Collectors.groupingBy(Entity::getId)); + assertEquals(1, entitiesByType.size()); + assertTrue(entitiesByType.containsKey("minecraft:villager")); + assertEquals(1, entitiesByType.get("minecraft:villager").size()); + } + + public void testReadEntities_1_18_PRE1() throws IOException { + McaEntitiesFile mca = McaFileHelpers.readAuto(copyResourceToTmp("1_18_PRE1/entities/r.-2.-3.mca")); + assertNotNull(mca); + EntitiesChunk chunk = mca.stream().filter(Objects::nonNull).findFirst().orElse(null); + assertNotNull(chunk); + List entities = chunk.getEntities(); + assertNotNull(entities); + assertFalse(entities.isEmpty()); + + // mca specific checks (will need to be changed if mca file changes in meaningful ways) + assertEquals(7, entities.size()); + Map> entitiesByType = chunk.stream().collect(Collectors.groupingBy(Entity::getId)); + assertEquals(3, entitiesByType.size()); + assertTrue(entitiesByType.containsKey("minecraft:villager")); + assertEquals(4, entitiesByType.get("minecraft:villager").size()); + assertTrue(entitiesByType.containsKey("minecraft:chicken")); + assertEquals(2, entitiesByType.get("minecraft:chicken").size()); + assertTrue(entitiesByType.containsKey("minecraft:cat")); + assertEquals(1, entitiesByType.get("minecraft:cat").size()); + } + + // TODO: make this a real test +// public void testMoveChunk_1_20_4() throws IOException { +// String mcaResourcePath = "1_20_4/entities/r.-3.-3.mca"; +// McaEntitiesFile mca = assertThrowsNoException(() -> McaFileHelpers.readAuto(getResourceFile(mcaResourcePath))); // , LoadFlags.RAW +// assertNotNull(mca); +// +// assertTrue(mca.moveRegion(0, 0, false)); +// +// String newMcaName = mca.createRegionName(); +// assertEquals("r.0.0.mca", newMcaName); +// int i = 0; +// for (EntitiesChunk chunk : mca) { +// if (chunk != null) { +// chunk.updateHandle(); +// TextNbtHelpers.writeTextNbtFile(Paths.get("TESTDBG", mcaResourcePath + ".MOVEDTO." + newMcaName + ".i" + String.format("%04d", i) + "." + chunk.getChunkXZ().toString("x%dz%d") + ".original.snbt"), chunk.data, /*pretty print*/ true, /*sorted*/ true); +// } +// i++; +// } +// } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/McaFileBaseTest.java b/src/test/java/io/github/ensgijs/nbt/mca/McaFileBaseTest.java new file mode 100644 index 00000000..a5ede3a9 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/McaFileBaseTest.java @@ -0,0 +1,31 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.mca.util.IntPointXZ; + +// TODO: implement abstract test pattern for McaFileBase & refactor MCAFileTest like mad +public class McaFileBaseTest extends McaTestCase { + public void testGetRelativeChunkXZ() { + // few enough iterations to just be lazy and do an exhaustive test + for (int i = 0; i < 1024; i++) { + IntPointXZ xz = McaFileBase.getRelativeChunkXZ(i); + assertEquals(i, McaFileBase.getChunkIndex(xz.getX(), xz.getZ())); + } + assertThrowsException(() -> McaFileBase.getRelativeChunkXZ(-1), IndexOutOfBoundsException.class); + assertThrowsException(() -> McaFileBase.getRelativeChunkXZ(1024), IndexOutOfBoundsException.class); + } + + public void testGetChunkIndex() { + assertEquals(0, McaFileBase.getChunkIndex(0, 0)); + assertEquals(0, McaFileBase.getChunkIndex(512, 512)); + assertEquals(0, McaFileBase.getChunkIndex(-512, -512)); + + assertEquals(1023, McaFileBase.getChunkIndex(511, 511)); + + assertEquals(31, McaFileBase.getChunkIndex(31, 0)); + assertEquals(32, McaFileBase.getChunkIndex(0, 1)); + + assertEquals(-1, McaFileBase.getChunkIndex(ChunkBase.NO_CHUNK_COORD_SENTINEL, ChunkBase.NO_CHUNK_COORD_SENTINEL)); + assertEquals(-1, McaFileBase.getChunkIndex(ChunkBase.NO_CHUNK_COORD_SENTINEL, 0)); + assertEquals(-1, McaFileBase.getChunkIndex(0, ChunkBase.NO_CHUNK_COORD_SENTINEL)); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/McaPoiFileTest.java b/src/test/java/io/github/ensgijs/nbt/mca/McaPoiFileTest.java new file mode 100644 index 00000000..4d6960cc --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/McaPoiFileTest.java @@ -0,0 +1,50 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.mca.io.McaFileHelpers; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public class McaPoiFileTest extends McaFileBaseTest { + public void testPoiMca_1_17_1() { + McaPoiFile mca = assertThrowsNoException(() -> McaFileHelpers.readAuto(copyResourceToTmp("1_17_1/poi/r.-3.-2.mca"))); + PoiChunk chunk = mca.stream().filter(Objects::nonNull).findFirst().orElse(null); + assertNotNull(chunk); + assertEquals(DataVersion.JAVA_1_17_1, chunk.getDataVersionEnum()); + assertTrue(chunk.isPoiSectionValid(3)); + assertTrue(chunk.isPoiSectionValid(4)); + + assertFalse(chunk.isEmpty()); + Map> recordsByType = chunk.stream().collect(Collectors.groupingBy(PoiRecord::getType)); + assertTrue(recordsByType.containsKey("minecraft:home")); + assertTrue(recordsByType.containsKey("minecraft:cartographer")); + assertTrue(recordsByType.containsKey("minecraft:nether_portal")); + assertEquals(1, recordsByType.get("minecraft:home").size()); + assertEquals(6, recordsByType.get("minecraft:nether_portal").size()); + assertEquals(new PoiRecord(-1032, 63, -670, "minecraft:home"), recordsByType.get("minecraft:home").get(0)); + // it'd be better if we had a bell in this chunk to test a non-zero value here + assertEquals(0, recordsByType.get("minecraft:home").get(0).getFreeTickets()); + } + + // TODO: make this a real test +// public void testMoveChunk_1_20_4() throws IOException { +// String mcaResourcePath = "1_20_4/poi/r.-3.-3.mca"; +// McaPoiFile mca = assertThrowsNoException(() -> McaFileHelpers.readAuto(getResourceFile(mcaResourcePath))); // , LoadFlags.RAW +// assertNotNull(mca); +// +// assertTrue(mca.moveRegion(0, 0, false)); +// +// String newMcaName = mca.createRegionName(); +// assertEquals("r.0.0.mca", newMcaName); +// int i = 0; +// for (PoiChunk chunk : mca) { +// if (chunk != null) { +// chunk.updateHandle(); +// TextNbtHelpers.writeTextNbtFile(Paths.get("TESTDBG", mcaResourcePath + ".MOVEDTO." + newMcaName + ".i" + String.format("%04d", i) + "." + chunk.getChunkXZ().toString("x%dz%d") + ".original.snbt"), chunk.data, /*pretty print*/ true, /*sorted*/ true); +// } +// i++; +// } +// } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/McaReadWriteIdempotencyTest.java b/src/test/java/io/github/ensgijs/nbt/mca/McaReadWriteIdempotencyTest.java new file mode 100644 index 00000000..62146f3f --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/McaReadWriteIdempotencyTest.java @@ -0,0 +1,262 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.io.TextNbtHelpers; +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.mca.io.McaFileHelpers; +import io.github.ensgijs.nbt.mca.util.ChunkIterator; +import io.github.ensgijs.nbt.mca.util.IntPointXZ; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.function.BiPredicate; +import java.util.function.Function; +import java.util.stream.Collectors; + +// TODO: scan resources folder and auto-run this test for each mca file and type so we don't need to update tests every time we add a new test region file. +public class McaReadWriteIdempotencyTest extends McaFileBaseTest { + /** + * When set, text nbt files will be generated for every chunk scanned. + * The output can be found in the project root directory in a folder called TESTDBG. + * This folder should be set in .getignore + *

When these test

+ */ + private boolean DUMP_TESTDBG_OUTPUT = true; + + /** + * When set, the nbt tag for the region file will be cleared after parsing and before writing to check for any missed tags. + * When set to false this will also cause the dumped nbt to be in file order (unsorted) - but the order is + * expected to be the same between read and write. + *

tip: it's a good idea when adding support for a new version to set the following no mater the flavor to see what's there (look at the diff in TESTDBG). + *

{@code
+     * DUMP_TESTDBG_OUTPUT = true;
+     * CLEAR_DATA_TAG_BEFORE_REGURGITATION = true;
+     * }

+ */ + private boolean CLEAR_DATA_TAG_BEFORE_REGURGITATION = true; + + /** + * Reads an mca file, writes it, reads it back in, verifies that the NBT of the first non-empty chunk is identical + * between the reads and that the data versions are correct and that McaFileHelpers.readAuto returned the correct type. + */ + private > void validateReadWriteParity(DataVersion expectedDataVersion, String mcaResourcePath, Class clazz) throws IOException { + validateReadWriteParity(expectedDataVersion, expectedDataVersion, mcaResourcePath, clazz); + } + /** + * Reads an mca file, writes it, reads it back in, verifies that the NBT of the first non-empty chunk is identical + * between the reads and that the data versions are correct and that McaFileHelpers.readAuto returned the correct type. + */ + private > void validateReadWriteParity(DataVersion minExpectedDataVersion, DataVersion maxExpectedDataVersion, String mcaResourcePath, Class clazz) throws IOException { + long flags = LoadFlags.LOAD_ALL_DATA; // | LoadFlags.RELEASE_CHUNK_DATA_TAG; +// System.out.printf("LoadFlags: 0x%08X_%08X%n", (int)(flags >> 32), flags & 0xFFFF_FFFFL); + FT mcaA = assertThrowsNoException(() -> McaFileHelpers.readAuto( + getResourceFile(mcaResourcePath), + flags)); + assertTrue(clazz.isInstance(mcaA)); + assertTrue(mcaA.stream().anyMatch(Objects::nonNull)); + + String assertionMsg = mcaResourcePath + + "> Expected: " + minExpectedDataVersion + (minExpectedDataVersion != maxExpectedDataVersion ? " to " + maxExpectedDataVersion : "") + + ";Actual: " + DataVersion.bestFor(mcaA.getDefaultChunkDataVersion()); + + if (minExpectedDataVersion == maxExpectedDataVersion) { + assertEquals(assertionMsg, minExpectedDataVersion.id(), mcaA.getMinChunkDataVersion()); + assertEquals(assertionMsg, maxExpectedDataVersion.id(), mcaA.getMaxChunkDataVersion()); + } else { + assertTrue(assertionMsg, mcaA.getMinChunkDataVersion() >= minExpectedDataVersion.id()); + assertTrue(assertionMsg, mcaA.getMinChunkDataVersion() <= maxExpectedDataVersion.id()); + assertTrue(assertionMsg, mcaA.getMaxChunkDataVersion() >= minExpectedDataVersion.id()); + assertTrue(assertionMsg, mcaA.getMaxChunkDataVersion() <= maxExpectedDataVersion.id()); + } + + for (CT chunk : mcaA) { + if (chunk == null) continue; + assertTrue( + chunk.getChunkXZ() + "; " + assertionMsg, + minExpectedDataVersion.id() <= chunk.getDataVersionEnum().id() && maxExpectedDataVersion.id() >= chunk.getDataVersionEnum().id()); + if (CLEAR_DATA_TAG_BEFORE_REGURGITATION) { + // yes, we have to clear each section tag because Section's hold onto the tag ref. + if (chunk instanceof SectionedChunkBase) { + for (SectionBase section : (SectionedChunkBase) chunk) { + section.data.clear(); + } + } + chunk.data.clear(); + } + } + + File tmpFile = super.getNewTmpFile(Paths.get("out", mcaResourcePath).toString()); + assertThrowsNoException(() -> McaFileHelpers.write(mcaA, tmpFile)); + + FT mcaB = assertThrowsNoException(() -> McaFileHelpers.readAuto(tmpFile)); + assertTrue(clazz.isInstance(mcaB)); + assertTrue(mcaB.stream().anyMatch(Objects::nonNull)); + assertEquals(mcaA.getDefaultChunkDataVersion(), mcaB.getDefaultChunkDataVersion()); + assertEquals(mcaA.getMinChunkDataVersion(), mcaB.getMinChunkDataVersion()); + assertEquals(mcaA.getMaxChunkDataVersion(), mcaB.getMaxChunkDataVersion()); + + for (int i = 0; i < 1024; i++) { + CT chunkA = mcaA.getChunk(i); + CT chunkB = mcaB.getChunk(i); + if (chunkA != null && chunkB != null) { + if (DUMP_TESTDBG_OUTPUT) { + try { + TextNbtHelpers.writeTextNbtFile(Paths.get("TESTDBG", mcaResourcePath + ".i" + String.format("%04d", i) + "." + chunkA.getChunkXZ().toString("x%dz%d") + ".original.snbt"), chunkA.data, /*pretty print*/ true, /*sorted*/ true); + if (!chunkA.data.equals(chunkB.data)) { + TextNbtHelpers.writeTextNbtFile(Paths.get("TESTDBG", mcaResourcePath + ".i" + String.format("%04d", i) + "." + chunkA.getChunkXZ().toString("x%dz%d") + ".regurgitated.snbt"), chunkB.data, /*pretty print*/ true, /*sorted*/ true); + } + } catch (IOException ex) { + ex.printStackTrace(); + fail(ex.getMessage()); + } + } + + assertEquals(chunkA.getDataVersionEnum(), chunkB.getDataVersionEnum()); + assertEquals("Expected original and regurgitated files to be the same, but they weren't. Enable DUMP_TESTDBG_OUTPUT for the failing test, rerun, and look at the diff.", chunkA.data, chunkB.data); + } else { + assertNull(chunkA); + assertNull(chunkB); + } + } + } + + public void testMcaReadWriteParity_1_9_4() throws IOException { + validateReadWriteParity(DataVersion.JAVA_1_9_4, "1_9_4/region/r.2.-1.mca", McaRegionFile.class); + } + + public void testMcaReadWriteParity_1_12_2() throws IOException { + validateReadWriteParity(DataVersion.JAVA_1_12_2, "1_12_2/region/r.0.0.mca", McaRegionFile.class); + } + + public void testMcaReadWriteParity_1_13_0() throws IOException { + validateReadWriteParity(DataVersion.JAVA_1_13_0, "1_13_0/region/r.0.0.mca", McaRegionFile.class); + } + + public void ignore_testMcaReadWriteParity_1_13_1() throws IOException { + validateReadWriteParity(DataVersion.JAVA_1_13_1, "1_13_1/region/r.2.2.mca", McaRegionFile.class); + } + + public void testMcaReadWriteParity_1_13_2() throws IOException { + validateReadWriteParity(DataVersion.JAVA_1_13_2, "1_13_2/region/r.-2.-2.mca", McaRegionFile.class); + } + + public void testMcaReadWriteParity_1_14_4() throws IOException { + validateReadWriteParity(DataVersion.JAVA_1_14_4, "1_14_4/region/r.-1.0.mca", McaRegionFile.class); + validateReadWriteParity(DataVersion.JAVA_1_14_4, "1_14_4/poi/r.-1.0.mca", McaPoiFile.class); + } + + public void testMcaReadWriteParity_1_15_2() throws IOException { + validateReadWriteParity(DataVersion.JAVA_1_15_2, "1_15_2/region/r.-1.0.mca", McaRegionFile.class); + validateReadWriteParity(DataVersion.JAVA_1_15_2, "1_15_2/poi/r.-1.0.mca", McaPoiFile.class); + + validateReadWriteParity(DataVersion.JAVA_1_15_2, "1_15_2/region/r.0.0.mca", McaRegionFile.class); + } + + public void testMcaReadWriteParity_1_16_5() throws IOException { + validateReadWriteParity(DataVersion.JAVA_1_16_5, "1_16_5/region/r.0.-1.mca", McaRegionFile.class); + validateReadWriteParity(DataVersion.JAVA_1_16_5, "1_16_5/poi/r.0.-1.mca", McaPoiFile.class); + } + + public void testMcaReadWriteParity_1_17_1() throws IOException { + validateReadWriteParity(DataVersion.JAVA_1_17_1, "1_17_1/region/r.-3.-2.mca", McaRegionFile.class); + validateReadWriteParity(DataVersion.JAVA_1_17_1, "1_17_1/poi/r.-3.-2.mca", McaPoiFile.class); + validateReadWriteParity(DataVersion.JAVA_1_17_1, "1_17_1/entities/r.-3.-2.mca", McaEntitiesFile.class); + } + + public void testMcaReadWriteParity_1_18_PRE1() throws IOException { + validateReadWriteParity(DataVersion.JAVA_1_18_PRE1, "1_18_PRE1/region/r.-2.-3.mca", McaRegionFile.class); + validateReadWriteParity(DataVersion.JAVA_1_18_PRE1, "1_18_PRE1/poi/r.-2.-3.mca", McaPoiFile.class); + validateReadWriteParity(DataVersion.JAVA_1_18_PRE1, "1_18_PRE1/entities/r.-2.-3.mca", McaEntitiesFile.class); + } + + public void testMcaReadWriteParity_1_18_1() throws IOException { + validateReadWriteParity(DataVersion.JAVA_1_18_1, "1_18_1/region/r.0.-2.mca", McaRegionFile.class); + validateReadWriteParity(DataVersion.JAVA_1_18_1, "1_18_1/poi/r.0.-2.mca", McaPoiFile.class); + validateReadWriteParity(DataVersion.JAVA_1_18_1, "1_18_1/entities/r.0.-2.mca", McaEntitiesFile.class); + + validateReadWriteParity(DataVersion.JAVA_1_18_1, "1_18_1/region/r.8.1.mca", McaRegionFile.class); + validateReadWriteParity(DataVersion.JAVA_1_18_1, "1_18_1/entities/r.8.1.mca", McaEntitiesFile.class); + } + + public void testMcaReadWriteParity_1_20_4() throws IOException { +// DUMP_TESTDBG_OUTPUT = true; + validateReadWriteParity(DataVersion.JAVA_1_20_4, "1_20_4/region/r.-3.-3.mca", McaRegionFile.class); + validateReadWriteParity(DataVersion.JAVA_1_20_4, "1_20_4/poi/r.-3.-3.mca", McaPoiFile.class); + validateReadWriteParity(DataVersion.JAVA_1_20_4, "1_20_4/entities/r.-3.-3.mca", McaEntitiesFile.class); + } + + + // + private final static String MC_SAVES_ROOT = "C:/Users/Ross/AppData/Roaming/.minecraft/saves/"; + private final static String TEST_RESOURCE_ROOT = "src/test/resources/"; // output + +// public void testStripAndImportRegionFiles() throws IOException { +// // easyImport("1_18_1", 275, 33); +// // pillager outpost and a chunk of a mineshaft - world seed: -4846182428012336372L +// easyImport("1_20_4", XZ(-94, -85), XZ(-94, -86), XZ(-95, -85), XZ(-95, -86), XZ(-91, -87)); +// } + + // + private void easyImport(String saveName, int x, int z) throws IOException { + easyImport(saveName, IntPointXZ.XZ(x, z)); + } + + private void easyImport(String saveName, IntPointXZ... chunkCoords) throws IOException { + List mcaFileNames = Arrays.stream(chunkCoords) + .map(c -> McaFileHelpers.createNameFromChunkLocation(c.getX(), c.getZ())) + .distinct() + .collect(Collectors.toList()); + Set coordsFilter = new HashSet<>(Arrays.asList(chunkCoords)); + for (String rname : mcaFileNames) { + copyRegion(saveName, rname, (x, z) -> coordsFilter.contains(new IntPointXZ(x, z))); + } + } + + private DataVersion copyRegion(Path mcaPathIn, Function, File> outFileNameProvider, BiPredicate chunkFilter) throws IOException { + if (!mcaPathIn.toFile().exists()) return DataVersion.UNKNOWN; + McaFileBase mcaIn = McaFileHelpers.readAuto(mcaPathIn.toFile(), LoadFlags.RAW); + File mcaOutFile = outFileNameProvider.apply(mcaIn); + McaFileBase mcaOut = McaFileHelpers.autoMCAFile(mcaOutFile); + mcaOut.setDefaultChunkDataVersion(mcaIn.getDefaultChunkDataVersion()); + ChunkIterator iter = mcaIn.iterator(); + while (iter.hasNext()) { + ChunkBase chunk = iter.next(); + if (chunkFilter.test(iter.currentAbsoluteX(), iter.currentAbsoluteZ())) { + mcaOut.setChunk(iter.currentIndex(), chunk); + } + } + McaFileHelpers.write(mcaOut, mcaOutFile); + System.out.println("Wrote " + mcaOutFile.getAbsolutePath()); + return DataVersion.bestFor(mcaIn.getDefaultChunkDataVersion()); + } + + private File makeOutName(McaFileBase mca, String type) { + DataVersion dv = mca.getDefaultChunkDataVersionEnum(); + String verString = dv.name().substring(5); // strip JAVA_ prefix + Path outPath = Paths.get(TEST_RESOURCE_ROOT, verString, type); + if (!outPath.toFile().exists()) + outPath.toFile().mkdirs(); + return Paths.get(outPath.toString(), mca.createRegionName()).toFile(); + } + + private void copyRegion(String saveName, String rname, BiPredicate chunkFilter) throws IOException { + DataVersion dv = copyRegion( + Paths.get(MC_SAVES_ROOT, saveName, "region", rname), + mca -> makeOutName(mca, "region"), + chunkFilter); + if (dv.hasPoiMca()) + copyRegion( + Paths.get(MC_SAVES_ROOT, saveName, "poi", rname), + mca -> makeOutName(mca, "poi"), + chunkFilter); + if (dv.hasEntitiesMca()) + copyRegion( + Paths.get(MC_SAVES_ROOT, saveName, "entities", rname), + mca -> makeOutName(mca, "entities"), + chunkFilter); + } + // + // +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/McaRegionFileTest.java b/src/test/java/io/github/ensgijs/nbt/mca/McaRegionFileTest.java new file mode 100644 index 00000000..8eb933d5 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/McaRegionFileTest.java @@ -0,0 +1,631 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.mca.io.McaFileHelpers; +import io.github.ensgijs.nbt.mca.util.ChunkIterator; +import io.github.ensgijs.nbt.mca.util.SectionIterator; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.ListTag; + +import java.io.*; +import java.util.Objects; + +public class McaRegionFileTest extends McaTestCase { + + public void testGetChunkIndex() { + assertEquals(0, McaRegionFile.getChunkIndex(0, 0)); + assertEquals(0, McaRegionFile.getChunkIndex(32, 32)); + assertEquals(0, McaRegionFile.getChunkIndex(-32, -32)); + assertEquals(0, McaRegionFile.getChunkIndex(0, 32)); + assertEquals(0, McaRegionFile.getChunkIndex(-32, 32)); + assertEquals(1023, McaRegionFile.getChunkIndex(31, 31)); + assertEquals(1023, McaRegionFile.getChunkIndex(-1, -1)); + assertEquals(1023, McaRegionFile.getChunkIndex(63, 63)); + assertEquals(632, McaRegionFile.getChunkIndex(24, -13)); + int i = 0; + for (int cz = 0; cz < 32; cz++) { + for (int cx = 0; cx < 32; cx++) { + assertEquals(i++, McaRegionFile.getChunkIndex(cx, cz)); + } + } + } + + public void testChangeData() { + McaRegionFile mcaFile = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/r.2.2.mca"))); + assertNotNull(mcaFile); + mcaFile.setChunk(0, null); + File tmpFile = getNewTmpFile("1_13_1/region/r.2.2.mca"); + Integer x = assertThrowsNoException(() -> McaFileHelpers.write(mcaFile, tmpFile, true)); + assertNotNull(x); + assertEquals(2, x.intValue()); + McaRegionFile again = assertThrowsNoException(() -> McaFileHelpers.read(tmpFile)); + assertNotNull(again); + for (int i = 0; i < 1024; i++) { + if (i != 512 && i != 1023) { + assertNull(again.getChunk(i)); + } else { + assertNotNull(again.getChunk(i)); + } + } + } + + public void testChangeLastUpdate() { + McaRegionFile from = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/r.2.2.mca"))); + assertNotNull(from); + File tmpFile = getNewTmpFile("1_13_1/region/r.2.2.mca"); + assertThrowsNoException(() -> McaFileHelpers.write(from, tmpFile, true)); + McaRegionFile to = assertThrowsNoException(() -> McaFileHelpers.read(tmpFile)); + assertNotNull(to); + assertFalse(from.getChunk(0).getLastMCAUpdate() == to.getChunk(0).getLastMCAUpdate()); + assertFalse(from.getChunk(512).getLastMCAUpdate() == to.getChunk(512).getLastMCAUpdate()); + assertFalse(from.getChunk(1023).getLastMCAUpdate() == to.getChunk(1023).getLastMCAUpdate()); + assertTrue(to.getChunk(0).getLastMCAUpdate() == to.getChunk(512).getLastMCAUpdate()); + assertTrue(to.getChunk(0).getLastMCAUpdate() == to.getChunk(1023).getLastMCAUpdate()); + } + + public void testGetters() { + McaRegionFile f = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/r.2.2.mca"))); + assertNotNull(f); + + assertThrowsRuntimeException(() -> f.getChunk(-1), IndexOutOfBoundsException.class); + assertThrowsRuntimeException(() -> f.getChunk(1024), IndexOutOfBoundsException.class); + assertNotNull(assertThrowsNoRuntimeException(() -> f.getChunk(0))); + assertNotNull(assertThrowsNoRuntimeException(() -> f.getChunk(1023))); + assertNotNull(assertThrowsNoRuntimeException(() -> f.getChunk(96, 64))); + assertNotNull(assertThrowsNoRuntimeException(() -> f.getChunk(95, 95))); + assertNull(assertThrowsNoRuntimeException(() -> f.getChunk(63, 64))); + assertNull(assertThrowsNoRuntimeException(() -> f.getChunk(95, 64))); + assertNotNull(assertThrowsNoRuntimeException(() -> f.getChunk(64, 96))); + assertNull(assertThrowsNoRuntimeException(() -> f.getChunk(64, 63))); + assertNull(assertThrowsNoRuntimeException(() -> f.getChunk(64, 95))); + //not loaded + + McaRegionFile u = new McaRegionFile(2, 2); + assertThrowsRuntimeException(() -> u.getChunk(-1), IndexOutOfBoundsException.class); + assertThrowsRuntimeException(() -> u.getChunk(1024), IndexOutOfBoundsException.class); + assertNull(assertThrowsNoRuntimeException(() -> u.getChunk(0))); + assertNull(assertThrowsNoRuntimeException(() -> u.getChunk(1023))); + + assertEquals(1628, f.getChunk(0).getDataVersion()); + assertEquals(1538048269, f.getChunk(0).getLastMCAUpdate()); + assertEquals(1205486986, f.getChunk(0).getLastUpdateTick()); + assertNotNull(f.getChunk(0).getLegacyBiomes()); + assertNull(f.getChunk(0).getHeightMaps()); + assertNull(f.getChunk(0).getCarvingMasks()); + assertEquals(ListTag.createUnchecked(null), f.getChunk(0).getEntities()); + assertNull(f.getChunk(0).getTileEntities()); + assertNull(f.getChunk(0).getTileTicks()); + assertNull(f.getChunk(0).getLiquidTicks()); + assertNull(f.getChunk(0).getLights()); + assertNull(f.getChunk(0).getLiquidsToBeTicked()); + assertNull(f.getChunk(0).getToBeTicked()); + assertNull(f.getChunk(0).getPostProcessing()); + assertNotNull(f.getChunk(0).getStructures()); + + assertNotNull(f.getChunk(0).getSection(0).getSkyLight()); + assertEquals(2048, f.getChunk(0).getSection(0).getSkyLight().length); + assertNotNull(f.getChunk(0).getSection(0).getBlockLight()); + assertEquals(2048, f.getChunk(0).getSection(0).getBlockLight().length); +// assertNotNull(f.getChunk(0).getSection(0).getBlockStates()); +// assertEquals(256, f.getChunk(0).getSection(0).getBlockStates().length); + } + + private TerrainChunk createChunkWithPos() { + CompoundTag data = new CompoundTag(); + CompoundTag level = new CompoundTag(); + data.put("Level", level); + data.putInt("DataVersion", DataVersion.JAVA_1_16_5.id()); + return new TerrainChunk(data); + } + + public void testSetters() { + McaRegionFile f = new McaRegionFile(2, 2); + + assertThrowsNoRuntimeException(() -> f.setChunk(0, createChunkWithPos())); + assertEquals(createChunkWithPos().updateHandle(64, 64), f.getChunk(0).updateHandle(64, 64)); + assertThrowsRuntimeException(() -> f.setChunk(1024, createChunkWithPos()), IndexOutOfBoundsException.class); + assertThrowsNoRuntimeException(() -> f.setChunk(1023, createChunkWithPos())); + assertThrowsNoRuntimeException(() -> f.setChunk(0, null)); + assertNull(f.getChunk(0)); + assertThrowsNoRuntimeException(() -> f.setChunk(1023, createChunkWithPos())); + assertThrowsNoRuntimeException(() -> f.setChunk(1023, createChunkWithPos())); + + f.getChunk(1023).setDataVersion(1627); + assertEquals(1627, f.getChunk(1023).getDataVersion()); + f.getChunk(1023).setLastMCAUpdate(12345678); + assertEquals(12345678, f.getChunk(1023).getLastMCAUpdate()); + f.getChunk(1023).setLastUpdateTick(87654321); + assertEquals(87654321, f.getChunk(1023).getLastUpdateTick()); + f.getChunk(1023).setInhabitedTimeTicks(13243546); + assertEquals(13243546, f.getChunk(1023).getInhabitedTimeTicks()); + assertThrowsRuntimeException(() -> f.getChunk(1023).setLegacyBiomes(new int[255]), IllegalArgumentException.class); + int[] biomes = new int[256]; + assertThrowsNoRuntimeException(() -> f.getChunk(1023).setLegacyBiomes(biomes)); + assertSame(biomes, f.getChunk(1023).getLegacyBiomes()); + CompoundTag compoundTag = getSomeCompoundTag(); + f.getChunk(1023).setHeightMaps(compoundTag); + assertSame(compoundTag, f.getChunk(1023).getHeightMaps()); + compoundTag = getSomeCompoundTag(); + f.getChunk(1023).setCarvingMasks(compoundTag); + assertSame(compoundTag, f.getChunk(1023).getCarvingMasks()); + ListTag compoundTagListTag = getSomeCompoundTagList(); + f.getChunk(1023).setEntities(compoundTagListTag); + assertSame(compoundTagListTag, f.getChunk(1023).getEntities()); + compoundTagListTag = getSomeCompoundTagList(); + f.getChunk(1023).setTileEntities(compoundTagListTag); + assertSame(compoundTagListTag, f.getChunk(1023).getTileEntities()); + compoundTagListTag = getSomeCompoundTagList(); + f.getChunk(1023).setTileTicks(compoundTagListTag); + assertSame(compoundTagListTag, f.getChunk(1023).getTileTicks()); + compoundTagListTag = getSomeCompoundTagList(); + f.getChunk(1023).setLiquidTicks(compoundTagListTag); + assertSame(compoundTagListTag, f.getChunk(1023).getLiquidTicks()); + ListTag> listTagListTag = getSomeListTagList(); + f.getChunk(1023).setLights(listTagListTag); + assertSame(listTagListTag, f.getChunk(1023).getLights()); + listTagListTag = getSomeListTagList(); + f.getChunk(1023).setLiquidsToBeTicked(listTagListTag); + assertSame(listTagListTag, f.getChunk(1023).getLiquidsToBeTicked()); + listTagListTag = getSomeListTagList(); + f.getChunk(1023).setToBeTicked(listTagListTag); + assertSame(listTagListTag, f.getChunk(1023).getToBeTicked()); + listTagListTag = getSomeListTagList(); + f.getChunk(1023).setPostProcessing(listTagListTag); + assertSame(listTagListTag, f.getChunk(1023).getPostProcessing()); + compoundTag = getSomeCompoundTag(); + f.getChunk(1023).setStructures(compoundTag); + assertSame(compoundTag, f.getChunk(1023).getStructures()); + TerrainSection s = f.getChunk(1023).createSection(); + f.getChunk(1023).setSection(0, s); + assertEquals(0, s.getSectionY()); + assertEquals(s, f.getChunk(1023).getSection(0)); +// assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(null), NullPointerException.class); +// assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(new long[321]), IllegalArgumentException.class); +// assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(new long[255]), IllegalArgumentException.class); +// assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(new long[4097]), IllegalArgumentException.class); +// assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(new long[320])); +// assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(new long[4096])); +// assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(new long[256])); + assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockLight(new byte[2047]), IllegalArgumentException.class); + assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockLight(new byte[2049]), IllegalArgumentException.class); + assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockLight(new byte[2048])); + assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockLight(null)); + assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setSkyLight(new byte[2047]), IllegalArgumentException.class); + assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setSkyLight(new byte[2049]), IllegalArgumentException.class); + assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setSkyLight(new byte[2048])); + assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setSkyLight(null)); + } + + public void testGetBiomeAt2D() { + McaRegionFile f = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/r.2.2.mca"))); + assertEquals(21, f.getBiomeAt(1024, 1024)); + assertEquals(-1, f.getBiomeAt(1040, 1024)); + f.setChunk(0, 1, TerrainChunk.newChunk(2201)); + assertEquals(-1, f.getBiomeAt(1024, 1040)); + } + + public void testSetBiomeAt_2D_2dBiomeWorld() { + McaRegionFile f = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/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() { + McaRegionFile f = new McaRegionFile(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++) { + assertTrue(i % 16 == 0 ? biomes[i] == 20 : biomes[i] == -1); + } + } + +// public void testCleanupPaletteAndBlockStates() { +// McaRegionFile f = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/r.2.2.mca"))); +// assertThrowsNoRuntimeException(f::cleanupPalettesAndBlockStates); +// TerrainChunk c = f.getChunk(0, 0); +// TerrainSection s = c.getSection(0); +// assertEquals(10, s.getBlockPalette().size()); +// for (int i = 11; i <= 15; i++) { +// s.addToPalette(block("minecraft:" + i)); +// } +// assertEquals(15, s.getBlockPalette().size()); +// f.cleanupPalettesAndBlockStates(); +// assertEquals(10, s.getBlockPalette().size()); +// assertEquals(256, s.updateHandle(0).getLongArray("BlockStates").length); +// int y = 0; +// for (int i = 11; i <= 17; i++) { +// f.setBlockStateAt(1, y++, 1, block("minecraft:" + i), false); +// } +// assertEquals(17, s.getBlockPalette().size()); +// assertEquals(320, s.updateHandle(0).getLongArray("BlockStates").length); +// f.cleanupPalettesAndBlockStates(); +// assertEquals(17, s.getBlockPalette().size()); +// assertEquals(320, s.updateHandle(0).getLongArray("BlockStates").length); +// f.setBlockStateAt(1, 0, 1, block("minecraft:bedrock"), false); +// assertEquals(17, s.getBlockPalette().size()); +// assertEquals(320, s.updateHandle(0).getLongArray("BlockStates").length); +// f.cleanupPalettesAndBlockStates(); +// assertEquals(16, s.getBlockPalette().size()); +// assertEquals(256, s.updateHandle(0).getLongArray("BlockStates").length); +// } + + public void testMaxAndMinSectionY() { + McaRegionFile f = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/r.2.2.mca"))); + TerrainChunk c = f.getChunk(0, 0); + assertEquals(0, c.getMinSectionY()); + assertEquals(5, c.getMaxSectionY()); + c.setSection(-64 / 16, TerrainSection.newSection()); + c.setSection((320 - 16) / 16, TerrainSection.newSection()); + assertEquals(-4, c.getMinSectionY()); + assertEquals(19, c.getMaxSectionY()); + } + +// public void testSetBlockDataAt() { +// McaRegionFile f = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/r.2.2.mca"))); +// assertEquals(f.getMaxChunkDataVersion(), f.getMinChunkDataVersion()); +// assertTrue(f.getDefaultChunkDataVersion() > 0); +// TerrainSection section = f.getChunk(0, 0).getSection(0); +// assertEquals(10, section.getBlockPalette().size()); +// assertEquals(0b0001000100010001000100010001000100010001000100010001000100010001L, section.getBlockStates()[0]); +// f.setBlockStateAt(0, 0, 0, block("minecraft:custom"), false); +// assertEquals(11, section.getBlockPalette().size()); +// assertEquals(0b0001000100010001000100010001000100010001000100010001000100011010L, section.getBlockStates()[0]); +// +// //test "line break" +// int y = 1; +// for (int i = 12; i <= 17; i++) { +// f.setBlockStateAt(0, y++, 0, block("minecraft:" + i), false); +// } +// assertEquals(17, section.getBlockPalette().size()); +// assertEquals(320, section.getBlockStates().length); +// assertEquals(0b0001000010000100001000010000100001000010000100001000010000101010L, section.getBlockStates()[0]); +// assertEquals(0b0010000100001000010000100001000010000100001000010000100001000010L, section.getBlockStates()[1]); +// f.setBlockStateAt(12, 0, 0, block("minecraft:18"), false); +// assertEquals(0b0001000010000100001000010000100001000010000100001000010000101010L, section.getBlockStates()[0]); +// assertEquals(0b0010000100001000010000100001000010000100001000010000100001000011L, section.getBlockStates()[1]); +// +// //test chunkdata == null +// 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()); +// assertEquals(256, s.get(0).getLongArray("BlockStates").length); +// assertEquals(0b0000000000000000000000000000000000000000000000000000000000010000L, s.get(0).getLongArray("BlockStates")[0]); +// +// //test section == null +// assertNull(f.getChunk(66, 64)); +// TerrainChunk c = f.createChunk(); +// f.setChunk(66, 64, c); +// assertNotNull(f.getChunk(66, 64)); +// CompoundTag levelTag = f.getChunk(66, 64).updateHandle(66, 64).getCompoundTag("Level"); +// assertNotNull(levelTag); +// ListTag sectionsTag = levelTag.getListTag("Sections"); +// assertNotNull(sectionsTag); +// ListTag ss = sectionsTag.asCompoundTagList(); +// assertEquals(0, ss.size()); +// f.setBlockStateAt(33, 0, 0, block("minecraft:air"), false); +// ss = f.getChunk(66, 64).updateHandle(66, 64).getCompoundTag("Level").getListTag("Sections").asCompoundTagList(); +// assertEquals(1, ss.size()); +// f.setBlockStateAt(33, 0, 0, block("minecraft:foo"), false); +// ss = f.getChunk(66, 64).updateHandle(66, 64).getCompoundTag("Level").getListTag("Sections").asCompoundTagList(); +// assertEquals(1, ss.size()); +// assertEquals(2, ss.get(0).getListTag("Palette").size()); +// assertEquals(256, s.get(0).getLongArray("BlockStates").length); +// assertEquals(0b0000000000000000000000000000000000000000000000000000000000010000L, ss.get(0).getLongArray("BlockStates")[0]); +// +// //test force cleanup +// ListTag sss = f.getChunk(31, 31).updateHandle(65, 65).getCompoundTag("Level").getListTag("Sections").asCompoundTagList(); +// assertEquals(12, sss.get(0).getListTag("Palette").size()); +// y = 0; +// for (int i = 13; i <= 17; i++) { +// f.setBlockStateAt(1008, y++, 1008, block("minecraft:" + i), false); +// } +// f.getChunk(31, 31).getSection(0).cleanupPaletteAndBlockStates(); +// sss = f.getChunk(31, 31).updateHandle(65, 65).getCompoundTag("Level").getListTag("Sections").asCompoundTagList(); +// assertEquals(17, sss.get(0).getListTag("Palette").size()); +// assertEquals(320, sss.get(0).getLongArray("BlockStates").length); +// f.setBlockStateAt(1008, 4, 1008, block("minecraft:16"), true); +// sss = f.getChunk(31, 31).updateHandle(65, 65).getCompoundTag("Level").getListTag("Sections").asCompoundTagList(); +// assertEquals(16, sss.get(0).getListTag("Palette").size()); +// assertEquals(256, sss.get(0).getLongArray("BlockStates").length); +// } + +// public void testSetBlockDataAt2527() { +// //test "line break" for DataVersion 2527 +// McaRegionFile f = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/r.2.2.mca"))); +// TerrainChunk p = f.getChunk(0, 0); +// p.setDataVersion(999999); +// TerrainSection section = f.getChunk(0, 0).getSection(0); +// assertEquals(10, section.getBlockPalette().size()); +// assertEquals(0b0001000100010001000100010001000100010001000100010001000100010001L, section.getBlockStates()[0]); +// f.setBlockStateAt(0, 0, 0, block("minecraft:custom"), false); +// assertEquals(11, section.getBlockPalette().size()); +// assertEquals(0b0001000100010001000100010001000100010001000100010001000100011010L, section.getBlockStates()[0]); +// int y = 1; +// for (int i = 12; i <= 17; i++) { +// f.setBlockStateAt(0, y++, 0, block("minecraft:" + i), false); +// } +// assertEquals(17, section.getBlockPalette().size()); +// assertEquals(342, section.getBlockStates().length); +// } +// +// public void testGetBlockDataAt() { +// McaRegionFile f = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/r.2.2.mca"))); +// assertEquals(block("minecraft:bedrock"), f.getBlockStateAt(0, 0, 0)); +// assertNull(f.getBlockStateAt(16, 0, 0)); +// assertEquals(block("minecraft:dirt"), f.getBlockStateAt(0, 62, 0)); +// assertEquals(block("minecraft:dirt"), f.getBlockStateAt(15, 67, 15)); +// assertNull(f.getBlockStateAt(3, 100, 3)); +// } + + public void testGetChunkStatus() { + McaRegionFile f = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/r.2.2.mca"))); + assertEquals("mobs_spawned", f.getChunk(0, 0).getStatus()); + } + + public void testSetChunkStatus() { + McaRegionFile f = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/r.2.2.mca"))); + assertThrowsNoRuntimeException(() -> f.getChunk(0, 0).setStatus("base")); + assertEquals("base", f.getChunk(0, 0).updateHandle(64, 64).getCompoundTag("Level").getString("Status")); + assertNull(f.getChunk(1, 0)); + } + + public void testChunkInitReferences() { + CompoundTag t = new CompoundTag(); + assertThrowsRuntimeException(() -> new TerrainChunk(null), NullPointerException.class); + assertThrowsRuntimeException(() -> new TerrainChunk(t), IllegalArgumentException.class); + } + + public void testChunkInvalidCompressionType() { + assertThrowsException(() -> { + try (RandomAccessFile raf = new RandomAccessFile(getResourceFile("invalid_compression.dat"), "r")) { + TerrainChunk c = new TerrainChunk(0); + c.deserialize(raf, LoadFlags.LOAD_ALL_DATA, 0, 0, 0); + } + }, IOException.class); + } + + public void testChunkInvalidDataTag() { + assertThrowsException(() -> { + try (RandomAccessFile raf = new RandomAccessFile(getResourceFile("invalid_data_tag.dat"), "r")) { + TerrainChunk c = new TerrainChunk(0); + c.deserialize(raf, LoadFlags.LOAD_ALL_DATA, 0, 0, 0); + } + }, IOException.class); + } + + private void assertLoadFlag(Object field, long flags, long wantedFlag) { + if((flags & wantedFlag) != 0) { + assertNotNull(String.format("Should not be null. Flags=%08x, Wanted flag=%08x", flags, wantedFlag), field); + } else { + assertNull(String.format("Should be null. Flags=%08x, Wanted flag=%08x", flags, wantedFlag), field); + } + } + + private void assertPartialChunk(TerrainChunk c, long loadFlags) { + assertLoadFlag(c.getLegacyBiomes(), loadFlags, LoadFlags.BIOMES); + assertLoadFlag(c.getHeightMaps(), loadFlags, LoadFlags.HEIGHTMAPS); + assertLoadFlag(c.getEntities(), loadFlags, LoadFlags.ENTITIES); + assertLoadFlag(c.getCarvingMasks(), loadFlags, LoadFlags.CARVING_MASKS); + assertLoadFlag(c.getLights(), loadFlags, LoadFlags.LIGHTS); + assertLoadFlag(c.getPostProcessing(), loadFlags, LoadFlags.POST_PROCESSING); + assertLoadFlag(c.getLiquidTicks(), loadFlags, LoadFlags.LIQUID_TICKS); + assertLoadFlag(c.getLiquidsToBeTicked(), loadFlags, LoadFlags.LIQUIDS_TO_BE_TICKED); + assertLoadFlag(c.getTileTicks(), loadFlags, LoadFlags.TILE_TICKS); + assertLoadFlag(c.getTileEntities(), loadFlags, LoadFlags.TILE_ENTITIES); + assertLoadFlag(c.getToBeTicked(), loadFlags, LoadFlags.TO_BE_TICKED); + assertLoadFlag(c.getSection(0), loadFlags, LoadFlags.BLOCK_LIGHTS| LoadFlags.BLOCK_STATES| LoadFlags.SKY_LIGHT); + if ((loadFlags & (LoadFlags.BLOCK_LIGHTS| LoadFlags.BLOCK_STATES| LoadFlags.SKY_LIGHT)) != 0) { + TerrainSection s = c.getSection(0); + assertNotNull(String.format("Section is null. Flags=%08x", loadFlags), s); +// assertLoadFlag(s.getBlockStates(), loadFlags, BLOCK_STATES); + assertLoadFlag(s.getBlockLight(), loadFlags, LoadFlags.BLOCK_LIGHTS); + assertLoadFlag(s.getSkyLight(), loadFlags, LoadFlags.SKY_LIGHT); + } + } + + public void testReleaseChunkDataTagFlag_preventsUpdatingHandle_whilePartialLoadingAloneDoseNot() { + long[] flags = new long[] { + LoadFlags.BIOMES, + LoadFlags.HEIGHTMAPS, + LoadFlags.ENTITIES, + LoadFlags.CARVING_MASKS, + LoadFlags.LIGHTS, + LoadFlags.POST_PROCESSING, + LoadFlags.LIQUID_TICKS, + LoadFlags.LIQUIDS_TO_BE_TICKED, + LoadFlags.TILE_TICKS, + LoadFlags.TILE_ENTITIES, + LoadFlags.TO_BE_TICKED, + LoadFlags.BLOCK_STATES, + LoadFlags.BLOCK_LIGHTS, + LoadFlags.SKY_LIGHT, + LoadFlags.LOAD_ALL_DATA + }; + + McaRegionFile f = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_13_1/region/r.2.2.mca"))); + TerrainChunk c = f.getChunk(0); + c.setCarvingMasks(getSomeCompoundTag()); + c.setEntities(getSomeCompoundTagList()); + c.setLights(getSomeListTagList()); + c.setTileEntities(getSomeCompoundTagList()); + c.setTileTicks(getSomeCompoundTagList()); + c.setLiquidTicks(getSomeCompoundTagList()); + c.setToBeTicked(getSomeListTagList()); + c.setLiquidsToBeTicked(getSomeListTagList()); + c.setHeightMaps(getSomeCompoundTag()); + c.setPostProcessing(getSomeListTagList()); + c.getSection(0).setBlockLight(new byte[2048]); + File tmp = this.getNewTmpFile("1_13_1/region/r.2.2.mca"); + assertThrowsNoException(() -> McaFileHelpers.write(f, tmp)); + + for (long flag : flags) { + McaRegionFile mcaFile = assertThrowsNoException(() -> McaFileHelpers.read(tmp, flag | LoadFlags.RELEASE_CHUNK_DATA_TAG)); + c = mcaFile.getChunk(0, 0); + assertPartialChunk(c, flag); + assertThrowsException(() -> McaFileHelpers.write(mcaFile, getNewTmpFile("r.12.34.mca")), UnsupportedOperationException.class); + } + + for (long flag : flags) { + McaRegionFile mcaFile = assertThrowsNoException(() -> McaFileHelpers.read(tmp, flag)); + c = mcaFile.getChunk(0, 0); + assertPartialChunk(c, flag); + assertThrowsNoException(() -> McaFileHelpers.write(mcaFile, getNewTmpFile("r.12.34.mca"))); + } + } + + public void test1_15GetBiomeAt() { + McaRegionFile f = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_15_2/region/r.0.0.mca"))); + assertEquals(162, f.getBiomeAt(31, 0, 63)); + assertEquals(4, f.getBiomeAt(16, 0, 48)); + assertEquals(4, f.getBiomeAt(16, 0, 63)); + assertEquals(162, f.getBiomeAt(31, 0, 48)); + assertEquals(162, f.getBiomeAt(31, 100, 63)); + assertEquals(4, f.getBiomeAt(16, 100, 48)); + assertEquals(4, f.getBiomeAt(16, 100, 63)); + assertEquals(162, f.getBiomeAt(31, 100, 48)); + assertEquals(162, f.getBiomeAt(31, 106, 63)); + assertEquals(4, f.getBiomeAt(16, 106, 48)); + assertEquals(4, f.getBiomeAt(16, 106, 63)); + assertEquals(162, f.getBiomeAt(31, 106, 48)); + } + + public void testChunkSectionPutSection() { + McaRegionFile mca = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_17_1/region/r.-3.-2.mca"))); + TerrainChunk chunk = mca.stream().filter(Objects::nonNull).findFirst().orElse(null); + assertNotNull(chunk); + final TerrainSection 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 TerrainSection newSection = chunk.createSection(); + final TerrainSection 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.getSectionY()); // insertion should update section height + final TerrainSection section3 = chunk.putSection(3, section2); // should be OK to put old section 2 into section 3 place now + + final TerrainSection section1 = chunk.getSection(1); + final TerrainSection 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 TerrainSection section0 = chunk.getSection(0); + final TerrainSection 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() { + McaRegionFile mca = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_17_1/region/r.-3.-2.mca"))); + TerrainChunk chunk = mca.stream().filter(Objects::nonNull).findFirst().orElse(null); + assertNotNull(chunk); + assertEquals(SectionBase.NO_SECTION_Y_SENTINEL, chunk.getSectionY(null)); + assertEquals(SectionBase.NO_SECTION_Y_SENTINEL, chunk.getSectionY(chunk.createSection())); + TerrainSection section = chunk.getSection(5); + section.setHeight(-5); + assertEquals(5, chunk.getSectionY(section)); + assertEquals(5, section.getSectionY()); // getSectionY should sync Y + } + + public void testChunkSectionMinMaxSectionY() { + TerrainChunk chunk = new TerrainChunk(42); + chunk.setDataVersion(DataVersion.JAVA_1_17_1.id()); + assertEquals(SectionBase.NO_SECTION_Y_SENTINEL, chunk.getMinSectionY()); + assertEquals(SectionBase.NO_SECTION_Y_SENTINEL, chunk.getMaxSectionY()); + TerrainSection section = chunk.createSection(3); + assertEquals(3, section.getSectionY()); + assertEquals(3, chunk.getMinSectionY()); + assertEquals(3, chunk.getMaxSectionY()); + } + + public void testMCAFileChunkIterator() { + McaRegionFile mca = assertThrowsNoException(() -> McaFileHelpers.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, wz = -2 * 32; z < 32; z++, wz++) { + for (int x = 0, wx = -3 * 32; x < 32; x++, wx++) { + assertTrue(iter.hasNext()); + TerrainChunk chunk = iter.next(); + assertEquals(i, iter.currentIndex()); + assertEquals(x, iter.currentX()); + assertEquals(z, iter.currentZ()); + assertEquals(wx, iter.currentAbsoluteX()); + assertEquals(wz, iter.currentAbsoluteZ()); + 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() { + McaRegionFile mca = assertThrowsNoException(() -> McaFileHelpers.read(copyResourceToTmp("1_17_1/region/r.-3.-2.mca"))); + assertEquals(1, mca.count()); + TerrainChunk 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()); + TerrainSection section = iter.next(); + assertNotNull(section); + assertEquals(y, iter.sectionY()); + assertEquals(y, section.getSectionY()); + 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/io/github/ensgijs/nbt/mca/McaTerrainFileTest.java b/src/test/java/io/github/ensgijs/nbt/mca/McaTerrainFileTest.java new file mode 100644 index 00000000..dffaf519 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/McaTerrainFileTest.java @@ -0,0 +1,26 @@ +package io.github.ensgijs.nbt.mca; + +public class McaTerrainFileTest extends McaFileBaseTest { + // TODO: add coverage + public void testMakeBuildHappy() {} + + // TODO: make this a real test +// public void testMoveChunk_1_20_4() throws IOException { +// String mcaResourcePath = "1_20_4/region/r.-3.-3.mca"; +// McaRegionFile mca = assertThrowsNoException(() -> McaFileHelpers.readAuto(getResourceFile(mcaResourcePath))); // , LoadFlags.RAW +// assertNotNull(mca); +// +// assertTrue(mca.moveRegion(0, 0, false)); +// +// String newMcaName = mca.createRegionName(); +// assertEquals("r.0.0.mca", newMcaName); +// int i = 0; +// for (TerrainChunk chunk : mca) { +// if (chunk != null) { +// chunk.updateHandle(); +// TextNbtHelpers.writeTextNbtFile(Paths.get("TESTDBG", mcaResourcePath + ".MOVEDTO." + newMcaName + ".i" + String.format("%04d", i) + "." + chunk.getChunkXZ().toString("x%dz%d") + ".original.snbt"), chunk.data, /*pretty print*/ true, /*sorted*/ true); +// } +// i++; +// } +// } +} diff --git a/src/test/java/net/querz/mca/MCATestCase.java b/src/test/java/io/github/ensgijs/nbt/mca/McaTestCase.java similarity index 85% rename from src/test/java/net/querz/mca/MCATestCase.java rename to src/test/java/io/github/ensgijs/nbt/mca/McaTestCase.java index 1c87e4c7..c69fc79a 100644 --- a/src/test/java/net/querz/mca/MCATestCase.java +++ b/src/test/java/io/github/ensgijs/nbt/mca/McaTestCase.java @@ -1,10 +1,10 @@ -package net.querz.mca; +package io.github.ensgijs.nbt.mca; -import net.querz.nbt.tag.CompoundTag; -import net.querz.nbt.tag.ListTag; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.NbtTestCase; -public abstract class MCATestCase extends NBTTestCase { +public abstract class McaTestCase extends NbtTestCase { public CompoundTag block(String name) { CompoundTag c = new CompoundTag(); diff --git a/src/test/java/io/github/ensgijs/nbt/mca/PoiChunkBaseTest.java b/src/test/java/io/github/ensgijs/nbt/mca/PoiChunkBaseTest.java new file mode 100644 index 00000000..c70a1252 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/PoiChunkBaseTest.java @@ -0,0 +1,452 @@ +package io.github.ensgijs.nbt.mca; + + +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.util.Mutable; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; + +/** + * All implementors of {@link PoiChunkBase} should create a test which inherits this one and add + * tests to cover any additional functionality added by that concretion. + */ +public abstract class PoiChunkBaseTest> extends ChunkBaseTest { + protected static final DataVersion DEFAULT_TEST_VERSION = DataVersion.latest(); + + protected abstract RT createPoiRecord(int x, int y, int z, String type); + + @Override + protected CompoundTag createTag(int dataVersion, int chunkX, int chunkZ) { + final CompoundTag tag = super.createTag(dataVersion, chunkX, chunkZ); + // annoyingly poi chunks don't record their chunk XZ + + final CompoundTag sectionContainerTag = new CompoundTag(); + CompoundTag sectionTag; + ListTag recordsListTag; + tag.put("Sections", sectionContainerTag); + + // r.-3.-2.mca + // chunks -96 -64 to -65 -33 + // blocks -1536 -1024 to -1025 -513 + // + // within chunk -65 -42 + // blocks -1040 -672 to -1025 -657 + + // section marked invalid, + sectionTag = new CompoundTag(); + recordsListTag = new ListTag<>(CompoundTag.class); + recordsListTag.add(PoiRecordTest.makeTag(1, "minecraft:cartographer", -1032, 41, -667)); + recordsListTag.add(PoiRecordTest.makeTag(1, "minecraft:shepherd", -1032, 42, -667)); + recordsListTag.add(PoiRecordTest.makeTag(1, "minecraft:toolsmith", -1032, 43, -667)); + sectionTag.putBoolean("Valid", false); + sectionTag.put("Records", recordsListTag); + sectionContainerTag.put("2", sectionTag); + + // fully valid + sectionTag = new CompoundTag(); + recordsListTag = new ListTag<>(CompoundTag.class); + recordsListTag.add(PoiRecordTest.makeTag(0, "minecraft:home", -1032, 63, -670)); + recordsListTag.add(PoiRecordTest.makeTag(1, "minecraft:cartographer", -1032, 63, -667)); + sectionTag.putBoolean("Valid", true); + sectionTag.put("Records", recordsListTag); + sectionContainerTag.put("3", sectionTag); + + // fully valid + sectionTag = new CompoundTag(); + recordsListTag = new ListTag<>(CompoundTag.class); + recordsListTag.add(PoiRecordTest.makeTag(0, "minecraft:nether_portal", -1031, 71, -667)); + recordsListTag.add(PoiRecordTest.makeTag(0, "minecraft:nether_portal", -1031, 71, -668)); + recordsListTag.add(PoiRecordTest.makeTag(0, "minecraft:nether_portal", -1031, 72, -667)); + recordsListTag.add(PoiRecordTest.makeTag(0, "minecraft:nether_portal", -1031, 72, -668)); + recordsListTag.add(PoiRecordTest.makeTag(0, "minecraft:nether_portal", -1031, 73, -667)); + recordsListTag.add(PoiRecordTest.makeTag(0, "minecraft:nether_portal", -1031, 73, -668)); + sectionTag.putBoolean("Valid", true); + sectionTag.put("Records", recordsListTag); + sectionContainerTag.put("4", sectionTag); + + return tag; + } + + @Override + protected void validateAllDataConstructor(T chunk, int expectedChunkX, int expectedChunkZ) { + assertTrue(chunk.isPoiSectionValid(0)); + assertFalse(chunk.isPoiSectionValid(2)); + assertTrue(chunk.isPoiSectionValid(3)); + assertTrue(chunk.isPoiSectionValid(5)); + assertEquals(11, chunk.size()); + + RT record = chunk.getFirst(-1032, 63, -667); + assertEquals(new PoiRecord(-1032, 63, -667, "minecraft:cartographer"), record); + assertEquals(1, record.getFreeTickets()); // not part of .equlas check + + List records = chunk.getAll("minecraft:home"); + assertEquals(1, records.size()); + record = records.get(0); + assertEquals(new PoiRecord(-1032, 63, -670, "minecraft:home"), record); + assertEquals(0, record.getFreeTickets()); // not part of .equals check + + records = chunk.getAll("minecraft:nether_portal"); + assertEquals(6, records.size()); + + records = chunk.getAll("minecraft:cartographer"); + assertEquals(2, records.size()); + } + + public void testSectionsTagRequired() { + validateTagRequired(DataVersion.latest(), "Sections"); + } + + public void testValidTagNotRequired() { + T chunk = validateTagNotRequired(DataVersion.latest(), tag -> { + assertNotNull(tag.getCompoundTag("Sections").getCompoundTag("2").remove("Valid")); + }); + assertTrue(chunk.isPoiSectionValid(2)); + } + + @SuppressWarnings("unchecked") + public void testRecordsTagNotRequired() { + Mutable countRemoved = new Mutable<>(); + T chunk = validateTagNotRequired(DataVersion.latest(), tag -> { + ListTag recordsRemovedTag = (ListTag) tag.getCompoundTag("Sections").getCompoundTag("4").remove("Records"); + assertNotNull(recordsRemovedTag); + countRemoved.set(recordsRemovedTag.size()); + }); + assertTrue(countRemoved.get() > 0); + assertEquals(createFilledChunk(-65, -42, DEFAULT_TEST_VERSION).size() - countRemoved.get(), chunk.size()); + } + + public void testAdd() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(0, 64, 0, "A")); + chunk.add(createPoiRecord(0, 64, 1, "B")); + chunk.add(createPoiRecord(1, 64, 0, "C")); + assertEquals(3, chunk.size()); + + assertThrowsIllegalArgumentException(() -> chunk.add(null)); + + assertEquals(3, chunk.size()); + assertEquals("A", chunk.getAll().get(0).getType()); + assertEquals("B", chunk.getAll().get(1).getType()); + assertEquals("C", chunk.getAll().get(2).getType()); + } + + public void testIsEmpty() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + assertTrue(chunk.isEmpty()); + chunk.add(createPoiRecord(0, 64, 0, "A")); + assertFalse(chunk.isEmpty()); + } + + public void testContains() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(0, 64, 0, "A")); + chunk.add(createPoiRecord(0, 64, 1, "B")); + chunk.add(createPoiRecord(1, 64, 0, "C")); + assertTrue(chunk.contains(createPoiRecord(0, 64, 1, "B"))); + assertFalse(chunk.contains(createPoiRecord(0, 64, 1, "D"))); + assertFalse(chunk.contains(null)); + } + + public void testClear() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(0, 64, 0, "A")); + chunk.invalidateSection(-3); + assertFalse(chunk.isPoiSectionValid(-3)); + chunk.clear(); + assertTrue(chunk.isEmpty()); + assertTrue(chunk.isPoiSectionValid(-3)); + } + + public void testSectionInvalidation() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + assertTrue(chunk.isPoiSectionValid(Byte.MIN_VALUE)); + assertTrue(chunk.isPoiSectionValid(Byte.MAX_VALUE)); + chunk.invalidateSection(Byte.MIN_VALUE); + chunk.invalidateSection(Byte.MAX_VALUE); + assertFalse(chunk.isPoiSectionValid(Byte.MIN_VALUE)); + assertFalse(chunk.isPoiSectionValid(Byte.MAX_VALUE)); + assertTrue(chunk.isPoiSectionValid(Byte.MIN_VALUE + 1)); + assertTrue(chunk.isPoiSectionValid(Byte.MAX_VALUE - 1)); + + assertThrowsIllegalArgumentException(() -> chunk.invalidateSection(Byte.MIN_VALUE - 1)); + assertThrowsIllegalArgumentException(() -> chunk.invalidateSection(Byte.MAX_VALUE + 1)); + assertThrowsIllegalArgumentException(() -> chunk.isPoiSectionValid(Byte.MIN_VALUE - 1)); + assertThrowsIllegalArgumentException(() -> chunk.isPoiSectionValid(Byte.MAX_VALUE + 1)); + } + + public void testGetFirstByXYZ() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(3, 2, 1, "A")); + chunk.add(createPoiRecord(1, 2, 3, "B")); + chunk.add(createPoiRecord(1, 2, 3, "C")); + chunk.add(createPoiRecord(1, 2, -3, "D")); + + assertEquals("B", chunk.getFirst(1, 2, 3).getType()); + assertEquals("D", chunk.getFirst(1, 2, -3).getType()); + assertNull(chunk.getFirst(9, 9, 9)); + } + + public void testGetAllByXYZ() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(3, 2, 1, "A")); + chunk.add(createPoiRecord(1, 2, 3, "B")); + chunk.add(createPoiRecord(1, 2, 3, "C")); + chunk.add(createPoiRecord(1, 2, -3, "D")); + + List regions = chunk.getAll(1, 2, 3); + assertEquals(2, regions.size()); + assertEquals("B", regions.get(0).getType()); + assertEquals("C", regions.get(1).getType()); + + regions = chunk.getAll(1, 2, -3); + assertEquals(1, regions.size()); + assertEquals("D", regions.get(0).getType()); + + regions = chunk.getAll(9, 9, 9); + assertTrue(regions.isEmpty()); + } + + public void testGetAllByType() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(3, 2, 1, "A")); + chunk.add(createPoiRecord(1, 2, 3, "B")); + chunk.add(createPoiRecord(1, 2, 3, "C")); + chunk.add(createPoiRecord(1, 2, -3, "A")); + + List regions = chunk.getAll("A"); + assertEquals(2, regions.size()); + assertEquals("A", regions.get(0).getType()); + assertEquals("A", regions.get(1).getType()); + assertNotSame(regions.get(0), regions.get(1)); + + regions = chunk.getAll("X"); + assertTrue(regions.isEmpty()); + } + + public void testRemoveByObject() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(3, 2, 1, "A")); + RT recordB = createPoiRecord(1, 2, 3, "B"); + chunk.add(recordB); + chunk.add(createPoiRecord(1, 2, 3, "C")); + + // removes by ref + assertTrue(chunk.remove(recordB)); + assertEquals(2, chunk.size()); + + // not found + assertFalse(chunk.remove(recordB)); + assertEquals(2, chunk.size()); + + // null + assertFalse(chunk.remove(null)); + assertEquals(2, chunk.size()); + + // removes by equality + assertTrue(chunk.remove(createPoiRecord(1, 2, 3, "C"))); + assertEquals(1, chunk.size()); + assertEquals("A", chunk.getAll().get(0).getType()); + } + + public void testRemoveAllByCollection() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(0, 64, 0, "A")); + chunk.add(createPoiRecord(0, 64, 1, "B")); + chunk.add(createPoiRecord(1, 64, 0, "C")); + chunk.add(createPoiRecord(2, 64, 2, "A")); + chunk.add(createPoiRecord(0, 65, 0, "B")); + + List rem = new ArrayList<>(chunk.getAll("A")); + rem.add(createPoiRecord(0, 64, 1, "B")); + rem.add(null); + rem.add(new Object()); + + assertTrue(chunk.removeAll(rem)); + assertEquals(2, chunk.size()); + assertEquals("C", chunk.getAll().get(0).getType()); + assertEquals("B", chunk.getAll().get(1).getType()); + + assertFalse(chunk.removeAll(rem)); + assertEquals(2, chunk.size()); + } + + public void testRemoveAllByXYZ() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(3, 2, 1, "A")); + chunk.add(createPoiRecord(1, 2, 3, "B")); + chunk.add(createPoiRecord(1, 2, 3, "C")); + chunk.add(createPoiRecord(1, 2, -3, "D")); + assertTrue(chunk.removeAll(1, 2, 3)); + assertEquals(2, chunk.size()); + List regions = chunk.getAll(); + assertEquals("A", regions.get(0).getType()); + assertEquals("D", regions.get(1).getType()); + } + + public void testRemoveAllByType() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(3, 2, 1, "A")); + chunk.add(createPoiRecord(1, 2, 3, "B")); + chunk.add(createPoiRecord(1, 2, 3, "A")); + chunk.add(createPoiRecord(1, 2, -3, "D")); + assertTrue(chunk.removeAll("A")); + assertEquals(2, chunk.size()); + assertEquals("B", chunk.getAll().get(0).getType()); + assertEquals("D", chunk.getAll().get(1).getType()); + + // not found + assertFalse(chunk.removeAll("X")); + assertEquals(2, chunk.size()); + + // null + assertFalse(chunk.removeAll((String) null)); + assertEquals(2, chunk.size()); + + // empty + assertFalse(chunk.removeAll("")); + assertEquals(2, chunk.size()); + } + + public void testRemoveFirstByXYZ() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(3, 2, 1, "A")); + chunk.add(createPoiRecord(1, 2, 3, "B")); + chunk.add(createPoiRecord(1, 2, 3, "C")); + assertEquals(3, chunk.size()); + RT record = chunk.removeFirst(1, 2, 3); + assertEquals("B", record.getType()); + assertEquals(2, chunk.size()); + // not found + assertNull(chunk.removeFirst(9, 9, 9)); + assertEquals(2, chunk.size()); + } + + public void testContainsAll() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(0, 64, 0, "A")); + chunk.add(createPoiRecord(0, -653, 1, "B")); + chunk.add(createPoiRecord(1, 1587, 0, "C")); + List ref = new ArrayList<>(); + assertTrue(chunk.containsAll(ref)); + ref.add(createPoiRecord(0, -653, 1, "B")); + assertTrue(chunk.containsAll(ref)); + ref.add(createPoiRecord(0, 0, 0, "A")); + assertFalse(chunk.containsAll(ref)); + ref.remove(0); + assertFalse(chunk.containsAll(ref)); + ref.clear(); + ref.add(null); + assertFalse(chunk.containsAll(ref)); + ref.add(createPoiRecord(0, -653, 1, "B")); + assertFalse(chunk.containsAll(ref)); + } + + public void testAddAll() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(0, 0, 0, "A")); + chunk.add(createPoiRecord(0, 0, 1, "A")); + chunk.add(createPoiRecord(0, 0, 2, "A")); + + List ref = new ArrayList<>(); + assertFalse(chunk.addAll(ref)); + ref.add(createPoiRecord(0, 0, 4, "A")); + ref.add(createPoiRecord(0, 0, 5, "A")); + assertTrue(chunk.addAll(ref)); + assertEquals(5, chunk.size()); + + ref.clear(); + ref.add(null); + assertFalse(chunk.addAll(ref)); + ref.add(createPoiRecord(0, 0, 6, "B")); + assertTrue(chunk.addAll(ref)); + assertEquals(6, chunk.size()); + assertFalse(chunk.stream().anyMatch(Objects::isNull)); + + // currently duplicates are not prevented + // if that changes in the future and this fails then update this test :) + ref.clear(); + ref.add(createPoiRecord(0, 0, 4, "A")); + ref.add(createPoiRecord(0, 0, 5, "A")); + assertTrue(chunk.addAll(ref)); + assertEquals(8, chunk.size()); + } + + public void testRetainAll() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(0, 0, 0, "A")); + chunk.add(createPoiRecord(0, 0, 1, "A")); + chunk.add(createPoiRecord(0, 0, 2, "A")); + + List ref = new ArrayList<>(); + ref.add(createPoiRecord(0, 0, 0, "A")); + ref.add(createPoiRecord(0, 0, 1, "A")); + ref.add(createPoiRecord(0, 0, 2, "A")); + assertFalse(chunk.retainAll(ref)); + assertEquals(3, chunk.size()); + + ref.remove(1); + ref.add(createPoiRecord(0, 0, 9, "B")); + assertTrue(chunk.retainAll(ref)); + assertEquals(2, chunk.size()); + assertEquals(0, chunk.getAll().get(0).getZ()); + assertEquals(2, chunk.getAll().get(1).getZ()); + } + + public void testSet() { + T chunk = createChunk(DEFAULT_TEST_VERSION); + chunk.add(createPoiRecord(0, 0, 0, "A")); + chunk.invalidateSection(2); + + List ref = new ArrayList<>(); + ref.add(createPoiRecord(0, 0, 0, "B")); + ref.add(createPoiRecord(0, 0, 1, "A")); + chunk.set(ref); + assertEquals(2, chunk.size()); + assertFalse(chunk.contains(createPoiRecord(0, 0, 0, "A"))); + assertTrue(chunk.contains(createPoiRecord(0, 0, 0, "B"))); + assertTrue(chunk.contains(createPoiRecord(0, 0, 1, "A"))); + assertTrue(chunk.isPoiSectionValid(2)); + + chunk.invalidateSection(2); + chunk.set(null); + assertEquals(0, chunk.size()); + assertTrue(chunk.isPoiSectionValid(2)); + } + + public void testTypeFilteredIterator() { + T chunk = createFilledChunk(-65, -42, DEFAULT_TEST_VERSION); + final int originalSize = chunk.size(); + Iterator iter = chunk.iterator("minecraft:nether_portal"); + assertNotNull(iter); + for (int i = 0; i < 6; i++) { + assertTrue(iter.hasNext()); + assertNotNull(iter.next()); + } + assertFalse(iter.hasNext()); + + assertNotNull(chunk.iterator("")); + assertNotNull(chunk.iterator(null)); + assertFalse(chunk.iterator("").hasNext()); + assertFalse(chunk.iterator(null).hasNext()); + } + + public void testUpdateHandle() { + // identity + CompoundTag expectedTag = createTag(DEFAULT_TEST_VERSION.id(), -65, -42); + T chunk = createFilledChunk(-65, -42, DEFAULT_TEST_VERSION); + chunk.getHandle().clear(); + assertEquals(expectedTag, chunk.updateHandle()); + + // writes empty section if it's marked invalid + chunk.invalidateSection(Byte.MAX_VALUE); + CompoundTag newSection = new CompoundTag(); + newSection.putBoolean("Valid", false); + newSection.put("Records", new ListTag<>(CompoundTag.class)); + expectedTag.getCompoundTag("Sections").put(Integer.toString(Byte.MAX_VALUE), newSection); + assertEquals(expectedTag, chunk.updateHandle()); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/PoiChunkTest.java b/src/test/java/io/github/ensgijs/nbt/mca/PoiChunkTest.java new file mode 100644 index 00000000..dfe73dad --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/PoiChunkTest.java @@ -0,0 +1,56 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.io.TextNbtParser; +import io.github.ensgijs.nbt.mca.io.LoadFlags; +import io.github.ensgijs.nbt.mca.io.MoveChunkFlags; +import io.github.ensgijs.nbt.query.NbtPath; +import io.github.ensgijs.nbt.tag.CompoundTag; + +import java.io.IOException; + +public class PoiChunkTest extends PoiChunkBaseTest { + + @Override + protected PoiChunk createChunk(DataVersion dataVersion) { + return new PoiChunk(dataVersion.id()); + } + + @Override + protected PoiChunk createChunk(CompoundTag tag) { + return new PoiChunk(tag); + } + + @Override + protected PoiChunk createChunk(CompoundTag tag, long loadFlags) { + return new PoiChunk(tag, loadFlags); + } + + @Override + protected PoiRecord createPoiRecord(int x, int y, int z, String type) { + return new PoiRecord(x, y, z, type); + } + + + public void testMoveChunk_viaPoiRecords() { + PoiChunk chunk = createFilledChunk(0, 0, DEFAULT_TEST_VERSION); + PoiRecord record = new PoiRecord(1, 200, 3, "whatever"); + chunk.add(record); + assertTrue(chunk.moveChunk(-3, 4, MoveChunkFlags.MOVE_CHUNK_DEFAULT_FLAGS)); + assertEquals((-3 * 16) + 1, record.x); + assertEquals(200, record.y); + assertEquals((4 * 16) + 3, record.z); + } + + public void testMoveChunk_poiRecordsNotParsed() throws IOException { + final String nbtText = "{DataVersion:3700,Sections:{\"5\":{Records:[{free_tickets:0,pos:[I;-1219,83,-1339],type:\"minecraft:bee_nest\"}],Valid:1b}}}"; + PoiChunk chunk = new PoiChunk(TextNbtParser.parseInline(nbtText), LoadFlags.RAW); + chunk.chunkX = -77; + chunk.chunkZ = -84; + assertTrue(chunk.moveChunk(-3, 4, MoveChunkFlags.MOVE_CHUNK_DEFAULT_FLAGS)); +// System.out.println(TextNbtHelpers.toTextNbt(chunk.getHandle())); + int[] newPos = NbtPath.of("Sections.5.Records[0].pos").getIntArray(chunk.getHandle()); + assertEquals(-35, newPos[0]); + assertEquals(83, newPos[1]); + assertEquals(69, newPos[2]); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/PoiRecordTest.java b/src/test/java/io/github/ensgijs/nbt/mca/PoiRecordTest.java new file mode 100644 index 00000000..21cbcd7b --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/PoiRecordTest.java @@ -0,0 +1,85 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.tag.CompoundTag; +import junit.framework.TestCase; + +public class PoiRecordTest extends TestCase { + public static CompoundTag makeTag(int tickets, String type, int x, int y, int z) { + CompoundTag tag = new CompoundTag(); + tag.putString("type", type); + tag.putIntArray("pos", new int[] {x, y, z}); + tag.putInt("free_tickets", tickets); + return tag; + } + + public void testConstructor_CompoundTag() { + PoiRecord record = new PoiRecord(makeTag(3, "minecraft:test", 7, -42, 1777)); + assertEquals(3, record.getFreeTickets()); + assertEquals("minecraft:test", record.getType()); + assertEquals(7, record.getX()); + assertEquals(-42, record.getY()); + assertEquals(1777, record.getZ()); + } + + public void testCopyConstructor() { + CompoundTag tag = makeTag(3, "minecraft:test", 1_234_679, 315, -8_546_776); + PoiRecord recordA = new PoiRecord(tag); + PoiRecord recordB = new PoiRecord(recordA); + assertEquals(recordA, recordB); + assertEquals(recordA.getFreeTickets(), recordB.getFreeTickets()); + assertEquals(recordA.getHandle(), recordB.getHandle()); + assertNotSame(recordA.getHandle(), recordB.getHandle()); + } + + public void testUpdateHandle() { + CompoundTag tag = makeTag(0, "minecraft:test", 1_234_679, 315, -8_546_776); + PoiRecord record = new PoiRecord(tag); + assertEquals(tag, record.updateHandle()); + // if impl changes to hold onto tag this test will need to be updated, this is here to catch that + assertNotSame(tag, record.updateHandle()); + } + + public void testGetHandle() { + CompoundTag tag = makeTag(0, "minecraft:test", 1_234_679, 315, -8_546_776); + PoiRecord record = new PoiRecord(tag); + assertEquals(tag, record.getHandle()); + // if impl changes to hold onto tag this test will need to be updated, this is here to catch that + assertNotSame(tag, record.getHandle()); + } + + public void testGetSectionY() { + PoiRecord record = new PoiRecord(); + record.setY(77); + assertEquals(4, record.getSectionY()); + record.setY(-38); + assertEquals(-3, record.getSectionY()); + } + + public void testEquals() { + PoiRecord record1 = new PoiRecord(1, 2, 3, "foo", 5); + PoiRecord record2 = new PoiRecord(1, 2, 3, "foo", 5); + assertEquals(record1, record2); + record1.setX(0); + assertFalse(record1.equals(record2)); + record1.setX(1); + record1.setY(0); + assertFalse(record1.equals(record2)); + record1.setY(2); + record1.setZ(0); + assertFalse(record1.equals(record2)); + record1.setZ(3); + record1.setFreeTickets(0); + assertTrue(record1.equals(record2)); // free tickets should not be considered in equals + record1.setFreeTickets(5); + record1.setType("bar"); + assertFalse(record1.equals(record2)); + record1.setType("foo"); + assertEquals(record1, record2); // make sure there wasn't a fubar in the test chain + } + + public void testHashCode_ignoresFreeTickets() { + PoiRecord record1 = new PoiRecord(1, 2, 3, "foo", 5); + PoiRecord record2 = new PoiRecord(1, 2, 3, "foo", 7); + assertEquals(record1.hashCode(), record2.hashCode()); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/TerrainChunkBaseTest.java b/src/test/java/io/github/ensgijs/nbt/mca/TerrainChunkBaseTest.java new file mode 100644 index 00000000..10755bea --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/TerrainChunkBaseTest.java @@ -0,0 +1,90 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.io.TextNbtHelpers; +import io.github.ensgijs.nbt.mca.util.IntPointXZ; +import io.github.ensgijs.nbt.mca.util.RegionBoundingRectangle; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.IntArrayTag; + +import java.io.IOException; + +import static org.junit.Assert.assertArrayEquals; + +public abstract class TerrainChunkBaseTest extends ChunkBaseTest { + + public void testMoveBoundingBox() { + IntArrayTag bb = new IntArrayTag(1, 2, 3, 4, 5, 6); + assertTrue(TerrainChunkBase.moveBoundingBox( + bb, + new IntPointXZ(-12, 42), + new RegionBoundingRectangle(0, 0) + )); + assertArrayEquals(new int[] {}, bb.getValue()); + + bb = new IntArrayTag(1, 2, 3, 4, 5, 6); + assertTrue(TerrainChunkBase.moveBoundingBox( + bb, + new IntPointXZ(-12, 42), + new RegionBoundingRectangle(-1, 0) + )); + assertArrayEquals(new int[] {1 - 12, 2, 3 + 42, 4 - 12, 5, 6 + 42}, bb.getValue()); + } + + public void testMoveStructureStart_happyCase() throws IOException { + T chunk = createChunk(DataVersion.latest()); + CompoundTag startsTag = TextNbtHelpers.readTextNbtFile(getResourceFile("chunk_mover_samples/single_start_record_with_all_xz_fields.r.-3.-3.c.-95.-85.snbt")).getTagAutoCast(); + IntPointXZ regionDeltaXZ = new IntPointXZ(3, 3); + assertTrue(chunk.moveStructureStart( + startsTag, + regionDeltaXZ.transformRegionToChunk(), + regionDeltaXZ.transformRegionToBlock(), + new RegionBoundingRectangle(0, 0))); +// System.out.println(TextNbtHelpers.toTextNbt(startsTag)); + CompoundTag expectedStartsTag = TextNbtHelpers.readTextNbtFile(getResourceFile("chunk_mover_samples/single_start_record_with_all_xz_fields.r.0.0.c.1.11.snbt")).getTagAutoCast(); + assertEquals(expectedStartsTag, startsTag); + } + + public void testMoveStructureStart_structOutOfRegionBounds() throws IOException { + T chunk = createChunk(DataVersion.latest()); + CompoundTag startsTag = TextNbtHelpers.readTextNbtFile(getResourceFile("chunk_mover_samples/single_start_record_with_all_xz_fields.r.-3.-3.c.-95.-85.snbt")).getTagAutoCast(); + IntPointXZ regionDeltaXZ = new IntPointXZ(3, 3); + assertTrue(chunk.moveStructureStart( + startsTag, + regionDeltaXZ.transformRegionToChunk(), + regionDeltaXZ.transformRegionToBlock(), + new RegionBoundingRectangle(1, 1))); // an out-of-bounds area +// System.out.println(TextNbtHelpers.toTextNbt(startsTag)); + assertEquals(1, startsTag.size()); + assertEquals("INVALID", startsTag.getString("id")); + } + + public void testMoveStructureStart_structPartiallyOutOfRegionBounds_clipped() throws IOException { + T chunk = createChunk(DataVersion.latest()); + CompoundTag startsTag = TextNbtHelpers.readTextNbtFile(getResourceFile("chunk_mover_samples/single_start_record_with_all_xz_fields.r.-3.-3.c.-95.-85.snbt")).getTagAutoCast(); + // specify that we want to move the chunk to 0 0 + IntPointXZ chunkDeltaXZ = new IntPointXZ(-startsTag.getInt("ChunkX"), -startsTag.getInt("ChunkZ")); + assertTrue(chunk.moveStructureStart( + startsTag, + chunkDeltaXZ, + chunkDeltaXZ.transformChunkToBlock(), + new RegionBoundingRectangle(0, 0))); +// System.out.println(TextNbtHelpers.toTextNbt(startsTag)); + CompoundTag expectedStartsTag = TextNbtHelpers.readTextNbtFile(getResourceFile("chunk_mover_samples/single_start_record_with_all_xz_fields.r.0.0.c.0.0_POST_MOVE_CLIPPED.snbt")).getTagAutoCast(); + assertEquals(expectedStartsTag, startsTag); + } + + public void testMoveStructureStart_structPartiallyOutOfRegionBounds_noclip() throws IOException { + T chunk = createChunk(DataVersion.latest()); + CompoundTag startsTag = TextNbtHelpers.readTextNbtFile(getResourceFile("chunk_mover_samples/single_start_record_with_all_xz_fields.r.-3.-3.c.-95.-85.snbt")).getTagAutoCast(); + // specify that we want to move the chunk to 0 0 + IntPointXZ chunkDeltaXZ = new IntPointXZ(-startsTag.getInt("ChunkX"), -startsTag.getInt("ChunkZ")); + assertTrue(chunk.moveStructureStart( + startsTag, + chunkDeltaXZ, + chunkDeltaXZ.transformChunkToBlock(), + null)); // no clipping box +// System.out.println(TextNbtHelpers.toTextNbt(startsTag)); + CompoundTag expectedStartsTag = TextNbtHelpers.readTextNbtFile(getResourceFile("chunk_mover_samples/single_start_record_with_all_xz_fields.r.0.0.c.0.0_POST_MOVE_NOCLIP.snbt")).getTagAutoCast(); + assertEquals(expectedStartsTag, startsTag); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/TerrainChunkTest.java b/src/test/java/io/github/ensgijs/nbt/mca/TerrainChunkTest.java new file mode 100644 index 00000000..059c6d7c --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/TerrainChunkTest.java @@ -0,0 +1,56 @@ +package io.github.ensgijs.nbt.mca; + +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.ListTag; + +public class TerrainChunkTest extends TerrainChunkBaseTest { + @Override + protected TerrainChunk createChunk(DataVersion dataVersion) { + return TerrainChunk.newChunk(dataVersion.id()); + } + + @Override + protected TerrainChunk createChunk(CompoundTag tag) { + return new TerrainChunk(tag); + } + + @Override + protected TerrainChunk createChunk(CompoundTag tag, long loadFlags) { + return new TerrainChunk(tag, loadFlags); + } + + @Override + protected CompoundTag createTag(int dataVersion, int chunkX, int chunkZ) { + final CompoundTag tag = super.createTag(dataVersion, chunkX, chunkZ); + if (dataVersion < DataVersion.JAVA_1_18_21W39A.id()) { + tag.put("Level", new CompoundTag()); + } + if (dataVersion < 0) return tag; + // TODO: there should probably be a ctor that sets this up for library users to help create new, empty, chunks + TerrainChunkBase.X_POS_PATH.get(dataVersion).put(tag, chunkX); + TerrainChunkBase.Z_POS_PATH.get(dataVersion).put(tag, chunkZ); + TerrainChunkBase.INHABITED_TIME_TICKS_PATH.get(dataVersion).put(tag, 42L); + TerrainChunkBase.POST_PROCESSING_PATH.get(dataVersion).putTag(tag, new ListTag>(ListTag.class)); + TerrainChunkBase.STATUS_PATH.get(dataVersion).put(tag, "empty"); + TerrainChunkBase.TILE_ENTITIES_PATH.get(dataVersion).putTag(tag, new ListTag<>(CompoundTag.class)); + TerrainChunkBase.TILE_TICKS_PATH.get(dataVersion).putTag(tag, new ListTag<>(CompoundTag.class)); + TerrainChunkBase.LIQUID_TICKS_PATH.get(dataVersion).putTag(tag, new ListTag<>(CompoundTag.class)); + TerrainChunkBase.IS_LIGHT_ON_PATH.get(dataVersion).put(tag, true); + TerrainChunkBase.SECTIONS_PATH.get(dataVersion).putTag(tag, new ListTag<>(CompoundTag.class)); + CompoundTag structuresTag = new CompoundTag(); + TerrainChunkBase.STRUCTURES_PATH.get(dataVersion).putTag(tag, structuresTag); + TerrainChunkBase.STRUCTURES_REFERENCES_PATH.get(dataVersion).putTag(structuresTag, new CompoundTag()); + TerrainChunkBase.STRUCTURES_STARTS_PATH.get(dataVersion).putTag(structuresTag, new CompoundTag()); + if (TerrainChunkBase.Y_POS_PATH.get(dataVersion) != null) { + TerrainChunkBase.Y_POS_PATH.get(dataVersion).put(tag, TerrainChunkBase.DEFAULT_WORLD_BOTTOM_Y_POS.get(dataVersion)); + } + return tag; + } + + @Override + protected void validateAllDataConstructor(TerrainChunk chunk, int expectedChunkX, int expectedChunkZ) { + // TODO override createTag and put interesting stuff in the chunk and validate it here + assertEquals(expectedChunkX, chunk.getChunkX()); + assertEquals(expectedChunkZ, chunk.getChunkZ()); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/entities/EntityFactoryTest.java b/src/test/java/io/github/ensgijs/nbt/mca/entities/EntityFactoryTest.java new file mode 100644 index 00000000..023bf1e6 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/entities/EntityFactoryTest.java @@ -0,0 +1,271 @@ +package io.github.ensgijs.nbt.mca.entities; + +import io.github.ensgijs.nbt.mca.DataVersion; +import io.github.ensgijs.nbt.mca.McaTestCase; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.ListTag; + +import java.util.ArrayList; +import java.util.List; + +public class EntityFactoryTest extends McaTestCase { + + // + private static class EntityStub extends EntityBase { + final String givenNormalizedId; + final EntityCreatorStub creator; + + public EntityStub(EntityCreatorStub creator, String givenNormalizedId, CompoundTag data, int dataVersion) { + super(data, dataVersion); + this.creator = creator; + this.givenNormalizedId = givenNormalizedId; + this.dataVersion = dataVersion; + } + + public EntityStub(String id, int dataVersion) { + super(dataVersion, id, 0, 0, 0); + this.creator = null; + this.givenNormalizedId = null; + } + } + + private static class EntityCreatorStub implements EntityCreator { + final String name; + boolean returnNull; + boolean violateIdContract; + RuntimeException throwMe; + public EntityCreatorStub(String name) { + this.name = name; + } + + @Override + public EntityStub create(String normalizedId, CompoundTag tag, int dataVersion) { + if (throwMe != null) throw throwMe; + if (returnNull) return null; + EntityStub entity = new EntityStub(this, normalizedId, tag, dataVersion); + if (violateIdContract) { + entity.id = null; + } + return entity; + } + + @Override + public String toString() { + return name; + } + } + // + + final EntityCreatorStub defaultedCreator = new EntityCreatorStub("TEST DEFAULT CREATOR"); + + @Override + public void setUp() throws Exception { + super.setUp(); + EntityFactory.clearCreators(); + EntityFactory.clearEntityIdRemap(); + EntityFactory.setDefaultEntityCreator(defaultedCreator); + } + + @Override + public void tearDown() throws Exception { + super.tearDown(); + EntityFactory.clearCreators(); + EntityFactory.resetEntityIdRemap(); + } + + public void testNormalizeId() { + assertThrowsIllegalArgumentException(() -> EntityFactory.normalizeId(null)); + assertThrowsNoException(() -> EntityFactory.normalizeId("not_null")); + assertThrowsIllegalArgumentException(() -> EntityFactory.normalizeId("minecraft:")); + + assertEquals("PIG_ZIG", EntityFactory.normalizeId("minecraft:Pig_zig")); + assertEquals("PIGZIG", EntityFactory.normalizeId("minecraft:PigZig")); + } + + public void testNormalizeAndRemapId() { + assertThrowsIllegalArgumentException(() -> EntityFactory.normalizeAndRemapId(null)); + assertThrowsNoException(() -> EntityFactory.normalizeAndRemapId("not_null")); + assertThrowsIllegalArgumentException(() -> EntityFactory.normalizeAndRemapId("minecraft:")); + // not mapped + assertEquals("PIGZIG", EntityFactory.normalizeAndRemapId("minecraft:PigZig")); + // remap + EntityFactory.registerIdRemap("pigzig", "pig_zig"); + assertEquals("PIG_ZIG", EntityFactory.normalizeAndRemapId("minecraft:PigZig")); + } + + public void testDefaultCreatorNotNull() { + assertNotNull(EntityFactory.getDefaultEntityCreator()); + } + + public void testSetDefaultCreator() { + assertNotNull(EntityFactory.getDefaultEntityCreator()); + assertThrowsIllegalArgumentException(() -> EntityFactory.setDefaultEntityCreator(null)); + EntityCreator ec = new EntityCreatorStub(getName()); + EntityFactory.setDefaultEntityCreator(ec); + assertSame(ec, EntityFactory.getDefaultEntityCreator()); + } + + public void testRegisterCreator_basic() { + EntityCreator ec = new EntityCreatorStub(getName()); + assertNull(EntityFactory.getCreatorById("FOO")); + EntityFactory.registerCreator(ec, "foo"); + assertSame(ec, EntityFactory.getCreatorById("FOO")); + assertNull(EntityFactory.getCreatorById("BAR")); + } + + public void testRegisterCreator_multiple() { + EntityCreator ec = new EntityCreatorStub(getName()); + EntityFactory.registerCreator(ec, "foo", "minecraft:bar"); + assertSame(ec, EntityFactory.getCreatorById("FOO")); + assertSame(ec, EntityFactory.getCreatorById("BAR")); + assertNull(EntityFactory.getCreatorById("BAZ")); + } + + public void testRegisterCreator_throwsOnNullId() { + EntityCreator ec = new EntityCreatorStub(getName()); + assertThrowsIllegalArgumentException(() -> EntityFactory.registerCreator(ec, (String) null)); + assertThrowsIllegalArgumentException(() -> EntityFactory.registerCreator(ec, "foo", null, "minecraft:bar")); + } + + public void testRegisterCreator_afterRegisteringRemapping() { + EntityCreator fooEc = new EntityCreatorStub(getName()); + EntityFactory.registerIdRemap("bar", "foo"); + EntityFactory.registerIdRemap("baz", "foo"); + EntityFactory.registerCreator(fooEc, "foo"); + assertSame(fooEc, EntityFactory.getCreatorById("FOO")); + assertSame(fooEc, EntityFactory.getCreatorById("BAR")); + assertSame(fooEc, EntityFactory.getCreatorById("BAZ")); + assertNull(EntityFactory.getCreatorById("ZAP")); + } + + public void testRegisteringRemapping_afterRegisterCreator() { + EntityCreator fooEc = new EntityCreatorStub(getName()); + EntityFactory.registerCreator(fooEc, "foo"); + EntityFactory.registerIdRemap("bar", "foo"); + EntityFactory.registerIdRemap("baz", "foo"); + assertSame(fooEc, EntityFactory.getCreatorById("FOO")); + assertSame(fooEc, EntityFactory.getCreatorById("BAR")); + assertSame(fooEc, EntityFactory.getCreatorById("BAZ")); + assertNull(EntityFactory.getCreatorById("ZAP")); + } + + public void testReverseIdRemap() { + List rev = EntityFactory.reverseIdRemap("foo"); + assertNotNull(rev); + assertTrue(rev.isEmpty()); + + EntityFactory.registerIdRemap("bar", "foo"); + + // this isn't a reverse lookup, it would be forward + rev = EntityFactory.reverseIdRemap("bar"); + assertNotNull(rev); + assertTrue(rev.isEmpty()); + + // one reverse match + rev = EntityFactory.reverseIdRemap("foo"); + assertNotNull(rev); + assertEquals(1, rev.size()); + + + EntityFactory.registerIdRemap("oof", "foo"); + rev = EntityFactory.reverseIdRemap("foo"); + assertNotNull(rev); + assertEquals(2, rev.size()); + } + + public void testGetRegisteredCreatorIdKeys() { + assertTrue(EntityFactory.getRegisteredCreatorIdKeys().isEmpty()); + EntityFactory.registerCreator(new EntityCreatorStub("A"), "FOO", "BAR"); + EntityFactory.registerCreator(new EntityCreatorStub("B"), "ZOO"); + assertEquals(3, EntityFactory.getRegisteredCreatorIdKeys().size()); + assertTrue(EntityFactory.getRegisteredCreatorIdKeys().contains("FOO")); + assertTrue(EntityFactory.getRegisteredCreatorIdKeys().contains("BAR")); + assertTrue(EntityFactory.getRegisteredCreatorIdKeys().contains("ZOO")); + } + + public void testCreate() { + CompoundTag tag = new CompoundTag(); + tag.putString("id", "whatever"); + EntityStub entityStub = EntityFactory.createAutoCast(tag, DataVersion.latest().id()); + assertNotNull(entityStub); + assertSame(defaultedCreator, entityStub.creator); + assertEquals("WHATEVER", entityStub.givenNormalizedId); + assertSame(tag, entityStub.getHandle()); + + EntityCreatorStub ec = new EntityCreatorStub(getName()); + EntityFactory.registerIdRemap("Muggle", "non_wizard"); + EntityFactory.registerCreator(ec, "non_wizard"); + + // check that the default is still used + entityStub = EntityFactory.createAutoCast(tag, DataVersion.latest().id()); + assertNotNull(entityStub); + assertSame(defaultedCreator, entityStub.creator); + assertEquals("WHATEVER", entityStub.givenNormalizedId); + assertSame(tag, entityStub.getHandle()); + + // check behavior with use of preferred name + tag = new CompoundTag(); + tag.putString("id", "non_wizard"); + entityStub = EntityFactory.createAutoCast(tag, DataVersion.latest().id()); + assertNotNull(entityStub); + assertSame(ec, entityStub.creator); + assertEquals("NON_WIZARD", entityStub.givenNormalizedId); + assertSame(tag, entityStub.getHandle()); + + // check behavior with use of legacy name + tag = new CompoundTag(); + tag.putString("id", "Muggle"); + entityStub = EntityFactory.createAutoCast(tag, DataVersion.latest().id()); + assertNotNull(entityStub); + assertSame(ec, entityStub.creator); + assertEquals("NON_WIZARD", entityStub.givenNormalizedId); + assertSame(tag, entityStub.getHandle()); + } + + public void testToListTag() { + List entities = new ArrayList<>(); + entities.add(new EntityStub("frank", DataVersion.latest().id())); + entities.add(new EntityStub("sam", DataVersion.latest().id())); + ListTag eTag = EntityFactory.toListTag(entities); + assertEquals(2, eTag.size()); + assertEquals("frank", eTag.get(0).getString("id")); + + entities.clear(); + EntityStub bubba = new EntityStub("bubba", DataVersion.latest().id()); + entities.add(bubba); + assertSame(eTag, EntityFactory.toListTag(entities, eTag)); + assertEquals(1, eTag.size()); + assertEquals("bubba", eTag.get(0).getString("id")); + + entities.add(bubba); + assertThrowsIllegalArgumentException(() -> EntityFactory.toListTag(entities)); + } + + public void testCreate_throwsIllegalEntityTagException_whenCreatorReturnsNull() { + CompoundTag tag = new CompoundTag(); + tag.putString("id", "whatever"); + defaultedCreator.returnNull = true; + assertThrowsException(() -> EntityFactory.createAutoCast(tag, DataVersion.latest().id()), + IllegalEntityTagException.class); + } + + public void testCreate_throwsIllegalEntityTagException_whenCreatorThrows() { + CompoundTag tag = new CompoundTag(); + tag.putString("id", "whatever"); + defaultedCreator.throwMe = new NullPointerException(); + try { + EntityFactory.createAutoCast(tag, DataVersion.latest().id()); + fail(); + } catch (IllegalEntityTagException ex) { + assertSame(tag, ex.getTag()); + } + } + + public void testCreate_throwsIllegalStateException_whenCreatorReturnsEntityWithoutId() { + CompoundTag tag = new CompoundTag(); + tag.putString("id", "whatever"); + defaultedCreator.violateIdContract = true; + assertThrowsException(() -> EntityFactory.createAutoCast(tag, DataVersion.latest().id()), + IllegalStateException.class); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/entities/EntityImplTest.java b/src/test/java/io/github/ensgijs/nbt/mca/entities/EntityImplTest.java new file mode 100644 index 00000000..5229aa64 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/entities/EntityImplTest.java @@ -0,0 +1,819 @@ +package io.github.ensgijs.nbt.mca.entities; + +import io.github.ensgijs.nbt.mca.DataVersion; +import io.github.ensgijs.nbt.mca.McaTestCase; +import io.github.ensgijs.nbt.tag.CompoundTag; + +import static org.junit.Assert.*; + +import java.util.*; + +public class EntityImplTest extends McaTestCase { + + // + + protected CompoundTag makeEntityTag(String id) { + CompoundTag tag = new CompoundTag(); + tag.putString("id", id); + return tag; + } + + protected CompoundTag setBoolean(CompoundTag tag, String name, Boolean value) { + if (value == null) tag.remove(name); + else tag.putBoolean(name, value); + return tag; + } + + protected CompoundTag setInvulnerable(CompoundTag tag, Boolean value) { + return setBoolean(tag, "Invulnerable", value); + } + + protected CompoundTag setSilent(CompoundTag tag, Boolean value) { + return setBoolean(tag, "Silent", value); + } + + protected CompoundTag setGlowing(CompoundTag tag, Boolean value) { + return setBoolean(tag, "Glowing", value); + } + + protected CompoundTag setPos(CompoundTag tag, double x, double y, double z) { + tag.putDoubleArrayAsTagList("Pos", x, y, z); + return tag; + } + + protected CompoundTag setMotion(CompoundTag tag, double dx, double dy, double dz) { + tag.putDoubleArrayAsTagList("Motion", dx, dy, dz); + return tag; + } + + protected CompoundTag setRotation(CompoundTag tag, float yaw, float pitch) { + tag.putFloatArrayAsTagList("Rotation", yaw, pitch); + return tag; + } + + // + + // + + protected void assertPositionEquals(EntityBase entity, double expectX, double expectY, double expectZ) { + // assertEquals(double...) takes care of non-finite equality checking too! + assertEquals(expectX, entity.getX(), 1e-4); + assertEquals(expectY, entity.getY(), 1e-4); + assertEquals(expectZ, entity.getZ(), 1e-4); + } + + protected void assertMotionEquals(EntityBase entity, double expectDX, double expectDY, double expectDZ) { + // assertEquals(double...) takes care of non-finite equality checking too! + assertEquals(expectDX, entity.getMotionDX(), 1e-7); + assertEquals(expectDY, entity.getMotionDY(), 1e-7); + assertEquals(expectDZ, entity.getMotionDZ(), 1e-7); + } + + // sets values for everything but passenger and UUID + protected CompoundTag makeTestEntityTag() { + CompoundTag tag = makeEntityTag("pig"); + setPos(tag, 1420.276, 71.0, -317.416); + setRotation(tag, 346.4548f, -40f); + setMotion(tag, -0.01312, 0.117600, 0.05052); + tag.putShort("Air", (short) 147); + tag.putString("CustomName", "{\"text\":\"bob\"}"); + tag.putFloat("FallDistance", 23.787f); + tag.putShort("Fire", (short) -25); + tag.putInt("PortalCooldown", 96); + tag.putStringsAsTagList("Tags", Arrays.asList("T1", "another_one")); + tag.putInt("TicksFrozen", 291); + + tag.putBoolean("CustomNameVisible", true); + tag.putBoolean("Glowing", true); + tag.putBoolean("HasVisualFire", true); + tag.putBoolean("Invulnerable", true); + tag.putBoolean("NoGravity", true); + tag.putBoolean("OnGround", true); + tag.putBoolean("Silent", true); + + return tag; + } + + // + + // + + public void testConstructor_allTags() { + // Constructor for tags containing passengers transitively tested by #testUpdateHandle_withPassengers() + CompoundTag tag = makeTestEntityTag(); + UUID uuid = UUID.randomUUID(); + EntityUtil.setUuid(DataVersion.latest().id(), tag, uuid); + CompoundTag originalTagCopy = tag.clone(); + + EntityBase entity = new EntityBase(tag, DataVersion.latest().id()); + assertEquals(DataVersion.latest().id(), entity.getDataVersion()); + assertEquals("pig", entity.getId()); + assertEquals(uuid, entity.getUuid()); + + assertEquals(1420.276, entity.getX(), 1e-8); + assertEquals(71.0, entity.getY(), 1e-8); + assertEquals(-317.416, entity.getZ(), 1e-8); + + assertEquals(346.4548f, entity.getRotationYaw(), 1e-5f); + assertEquals(-40f, entity.getRotationPitch(), 1e-5f); + + assertEquals(-0.01312, entity.getMotionDX(), 1e-8); + assertEquals(0.117600, entity.getMotionDY(), 1e-8); + assertEquals(0.05052, entity.getMotionDZ(), 1e-8); + + assertEquals((short) 147, entity.getAir()); + assertEquals("{\"text\":\"bob\"}", entity.getCustomName()); + assertEquals(23.787f, entity.getFallDistance(), 1e-5f); + assertEquals((short) -25, entity.getFire()); + + assertEquals(96, entity.getPortalCooldown()); + assertEquals(Arrays.asList("T1", "another_one"), entity.getScoreboardTags()); + assertEquals(291, entity.getTicksFrozen()); + + assertTrue(entity.isCustomNameVisible()); + assertTrue(entity.isGlowing()); + assertTrue(entity.hasVisualFire()); + assertTrue(entity.isInvulnerable()); + assertTrue(entity.hasNoGravity()); + assertTrue(entity.isOnGround()); + assertTrue(entity.isSilent()); + + assertSame(tag, entity.getHandle()); + assertNotSame(originalTagCopy, entity.getHandle()); + assertEquals(originalTagCopy, entity.updateHandle()); + + // now to check for copy-paste errors on booleans + tag.putBoolean("CustomNameVisible", false); + entity = new EntityBase(tag, DataVersion.latest().id()); + assertFalse(entity.isCustomNameVisible()); + + tag.putBoolean("Glowing", false); + entity = new EntityBase(tag, DataVersion.latest().id()); + assertFalse(entity.isGlowing()); + + tag.putBoolean("HasVisualFire", false); + entity = new EntityBase(tag, DataVersion.latest().id()); + assertFalse(entity.hasVisualFire()); + + tag.putBoolean("Invulnerable", false); + entity = new EntityBase(tag, DataVersion.latest().id()); + assertFalse(entity.isInvulnerable()); + + tag.putBoolean("NoGravity", false); + entity = new EntityBase(tag, DataVersion.latest().id()); + assertFalse(entity.hasNoGravity()); + + tag.putBoolean("OnGround", false); + entity = new EntityBase(tag, DataVersion.latest().id()); + assertFalse(entity.isOnGround()); + + tag.putBoolean("Silent", false); + entity = new EntityBase(tag, DataVersion.latest().id()); + assertFalse(entity.isSilent()); + } + + public void testCopyConstructor_withStackedPassengers() { + EntityBase pig = new EntityBase(makeTestEntityTag(), DataVersion.latest().id()); + pig.setPosition(12, 65, -44); + EntityBase chicken = new EntityBase(DataVersion.latest().id(), "chicken"); + EntityBase zombie = new EntityBase(DataVersion.latest().id(), "zombie"); + UUID pigUuid = pig.generateNewUuid(); + UUID chickenUuid = chicken.generateNewUuid(); + UUID zombieUuid = zombie.generateNewUuid(); + chicken.addPassenger(zombie); + pig.addPassenger(chicken); + + EntityBase pig2 = pig.clone(); + assertNotEquals(pigUuid, pig2.getUuid()); + assertTrue(pig2.hasPassengers()); + assertEquals(1, pig2.getPassengers().size()); + + EntityBase chicken2 = (EntityBase) pig2.getPassengers().get(0); + assertNotEquals(chickenUuid, chicken2.getUuid()); + assertTrue(chicken2.hasPassengers()); + assertEquals(1, chicken2.getPassengers().size()); + + EntityBase zombie2 = (EntityBase) chicken2.getPassengers().get(0); + assertNotEquals(zombieUuid, zombie2.getUuid()); + assertFalse(zombie2.hasPassengers()); + } + + // + + // + + public void testUpdateHandle_positionTagNotOutputUnlessRotationIsValid() { + EntityBase entity = new EntityBase(DataVersion.latest().id(), "zombie"); + + assertFalse(entity.updateHandle().containsKey("Pos")); + + entity.setPosition(1, 2, 3); + assertTrue(entity.updateHandle().containsKey("Pos")); + + entity.setPosition(0, 0, Double.NaN); + assertFalse(entity.updateHandle().containsKey("Pos")); + + entity.setPosition(0, Double.NaN, 0); + assertFalse(entity.updateHandle().containsKey("Pos")); + + entity.setPosition(Double.NaN, 0, 0); + assertFalse(entity.updateHandle().containsKey("Pos")); + } + + public void testUpdateHandle_rotationTagNotOutputUnlessRotationIsValid() { + EntityBase entity = new EntityBase(DataVersion.latest().id(), "zombie", 0, 0, 0); + entity.setRotation(0, Float.NaN); + assertFalse(entity.updateHandle().containsKey("Rotation")); + + entity.setRotation(Float.NaN, 0); + assertFalse(entity.updateHandle().containsKey("Rotation")); + + entity.setRotation(0, 0); + assertTrue(entity.updateHandle().containsKey("Rotation")); + } + + public void testUpdateHandle_motionTagNotOutputUnlessMotionIsValid() { + EntityBase entity = new EntityBase(DataVersion.latest().id(), "zombie", 0, 0, 0); + entity.setMotion(0, 0, Double.NaN); + assertFalse(entity.updateHandle().containsKey("Motion")); + + entity.setMotion(0, Double.NaN, 0); + assertFalse(entity.updateHandle().containsKey("Motion")); + + entity.setMotion(Double.NaN, 0, 0); + assertFalse(entity.updateHandle().containsKey("Motion")); + + entity.setMotion(0, 0, 0); + assertTrue(entity.updateHandle().containsKey("Motion")); + } + + public void testUpdateHandle_airTagNotOutputWhenAirUnset() { + EntityBase entity = new EntityBase(DataVersion.latest().id(), "zombie", 0, 0, 0); + assertFalse(entity.updateHandle().containsKey("Air")); + + entity.setAir((short) 500); + assertTrue(entity.updateHandle().containsKey("Air")); + + entity.setAir(Entity.AIR_UNSET); + assertFalse(entity.updateHandle().containsKey("Air")); + } + + public void testUpdateHandle_uuidGeneratedWhenUnset() { + EntityBase entity = new EntityBase(DataVersion.latest().id(), "zombie", 0, 0, 0); + assertNull(entity.getUuid()); + assertTrue( entity.updateHandle().containsKey("UUID")); + assertNotNull(entity.getUuid()); + } + + public void testUpdateHandle_withPassengers() { + // transitively tests constructor with passengers + EntityBase pig = new EntityBase(makeTestEntityTag(), DataVersion.latest().id()); + pig.setPosition(12, 65, -44); + EntityBase chicken = new EntityBase(DataVersion.latest().id(), "chicken"); + EntityBase zombie = new EntityBase(DataVersion.latest().id(), "zombie"); + EntityBase skeleton = new EntityBase(DataVersion.latest().id(), "skeleton"); + pig.addPassenger(chicken); + assertTrue(chicken.isPositionValid()); + pig.addPassenger(skeleton); + assertTrue(skeleton.isPositionValid()); + chicken.addPassenger(zombie); + assertTrue(zombie.isPositionValid()); + + CompoundTag pigTag = pig.updateHandle(); + + assertTrue(pigTag.containsKey("Passengers")); + assertEquals(2, pigTag.getListTag("Passengers").asCompoundTagList().size()); + CompoundTag chickenTag = pigTag.getListTag("Passengers").asCompoundTagList().get(0); + CompoundTag skeletonTag = pigTag.getListTag("Passengers").asCompoundTagList().get(1); + + assertTrue(chickenTag.containsKey("Passengers")); + assertEquals(1, chickenTag.getListTag("Passengers").asCompoundTagList().size()); + CompoundTag zombieTag = chickenTag.getListTag("Passengers").asCompoundTagList().get(0); + + assertFalse(skeletonTag.containsKey("Passengers")); + assertFalse(zombieTag.containsKey("Passengers")); + + assertNotNull(pig.getUuid()); + assertEquals(pig.getUuid(), EntityUtil.getUuid(DataVersion.latest().id(), pigTag)); + assertEquals("pig", pigTag.getString("id")); + + assertNotNull(chicken.getUuid()); + assertEquals(chicken.getUuid(), EntityUtil.getUuid(DataVersion.latest().id(), chickenTag)); + assertEquals("chicken", chickenTag.getString("id")); + + assertNotNull(skeleton.getUuid()); + assertEquals(skeleton.getUuid(), EntityUtil.getUuid(DataVersion.latest().id(), skeletonTag)); + assertEquals("skeleton", skeletonTag.getString("id")); + + assertNotNull(zombie.getUuid()); + assertEquals(zombie.getUuid(), EntityUtil.getUuid(DataVersion.latest().id(), zombieTag)); + assertEquals("zombie", zombieTag.getString("id")); + } + + // + + // + + public void testPassengers_cannotSetSelfAsPassenger() { + EntityBase entity = new EntityBase(DataVersion.latest().id(), "zombie", 0, 0, 0); + assertThrowsIllegalArgumentException(() -> entity.addPassenger(entity)); + assertNull(entity.getPassengers()); + } + + public void testPassengers_cannotAddNullPassenger() { + EntityBase entity = new EntityBase(DataVersion.latest().id(), "zombie", 0, 0, 0); + assertThrowsIllegalArgumentException(() -> entity.addPassenger(null)); + assertNull(entity.getPassengers()); + } + + public void testPassengers_addPassengerSetsRiderPositionIfUnset() { + EntityBase spider = new EntityBase(DataVersion.latest().id(), "spider", 42.743, 68, -96.23); + spider.setMotion(0.1, -0.05, 0.008); + EntityBase skeleton = new EntityBase(DataVersion.latest().id(), "skeleton"); + assertFalse(skeleton.isPositionValid()); + spider.addPassenger(skeleton); + assertTrue(skeleton.isPositionValid()); + assertEquals(42.743, skeleton.getX(), 1e-8); + assertEquals(68, skeleton.getY(), 1e-8); + assertEquals(-96.23, skeleton.getZ(), 1e-8); + + // also copies motion + assertEquals(0.1, skeleton.getMotionDX(), 1e-8); + assertEquals(-0.05, skeleton.getMotionDY(), 1e-8); + assertEquals(0.008, skeleton.getMotionDZ(), 1e-8); + } + + public void testSetPassengers_syncsPassengerPosition() { + EntityBase boat = new EntityBase(DataVersion.latest().id(), "boat"); + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + EntityBase cow = new EntityBase(DataVersion.latest().id(), "cow"); + boat.setPosition(12.34, -14.5, 626.989); + boat.setPassengers(pig, cow); + assertPositionEquals(boat, 12.34, -14.5, 626.989); + assertPositionEquals(pig, 12.34, -14.5, 626.989); + assertPositionEquals(cow, 12.34, -14.5, 626.989); + } + + public void testSetPassengers_syncsPassengerMotion() { + EntityBase boat = new EntityBase(DataVersion.latest().id(), "boat"); + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + EntityBase cow = new EntityBase(DataVersion.latest().id(), "cow"); + boat.setMotion(0.1, -0.12, -0.03); + boat.setPassengers(pig, cow); + assertMotionEquals(boat, 0.1, -0.12, -0.03); + assertMotionEquals(pig, 0.1, -0.12, -0.03); + assertMotionEquals(cow, 0.1, -0.12, -0.03); + } + + public void testSetPassengers_null_clearsPassengers() { + EntityBase boat = new EntityBase(DataVersion.latest().id(), "boat"); + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + EntityBase cow = new EntityBase(DataVersion.latest().id(), "cow"); + assertFalse(boat.hasPassengers()); + + boat.setPassengers(pig, cow); + assertTrue(boat.hasPassengers()); + assertNotNull(boat.getPassengers()); + assertEquals(2, boat.getPassengers().size()); + + boat.setPassengers((Entity) null); + assertFalse(boat.hasPassengers()); + assertNull(boat.getPassengers()); + + boat.setPassengers(pig, cow); + boat.setPassengers((List) null); + assertFalse(boat.hasPassengers()); + assertNull(boat.getPassengers()); + } + + public void testSetPassengers_emptyList_clearsPassengers() { + EntityBase boat = new EntityBase(DataVersion.latest().id(), "boat"); + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + EntityBase cow = new EntityBase(DataVersion.latest().id(), "cow"); + + boat.setPassengers(pig, cow); + assertTrue(boat.hasPassengers()); + + boat.setPassengers(new ArrayList<>()); + assertFalse(boat.hasPassengers()); + assertNull(boat.getPassengers()); + } + + // + + // + + public void testSetPosition_basic() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + pig.setPosition(19.123, 68.5, -47.89); + assertPositionEquals(pig, 19.123, 68.5, -47.89); + + pig.setX(17.45); + assertPositionEquals(pig, 17.45, 68.5, -47.89); + + pig.setY(65.78); + assertPositionEquals(pig, 17.45, 65.78, -47.89); + + pig.setZ(-42.111); + assertPositionEquals(pig, 17.45, 65.78, -42.111); + } + + public void testSetPosition_withPassengersHavingPositions() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + pig.setPosition(19.123, 68.5, -47.89); + EntityBase chicken = new EntityBase(DataVersion.latest().id(), "chicken"); + chicken.setPosition(19.456, 68.85, -47.87); + EntityBase zombie = new EntityBase(DataVersion.latest().id(), "zombie"); + zombie.setPosition(19.789, 69.15, -47.86); + chicken.addPassenger(zombie); + pig.addPassenger(chicken); + + assertPositionEquals(pig, 19.123, 68.5, -47.89); + assertPositionEquals(chicken, 19.456, 68.85, -47.87); + assertPositionEquals(zombie, 19.789, 69.15, -47.86); + + // passengers should move with their mounts + pig.setPosition(0, 0, 0); + assertPositionEquals(pig, 0, 0, 0); + assertPositionEquals(chicken, 19.456 - 19.123, 68.85 - 68.5, -47.87 + 47.89); + assertPositionEquals(zombie, 19.789 - 19.123, 69.15 - 68.5, -47.86 + 47.89); + } + + public void testSetPosition_withPassengersHavingInvalidPositions() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + EntityBase chicken = new EntityBase(DataVersion.latest().id(), "chicken"); + EntityBase zombie = new EntityBase(DataVersion.latest().id(), "zombie"); + pig.addPassenger(chicken); + chicken.addPassenger(zombie); + assertFalse(pig.isPositionValid()); + assertFalse(chicken.isPositionValid()); + assertFalse(zombie.isPositionValid()); + + // setting mount position should also set passengers + pig.setPosition(19.123, 68.5, -47.89); + assertTrue(pig.isPositionValid()); + assertTrue(chicken.isPositionValid()); + assertTrue(zombie.isPositionValid()); + assertPositionEquals(pig, 19.123, 68.5, -47.89); + assertPositionEquals(chicken, 19.123, 68.5, -47.89); + assertPositionEquals(zombie, 19.123, 68.5, -47.89); + + // should not move mount position, but should move passenger to also all NaN + chicken.setPosition(Double.NaN, Double.NaN, Double.NaN); + assertTrue(pig.isPositionValid()); + assertFalse(chicken.isPositionValid()); + assertFalse(zombie.isPositionValid()); + assertPositionEquals(pig, 19.123, 68.5, -47.89); + assertPositionEquals(chicken, Double.NaN, Double.NaN, Double.NaN); + assertPositionEquals(zombie, Double.NaN, Double.NaN, Double.NaN); + + // test the rest using XYZ setters to avoid a whitebox testing trap in case someday the impl changes + + zombie.setPosition(pig.getX(), pig.getY(), pig.getZ()); + pig.setX(0); + assertPositionEquals(pig, 0, 68.5, -47.89); + assertPositionEquals(chicken, 0, Double.NaN, Double.NaN); + assertPositionEquals(zombie, 0, 68.5, -47.89); + + pig.setY(Double.NaN); + assertPositionEquals(pig, 0, Double.NaN, -47.89); + assertPositionEquals(chicken, 0, Double.NaN, Double.NaN); + assertPositionEquals(zombie, 0, Double.NaN, -47.89); + + pig.setY(42); + assertPositionEquals(pig, 0, 42, -47.89); + assertPositionEquals(chicken, 0, 42, Double.NaN); + assertPositionEquals(zombie, 0, 42, -47.89); + + zombie.setZ(-47.5); + assertPositionEquals(pig, 0, 42, -47.89); + assertPositionEquals(chicken, 0, 42, Double.NaN); + assertPositionEquals(zombie, 0, 42, -47.5); + + pig.setZ(0); + assertPositionEquals(pig, 0, 42,0); + assertPositionEquals(chicken, 0, 42, 0); + assertPositionEquals(zombie, 0, 42, 0); + } + + public void testMovePosition_basic() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + pig.setPosition(19.123, 68.5, -47.89); + assertPositionEquals(pig, 19.123, 68.5, -47.89); + pig.movePosition(-7, -1, 4); + assertPositionEquals(pig, 19.123 - 7, 68.5 - 1, -47.89 + 4); + } + + public void testMovePosition_cannotMoveInvalidPosition() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + assertThrowsException(() -> pig.movePosition(-7, -1, 4), IllegalStateException.class); + pig.setX(0); + assertThrowsException(() -> pig.movePosition(-7, -1, 4), IllegalStateException.class); + pig.setY(0); + assertThrowsException(() -> pig.movePosition(-7, -1, 4), IllegalStateException.class); + pig.setZ(0); + assertThrowsNoException(() -> pig.movePosition(-7, -1, 4)); + assertPositionEquals(pig, -7, -1, 4); + } + + public void testMovePosition_withPassengers() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + EntityBase chicken = new EntityBase(DataVersion.latest().id(), "chicken"); + EntityBase zombie = new EntityBase(DataVersion.latest().id(), "zombie"); + chicken.addPassenger(zombie); + pig.addPassenger(chicken); + + pig.setPosition(0, 0, 0); + // add offsets + chicken.movePosition(0.1, 0.5, 0); + zombie.movePosition(0, 0.15, -0.1); + // check everyone is where they should be + assertPositionEquals(pig, 0, 0, 0); + assertPositionEquals(chicken, 0.1, 0.5, 0); + assertPositionEquals(zombie, 0.1, 0.65, -0.1); + + // move the root pig + pig.movePosition(-7, 80, 4); + assertPositionEquals(pig, -7, 80, 4); + // check that offsets are preserved + assertPositionEquals(chicken, -6.9, 80.5, 4); + assertPositionEquals(zombie, -6.9, 80.65, 3.9); + + // now with some invalid positions in the mix + pig.setPosition(0, 0, 0); + chicken.setPosition(Double.NaN, Double.NaN, Double.NaN); + zombie.setPosition(-1, 0.5, 1); + + pig.movePosition(-7, -1, 4); + assertPositionEquals(pig, -7, -1, 4); + assertPositionEquals(chicken, -7, -1, 4); + assertPositionEquals(zombie, -7, -1, 4); + } + + // + + // + + public void testSetRotation_basic() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + pig.setRotation(73.23f, -11.55f); + assertEquals(73.23f, pig.getRotationYaw(), 1e-3); + assertEquals(-11.55f, pig.getRotationPitch(), 1e-3); + + // also checks yaw normalization + pig.setRotationYaw(-30.78f - 720); + assertEquals(360 - 30.78f, pig.getRotationYaw(), 1e-3); + + pig.setRotationPitch(45.976f); + assertEquals(45.976f, pig.getRotationPitch(), 1e-3); + + // pitch clamping + pig.setRotationPitch(145.976f); + assertEquals(90f, pig.getRotationPitch(), 1e-3); + pig.setRotationPitch(-145.976f); + assertEquals(-90f, pig.getRotationPitch(), 1e-3); + } + + public void testSetRotation_doesNotAffectPassengers() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + pig.setRotation(30f, 45f); + EntityBase chicken = new EntityBase(DataVersion.latest().id(), "chicken"); + chicken.setRotation(51f, -17f); + pig.addPassenger(chicken); + assertEquals(30f, pig.getRotationYaw(), 1e-3); + assertEquals(51f, chicken.getRotationYaw(), 1e-3); + assertEquals(45f, pig.getRotationPitch(), 1e-3); + assertEquals(-17f, chicken.getRotationPitch(), 1e-3); + + pig.setRotationYaw(32f); + assertEquals(32f, pig.getRotationYaw(), 1e-3); // changed + assertEquals(51f, chicken.getRotationYaw(), 1e-3); // unchanged + + chicken.setRotationYaw(52f); + assertEquals(32f, pig.getRotationYaw(), 1e-3); // unchanged + assertEquals(52f, chicken.getRotationYaw(), 1e-3); // changed + + pig.setRotationPitch(60f); + assertEquals(60f, pig.getRotationPitch(), 1e-3); // changed + assertEquals(-17f, chicken.getRotationPitch(), 1e-3); // unchanged + + chicken.setRotationPitch(20f); + assertEquals(60f, pig.getRotationPitch(), 1e-3); // unchanged + assertEquals(20f, chicken.getRotationPitch(), 1e-3); // changed + } + + public void testCardinalAngleHelpers() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + pig.setFacingCardinalAngle(0); + assertEquals(0f, pig.getFacingCardinalAngle(), 1e-4f); + assertEquals(180f, pig.getRotationYaw(), 1e-4f); + + pig.setFacingCardinalAngle(90); + assertEquals(90f, pig.getFacingCardinalAngle(), 1e-4f); + assertEquals(270f, pig.getRotationYaw(), 1e-4f); + + pig.setFacingCardinalAngle(179); + assertEquals(179f, pig.getFacingCardinalAngle(), 1e-4f); + assertEquals(359f, pig.getRotationYaw(), 1e-4f); + + pig.setFacingCardinalAngle(181); + assertEquals(181f, pig.getFacingCardinalAngle(), 1e-4f); + assertEquals(1f, pig.getRotationYaw(), 1e-4f); + + pig.setFacingCardinalAngle(361); + assertEquals(1f, pig.getFacingCardinalAngle(), 1e-4f); + assertEquals(181f, pig.getRotationYaw(), 1e-4f); + + pig.setFacingCardinalAngle(-90); + assertEquals(270f, pig.getFacingCardinalAngle(), 1e-4f); + assertEquals(90f, pig.getRotationYaw(), 1e-4f); + } + + public void testRotate_givenAngleMustBeFinite() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + assertThrowsIllegalArgumentException(() -> pig.rotate(Float.POSITIVE_INFINITY)); + } + + public void testRotate_passingHighExponentArgDoesNotSquashExistingYaw() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + pig.setRotationYaw(213.87139458f); + pig.rotate((float)Long.MAX_VALUE); + float yaw = pig.getRotationYaw(); + float fraction = yaw - ((int) yaw); + assertEquals(0.87139458f, fraction, 1e-4); + + pig.setRotationYaw(213.87139458f); + pig.rotate(100000000000.12); + assertEquals(133.99139458f, pig.getRotationYaw(), 1e-3); // yes i did the math by hand to get this number + } + + public void testRotate_noPassengers() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + pig.setRotationYaw(Float.NaN); + assertThrowsException(() -> pig.rotate(42), IllegalStateException.class); + pig.setRotationYaw(10); + pig.rotate(30); + assertEquals(40f, pig.getRotationYaw(), 1e-4f); + pig.rotate(-45); + assertEquals(355f, pig.getRotationYaw(), 1e-4f); + } + + public void testRotate_rotatesPassengers() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + EntityBase chicken = new EntityBase(DataVersion.latest().id(), "chicken"); + EntityBase zombie = new EntityBase(DataVersion.latest().id(), "zombie"); + pig.setRotationYaw(90f); + chicken.setRotationYaw(-15f); + zombie.setRotationYaw(22.5f); + + // validate that adding passengers doesn't rotate them based on mount yaw + pig.addPassenger(chicken); + chicken.addPassenger(zombie); + pig.setRotationYaw(177f); + assertEquals(177f, pig.getRotationYaw(), 1e-4f); + assertEquals(345f, chicken.getRotationYaw(), 1e-4f); + assertEquals(22.5f, zombie.getRotationYaw(), 1e-4f); + + // rotating mount should rotate passengers + pig.setRotationYaw(0f); + pig.rotate(45f); + assertEquals(45f, pig.getRotationYaw(), 1e-4f); + assertEquals(45f - 15f, chicken.getRotationYaw(), 1e-4f); + assertEquals(45f + 22.5f, zombie.getRotationYaw(), 1e-4f); + + // rotating middle mount should rotate passengers, but not entity being ridden + chicken.rotate(-10f); + assertEquals(45f, pig.getRotationYaw(), 1e-4f); + assertEquals(45f - 15f - 10f, chicken.getRotationYaw(), 1e-4f); + assertEquals(45f + 22.5f - 10f, zombie.getRotationYaw(), 1e-4f); + } + + + // + + // + + public void testSetMotion_basic() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + assertMotionEquals(pig, 0, 0, 0); + pig.setMotion(0.1, -0.12, -0.03); + assertMotionEquals(pig, 0.1, -0.12, -0.03); + + pig.setMotionDX(1); + assertMotionEquals(pig, 1, -0.12, -0.03); + + pig.setMotionDY(2); + assertMotionEquals(pig, 1, 2, -0.03); + + pig.setMotionDZ(-3); + assertMotionEquals(pig, 1, 2, -3); + + assertTrue(pig.isMotionValid()); + pig.setMotionDX(Double.NaN); + assertFalse(pig.isMotionValid()); + pig.setMotionDX(0); + pig.setMotionDY(Double.POSITIVE_INFINITY); + assertFalse(pig.isMotionValid()); + pig.setMotionDY(0); + pig.setMotionDZ(Double.NEGATIVE_INFINITY); + assertFalse(pig.isMotionValid()); + + pig.setMotionDZ(0); + assertTrue(pig.isMotionValid()); + } + + public void testSetMotion_withPassengers() { + EntityBase boat = new EntityBase(DataVersion.latest().id(), "boat"); + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + EntityBase cow = new EntityBase(DataVersion.latest().id(), "cow"); + boat.setPassengers(pig, cow); + assertMotionEquals(boat, 0, 0, 0); + assertMotionEquals(pig, 0, 0, 0); + assertMotionEquals(cow, 0, 0, 0); + + boat.setMotionDX(1); + assertMotionEquals(boat, 1, 0, 0); + assertMotionEquals(pig, 1, 0, 0); + assertMotionEquals(cow, 1, 0, 0); + + boat.setMotionDY(2); + assertMotionEquals(boat, 1, 2, 0); + assertMotionEquals(pig, 1, 2, 0); + assertMotionEquals(cow, 1, 2, 0); + + boat.setMotionDZ(-3); + assertMotionEquals(boat, 1, 2, -3); + assertMotionEquals(pig, 1, 2, -3); + assertMotionEquals(cow, 1, 2, -3); + + assertTrue(boat.isMotionValid()); + assertTrue(pig.isMotionValid()); + assertTrue(cow.isMotionValid()); + boat.setMotionDX(Double.NaN); + assertFalse(boat.isMotionValid()); + assertFalse(pig.isMotionValid()); + assertFalse(cow.isMotionValid()); + + boat.setMotionDX(0); + boat.setMotionDY(Double.POSITIVE_INFINITY); + assertFalse(boat.isMotionValid()); + assertFalse(pig.isMotionValid()); + assertFalse(cow.isMotionValid()); + + boat.setMotionDY(0); + boat.setMotionDZ(Double.NEGATIVE_INFINITY); + assertFalse(boat.isMotionValid()); + assertFalse(pig.isMotionValid()); + assertFalse(cow.isMotionValid()); + + boat.setMotionDZ(0); + assertTrue(boat.isMotionValid()); + assertTrue(pig.isMotionValid()); + assertTrue(cow.isMotionValid()); + } + + public void testAddPassenger_syncsPassengerMotion() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + EntityBase chicken = new EntityBase(DataVersion.latest().id(), "chicken"); + EntityBase zombie = new EntityBase(DataVersion.latest().id(), "zombie"); + + pig.setMotion(0.1, 0.2, 0.3); + + chicken.addPassenger(zombie); + assertMotionEquals(chicken, 0, 0, 0); + assertMotionEquals(zombie, 0, 0, 0); + + pig.addPassenger(chicken); + assertMotionEquals(pig, 0.1, 0.2, 0.3); + assertMotionEquals(chicken, 0.1, 0.2, 0.3); + assertMotionEquals(zombie, 0.1, 0.2, 0.3); + } + + // + + public void testGenerateNewUuid() { + EntityBase pig = new EntityBase(DataVersion.latest().id(), "pig"); + EntityBase chicken = new EntityBase(DataVersion.latest().id(), "chicken"); + EntityBase zombie = new EntityBase(DataVersion.latest().id(), "zombie"); + chicken.addPassenger(zombie); + pig.addPassenger(chicken); + + UUID pigUuid = UUID.randomUUID(); + UUID chickenUuid = UUID.randomUUID(); + UUID zombieUuid = UUID.randomUUID(); + + pig.setUuid(pigUuid); + chicken.setUuid(chickenUuid); + zombie.setUuid(zombieUuid); + + pig.generateNewUuid(); + + assertNotNull(pig.getUuid()); + assertNotNull(chicken.getUuid()); + assertNotNull(zombie.getUuid()); + + assertNotEquals(pigUuid, pig.getUuid()); + assertNotEquals(chickenUuid, chicken.getUuid()); + assertNotEquals(zombieUuid, zombie.getUuid()); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/entities/EntityUtilTest.java b/src/test/java/io/github/ensgijs/nbt/mca/entities/EntityUtilTest.java new file mode 100644 index 00000000..f5759f05 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/entities/EntityUtilTest.java @@ -0,0 +1,103 @@ +package io.github.ensgijs.nbt.mca.entities; + +import io.github.ensgijs.nbt.mca.DataVersion; +import io.github.ensgijs.nbt.mca.McaTestCase; +import io.github.ensgijs.nbt.tag.CompoundTag; + +import java.util.UUID; + +public class EntityUtilTest extends McaTestCase { + + public void testUuidSetGet() { + final UUID uuid = UUID.fromString("7a10303f-dacf-4e35-a8be-1ce2818b0372"); // UUID.randomUUID(); + + // test set and get uuid symmetry + CompoundTag tag = new CompoundTag(); + EntityUtil.setUuid(DataVersion.JAVA_1_14_4.id(), tag, uuid); + assertEquals(tag.toString(), uuid, EntityUtil.getUuid(DataVersion.JAVA_1_14_4.id(), tag)); + + tag = new CompoundTag(); + EntityUtil.setUuid(DataVersion.JAVA_1_17_1.id(), tag, uuid); + assertEquals(tag.toString(), uuid, EntityUtil.getUuid(DataVersion.JAVA_1_17_1.id(), tag)); + + // test no uuid fields produces null + tag = new CompoundTag(); + assertNull(EntityUtil.getUuid(DataVersion.JAVA_1_14_4.id(), tag)); + assertNull(EntityUtil.getUuid(DataVersion.JAVA_1_17_1.id(), tag)); + + // test attempt to set zero uuid + assertThrowsIllegalArgumentException(() -> + EntityUtil.setUuid(DataVersion.JAVA_1_14_4.id(), new CompoundTag(), EntityUtil.ZERO_UUID)); + assertThrowsIllegalArgumentException(() -> + EntityUtil.setUuid(DataVersion.JAVA_1_17_1.id(), new CompoundTag(), EntityUtil.ZERO_UUID)); + + // test attempt to set null + assertThrowsIllegalArgumentException(() -> + EntityUtil.setUuid(DataVersion.JAVA_1_14_4.id(), null, EntityUtil.ZERO_UUID)); + assertThrowsIllegalArgumentException(() -> + EntityUtil.setUuid(DataVersion.JAVA_1_17_1.id(), null, EntityUtil.ZERO_UUID)); + + + // test reading zero uuid produces null + tag = new CompoundTag(); + tag.putLong("UUIDMost", 0); + tag.putLong("UUIDLeast", 0); + assertNull(EntityUtil.getUuid(DataVersion.JAVA_1_14_4.id(), tag)); + + tag = new CompoundTag(); + tag.putIntArray("UUID", new int[] {0, 0, 0, 0}); + assertNull(EntityUtil.getUuid(DataVersion.JAVA_1_17_1.id(), tag)); + } + + public void testRemoveUuid() { + final UUID uuid = UUID.fromString("7a10303f-dacf-4e35-a8be-1ce2818b0372"); // UUID.randomUUID(); + + // test set and get uuid symmetry + CompoundTag tag = new CompoundTag(); + EntityUtil.setUuid(DataVersion.JAVA_1_14_4.id(), tag, uuid); + EntityUtil.setUuid(DataVersion.JAVA_1_17_1.id(), tag, uuid); + assertTrue(tag.containsKey("UUIDMost")); + assertTrue(tag.containsKey("UUIDLeast")); + assertTrue(tag.containsKey("UUID")); + + EntityUtil.removeUuid(tag); + assertFalse(tag.containsKey("UUIDMost")); + assertFalse(tag.containsKey("UUIDLeast")); + assertFalse(tag.containsKey("UUID")); + } + + public void testNormalizeYaw_floats() { + assertEquals(0f, EntityUtil.normalizeYaw(360f), 1e-4f); + assertEquals(0f, EntityUtil.normalizeYaw(-360f), 1e-4f); + assertEquals(270f, EntityUtil.normalizeYaw(-90f), 1e-4f); + assertEquals(72.654f, EntityUtil.normalizeYaw(360f + 72.654f), 1e-4f); + assertEquals(360f - 72.654f, EntityUtil.normalizeYaw(-72.654f), 1e-4f); + assertEquals(30f, EntityUtil.normalizeYaw(-7 * 360f + 30f), 1e-4f); + assertEquals(330f, EntityUtil.normalizeYaw(-7 * 360f - 30f), 1e-4f); + assertTrue(Float.isNaN(EntityUtil.normalizeYaw(Float.NaN))); + } + + public void testNormalizeYaw_doubles() { + assertEquals(0f, EntityUtil.normalizeYaw(360d), 1e-4f); + assertEquals(0f, EntityUtil.normalizeYaw(-360d), 1e-4f); + assertEquals(270f, EntityUtil.normalizeYaw(-90d), 1e-4f); + assertEquals(72.654f, EntityUtil.normalizeYaw(360d + 72.654d), 1e-4f); + assertEquals(360f - 72.654f, EntityUtil.normalizeYaw(-72.654d), 1e-4f); + assertEquals(30f, EntityUtil.normalizeYaw(-7d * 360d + 30d), 1e-4f); + assertEquals(330f, EntityUtil.normalizeYaw(-7d * 360d - 30d), 1e-4f); + assertTrue(Float.isNaN(EntityUtil.normalizeYaw(Double.NaN))); + } + + public void testClampPitch() { + assertEquals(-90f, EntityUtil.clampPitch(-1111f), 1e-4f); + assertEquals(-90f, EntityUtil.clampPitch(-90.001f), 1e-4f); + assertEquals(-89.999f, EntityUtil.clampPitch(-89.999f), 1e-4f); + assertEquals(-0.001f, EntityUtil.clampPitch(-0.001f), 1e-4f); + assertEquals(0f, EntityUtil.clampPitch(0f), 1e-4f); + assertEquals(0.001f, EntityUtil.clampPitch(0.001f), 1e-4f); + assertEquals(89.999f, EntityUtil.clampPitch(89.999f), 1e-4f); + assertEquals(90f, EntityUtil.clampPitch(90.001f), 1e-4f); + assertEquals(90f, EntityUtil.clampPitch(1111f), 1e-4f); + assertTrue(Float.isNaN(EntityUtil.clampPitch(Float.NaN))); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/io/McaFileChunkIteratorTest.java b/src/test/java/io/github/ensgijs/nbt/mca/io/McaFileChunkIteratorTest.java new file mode 100644 index 00000000..9663e489 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/io/McaFileChunkIteratorTest.java @@ -0,0 +1,146 @@ +package io.github.ensgijs.nbt.mca.io; + +import io.github.ensgijs.nbt.mca.*; +import io.github.ensgijs.nbt.mca.util.IntPointXZ; + +import java.io.IOException; + +public class McaFileChunkIteratorTest extends McaTestCase { + + public void validateIteratePoiFile(long loadFlags) throws IOException { + McaFileChunkIterator iter = McaFileChunkIterator.iterate( + getResourceFile("1_20_4/poi/r.-3.-3.mca"), loadFlags, PoiChunk::new + ); +// System.out.println(iter); + assertTrue(iter.hasNext()); + PoiChunk chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-94, -71), chunk.getChunkXZ()); + assertEquals(1713564474, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-78, -70), chunk.getChunkXZ()); + assertEquals(1713564484, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-77, -84), chunk.getChunkXZ()); + assertEquals(1713564485, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-77, -73), chunk.getChunkXZ()); + assertEquals(1713564485, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-77, -68), chunk.getChunkXZ()); + assertEquals(1713564485, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-82, -67), chunk.getChunkXZ()); + assertEquals(1713564485, chunk.getLastMCAUpdate()); + + assertFalse(iter.hasNext()); + } + + public void testIteratePoiFile() throws IOException { + validateIteratePoiFile(LoadFlags.LOAD_ALL_DATA); + validateIteratePoiFile(LoadFlags.RAW); + } + + + public void validateIterateRegionFile(long loadFlags) throws IOException { + McaFileChunkIterator iter = McaFileChunkIterator.iterate( + getResourceFile("1_20_4/region/r.-3.-3.mca"), loadFlags, TerrainChunk::new + ); +// System.out.println(iter); + assertTrue(iter.hasNext()); + TerrainChunk chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-91, -87), chunk.getChunkXZ()); + assertEquals(1713564480, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-95, -86), chunk.getChunkXZ()); + assertEquals(1713564471, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-94, -86), chunk.getChunkXZ()); + assertEquals(1713564470, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-95, -85), chunk.getChunkXZ()); + assertEquals(1713564471, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-94, -85), chunk.getChunkXZ()); + assertEquals(1713564471, chunk.getLastMCAUpdate()); + + assertFalse(iter.hasNext()); + } + + public void testIterateRegionFile() throws IOException { + validateIterateRegionFile(LoadFlags.LOAD_ALL_DATA); + validateIterateRegionFile(LoadFlags.RAW); + } + + + public void validateIterateEntitiesFile(long loadFlags) throws IOException { + McaFileChunkIterator iter = McaFileChunkIterator.iterate( + getResourceFile("1_20_4/entities/r.-3.-3.mca"), loadFlags, EntitiesChunk::new + ); +// System.out.println(iter); + assertTrue(iter.hasNext()); + EntitiesChunk chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-91, -87), chunk.getChunkXZ()); + assertEquals(1713564491, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-95, -86), chunk.getChunkXZ()); + assertEquals(1713564491, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-94, -86), chunk.getChunkXZ()); + assertEquals(1713564491, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-95, -85), chunk.getChunkXZ()); + assertEquals(1713564491, chunk.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + chunk = iter.next(); + assertNotNull(chunk); + assertEquals(IntPointXZ.XZ(-94, -85), chunk.getChunkXZ()); + assertEquals(1713564491, chunk.getLastMCAUpdate()); + + assertFalse(iter.hasNext()); + } + + public void testIterateEntitiesFile() throws IOException { + validateIterateEntitiesFile(LoadFlags.LOAD_ALL_DATA); +// validateIterateEntitiesFile(LoadFlags.RAW); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/io/McaFileHelpersTest.java b/src/test/java/io/github/ensgijs/nbt/mca/io/McaFileHelpersTest.java new file mode 100644 index 00000000..12e823b3 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/io/McaFileHelpersTest.java @@ -0,0 +1,130 @@ +package io.github.ensgijs.nbt.mca.io; + +import io.github.ensgijs.nbt.mca.*; + +import java.io.File; +import java.nio.file.Paths; + +public class McaFileHelpersTest extends McaTestCase { + + public void testLocationConversion() { + assertEquals(0, McaFileHelpers.blockToChunk(0)); + assertEquals(0, McaFileHelpers.blockToChunk(15)); + assertEquals(1, McaFileHelpers.blockToChunk(16)); + assertEquals(-1, McaFileHelpers.blockToChunk(-1)); + assertEquals(-1, McaFileHelpers.blockToChunk(-16)); + assertEquals(-2, McaFileHelpers.blockToChunk(-17)); + + assertEquals(0, McaFileHelpers.blockToRegion(0)); + assertEquals(0, McaFileHelpers.blockToRegion(511)); + assertEquals(1, McaFileHelpers.blockToRegion(512)); + assertEquals(-1, McaFileHelpers.blockToRegion(-1)); + assertEquals(-1, McaFileHelpers.blockToRegion(-512)); + assertEquals(-2, McaFileHelpers.blockToRegion(-513)); + + assertEquals(0, McaFileHelpers.chunkToRegion(0)); + assertEquals(0, McaFileHelpers.chunkToRegion(31)); + assertEquals(1, McaFileHelpers.chunkToRegion(32)); + assertEquals(-1, McaFileHelpers.chunkToRegion(-1)); + assertEquals(-1, McaFileHelpers.chunkToRegion(-32)); + assertEquals(-2, McaFileHelpers.chunkToRegion(-33)); + + assertEquals(0, McaFileHelpers.regionToChunk(0)); + assertEquals(32, McaFileHelpers.regionToChunk(1)); + assertEquals(-32, McaFileHelpers.regionToChunk(-1)); + assertEquals(-64, McaFileHelpers.regionToChunk(-2)); + + assertEquals(0, McaFileHelpers.regionToBlock(0)); + assertEquals(512, McaFileHelpers.regionToBlock(1)); + assertEquals(-512, McaFileHelpers.regionToBlock(-1)); + assertEquals(-1024, McaFileHelpers.regionToBlock(-2)); + + assertEquals(0, McaFileHelpers.chunkToBlock(0)); + assertEquals(16, McaFileHelpers.chunkToBlock(1)); + assertEquals(-16, McaFileHelpers.chunkToBlock(-1)); + assertEquals(-32, McaFileHelpers.chunkToBlock(-2)); + } + + public void testBlockAbsoluteToChunkRelative_int() { + assertEquals(0, McaFileHelpers.blockAbsoluteToChunkRelative(0)); + assertEquals(0, McaFileHelpers.blockAbsoluteToChunkRelative(16 * 81)); + assertEquals(15, McaFileHelpers.blockAbsoluteToChunkRelative(16 * 81 - 1)); + assertEquals(0, McaFileHelpers.blockAbsoluteToChunkRelative(-16 * 81)); + assertEquals(15, McaFileHelpers.blockAbsoluteToChunkRelative(-16 * 81 - 1)); + assertEquals(1, McaFileHelpers.blockAbsoluteToChunkRelative(-16 * 81 + 1)); + } + + public void testBlockAbsoluteToChunkRelative_double() { + assertEquals(0.0, McaFileHelpers.blockAbsoluteToChunkRelative(0.0), 1e-10); + assertEquals(16 - 1e-6, McaFileHelpers.blockAbsoluteToChunkRelative(-1e-6), 1e-10); + assertEquals(3.4567, McaFileHelpers.blockAbsoluteToChunkRelative(16 * 81 + 3.4567), 1e-10); + assertEquals(16 - 3.4567, McaFileHelpers.blockAbsoluteToChunkRelative(16 * 81 - 3.4567), 1e-10); + assertEquals(16 - 3.4567, McaFileHelpers.blockAbsoluteToChunkRelative(-16 * 81 - 3.4567), 1e-10); + assertEquals(3.4567, McaFileHelpers.blockAbsoluteToChunkRelative(-16 * 81 + 3.4567), 1e-10); + } + + public void testCreateNameFromLocation() { + assertEquals("r.0.0.mca", McaFileHelpers.createNameFromBlockLocation(0, 0)); + assertEquals("r.0.0.mca", McaFileHelpers.createNameFromBlockLocation(511, 511)); + assertEquals("r.1.0.mca", McaFileHelpers.createNameFromBlockLocation(512, 511)); + assertEquals("r.0.-1.mca", McaFileHelpers.createNameFromBlockLocation(511, -1)); + assertEquals("r.0.-1.mca", McaFileHelpers.createNameFromBlockLocation(511, -512)); + assertEquals("r.0.-2.mca", McaFileHelpers.createNameFromBlockLocation(511, -513)); + assertEquals("r.0.1.mca", McaFileHelpers.createNameFromBlockLocation(511, 512)); + assertEquals("r.-1.0.mca", McaFileHelpers.createNameFromBlockLocation(-1, 511)); + assertEquals("r.-1.0.mca", McaFileHelpers.createNameFromBlockLocation(-512, 511)); + assertEquals("r.-2.0.mca", McaFileHelpers.createNameFromBlockLocation(-513, 511)); + + assertEquals("r.0.0.mca", McaFileHelpers.createNameFromChunkLocation(0, 0)); + assertEquals("r.0.0.mca", McaFileHelpers.createNameFromChunkLocation(31, 31)); + assertEquals("r.1.0.mca", McaFileHelpers.createNameFromChunkLocation(32, 31)); + assertEquals("r.0.-1.mca", McaFileHelpers.createNameFromChunkLocation(31, -1)); + assertEquals("r.0.-1.mca", McaFileHelpers.createNameFromChunkLocation(31, -32)); + assertEquals("r.0.-2.mca", McaFileHelpers.createNameFromChunkLocation(31, -33)); + assertEquals("r.0.1.mca", McaFileHelpers.createNameFromChunkLocation(31, 32)); + assertEquals("r.-1.0.mca", McaFileHelpers.createNameFromChunkLocation(-1, 31)); + assertEquals("r.-1.0.mca", McaFileHelpers.createNameFromChunkLocation(-32, 31)); + assertEquals("r.-2.0.mca", McaFileHelpers.createNameFromChunkLocation(-33, 31)); + + assertEquals("r.0.0.mca", McaFileHelpers.createNameFromRegionLocation(0, 0)); + assertEquals("r.1.0.mca", McaFileHelpers.createNameFromRegionLocation(1, 0)); + assertEquals("r.0.-1.mca", McaFileHelpers.createNameFromRegionLocation(0, -1)); + assertEquals("r.0.-2.mca", McaFileHelpers.createNameFromRegionLocation(0, -2)); + assertEquals("r.0.1.mca", McaFileHelpers.createNameFromRegionLocation(0, 1)); + assertEquals("r.-1.0.mca", McaFileHelpers.createNameFromRegionLocation(-1, 0)); + assertEquals("r.-2.0.mca", McaFileHelpers.createNameFromRegionLocation(-2, 0)); + } + + public void testMakeMyCoverageGreatAgain() { + assertThrowsException(() -> McaFileHelpers.read((String) null), NullPointerException.class); + assertThrowsException(() -> McaFileHelpers.write(null, (String) null), NullPointerException.class); + assertThrowsException(() -> McaFileHelpers.write(null, (File) null), NullPointerException.class); + assertThrowsException(() -> McaFileHelpers.write(null, (String) null, false), NullPointerException.class); + assertThrowsException(() -> McaFileHelpers.read("r.a.b.mca"), IllegalArgumentException.class); + assertThrowsException(() -> new McaRegionFile(0, 0).serialize(null), IllegalArgumentException.class); + + // test overwriting file + McaRegionFile m = new McaRegionFile(0, 0); + m.setChunk(0, TerrainChunk.newChunk()); + File target = getNewTmpFile("r.0.0.mca"); + assertThrowsNoException(() -> McaFileHelpers.write(m, target, false)); + assertThrowsNoException(() -> McaFileHelpers.write(m, target, false)); + } + + public void testAutoCreateMcaFile() { + McaFileBase mcaFile = McaFileHelpers.autoMCAFile(Paths.get("region", "r.1.2.mca")); + assertTrue(mcaFile instanceof McaRegionFile); + assertEquals(1, mcaFile.getRegionX()); + assertEquals(2, mcaFile.getRegionZ()); + + mcaFile = McaFileHelpers.autoMCAFile(Paths.get("poi", "r.3.-4.mca")); + assertTrue(mcaFile instanceof McaPoiFile); + assertEquals(3, mcaFile.getRegionX()); + assertEquals(-4, mcaFile.getRegionZ()); + + mcaFile = McaFileHelpers.autoMCAFile(Paths.get("entities", "r.-5.6.mca")); + assertTrue(mcaFile instanceof McaEntitiesFile); + assertEquals(-5, mcaFile.getRegionX()); + assertEquals(6, mcaFile.getRegionZ()); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/io/McaFileStreamingWriterTest.java b/src/test/java/io/github/ensgijs/nbt/mca/io/McaFileStreamingWriterTest.java new file mode 100644 index 00000000..eedcc3fc --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/io/McaFileStreamingWriterTest.java @@ -0,0 +1,58 @@ +package io.github.ensgijs.nbt.mca.io; + +import io.github.ensgijs.nbt.mca.McaRegionFile; +import io.github.ensgijs.nbt.mca.McaTestCase; +import io.github.ensgijs.nbt.mca.TerrainChunk; +import io.github.ensgijs.nbt.mca.util.IntPointXZ; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +public class McaFileStreamingWriterTest extends McaTestCase { + public void testWriteTerrainChunk() throws IOException { + File file = getNewTmpFile("streaming_writer/region/r.0.1.mca"); + McaFileStreamingWriter writer = new McaFileStreamingWriter(file); + + final TerrainChunk chunk1 = new TerrainChunk(); + chunk1.setLastMCAUpdate(12345678); + chunk1.updateHandle(0, 32); + writer.write(chunk1); + + final TerrainChunk chunkF = new TerrainChunk(); + chunkF.setLastMCAUpdate(87654321); + chunkF.updateHandle(0, 32); + assertThrowsException(() -> writer.write(chunkF), IOException.class); + + final TerrainChunk chunk2 = new TerrainChunk(); + chunk2.setLastMCAUpdate(54321678); + chunk2.updateHandle(5, 32 + 3); + writer.write(chunk2); + writer.close(); + + assertEquals(4 * 4096, Files.size(file.toPath())); + + McaFileChunkIterator iter = McaFileChunkIterator.iterate( + file, LoadFlags.LOAD_ALL_DATA, TerrainChunk::new + ); + + assertTrue(iter.hasNext()); + final TerrainChunk chunk1in = iter.next(); + assertEquals(IntPointXZ.XZ(0, 32), chunk1in.getChunkXZ()); + assertEquals(12345678, chunk1in.getLastMCAUpdate()); + + assertTrue(iter.hasNext()); + final TerrainChunk chunk2in = iter.next(); + assertEquals(IntPointXZ.XZ(5, 32 + 3), chunk2in.getChunkXZ()); + assertEquals(54321678, chunk2in.getLastMCAUpdate()); + + assertFalse(iter.hasNext()); + + McaRegionFile mca = McaFileHelpers.readAuto(file); + assertNotNull(mca); + assertNotNull(mca.getChunk(0, 32)); + assertEquals(12345678, mca.getChunk(0, 32).getLastMCAUpdate()); + assertNotNull(mca.getChunk(5, 32 + 3)); + assertEquals(54321678, mca.getChunk(5, 32 + 3).getLastMCAUpdate()); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/io/RandomAccessMcaFileTest.java b/src/test/java/io/github/ensgijs/nbt/mca/io/RandomAccessMcaFileTest.java new file mode 100644 index 00000000..ba4e7c5d --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/io/RandomAccessMcaFileTest.java @@ -0,0 +1,402 @@ +package io.github.ensgijs.nbt.mca.io; + +import io.github.ensgijs.nbt.io.TextNbtParser; +import io.github.ensgijs.nbt.mca.*; + +import java.io.EOFException; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.util.regex.Pattern; + +import io.github.ensgijs.nbt.mca.io.RandomAccessMcaFile.SectorManager; +import io.github.ensgijs.nbt.mca.io.RandomAccessMcaFile.SectorManager.SectorBlock; +import io.github.ensgijs.nbt.mca.util.IntPointXZ; +import io.github.ensgijs.nbt.mca.util.PalettizedCuboid; +import io.github.ensgijs.nbt.tag.CompoundTag; + +public class RandomAccessMcaFileTest extends McaTestCase { + + public void testSectorManager_sanity() throws IOException { + SectorManager sm = new SectorManager(); + assertEquals(2, sm.appendAtSector); // default is sector 2 + + int[] sectorTable = new int[1024]; + sectorTable[0] = new SectorBlock(5, 1).pack(); + sectorTable[33] = new SectorBlock(18, 1).pack(); + sectorTable[64] = new SectorBlock(9, 4).pack(); + sectorTable[1] = new SectorBlock(2, 1).pack(); + sectorTable[32] = new SectorBlock(3, 2).pack(); + sm.sync(sectorTable); + + assertEquals(19, sm.appendAtSector); + assertEquals(2, sm.freeSectors.size()); + assertEquals(new SectorBlock(6, 3), sm.freeSectors.get(0)); + assertEquals(new SectorBlock(13, 5), sm.freeSectors.get(1)); + + // take from first free block + assertEquals(new SectorBlock(6, 1), sm.allocate(1)); + assertEquals(2, sm.freeSectors.size()); + assertEquals(new SectorBlock(7, 2), sm.freeSectors.get(0)); + assertEquals(new SectorBlock(13, 5), sm.freeSectors.get(1)); + assertEquals(19, sm.appendAtSector); + + // take from second free block + assertEquals(new SectorBlock(13, 4), sm.allocate(4)); + assertEquals(2, sm.freeSectors.size()); + assertEquals(new SectorBlock(7, 2), sm.freeSectors.get(0)); + assertEquals(new SectorBlock(17, 1), sm.freeSectors.get(1)); + assertEquals(19, sm.appendAtSector); + + // no free block big enough - take off the end + assertEquals(new SectorBlock(19, 4), sm.allocate(4)); + assertEquals(2, sm.freeSectors.size()); + assertEquals(new SectorBlock(7, 2), sm.freeSectors.get(0)); + assertEquals(new SectorBlock(17, 1), sm.freeSectors.get(1)); + assertEquals(23, sm.appendAtSector); + + // release and merge into second free block + sm.release(13, 4); + assertEquals(2, sm.freeSectors.size()); + assertEquals(new SectorBlock(7, 2), sm.freeSectors.get(0)); + assertEquals(new SectorBlock(13, 5), sm.freeSectors.get(1)); + assertEquals(23, sm.appendAtSector); + + // release and merge into second free block case 2 + sm.release(18, 1); + assertEquals(2, sm.freeSectors.size()); + assertEquals(new SectorBlock(7, 2), sm.freeSectors.get(0)); + assertEquals(new SectorBlock(13, 6), sm.freeSectors.get(1)); + assertEquals(23, sm.appendAtSector); + + // release last block which touches the current appendAtSector + sm.release(19, 4); + assertEquals(13, sm.appendAtSector); + assertEquals(1, sm.freeSectors.size()); + assertEquals(new SectorBlock(7, 2), sm.freeSectors.get(0)); + + // taking the last free sector should be safe too + assertEquals(new SectorBlock(7, 1), sm.allocate(1)); + assertEquals(new SectorBlock(8, 1), sm.allocate(1)); + assertEquals(0, sm.freeSectors.size()); + assertEquals(13, sm.appendAtSector); + + // allocating with no free sectors also works + assertEquals(new SectorBlock(13, 1), sm.allocate(1)); + assertEquals(0, sm.freeSectors.size()); + assertEquals(14, sm.appendAtSector); + + + // nothing in table in sector 2 + sectorTable = new int[1024]; + sectorTable[547] = new SectorBlock(5, 1).pack(); + sm.sync(sectorTable); + assertEquals(1, sm.freeSectors.size()); + assertEquals(new SectorBlock(2, 3), sm.freeSectors.get(0)); + assertEquals(6, sm.appendAtSector); + + + // release between free sectors + sm.freeSectors.clear(); + sm.freeSectors.add(new SectorBlock(2, 1)); + sm.freeSectors.add(new SectorBlock(20, 1)); + sm.appendAtSector = 42; + sm.release(10, 2); + + assertEquals(3, sm.freeSectors.size()); + assertEquals(new SectorBlock(10, 2), sm.freeSectors.get(1)); + } + + public void testSectorManager_scan_throwsWhenGivenWrongSizedArray() { + assertThrowsException(() -> new SectorManager().sync(null), NullPointerException.class); + assertThrowsException(() -> new SectorManager().sync(new int[256]), IllegalArgumentException.class); + assertThrowsException(() -> new SectorManager().sync(new int[4096]), IllegalArgumentException.class); + } + + public void testHasChunkAbsolute() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "r"); + assertTrue(poiMca.hasChunkAbsolute(-77, -84)); + assertTrue(poiMca.hasChunkAbsolute(new IntPointXZ(-77, -73))); + assertTrue(poiMca.hasChunkAbsolute(-94, -71)); + assertTrue(poiMca.hasChunkAbsolute(-78, -70)); + assertTrue(poiMca.hasChunkAbsolute(-77, -68)); + assertTrue(poiMca.hasChunkAbsolute(-82, -67)); + + assertFalse(poiMca.hasChunkAbsolute(0, 0)); // out of bounds doesn't throw + assertFalse(poiMca.hasChunkAbsolute(new IntPointXZ(-82, -70))); + poiMca.close(); + } + + public void testHasChunkRelative() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "r"); + assertTrue(poiMca.hasChunkRelative(19, 12)); + assertTrue(poiMca.hasChunkRelative(19, 23)); + assertFalse(poiMca.hasChunkRelative(0, 0)); + assertFalse(poiMca.hasChunkRelative(31, 31)); + assertThrowsException(() -> poiMca.hasChunkRelative(new IntPointXZ(-1, 0)), IndexOutOfBoundsException.class); + poiMca.close(); + } + + public void testReadWriteReadIdempotency() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "rw"); + PoiChunk chunksA[] = { + poiMca.readAbsolute(-77, -84), + poiMca.readAbsolute(-77, -73), + poiMca.readAbsolute(-94, -71), + poiMca.readAbsolute(-78, -70), + poiMca.readAbsolute(-77, -68), + poiMca.readAbsolute(-82, -67)}; + poiMca.write(chunksA); + assertEquals(0, poiMca.optimizeFile()); + poiMca.flush(); + poiMca.close(); + poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "r"); + PoiChunk chunksB[] = { + poiMca.readAbsolute(-77, -84), + poiMca.readAbsolute(-77, -73), + poiMca.readAbsolute(-94, -71), + poiMca.readAbsolute(-78, -70), + poiMca.readAbsolute(-77, -68), + poiMca.readAbsolute(-82, -67)}; + + for (int i = 0; i < chunksA.length; i++) { + assertEquals(chunksA[i].getHandle(), chunksB[i].getHandle()); + } + poiMca.close(); + } + + public void testRemoveChunkAbsolute() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "rw"); + assertTrue(poiMca.removeChunkAbsolute(new IntPointXZ(-77, -84))); + assertTrue(poiMca.removeChunkAbsolute(-94, -71)); + assertTrue(poiMca.removeChunkAbsolute(-78, -70)); + assertTrue(poiMca.removeChunkAbsolute(-82, -67)); + assertFalse(poiMca.removeChunkAbsolute(1, -2)); // out of bounds + assertFalse(poiMca.removeChunkAbsolute(-90, -70)); // doesn't exist + + assertFalse(poiMca.hasChunkAbsolute(-77, -84)); + assertTrue(poiMca.hasChunkAbsolute(-77, -73)); + assertFalse(poiMca.hasChunkAbsolute(-94, -71)); + assertFalse(poiMca.hasChunkAbsolute(-78, -70)); + assertTrue(poiMca.hasChunkAbsolute(-77, -68)); + assertFalse(poiMca.hasChunkAbsolute(-82, -67)); + assertTrue(poiMca.optimizeFile() > 0); + poiMca.close(); + + poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "rw"); + assertFalse(poiMca.hasChunkAbsolute(-77, -84)); + assertTrue(poiMca.hasChunkAbsolute(-77, -73)); + assertFalse(poiMca.hasChunkAbsolute(-94, -71)); + assertFalse(poiMca.hasChunkAbsolute(-78, -70)); + assertTrue(poiMca.hasChunkAbsolute(-77, -68)); + assertFalse(poiMca.hasChunkAbsolute(-82, -67)); + poiMca.close(); + } + + public void testRemoveChunkRelative() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "rw"); + assertTrue(poiMca.removeChunkRelative(new IntPointXZ(19, 12))); + assertTrue(poiMca.removeChunkRelative(19, 23)); + assertFalse(poiMca.removeChunkRelative(new IntPointXZ(0, 0))); + assertFalse(poiMca.removeChunkRelative(31, 31)); + assertThrowsException(() -> poiMca.removeChunkRelative(new IntPointXZ(-1, 0)), IndexOutOfBoundsException.class); + assertThrowsException(() -> poiMca.removeChunkRelative(31, 32), IndexOutOfBoundsException.class); + poiMca.close(); + } + + public void testRemoveChunkRelative_outOfBoundsThrows() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "r"); + assertThrowsException(() -> poiMca.removeChunkRelative(32, 32), IndexOutOfBoundsException.class); + poiMca.close(); + } + + public void testRemoveChunk_removingAllChunks() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "rw"); + assertTrue(poiMca.removeChunkRelative(14, 29)); + assertTrue(poiMca.removeChunkAbsolute(-77, -73)); + assertTrue(poiMca.removeChunkAbsolute(-94, -71)); + assertTrue(poiMca.removeChunkAbsolute(-78, -70)); + assertTrue(poiMca.removeChunkAbsolute(-77, -68)); + assertTrue(poiMca.removeChunkRelative(19, 12)); + assertTrue(poiMca.optimizeFile() > 0); + poiMca.close(); + assertEquals(2 * 4096, Files.size(file.toPath())); + } + + public void testChunkSectorTableToString() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "r"); + assertEquals(1024 - 6L, Pattern.compile("\\s----(?=\\s)").matcher(poiMca.chunkSectorTableToString()).results().count()); + poiMca.close(); + + } + + public void testCanBeUsedToCreateNewMcaFile_empty() throws IOException { + File file = getNewTmpFile("poi/r.1.2.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "rw"); + assertEquals(new IntPointXZ(1, 2), poiMca.getRegionXZ()); + poiMca.touch(); + poiMca.close(); + assertEquals(2 * 4096, Files.size(file.toPath())); + } + + public void testGetChunkTimestamp() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "r"); + assertEquals(1713564485, poiMca.getChunkTimestampRelative(new IntPointXZ(14, 29))); + assertEquals(1713564485, poiMca.getChunkTimestampAbsolute(new IntPointXZ(-77, -73))); + assertEquals(-1, poiMca.getChunkTimestampAbsolute(-900, -70)); // out of bounds + assertEquals(-1, poiMca.getChunkTimestampAbsolute(-90, -70)); // in bounds, doesn't exist + assertEquals(-1, poiMca.getChunkTimestampRelative(25, 17)); // in bounds, doesn't exist + assertThrowsException(() -> poiMca.getChunkTimestampRelative(new IntPointXZ(-1, 29)), IndexOutOfBoundsException.class); + poiMca.close(); + } + + public void testRead_indexOutOfBounds() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "r"); + assertThrowsException(() -> poiMca.read(-1), IndexOutOfBoundsException.class); + assertThrowsException(() -> poiMca.read(1024), IndexOutOfBoundsException.class); + assertThrowsException(() -> poiMca.readRelative(new IntPointXZ(0, 32)), IndexOutOfBoundsException.class); + assertThrowsException(() -> poiMca.readAbsolute(new IntPointXZ(0, 0)), IndexOutOfBoundsException.class); + poiMca.close(); + } + + public void testRead_chunkSectorPointsOutsideFile_throwsEOF() throws IOException { + File file = getNewTmpFile("r.0.0.mca"); + RandomAccessFile raf = new RandomAccessFile(file, "rw"); + raf.setLength(4096 * 2); + raf.writeInt(0x0201); + raf.close(); + + assertEquals(2 * 4096, Files.size(file.toPath())); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "rw"); + assertThrowsException(() -> poiMca.read(0), EOFException.class); + assertThrowsException(poiMca::optimizeFile, IOException.class); + assertThrowsNoException(poiMca::close); + assertTrue(poiMca.fileFinalized); + } + + public void testRead_encodedChunkSizeTooLargeThrows() throws IOException { + File file = getNewTmpFile("r.0.0.mca"); + RandomAccessFile raf = new RandomAccessFile(file, "rw"); + raf.setLength(4096 * 4); + raf.writeInt(0x0201); + raf.seek(4096 * 2); + raf.writeInt(5000); + raf.close(); + + assertEquals(4 * 4096, Files.size(file.toPath())); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "r"); + assertThrowsException(() -> poiMca.read(0), CorruptMcaFileException.class); + poiMca.close(); + } + + public void testWrite_chunkOutOfBoundsThrows() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "rw"); + var chunk = poiMca.readAbsolute(-77, -73); + chunk.moveChunk(0, 0, MoveChunkFlags.MOVE_CHUNK_DEFAULT_FLAGS); + assertThrowsException(() -> poiMca.write(chunk), IndexOutOfBoundsException.class); + poiMca.close(); + } + + + public void testWrite_chunkXzNotSetThrows() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "rw"); + var chunk = new PoiChunk(TextNbtParser.parseInline("{DataVersion: 3700, Sections: {}}")); + assertThrowsException(() -> { + try { + poiMca.write(chunk); + } catch (IOException e) { + fail(); + } + return null; + }, IllegalArgumentException.class, s -> s.equals("Chunk XZ must be set!")); + poiMca.close(); + } + + public void testWrite_chunkWrittenForFirstTime() throws IOException { + File file = super.copyResourceToTmp("1_20_4/poi/r.-3.-3.mca"); + var poiMca = new RandomAccessMcaFile<>(PoiChunk.class, file, "rw"); + var chunk = new PoiChunk(TextNbtParser.parseInline("{DataVersion: 3700, Sections: {}}")); + chunk.moveChunk(-90, -70, 0); + poiMca.write(chunk); + assertTrue(poiMca.hasChunkAbsolute(-90, -70)); + poiMca.close(); + } + + public void testWrite_chunkSizeReduced_placedInPreviousSectorAndRemainderSectorsReleased() throws IOException { + File file = super.copyResourceToTmp("1_20_4/region/r.-3.-3.mca"); + var terrainMca = new RandomAccessMcaFile<>(TerrainChunk.class, file, "rw"); + terrainMca.touch(); + final int index = McaFileBase.getChunkIndex(5, 9); + assertEquals(0x0202, terrainMca.chunkSectors[index]); + TerrainChunk chunk = terrainMca.read(index); + assertNotNull(chunk); + assertEquals(new IntPointXZ(5 - 3 * 32, 9 - 3 * 32), chunk.getChunkXZ()); + for (var sector: chunk) { + sector.getBlockStates().fill(TextNbtParser.parseInline("{Name: \"minecraft:air\"}")); + } + terrainMca.write(chunk); + assertEquals(0x0201, terrainMca.chunkSectors[index]); + assertEquals(SectorBlock.unpack(0x0301), terrainMca.sectorManager.freeSectors.getFirst()); + terrainMca.close(); + } + + public void testWrite_chunkSizeIncreased_placedAtEndOfFile() throws IOException { + File file = super.copyResourceToTmp("1_20_4/region/r.-3.-3.mca"); + var terrainMca = new RandomAccessMcaFile<>(TerrainChunk.class, file, "rw"); + terrainMca.touch(); + final int index = McaFileBase.getChunkIndex(5, 9); + assertEquals(0x0202, terrainMca.chunkSectors[index]); + TerrainChunk chunk = terrainMca.read(index); + assertNotNull(chunk); + assertEquals(new IntPointXZ(5 - 3 * 32, 9 - 3 * 32), chunk.getChunkXZ()); + PalettizedCuboid bigSection = new PalettizedCuboid<>(16, TextNbtParser.parseInline("{Name: \"minecraft:air\"}")); + for (int i = 0; i < 16 * 16 * 16; i++) { + bigSection.set(i, TextNbtParser.parseInline("{Name: \"minecraft:random_garbage_" + String.format("%d%X", i, -i) + "\"}")); + } + chunk.getSection(8).setBlockStates(bigSection); + terrainMca.write(chunk); + assertEquals(0x0C0A, terrainMca.chunkSectors[index]); + assertEquals(SectorBlock.unpack(0x0202), terrainMca.sectorManager.freeSectors.getFirst()); + terrainMca.close(); + } + + public void testReadOnly_writeThrows() throws IOException { + File file = super.copyResourceToTmp("1_20_4/region/r.-3.-3.mca"); + RandomAccessFile raf = new RandomAccessFile(file, "rw"); + var terrainMca = new RandomAccessMcaFile<>(TerrainChunk.class, raf, IntPointXZ.XZ(-3, -3), "r"); + terrainMca.touch(); + TerrainChunk chunk = terrainMca.readRelative(5, 9); + assertNotNull(chunk); + assertThrowsException(() -> terrainMca.write(chunk), IOException.class); + terrainMca.close(); + } + + public void testReadOnly_optimizeFileThrows() throws IOException { + File file = super.copyResourceToTmp("1_20_4/region/r.-3.-3.mca"); + RandomAccessFile raf = new RandomAccessFile(file, "rw"); + var terrainMca = new RandomAccessMcaFile<>(TerrainChunk.class, raf, IntPointXZ.XZ(-3, -3), "r"); + assertThrowsException(() -> terrainMca.optimizeFile(), IOException.class); + terrainMca.close(); + } + + public void testReadOnly_flushDoesNotThrow() throws IOException { + File file = super.copyResourceToTmp("1_20_4/region/r.-3.-3.mca"); + RandomAccessFile raf = new RandomAccessFile(file, "rw"); + var terrainMca = new RandomAccessMcaFile<>(TerrainChunk.class, raf, IntPointXZ.XZ(-3, -3), "r"); + assertThrowsNoException(terrainMca::flush); + terrainMca.close(); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/io/RegionFileRelocatorTest.java b/src/test/java/io/github/ensgijs/nbt/mca/io/RegionFileRelocatorTest.java new file mode 100644 index 00000000..cc626b9f --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/io/RegionFileRelocatorTest.java @@ -0,0 +1,442 @@ +package io.github.ensgijs.nbt.mca.io; + +import io.github.ensgijs.nbt.mca.EntitiesChunk; +import io.github.ensgijs.nbt.mca.McaTestCase; +import io.github.ensgijs.nbt.mca.PoiChunk; +import io.github.ensgijs.nbt.mca.TerrainChunk; +import io.github.ensgijs.nbt.mca.entities.Entity; +import io.github.ensgijs.nbt.mca.entities.EntityFactory; +import io.github.ensgijs.nbt.mca.util.IntPointXZ; +import io.github.ensgijs.nbt.query.NbtPath; +import io.github.ensgijs.nbt.tag.CompoundTag; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; + +public class RegionFileRelocatorTest extends McaTestCase { + + public void testRelocate_1_20_4() throws IOException { + File outRoot = getNewTmpDirectory(); + RegionFileRelocator relocator = new RegionFileRelocator() + .sourceRoot(getResourceFile("1_20_4").getPath()) + .destinationRoot(outRoot.getPath()) + .removeMoveChunkFlags(MoveChunkFlags.DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION); + assertTrue(relocator.relocate(-3, -3, 0, 0)); + assertEquals(1, relocator.regionFilesRelocated()); + assertEquals(1, relocator.entitiesFilesRelocated()); + assertEquals(1, relocator.poiFilesRelocated()); + File newMca = Paths.get(outRoot.getPath(), "region", "r.0.0.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_20_4")); + McaFileChunkIterator iter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + TerrainChunk chunk = iter.next(); + assertEquals(5, chunk.getChunkX()); + assertEquals(9, chunk.getChunkZ()); + IntPointXZ xz = IntPointXZ.unpack(NbtPath.of("References.minecraft:mineshaft[0]").getLong(chunk.getStructures())); + assertEquals(new IntPointXZ(5, 9), xz); + int[] bb = NbtPath.of("starts.minecraft:mineshaft.Children[0].BB").getIntArray(chunk.getStructures()); + assertArrayEquals(new int[] {82, 34, 146, 92, 39, 155}, bb); + bb = NbtPath.of("starts.minecraft:mineshaft.Children[0].Entrances[1]").getIntArray(chunk.getStructures()); + assertArrayEquals(new int[] {1536 - 1451, 35, 1536 - 1382, 1536 - 1447, 37, 1536 - 1381}, bb); + + newMca = Paths.get(outRoot.getPath(), "entities", "r.0.0.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_20_4")); + + newMca = Paths.get(outRoot.getPath(), "poi", "r.0.0.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_20_4")); + } + + public void testRelocateAll_1_18_1() throws IOException { + File outRoot = getNewTmpDirectory(); + RegionFileRelocator relocator = new RegionFileRelocator() + .sourceRoot(getResourceFile("1_18_1").getPath()) + .destinationRoot(outRoot.getPath()) + .removeMoveChunkFlags(MoveChunkFlags.DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION); + assertEquals(2, relocator.relocateAll(10, 10)); + assertEquals(2, relocator.regionFilesRelocated()); + assertEquals(2, relocator.entitiesFilesRelocated()); + assertEquals(1, relocator.poiFilesRelocated()); + + File newMca = Paths.get(outRoot.getPath(), "region", "r.10.8.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_18_1")); + McaFileChunkIterator iter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + TerrainChunk chunk = iter.next(); + assertEquals(339, chunk.getChunkX()); + assertEquals(273, chunk.getChunkZ()); + + CompoundTag tileTag = NbtPath.of("block_entities[0]").getTag(chunk.getHandle()); + assertEquals(5431, tileTag.getInt("x")); + assertEquals(4381, tileTag.getInt("z")); + IntPointXZ xz = IntPointXZ.unpack(NbtPath.of("References.village[0]").getLong(chunk.getStructures())); + assertEquals(new IntPointXZ(0x154, 0x111), xz); + + + newMca = Paths.get(outRoot.getPath(), "region", "r.18.11.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_18_1")); + iter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + chunk = iter.next(); + assertEquals(595, chunk.getChunkX()); + assertEquals(353, chunk.getChunkZ()); + + CompoundTag blockTicksTag = NbtPath.of("block_ticks[0]").getTag(chunk.getHandle()); + assertEquals(9526, blockTicksTag.getInt("x")); + assertEquals(5650, blockTicksTag.getInt("z")); + + + newMca = Paths.get(outRoot.getPath(), "entities", "r.10.8.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_18_1")); + McaFileChunkIterator eiter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + EntitiesChunk echunk = eiter.next(); + List entities = echunk.getEntities(); + assertEquals(5437, (int) entities.get(3).getX()); + assertEquals(64, (int) entities.get(3).getY()); + assertEquals(4368, (int) entities.get(3).getZ()); + int[] potential_job_site = NbtPath.of("Brain.memories.minecraft:potential_job_site.value.pos").getIntArray(entities.get(3).getHandle()); + assertArrayEquals(new int[] {5433, 68, 4377}, potential_job_site); + + + newMca = Paths.get(outRoot.getPath(), "entities", "r.18.11.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_18_1")); + + newMca = Paths.get(outRoot.getPath(), "poi", "r.10.8.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_18_1")); + McaFileChunkIterator piter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + PoiChunk poiChunk = piter.next(); + int[] pos = NbtPath.of("Sections.4.Records[0].pos").getIntArray(poiChunk.getHandle()); + assertArrayEquals(new int[] {5433, 65, 4371}, pos); + } + + public void testRelocate_1_17_1() throws IOException { + File outRoot = getNewTmpDirectory(); + RegionFileRelocator relocator = new RegionFileRelocator() + .sourceRoot(getResourceFile("1_17_1").getPath()) + .destinationRoot(outRoot.getPath()) + .removeMoveChunkFlags(MoveChunkFlags.DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION); + assertTrue(relocator.relocate(-3, -2, 0, 0)); + assertEquals(1, relocator.regionFilesRelocated()); + assertEquals(1, relocator.entitiesFilesRelocated()); + assertEquals(1, relocator.poiFilesRelocated()); + File newMca = Paths.get(outRoot.getPath(), "region", "r.0.0.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_17_1")); + McaFileChunkIterator iter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + TerrainChunk chunk = iter.next(); + assertEquals(31, chunk.getChunkX()); + assertEquals(22, chunk.getChunkZ()); + + CompoundTag tileTag = NbtPath.of("Level.TileEntities[0]").getTag(chunk.getHandle()); + assertEquals(506, tileTag.getInt("x")); + assertEquals(358, tileTag.getInt("z")); + IntPointXZ xz = IntPointXZ.unpack(NbtPath.of("References.village[0]").getLong(chunk.getStructures())); + assertEquals(new IntPointXZ(33, 23), xz); + + + newMca = Paths.get(outRoot.getPath(), "entities", "r.0.0.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_17_1")); + McaFileChunkIterator eiter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + EntitiesChunk echunk = eiter.next(); + List entities = echunk.getEntities(); + assertEquals(504, (int) entities.get(0).getX()); + assertEquals(64, (int) entities.get(0).getY()); + assertEquals(357, (int) entities.get(0).getZ()); + int[] homePos = NbtPath.of("Brain.memories.minecraft:home.value.pos").getIntArray(entities.get(0).getHandle()); + assertArrayEquals(new int[] {504, 63, 354}, homePos); + + newMca = Paths.get(outRoot.getPath(), "poi", "r.0.0.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_17_1")); + McaFileChunkIterator piter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + PoiChunk poiChunk = piter.next(); + int[] pos = NbtPath.of("Sections.3.Records[0].pos").getIntArray(poiChunk.getHandle()); + assertArrayEquals(new int[] {504, 63, 354}, pos); + + } + + public void testRelocate_1_16_5() throws IOException { + File outRoot = getNewTmpDirectory(); + RegionFileRelocator relocator = new RegionFileRelocator() + .sourceRoot(getResourceFile("1_16_5").getPath()) + .destinationRoot(outRoot.getPath()) + .removeMoveChunkFlags(MoveChunkFlags.DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION); + assertTrue(relocator.relocate(0, -1, -1, 1)); + assertEquals(1, relocator.regionFilesRelocated()); + assertEquals(0, relocator.entitiesFilesRelocated()); + assertEquals(1, relocator.poiFilesRelocated()); + File newMca = Paths.get(outRoot.getPath(), "region", "r.-1.1.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_16_5")); + McaFileChunkIterator iter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + TerrainChunk chunk = iter.next(); + assertEquals(-28, chunk.getChunkX()); + assertEquals(37, chunk.getChunkZ()); + + List entities = EntityFactory.fromListTag(chunk.getEntities(), chunk.getDataVersion()); + assertEquals(-441, (int)entities.get(0).getX()); + assertEquals(34, (int)entities.get(0).getY()); + assertEquals(603, (int)entities.get(0).getZ()); + + CompoundTag tileTag = NbtPath.of("Level.TileEntities[0]").getTag(chunk.getHandle()); + assertEquals(-441, tileTag.getInt("x")); + assertEquals(603, tileTag.getInt("z")); + IntPointXZ xz = IntPointXZ.unpack(NbtPath.of("References.mineshaft[0]").getLong(chunk.getStructures())); + assertEquals(new IntPointXZ(-27, 39), xz); + + + newMca = Paths.get(outRoot.getPath(), "poi", "r.-1.1.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_16_5")); + McaFileChunkIterator piter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + PoiChunk poiChunk = piter.next(); + int[] pos = NbtPath.of("Sections.4.Records[0].pos").getIntArray(poiChunk.getHandle()); + assertArrayEquals(new int[] {-439, 73, 595}, pos); + } + + public void testRelocateAll_1_15_2() throws IOException { + File outRoot = getNewTmpDirectory(); + RegionFileRelocator relocator = new RegionFileRelocator() + .sourceRoot(getResourceFile("1_15_2").getPath()) + .destinationRoot(outRoot.getPath()) + .removeMoveChunkFlags(MoveChunkFlags.DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION); + assertEquals(2, relocator.relocateAll(5, 5)); + assertEquals(2, relocator.regionFilesRelocated()); + assertEquals(0, relocator.entitiesFilesRelocated()); + assertEquals(1, relocator.poiFilesRelocated()); + + File newMca = Paths.get(outRoot.getPath(), "region", "r.4.5.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_15_2")); + + McaFileChunkIterator iter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + TerrainChunk chunk = iter.next(); + assertEquals(157, chunk.getChunkX()); + assertEquals(171, chunk.getChunkZ()); + + List entities = EntityFactory.fromListTag(chunk.getEntities(), chunk.getDataVersion()); + assertEquals(2517, (int)entities.get(0).getX()); + assertEquals(67, (int)entities.get(0).getY()); + assertEquals(2745, (int)entities.get(0).getZ()); + + CompoundTag tileTag = NbtPath.of("Level.TileEntities[0]").getTag(chunk.getHandle()); + assertEquals(2521, tileTag.getInt("x")); + assertEquals(2748, tileTag.getInt("z")); + IntPointXZ xz = IntPointXZ.unpack(NbtPath.of("References.Village[0]").getLong(chunk.getStructures())); + assertEquals(new IntPointXZ(1 + 5 * 32, 12 + 5 * 32), xz); + + + newMca = Paths.get(outRoot.getPath(), "poi", "r.4.5.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_15_2")); + McaFileChunkIterator piter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + PoiChunk poiChunk = piter.next(); + int[] pos = NbtPath.of("Sections.4.Records[0].pos").getIntArray(poiChunk.getHandle()); + assertArrayEquals(new int[] {2519, 70, 2748}, pos); + + newMca = Paths.get(outRoot.getPath(), "region", "r.5.5.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_15_2")); + } + + public void testRelocate_1_14_4() throws IOException { + File outRoot = getNewTmpDirectory(); + RegionFileRelocator relocator = new RegionFileRelocator() + .sourceRoot(getResourceFile("1_14_4").getPath()) + .destinationRoot(outRoot.getPath()) + .removeMoveChunkFlags(MoveChunkFlags.DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION); + assertTrue(relocator.relocate(-1, 0, 0, 0)); + assertEquals(1, relocator.regionFilesRelocated()); + assertEquals(0, relocator.entitiesFilesRelocated()); + assertEquals(1, relocator.poiFilesRelocated()); + + File newMca = Paths.get(outRoot.getPath(), "region", "r.0.0.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_14_4")); + McaFileChunkIterator iter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + TerrainChunk chunk = iter.next(); + assertEquals(31, chunk.getChunkX()); + assertEquals(16, chunk.getChunkZ()); + + List entities = EntityFactory.fromListTag(chunk.getEntities(), chunk.getDataVersion()); + assertEquals(509, (int)entities.get(0).getX()); + assertEquals(40, (int)entities.get(0).getY()); + assertEquals(264, (int)entities.get(0).getZ()); + + CompoundTag tileTag = NbtPath.of("Level.TileEntities[0]").getTag(chunk.getHandle()); + assertEquals(508, tileTag.getInt("x")); + assertEquals(262, tileTag.getInt("z")); + + + newMca = Paths.get(outRoot.getPath(), "poi", "r.0.0.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_14_4")); + McaFileChunkIterator piter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + PoiChunk poiChunk = piter.next(); + int[] pos = NbtPath.of("Sections.4.Records[0].pos").getIntArray(poiChunk.getHandle()); + assertArrayEquals(new int[] {501, 64, 263}, pos); + } + + public void testRelocate_1_13_2() throws IOException { + File outRoot = getNewTmpDirectory(); + RegionFileRelocator relocator = new RegionFileRelocator() + .sourceRoot(getResourceFile("1_13_2").getPath()) + .destinationRoot(outRoot.getPath()) + .removeMoveChunkFlags(MoveChunkFlags.DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION); + assertTrue(relocator.relocate(-2, -2, 0, 0)); + assertEquals(1, relocator.regionFilesRelocated()); + assertEquals(0, relocator.entitiesFilesRelocated()); + assertEquals(0, relocator.poiFilesRelocated()); + File newMca = Paths.get(outRoot.getPath(), "region", "r.0.0.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_13_2")); + McaFileChunkIterator iter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + TerrainChunk chunk = iter.next(); + assertEquals(22, chunk.getChunkX()); + assertEquals(19, chunk.getChunkZ()); + + List entities = EntityFactory.fromListTag(chunk.getEntities(), chunk.getDataVersion()); + assertEquals(359, (int)entities.get(0).getX()); + assertEquals(63, (int)entities.get(0).getY()); + assertEquals(312, (int)entities.get(0).getZ()); + + IntPointXZ xz = IntPointXZ.unpack(NbtPath.of("References.Village[0]").getLong(chunk.getStructures())); + assertEquals(new IntPointXZ(21, 20), xz); + } + + public void testRelocate_1_13_1() throws IOException { + File outRoot = getNewTmpDirectory(); + RegionFileRelocator relocator = new RegionFileRelocator() + .sourceRoot(getResourceFile("1_13_1").getPath()) + .destinationRoot(outRoot.getPath()) + .removeMoveChunkFlags(MoveChunkFlags.DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION); + assertTrue(relocator.relocate(2, 2, 0, 0)); + assertEquals(1, relocator.regionFilesRelocated()); + assertEquals(0, relocator.entitiesFilesRelocated()); + assertEquals(0, relocator.poiFilesRelocated()); + File newMca = Paths.get(outRoot.getPath(), "region", "r.0.0.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_13_1")); + McaFileChunkIterator iter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + TerrainChunk chunk = iter.next(); + assertEquals(0, chunk.getChunkX()); + assertEquals(0, chunk.getChunkZ()); + chunk = iter.next(); + assertEquals(0, chunk.getChunkX()); + assertEquals(16, chunk.getChunkZ()); + chunk = iter.next(); + assertEquals(31, chunk.getChunkX()); + assertEquals(31, chunk.getChunkZ()); + } + + public void testRelocate_1_13_0() throws IOException { + File outRoot = getNewTmpDirectory(); + RegionFileRelocator relocator = new RegionFileRelocator() + .sourceRoot(getResourceFile("1_13_0").getPath()) + .destinationRoot(outRoot.getPath()) + .removeMoveChunkFlags(MoveChunkFlags.DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION); + assertTrue(relocator.relocate(0, 0, 1, 1)); + assertEquals(1, relocator.regionFilesRelocated()); + assertEquals(0, relocator.entitiesFilesRelocated()); + assertEquals(0, relocator.poiFilesRelocated()); + File newMca = Paths.get(outRoot.getPath(), "region", "r.1.1.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_13_0")); + + McaFileChunkIterator iter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + TerrainChunk chunk = iter.next(); + assertEquals(38, chunk.getChunkX()); + assertEquals(42, chunk.getChunkZ()); + IntPointXZ xz = IntPointXZ.unpack(NbtPath.of("References.Mineshaft[0]").getLong(chunk.getStructures())); + assertEquals(new IntPointXZ(34, 38), xz); + } + + public void testRelocate_1_12_2() throws IOException { + File outRoot = getNewTmpDirectory(); + RegionFileRelocator relocator = new RegionFileRelocator() + .sourceRoot(getResourceFile("1_12_2").getPath()) + .destinationRoot(outRoot.getPath()) + .removeMoveChunkFlags(MoveChunkFlags.DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION); + assertTrue(relocator.relocate(0, 0, 1, 1)); + assertEquals(1, relocator.regionFilesRelocated()); + assertEquals(0, relocator.entitiesFilesRelocated()); + assertEquals(0, relocator.poiFilesRelocated()); + File newMca = Paths.get(outRoot.getPath(), "region", "r.1.1.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_12_2")); + + McaFileChunkIterator iter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + TerrainChunk chunk = iter.next(); + assertEquals(42, chunk.getChunkX()); + assertEquals(43, chunk.getChunkZ()); + + List entities = EntityFactory.fromListTag(chunk.getEntities(), chunk.getDataVersion()); + assertEquals(680, (int)entities.get(0).getX()); + assertEquals(40, (int)entities.get(0).getY()); + assertEquals(703, (int)entities.get(0).getZ()); + } + + public void testRelocate_1_9_4() throws IOException { + File outRoot = getNewTmpDirectory(); + RegionFileRelocator relocator = new RegionFileRelocator() + .sourceRoot(getResourceFile("1_9_4").getPath()) + .destinationRoot(outRoot.getPath()) + .removeMoveChunkFlags(MoveChunkFlags.DISCARD_STRUCTURE_REFERENCES_OUTSIDE_REGION); + assertTrue(relocator.relocate(2, -1, 0, 0)); + assertEquals(1, relocator.regionFilesRelocated()); + assertEquals(0, relocator.entitiesFilesRelocated()); + assertEquals(0, relocator.poiFilesRelocated()); + File newMca = Paths.get(outRoot.getPath(), "region", "r.0.0.mca").toFile(); + assertTrue(newMca.exists()); + assertTrue(Files.size(newMca.toPath()) > 0x2000); +// McaDumper.dumpChunksAsTextNbt(newMca, Paths.get("TESTDBG", "relocation", "1_9_4")); + + McaFileChunkIterator iter = McaFileChunkIterator.iterate(newMca, LoadFlags.LOAD_ALL_DATA); + TerrainChunk chunk = iter.next(); + assertEquals(24, chunk.getChunkX()); + assertEquals(12, chunk.getChunkZ()); + + List entities = EntityFactory.fromListTag(chunk.getEntities(), chunk.getDataVersion()); + assertEquals(387, (int)entities.get(0).getX()); + assertEquals(71, (int)entities.get(0).getY()); + assertEquals(193, (int)entities.get(0).getZ()); + + // TODO: get a chunk that has a structure for 1.9.4 + } +} + +// McaDumper.dumpChunksAsTextNbt( +// Paths.get(getResourceFile("1_9_4").getPath(), "region", "r.2.-1.mca").toFile(), +// Paths.get("TESTDBG", "original", "1_9_4")); diff --git a/src/test/java/io/github/ensgijs/nbt/mca/util/BlockAlignedBoundingRectangleTest.java b/src/test/java/io/github/ensgijs/nbt/mca/util/BlockAlignedBoundingRectangleTest.java new file mode 100644 index 00000000..b4ece60f --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/util/BlockAlignedBoundingRectangleTest.java @@ -0,0 +1,161 @@ +package io.github.ensgijs.nbt.mca.util; + +import junit.framework.TestCase; + +import java.util.List; + +import static io.github.ensgijs.nbt.mca.util.IntPointXZ.XZ; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertThrows; + +public class BlockAlignedBoundingRectangleTest extends TestCase { + + public void testMinMaxXZ() { + BlockAlignedBoundingRectangle cbr = new BlockAlignedBoundingRectangle(16, -16, 16); + assertEquals(16, cbr.getMinBlockX()); + assertEquals(32, cbr.getMaxBlockX()); + assertEquals(-16, cbr.getMinBlockZ()); + assertEquals(0, cbr.getMaxBlockZ()); + } + + public void testContains_int() { + BlockAlignedBoundingRectangle cbr = new BlockAlignedBoundingRectangle(16, -16, 16); + assertTrue(cbr.containsBlock(16 + 8, -8)); + assertFalse(cbr.containsBlock(-8, 8)); + + assertTrue(cbr.containsBlock(16, -1)); + assertFalse(cbr.containsBlock(15, -1)); + assertFalse(cbr.containsBlock(16, 0)); + + assertTrue(cbr.containsBlock(16, -16)); + assertFalse(cbr.containsBlock(15, -16)); + assertFalse(cbr.containsBlock(16, -17)); + + assertTrue(cbr.containsBlock(31, -1)); + assertFalse(cbr.containsBlock(32, -1)); + assertFalse(cbr.containsBlock(31, 0)); + + assertTrue(cbr.containsBlock(31, -16)); + assertFalse(cbr.containsBlock(32, -16)); + assertFalse(cbr.containsBlock(31, -17)); + } + + public void testContains_IntPointXZ() { + BlockAlignedBoundingRectangle cbr = new BlockAlignedBoundingRectangle(0, 0, 16); + assertTrue(cbr.containsBlock(new IntPointXZ(8, 8))); + assertTrue(cbr.containsBlock(new IntPointXZ(0, 0))); + assertTrue(cbr.containsBlock(new IntPointXZ(15, 15))); + assertFalse(cbr.containsBlock(new IntPointXZ(-1, 7))); + assertFalse(cbr.containsBlock(new IntPointXZ(16, 7))); + assertFalse(cbr.containsBlock(new IntPointXZ(7, -1))); + assertFalse(cbr.containsBlock(new IntPointXZ(7, 16))); + } + + public void testContains_double() { + BlockAlignedBoundingRectangle cbr = new BlockAlignedBoundingRectangle(16, -16, 16); + assertTrue(cbr.containsBlock(16 + 7.5, -7.5)); + assertFalse(cbr.containsBlock(-8.5, 8.5)); + + assertTrue(cbr.containsBlock(16.0, -1e-14)); + assertTrue(cbr.containsBlock(16.0, -16.0)); + assertTrue(cbr.containsBlock(32 - 1e-14, -1e-14)); + assertTrue(cbr.containsBlock(32 - 1e-14, -16.0)); + + assertFalse(cbr.containsBlock(16 - 1e-14, -7.5)); // off left + assertFalse(cbr.containsBlock(32.0, -7.5)); // off right + assertFalse(cbr.containsBlock(16 + 7.5, 0.0)); // off top + assertFalse(cbr.containsBlock(16 + 7.5, -16.0 - 1e-14)); // off bottom + } + + public void testConstrain() { + BlockAlignedBoundingRectangle cbr = new BlockAlignedBoundingRectangle(0, 0, 16); + assertFalse(cbr.constrain(null)); + assertFalse(cbr.constrain(new int[] {1,2,3,4})); + + // edge to edge but all in bounds + int[] bb = new int[] {0, 0, 0, 15, 99, 15}; + assertTrue(cbr.constrain(bb)); + assertArrayEquals(new int[] {0, 0, 0, 15, 99, 15}, bb); + + // 1x1 on max bounds + bb = new int[] {15, 0, 15, 15, 99, 15}; + assertTrue(cbr.constrain(bb)); + assertArrayEquals(new int[] {15, 0, 15, 15, 99, 15}, bb); + + // bb encases cbr + bb = new int[] {-8, 0, -8, 20, 99, 20}; + assertTrue(cbr.constrain(bb)); + assertArrayEquals(new int[] {0, 0, 0, 15, 99, 15}, bb); + + // bb outside cbr entirely + assertFalse(cbr.constrain(new int[] {16, 0, 0, 20, 0, 0})); // to the right + assertFalse(cbr.constrain(new int[] {-5, 0, 0, -1, 0, 0})); // to the left + assertFalse(cbr.constrain(new int[] {0, 0, 16, 0, 0, 20})); // above + assertFalse(cbr.constrain(new int[] {0, 0, -5, 0, 0, -1})); // below + + // bb has one corner in bounds + bb = new int[] {-8, 0, -8, 8, 0, 8}; + assertTrue(cbr.constrain(bb)); + assertArrayEquals(new int[] {0, 0, 0, 8, 0, 8}, bb); + + bb = new int[] {8, 0, 8, 20, 0, 20}; + assertTrue(cbr.constrain(bb)); + assertArrayEquals(new int[] {8, 0, 8, 15, 0, 15}, bb); + + bb = new int[] {-8, 0, 8, 8, 0, 28}; + assertTrue(cbr.constrain(bb)); + assertArrayEquals(new int[] {0, 0, 8, 8, 0, 15}, bb); + + bb = new int[] {8, 0, -8, 20, 0, 8}; + assertTrue(cbr.constrain(bb)); + assertArrayEquals(new int[] {8, 0, 0, 15, 0, 8}, bb); + + // throws if bound min-max are out of order + assertThrows(IllegalArgumentException.class, () -> cbr.constrain(new int[] {8, 0, 8, 4, 0, 4})); + assertThrows(IllegalArgumentException.class, () -> cbr.constrain(new int[] {8, 0, 8, -4, 0, -4})); + } + + public void testMAX_WORLD_BORDER_BOUNDS() { + assertEquals(-1874999, ChunkBoundingRectangle.MAX_WORLD_BORDER_BOUNDS.getMinChunkX()); + assertEquals(-1874999, ChunkBoundingRectangle.MAX_WORLD_BORDER_BOUNDS.getMinChunkZ()); + assertEquals(1874999, ChunkBoundingRectangle.MAX_WORLD_BORDER_BOUNDS.getMaxChunkX()); + assertEquals(1874999, ChunkBoundingRectangle.MAX_WORLD_BORDER_BOUNDS.getMaxChunkZ()); + assertTrue(ChunkBoundingRectangle.MAX_WORLD_BORDER_BOUNDS.containsChunk(-1874999, -1874999)); + assertTrue(ChunkBoundingRectangle.MAX_WORLD_BORDER_BOUNDS.containsChunk(1874998, 1874998)); + assertFalse(ChunkBoundingRectangle.MAX_WORLD_BORDER_BOUNDS.containsChunk(1874999, 1874999)); + } + + public void testOf() { + BlockAlignedBoundingRectangle bbr = BlockAlignedBoundingRectangle.of(List.of( + XZ(-5, 4) + )); + assertEquals(new BlockAlignedBoundingRectangle(-5, 4, 1), bbr); + + bbr = BlockAlignedBoundingRectangle.of(List.of( + XZ(-5, 4), + XZ(-4, -1) + )); + assertEquals(new BlockAlignedBoundingRectangle(-5, -1, 6), bbr); + } + + public void testGrow() { + BlockAlignedBoundingRectangle cbr = new BlockAlignedBoundingRectangle(3, 4, 5); + BlockAlignedBoundingRectangle cbr2 = cbr.grow(2); + assertEquals(new BlockAlignedBoundingRectangle(3 - 2, 4 - 2, 5 + 2 * 2), cbr2); + assertEquals(cbr.getCenterX(), cbr2.getCenterX(), 1e-6); + assertEquals(cbr.getCenterZ(), cbr2.getCenterZ(), 1e-6); + + assertThrows(IllegalArgumentException.class, () -> cbr2.grow(-1)); + } + + public void testShrink() { + BlockAlignedBoundingRectangle cbr = new BlockAlignedBoundingRectangle(3, 4, 5); + BlockAlignedBoundingRectangle cbr2 = cbr.shrink(2); + assertEquals(new BlockAlignedBoundingRectangle(3 + 2, 4 + 2, 5 - 2 * 2), cbr2); + assertEquals(cbr.getCenterX(), cbr2.getCenterX(), 1e-6); + assertEquals(cbr.getCenterZ(), cbr2.getCenterZ(), 1e-6); + + assertThrows(IllegalArgumentException.class, () -> cbr2.shrink(-1)); + assertNull(cbr2.shrink(1)); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/util/BlockStateTagTest.java b/src/test/java/io/github/ensgijs/nbt/mca/util/BlockStateTagTest.java new file mode 100644 index 00000000..771b3730 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/util/BlockStateTagTest.java @@ -0,0 +1,276 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.NbtTestCase; +import io.github.ensgijs.nbt.io.TextNbtHelpers; +import io.github.ensgijs.nbt.io.TextNbtParser; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.IntTag; +import io.github.ensgijs.nbt.tag.StringTag; + +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.*; + +public class BlockStateTagTest extends NbtTestCase { + + public void testNameConstructor() { + BlockStateTag bs = new BlockStateTag("stone"); + assertEquals("{Name:stone}", bs.toString()); + assertThrows(NullPointerException.class, () -> new BlockStateTag((String) null)); + } + + public void testNamePropertiesConstructor() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + assertEquals("{Name:\"minecraft:pointed_dripstone\",Properties:{vertical_direction:down,waterlogged:false}}", bs.toString()); + + bs = new BlockStateTag("stone", Collections.emptyMap()); + assertEquals("{Name:stone}", bs.toString()); + + assertThrows(NullPointerException.class, () -> new BlockStateTag("stone", null)); + } + + public void testCompoundTagConstructor() { + CompoundTag rawTag = TextNbtParser.parseInline(""" + { + Properties: { + vertical_direction: down, + thickness: middle, + waterlogged: false + }, + Name: "minecraft:pointed_dripstone" + }"""); + + BlockStateTag bs = new BlockStateTag(rawTag); + assertEquals("minecraft:pointed_dripstone", bs.getName()); + assertEquals("down", bs.get("vertical_direction")); + assertSame(rawTag, bs.getHandle()); + assertSame(rawTag, bs.updateHandle()); + } + + public void testSetPropertiesByMap() { + BlockStateTag bs = new BlockStateTag("minecraft:pointed_dripstone"); + bs.setProperties(Map.of("waterlogged", false, "vertical_direction", "down")); + assertEquals("{Name:\"minecraft:pointed_dripstone\",Properties:{vertical_direction:down,waterlogged:false}}", bs.toString()); + + bs.setProperties(Map.of("waterlogged", true, "thickness", new StringTag("tip"))); + assertEquals("{Name:\"minecraft:pointed_dripstone\",Properties:{thickness:tip,waterlogged:true}}", bs.toString()); + + bs.setProperties(Collections.emptyMap()); + assertEquals("{Name:\"minecraft:pointed_dripstone\"}", bs.toString()); + } + + public void testSetPropertiesByCompoundTag() { + BlockStateTag bs = new BlockStateTag("minecraft:pointed_dripstone"); + bs.setProperties(Map.of("waterlogged", false, "vertical_direction", "down")); + assertEquals("{Name:\"minecraft:pointed_dripstone\",Properties:{vertical_direction:down,waterlogged:false}}", bs.toString()); + + bs.setProperties((CompoundTag) null); + assertEquals("{Name:\"minecraft:pointed_dripstone\"}", bs.toString()); + + CompoundTag newProp = new CompoundTag(); + newProp.putString("waterlogged", "true"); + newProp.putInt("vale", 42); + bs.setProperties(newProp); + assertEquals("{Name:\"minecraft:pointed_dripstone\",Properties:{vale:42,waterlogged:true}}", bs.toString()); + assertSame(newProp, bs.getHandle().get("Properties")); + bs.setProperties(newProp); + assertEquals("{Name:\"minecraft:pointed_dripstone\",Properties:{vale:42,waterlogged:true}}", bs.toString()); + assertSame(newProp, bs.getHandle().get("Properties")); + + bs.setProperties(new CompoundTag()); + assertEquals("{Name:\"minecraft:pointed_dripstone\"}", bs.toString()); + bs.put("vale", 99); + assertEquals(42, newProp.getInt("vale")); + } + + public void testSetName() { + BlockStateTag bs = new BlockStateTag("stone"); + bs.setName("diamond_block"); + assertEquals("diamond_block", bs.getName()); + assertEquals("diamond_block", bs.getHandle().getString("Name")); + } + + public void testIsEmpty() { + BlockStateTag bs = new BlockStateTag("stone"); + assertTrue(bs.isEmpty()); + bs.put("facing", "west"); + assertFalse(bs.isEmpty()); + } + + public void testHasProperty() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + assertTrue(bs.hasProperty("waterlogged")); + assertFalse(bs.hasProperty("facing")); + } + + public void testGet() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + assertEquals("false", bs.get("waterlogged")); + assertNull(bs.get("facing")); + } + + public void testPut() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + bs.put("waterlogged", true); + assertEquals("true", bs.get("waterlogged")); + bs.put("facing", new StringTag("north")); + assertEquals("north", bs.get("facing")); + bs.put("value", 420L); + assertEquals("420", bs.get("value")); + assertEquals("420", bs.put("value", null)); + assertFalse(bs.hasProperty("value")); + } + + public void testRemoveByKey() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + assertEquals("false", bs.remove("waterlogged")); + assertNull(bs.remove("waterlogged")); + } + + public void testRemoveByKeyValue() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + assertFalse(bs.remove("vertical_direction", "up")); + assertTrue(bs.remove("vertical_direction", new StringTag("down"))); + assertFalse(bs.remove("waterlogged", true)); + assertTrue(bs.remove("waterlogged", false)); + assertFalse(bs.remove("whatever", "thing")); + assertEquals("{Name:\"minecraft:pointed_dripstone\"}", bs.toString()); + } + + public void testPutAll() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + bs.putAll(Map.of( + "thickness", new StringTag("tip"), + "waterlogged", true + )); + assertEquals("{Name:\"minecraft:pointed_dripstone\",Properties:{thickness:tip,vertical_direction:down,waterlogged:true}}", + bs.toString()); + } + + public void testClear() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + bs.clear(); + assertTrue(bs.isEmpty()); + assertEquals("{Name:\"minecraft:pointed_dripstone\"}", bs.toString()); + } + + public void testGetOrDefault() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + assertEquals("false", bs.getOrDefault("waterlogged", true)); + assertEquals("42", bs.getOrDefault("life", new IntTag(42))); + assertNull(bs.getOrDefault("life", null)); + } + + public void testPutIfAbsent() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + assertEquals("false", bs.putIfAbsent("waterlogged", true)); + assertNull(bs.putIfAbsent("life", new IntTag(42))); + assertEquals("42", bs.get("life")); + } + + public void testReplaceIfPresent() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + + assertNull(bs.replace("whatever", 22)); + assertFalse(bs.hasProperty("whatever")); + + assertEquals("false", bs.replace("waterlogged", true)); + assertEquals("true", bs.get("waterlogged")); + } + + public void testReplaceIfMatchesExisting() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + + assertFalse(bs.replace("vertical_direction", "up", "down")); + assertEquals("down", bs.get("vertical_direction")); + assertTrue(bs.replace("vertical_direction", "down", "up")); + assertEquals("up", bs.get("vertical_direction")); + + assertFalse(bs.replace("whatever", "up", "down")); + } + + public void testComputeIfAbsent() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + assertThrows(NullPointerException.class, () -> bs.computeIfAbsent("foo", null)); + + assertEquals("false", bs.computeIfAbsent("waterlogged", k -> k)); + assertEquals("false", bs.get("waterlogged")); + + assertEquals("middle", bs.computeIfAbsent("thickness", k -> "middle")); + assertEquals("middle", bs.get("thickness")); + } + + public void testComputeIfPresent() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + assertThrows(NullPointerException.class, () -> bs.computeIfPresent("waterlogged", null)); + + assertEquals("true", bs.computeIfPresent("waterlogged", (k, v) -> "true")); + assertEquals("true", bs.get("waterlogged")); + + assertNull(bs.computeIfPresent("thickness", (k, v) -> "middle")); + assertNull(bs.get("thickness")); + } + + public void testCompute() { + BlockStateTag bs = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down", "thickness", "middle")); + assertThrows(NullPointerException.class, () -> bs.computeIfPresent("waterlogged", null)); + + assertEquals("tip", bs.compute("thickness", (k, v) -> { + assertEquals("thickness", k); + assertEquals("middle", v); + return "tip"; + })); + + assertNull(bs.compute("thickness", (k, v) -> null)); + + assertEquals("something", bs.compute("nothing", (k, v) -> "something")); + assertEquals("{Name:\"minecraft:pointed_dripstone\",Properties:{nothing:something,vertical_direction:down,waterlogged:false}}", bs.toString()); + } + + public void testEquals() { + BlockStateTag bs1 = new BlockStateTag( + "minecraft:pointed_dripstone", + Map.of("waterlogged", false, "vertical_direction", "down")); + BlockStateTag bs2 = new BlockStateTag("minecraft:pointed_dripstone"); + assertNotEquals(bs1, bs2); + bs2.put("waterlogged", "false"); + bs2.put("vertical_direction", "up"); + assertNotEquals(bs1, bs2); + bs2.put("vertical_direction", "down"); + assertEquals(bs1, bs2); + + assertNotEquals(null, bs1); + assertNotEquals(bs1, new Object()); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/util/ChunkBoundingRectangleTest.java b/src/test/java/io/github/ensgijs/nbt/mca/util/ChunkBoundingRectangleTest.java new file mode 100644 index 00000000..8202ea6f --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/util/ChunkBoundingRectangleTest.java @@ -0,0 +1,113 @@ +package io.github.ensgijs.nbt.mca.util; + + +import junit.framework.TestCase; + +import java.util.List; + +import static io.github.ensgijs.nbt.mca.util.IntPointXZ.XZ; +import static org.junit.Assert.assertThrows; + +public class ChunkBoundingRectangleTest extends TestCase { + + public void testRelocate_int() { + ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(1, -1); // [16..32), [-16..0) + assertEquals(16, cbr.getMinBlockX()); + assertEquals(-16, cbr.getMinBlockZ()); + assertEquals(32, cbr.getMaxBlockX()); + assertEquals(0, cbr.getMaxBlockZ()); + + assertEquals(1, cbr.getWidthChunkXZ()); + assertEquals(16, cbr.getWidthBlockXZ()); + + assertEquals(16, cbr.relocateX(0)); + assertEquals(16 + 3, cbr.relocateX(3)); + assertEquals(16 + 7, cbr.relocateX(16 + 7)); + assertEquals(16 + 15, cbr.relocateX(-1)); + + assertEquals(-1, cbr.relocateZ(16 * 53 - 1)); + assertEquals(-15, cbr.relocateZ(16 * -53 - 15)); + assertEquals(-16 + 3, cbr.relocateZ(3)); + assertEquals(-16 + 7, cbr.relocateZ(16 + 7)); + assertEquals(-16 + 15, cbr.relocateZ(-1)); + + cbr = new ChunkBoundingRectangle(0, 0, 32); + assertEquals(0, cbr.getMinBlockX()); + assertEquals(0, cbr.getMinBlockZ()); + assertEquals(512, cbr.getMaxBlockX()); + assertEquals(512, cbr.getMaxBlockZ()); + assertEquals(32, cbr.getWidthChunkXZ()); + assertEquals(512, cbr.getWidthBlockXZ()); + } + + public void testRelocate_double() { + ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(0, 1); // [0..16), [16..32) + + assertEquals(16 - 1e-6, cbr.relocateX(-1e-6), 1e-10); + assertEquals(16 - 0.5, cbr.relocateX(41 * 16 -0.5), 1e-10); + assertEquals(16 - 0.5, cbr.relocateX(-41 * 16 -0.5), 1e-10); + + assertEquals(16 + 1e-6, cbr.relocateZ(1e-6), 1e-10); + assertEquals(16 + 1e-6, cbr.relocateZ(41 * 16 + 1e-6), 1e-10); + assertEquals(16 + 0.5, cbr.relocateZ(41 * 16 + 0.5), 1e-10); + assertEquals(16 + 6.789, cbr.relocateZ(-41 * 16 + 6.789), 1e-10); + } + + public void testContainsChunk() { + ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(0, 0, 5); // [512..1024), [-512..0) | [32..64), [-32..0) + assertTrue(cbr.containsChunk(0, 0)); + assertTrue(cbr.containsChunk(4, 4)); + assertFalse(cbr.containsChunk(-1, 2)); + assertFalse(cbr.containsChunk(2, 5)); + } + + public void testAsBlockBounds() { + ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(4887, 6639); + assertEquals(16, cbr.getWidthBlockXZ()); + assertEquals(4887 * 16, cbr.getMinBlockX()); + assertEquals(6639 * 16, cbr.getMinBlockZ()); + assertEquals(4888 * 16, cbr.getMaxBlockX()); + assertEquals(6640 * 16, cbr.getMaxBlockZ()); + + BlockAlignedBoundingRectangle bbr = cbr.asBlockBounds(); + assertEquals(16, bbr.getWidthBlockXZ()); + assertEquals(4887 * 16, bbr.getMinBlockX()); + assertEquals(6639 * 16, bbr.getMinBlockZ()); + assertEquals(4888 * 16, bbr.getMaxBlockX()); + assertEquals(6640 * 16, bbr.getMaxBlockZ()); + } + + public void testOf() { + ChunkBoundingRectangle br = ChunkBoundingRectangle.of(List.of( + XZ(-5, 4) + )); + assertEquals(new ChunkBoundingRectangle(-5, 4, 1), br); + + br = ChunkBoundingRectangle.of(List.of( + XZ(-5, 4), + XZ(-4, -1) + )); + assertEquals(new ChunkBoundingRectangle(-5, -1, 6), br); + } + + public void testGrow() { + ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(3, 4, 5); + ChunkBoundingRectangle cbr2 = cbr.growChunks(2); + assertEquals(new ChunkBoundingRectangle(3 - 2, 4 - 2, 5 + 2 * 2), cbr2); + assertEquals(cbr.getCenterX(), cbr2.getCenterX(), 1e-6); + assertEquals(cbr.getCenterZ(), cbr2.getCenterZ(), 1e-6); + + assertThrows(IllegalArgumentException.class, () -> cbr2.growChunks(-1)); + } + + public void testShrink() { + ChunkBoundingRectangle cbr = new ChunkBoundingRectangle(3, 4, 5); + ChunkBoundingRectangle cbr2 = cbr.shrinkChunks(2); + assertEquals(new ChunkBoundingRectangle(3 + 2, 4 + 2, 5 - 2 * 2), cbr2); + assertEquals(cbr.getCenterX(), cbr2.getCenterX(), 1e-6); + assertEquals(cbr.getCenterZ(), cbr2.getCenterZ(), 1e-6); + + assertThrows(IllegalArgumentException.class, () -> cbr2.shrinkChunks(-1)); + assertNull(cbr2.shrinkChunks(1)); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/util/IntPointXZTest.java b/src/test/java/io/github/ensgijs/nbt/mca/util/IntPointXZTest.java new file mode 100644 index 00000000..e6792eb8 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/util/IntPointXZTest.java @@ -0,0 +1,161 @@ +package io.github.ensgijs.nbt.mca.util; + +import junit.framework.TestCase; + +public class IntPointXZTest extends TestCase { + + public void testConstructorAndGetters() { + IntPointXZ xz = new IntPointXZ(42, -64); + assertEquals(42, xz.getX()); + assertEquals(-64, xz.getZ()); + } + + public void testMultiplyInt() { + IntPointXZ xz = new IntPointXZ(42, -64); + xz = xz.multiply(8); + assertEquals(42 * 8, xz.getX()); + assertEquals(-64 * 8, xz.getZ()); + xz = xz.multiply(-3); + assertEquals(42 * 8 * -3, xz.getX()); + assertEquals(-64 * 8 * -3, xz.getZ()); + } + + public void testMultiplyIntPointXZ() { + IntPointXZ xz = new IntPointXZ(42, -64); + xz = xz.multiply(new IntPointXZ(-2, 4)); + assertEquals(42 * -2, xz.getX()); + assertEquals(-64 * 4, xz.getZ()); + } + + public void testDivideInt() { + IntPointXZ xz = new IntPointXZ(42, -63); + xz = xz.divide(8); + assertEquals(42 / 8, xz.getX()); + assertEquals(-63 / 8, xz.getZ()); + } + + public void testDivideIntPointXZ() { + IntPointXZ xz = new IntPointXZ(42, -63); + xz = xz.divide(new IntPointXZ(2, 3)); + assertEquals(42 / 2, xz.getX()); + assertEquals(-63 / 3, xz.getZ()); + } + + public void testAddIntInt() { + IntPointXZ xz = new IntPointXZ(42, -63); + xz = xz.add(32, -16); + assertEquals(42 + 32, xz.getX()); + assertEquals(-63 - 16, xz.getZ()); + } + + public void testAddIntPointXZ() { + IntPointXZ xz = new IntPointXZ(42, -63); + xz = xz.add(new IntPointXZ(14, 100)); + assertEquals(42 + 14, xz.getX()); + assertEquals(-63 + 100, xz.getZ()); + } + + public void testSubtractIntInt() { + IntPointXZ xz = new IntPointXZ(42, -63); + xz = xz.subtract(32, -16); + assertEquals(42 - 32, xz.getX()); + assertEquals(-63 + 16, xz.getZ()); + } + + public void testSubtractIntPointXZ() { + IntPointXZ xz = new IntPointXZ(42, -63); + xz = xz.subtract(new IntPointXZ(14, 100)); + assertEquals(42 - 14, xz.getX()); + assertEquals(-63 - 100, xz.getZ()); + } + + public void testTransformBlockToChunk() { + IntPointXZ xz = new IntPointXZ(42, -63); + xz = xz.transformBlockToChunk(); + assertEquals(2, xz.getX()); + assertEquals(-4, xz.getZ()); + + xz = new IntPointXZ(-1, 15); + xz = xz.transformBlockToChunk(); + assertEquals(-1, xz.getX()); + assertEquals(0, xz.getZ()); + } + + public void testTransformChunkToBlock() { + IntPointXZ xz = new IntPointXZ(2, -4); + xz = xz.transformChunkToBlock(); + assertEquals(32, xz.getX()); + assertEquals(-64, xz.getZ()); + + xz = new IntPointXZ(-1, 0); + xz = xz.transformChunkToBlock(); + assertEquals(-16, xz.getX()); + assertEquals(0, xz.getZ()); + } + + public void testTransformChunkToRegion() { + IntPointXZ xz = new IntPointXZ(0, 31); + xz = xz.transformChunkToRegion(); + assertEquals(0, xz.getX()); + assertEquals(0, xz.getZ()); + + xz = new IntPointXZ(-33, 97); + xz = xz.transformChunkToRegion(); + assertEquals(-2, xz.getX()); + assertEquals(3, xz.getZ()); + } + + public void testTransformRegionToChunk() { + IntPointXZ xz = new IntPointXZ(0, 1); + xz = xz.transformRegionToChunk(); + assertEquals(0, xz.getX()); + assertEquals(32, xz.getZ()); + + xz = new IntPointXZ(-1, -2); + xz = xz.transformRegionToChunk(); + assertEquals(-32, xz.getX()); + assertEquals(-64, xz.getZ()); + } + + public void testTransformBlockToRegion() { + IntPointXZ xz = new IntPointXZ(0, 512); + xz = xz.transformBlockToRegion(); + assertEquals(0, xz.getX()); + assertEquals(1, xz.getZ()); + + xz = new IntPointXZ(-513, 511); + xz = xz.transformBlockToRegion(); + assertEquals(-2, xz.getX()); + assertEquals(0, xz.getZ()); + } + + public void testTransformRegionToBlock() { + IntPointXZ xz = new IntPointXZ(1, -1); + xz = xz.transformRegionToBlock(); + assertEquals(512, xz.getX()); + assertEquals(-512, xz.getZ()); + + xz = new IntPointXZ(0, -2); + xz = xz.transformRegionToBlock(); + assertEquals(0, xz.getX()); + assertEquals(-1024, xz.getZ()); + } + + public void testEquals() { + IntPointXZ xz = new IntPointXZ(0, 0); + assertTrue(xz.equals(xz)); + assertFalse(xz.equals(null)); + assertTrue(xz.equals(new IntPointXZ(0, 0))); + assertTrue(new IntPointXZ(101, -97).equals(new IntPointXZ(101, -97))); + assertFalse(new IntPointXZ(101, -97).equals(new IntPointXZ(-97, 101))); + } + + public void testUnpackLong() { + assertEquals(new IntPointXZ(-95, -85), IntPointXZ.unpack(-360777252959L)); + assertEquals(new IntPointXZ(-91, -87), IntPointXZ.unpack(-369367187547L)); + } + + public void testPackLong() { + assertEquals(-360777252959L, IntPointXZ.pack(new IntPointXZ(-95, -85))); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/util/LongArrayTagPackedIntegersTest.java b/src/test/java/io/github/ensgijs/nbt/mca/util/LongArrayTagPackedIntegersTest.java new file mode 100644 index 00000000..8d6f6962 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/util/LongArrayTagPackedIntegersTest.java @@ -0,0 +1,1129 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.NbtTestCase; +import io.github.ensgijs.nbt.mca.DataVersion; +import io.github.ensgijs.nbt.tag.LongArrayTag; + +import java.util.ListIterator; +import java.util.NoSuchElementException; + +import static io.github.ensgijs.nbt.mca.util.LongArrayTagPackedIntegers.PackingStrategy.NO_SPLIT_VALUES_ACROSS_LONGS; +import static io.github.ensgijs.nbt.mca.util.LongArrayTagPackedIntegers.PackingStrategy.SPLIT_VALUES_ACROSS_LONGS; +import static io.github.ensgijs.nbt.mca.util.LongArrayTagPackedIntegers.calculateBitsRequired; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertThrows; + +public class LongArrayTagPackedIntegersTest extends NbtTestCase { + + // + private long[] getSplitValuesAcrossLongsTestData() { + return new long[] { + -7905747598138111418L, + -4034096956261382364L, + -1985479300307607665L, + 160190676332679752L, + 3537733969192683524L, + 2635249149083706482L, + -8048741705815158215L, + 5198158411513084188L, + 2563050546444967058L, + 1263510874847481933L, + -3952908983306664157L, + -6565560818798419055L, + -7910268677794852407L, + -9134254101975906012L, + 4079221292204147954L, + -7184891145390624151L, + 5631489967097282880L, + 2563051097278199968L, + 1317800911811943499L, + -3925781591302844377L, + -6565560818798419438L, + -3282780392202497399L, + 6426804121022679460L, + 4079221292212511922L, + -7472277643142573455L, + 5703689948425773360L, + 2563332572253333652L, + 1461986468094581325L, + 658829914959925032L, + 2662306004410966930L, + -6750565342366886838L, + 7581981832104790308L, + 4365763922339947762L, + -7039368025146592647L, + 5703689948425777464L, + 2635672192637025432L}; + } + private final String expectedSplitAcrossLongsGrid = """ + 70 69 69 69 71 73 73 73 71 71 71 70 64 64 63 63 + 70 70 70 69 71 71 73 71 71 73 71 71 64 64 64 63 + 77 71 70 70 71 71 71 71 73 73 73 71 65 64 64 64 + 77 72 71 71 71 70 70 71 72 73 71 71 71 71 71 71 + 77 72 72 72 71 71 70 70 71 71 71 71 71 73 71 71 + 76 78 78 78 78 77 71 71 70 70 69 71 73 73 73 71 + 74 78 79 80 79 78 72 78 78 78 78 77 71 73 72 71 + 74 78 80 80 80 78 73 78 78 80 78 78 75 75 71 71 + 75 78 79 80 79 78 73 78 80 80 80 78 77 76 75 68 + 76 78 78 78 78 77 74 78 79 80 78 78 77 77 75 69 + 76 76 76 75 75 75 74 78 78 78 78 78 77 75 75 70 + 77 76 76 76 78 79 79 79 79 74 74 75 75 75 75 71 + 77 77 77 76 79 80 81 80 79 75 74 74 74 73 72 71 + 78 78 77 77 79 81 81 81 79 75 75 74 74 73 73 72 + 78 78 78 77 79 79 81 80 79 75 79 79 79 79 79 72 + 79 79 78 78 78 79 79 79 79 76 79 80 81 79 79 73"""; + + + private long[] getNoSplitValuesAcrossLongsTestData() { + return new long[] { + 2490852214246277761L, + 2328405075627938441L, + 2490851869573055105L, + 2328405074959209610L, + 2472520261958959745L, + 2328687099825951881L, + 2328405073883103873L, + 2472802356486804097L, + 2346454725484151433L, + 2472802286691484290L, + 2346454656764678793L, + 2346454725618894466L, + 2346454725484414082L, + 2364504377354159746L, + 2346454725618631811L, + 2364504377354159234L, + 2364504377488640132L, + 2364504377354159747L, + 2382589282315732611L, + 2364504446208116868L, + 2400603749275731587L, + 2382554029224168069L, + 2418547572882605699L, + 2400603680825215621L, + 2364504377354422404L, + 2400603680825478275L, + 2364504446208117381L, + 2400603749678384771L, + 2382554029224168069L, + 2418653125998872195L, + 2382589282315733637L, + 2364504377354160260L, + 2400603680825215622L, + 2364504377488640133L, + 2400603680825476739L, + 2382554029224168069L, + 17616930435L}; + } + private final String expectedNoSplitAcrossLongsGrid = """ + 64 64 72 72 77 73 73 72 72 64 77 64 64 64 64 64 + 64 64 72 73 73 73 73 73 72 64 64 64 64 64 64 64 + 64 64 72 72 73 73 73 72 72 64 64 64 64 64 64 64 + 64 64 72 72 72 73 72 72 72 64 64 64 65 65 65 65 + 64 64 64 72 72 72 72 72 64 64 64 65 65 65 66 66 + 65 65 65 65 65 65 65 64 65 65 65 65 66 66 66 66 + 66 66 66 65 65 65 65 65 65 65 65 66 66 66 66 66 + 67 67 67 67 66 66 66 66 66 66 66 66 66 66 66 66 + 68 68 68 68 67 67 67 67 67 67 66 66 66 66 66 66 + 69 68 68 68 68 68 68 67 67 67 66 66 66 66 66 66 + 69 68 68 68 68 68 68 68 67 67 67 66 66 66 66 66 + 69 69 68 68 68 68 68 68 67 67 67 66 66 66 66 66 + 69 69 68 68 68 68 68 68 67 67 67 66 66 66 66 66 + 69 69 68 68 68 68 68 68 67 67 67 66 66 66 66 66 + 69 68 68 68 68 68 68 68 67 67 67 66 66 66 66 66 + 69 68 68 68 68 68 68 68 68 67 67 67 66 66 66 66"""; + // + + private int indexOf(int x, int z) { + return z << 4 | x; + } + + public void testCalculateBitsRequired() { + assertEquals(0, calculateBitsRequired(0)); + assertEquals(1, calculateBitsRequired(1)); + assertEquals(2, calculateBitsRequired(2)); + assertEquals(2, calculateBitsRequired(3)); + assertEquals(3, calculateBitsRequired(4)); + assertEquals(3, calculateBitsRequired(7)); + assertEquals(4, calculateBitsRequired(8)); + assertEquals(4, calculateBitsRequired(15)); + assertEquals(5, calculateBitsRequired(16)); + assertEquals(9, calculateBitsRequired(511)); + assertEquals(10, calculateBitsRequired(512)); + assertEquals(31, calculateBitsRequired(Integer.MAX_VALUE)); + } + + public void testBuilder_build_noGivenTag() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .dataVersion(DataVersion.latest()) + .length(64) + .minBitsPerValue(3) + .valueOffset(-4) + .build(); + assertEquals(NO_SPLIT_VALUES_ACROSS_LONGS, packed.getPackingStrategy()); + assertEquals(4, packed.getHandle().length()); + assertEquals(64, packed.length); + assertEquals(3, packed.getMinBitsPerValue()); + assertEquals(3, packed.getBitsPerValue()); + assertEquals(-4, packed.getValueOffset()); + assertEquals((int) Math.pow(2, 3) - 1 - 4, packed.getCurrentMaxPackableValue()); + assertEquals(0, packed.getActualUsedBitsPerValue()); + assertSame(packed.getHandle().getValue(), packed.longs()); + } + + public void testBuilder_build_packingStrategyHasDefault() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .length(64) + .minBitsPerValue(1) + .build(); + assertEquals(NO_SPLIT_VALUES_ACROSS_LONGS, packed.getPackingStrategy()); + } + + public void testBuilder_build_settingDataVersionToZero() { + var builder = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(64) + .minBitsPerValue(1); + builder.dataVersion(0); + assertEquals(NO_SPLIT_VALUES_ACROSS_LONGS, builder.build().getPackingStrategy()); + + } + + public void testBuilder_build_throwsWhenLongArrayTagHasUnexpectedLength() { + assertThrows(IllegalArgumentException.class, () -> LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(1) + .build(new LongArrayTag(getSplitValuesAcrossLongsTestData()))); + } + + public void testBuilder_build_givenValueArray() { + LongArrayTagPackedIntegers packed1 = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .valueOffset(-65) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + LongArrayTagPackedIntegers packed2 = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .valueOffset(-65) + .build(packed1.toArray()); + assertEquals(expectedNoSplitAcrossLongsGrid, packed2.toString2dGrid()); + } + + public void testBuilder_build_capacityRequired() { + var builder = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS); + assertThrowsException(builder::build, IllegalArgumentException.class, s -> s.contains("capacity")); + } + + public void testToString3dGrid() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(64) + .minBitsPerValue(1) + .initializeForStoring(5) + .build(new LongArrayTag(3785436329631921288L, 3932602464921073299L, 2797418351439529682L, 4L)); + assertEquals(""" + Y=0 + 0 1 2 2 + 3 3 4 4 + 3 4 4 4 + 4 4 4 4 + Y=1 + 0 1 2 2 + 3 3 2 2 + 3 3 4 4 + 3 4 4 4 + Y=2 + 5 1 2 2 + 3 3 2 2 + 3 3 2 2 + 3 3 4 4 + Y=3 + 5 5 5 2 + 5 5 2 2 + 3 3 2 2 + 3 3 2 4""", packed.toString3dGrid()); + } + + public void testToString3dGrid_throwsIfLengthDoesNotHaveACubeRoot() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(32) + .minBitsPerValue(1) + .build(); + assertThrows(UnsupportedOperationException.class, packed::toString3dGrid); + } + + public void testToString2dGrid_throwsIfLengthDoesNotHaveASquareRoot() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(32) + .minBitsPerValue(1) + .build(); + assertThrows(UnsupportedOperationException.class, packed::toString2dGrid); + } + + public void testNoSplitValuesAcrossLongs_extraTallWorldHeightmap() { + // world build height from -512 to 575 + final int chunkBottomSectionY = -32; + final int chunkTopSectionY = 36; + long[] longs = new long[] { + // + 9891638458552882L, + 9856436898204210L, + 9856436898202160L, + 9891638458552880L, + 9856436902398514L, + 9856436898202160L, + 9891638458548784L, + 9856445492333106L, + 9856436898202160L, + 9891638450160176L, + 9856445492333105L, + 9856436898202160L, + 9891621270290992L, + 9856436902398513L, + 9856436898202160L, + 9856436898202160L, + 9856436902398513L, + 9856436898202160L, + 9856436898202160L, + 9856436902398512L, + 10120448600832560L, + 9856436898202160L, + 9856436898202160L, + 11002806832L, + 9856436898202175L, + 9856436898202160L, + 4941562348080L, + 9856436899282944L, + 9856436898202160L, + 10120319688868400L, + 9856436961147455L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 9856436898202160L, + 560L}; + // + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .valueOffset(chunkBottomSectionY * 16 - 1) + .initializeForStoring(chunkTopSectionY * 16 + 15) + .build(new LongArrayTag(longs)); + + assertEquals(""" + 49 49 49 49 49 49 48 47 47 47 47 47 47 47 47 47 + 49 49 49 49 49 48 48 47 47 47 47 47 47 47 47 47 + 49 49 49 49 48 48 48 47 47 47 47 47 47 47 47 47 + 49 49 48 48 48 48 47 47 47 47 47 47 47 47 47 47 + 49 48 48 48 47 47 47 47 47 47 47 47 47 47 47 47 + 48 48 48 47 47 47 47 47 47 47 47 47 47 47 47 47 + 48 48 47 47 47 62 62 62 62 47 47 47 47 47 47 47 + 47 47 47 47 47 62 -512 -513 62 47 47 47 47 47 47 47 + 47 47 47 47 47 62 -513 -513 575 47 47 47 47 47 47 47 + 47 47 47 47 47 62 62 62 62 47 47 47 47 47 47 47 + 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 + 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 + 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 + 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 + 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 + 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 47 + """, packed.toString2dGrid() + "\n"); + } + + public void testNoSplitValuesAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .valueOffset(-65) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + assertEquals(expectedNoSplitAcrossLongsGrid, packed.toString2dGrid()); + assertSame(packed.getHandle().getValue(), packed.longs()); + } + + public void testSplitValuesAcrossLongs() { + LongArrayTag tag = new LongArrayTag(getSplitValuesAcrossLongsTestData()); + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(tag); + assertEquals(expectedSplitAcrossLongsGrid, packed.toString2dGrid()); + assertSame(packed.getHandle().getValue(), packed.longs()); + } + + public void testNoSplitValuesAcrossLongs_convertTo_Split() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .valueOffset(-65) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + assertEquals(37, packed.getHandle().length()); + packed.setPackingStrategy(SPLIT_VALUES_ACROSS_LONGS); + assertEquals(36, packed.getHandle().length()); + assertEquals(expectedNoSplitAcrossLongsGrid, packed.toString2dGrid()); + assertSame(packed.getHandle().getValue(), packed.longs()); + } + + public void testSplitValuesAcrossLongs_convertTo_NoSplit() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getSplitValuesAcrossLongsTestData())); + assertEquals(36, packed.getHandle().length()); + packed.setPackingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS); + assertEquals(37, packed.getHandle().length()); + assertEquals(expectedSplitAcrossLongsGrid, packed.toString2dGrid()); + assertSame(packed.getHandle().getValue(), packed.longs()); + } + + public void testSetMinBitsPerValue_noSplitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(64) + .minBitsPerValue(1) + .build(); + packed.set(42, 1); + assertThrows(IllegalArgumentException.class, () -> packed.setMinBitsPerValue(0)); + assertThrows(IllegalArgumentException.class, () -> packed.setMinBitsPerValue(32)); + assertEquals(1, packed.getMinBitsPerValue()); + assertEquals(1, packed.getHandle().length()); + assertEquals(0, packed.getCurrentMinPackableValue()); + assertEquals(1, packed.getCurrentMaxPackableValue()); + packed.setMinBitsPerValue(31); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(31, packed.getMinBitsPerValue()); + assertEquals(32, packed.getHandle().length()); + assertEquals(0, packed.getCurrentMinPackableValue()); + assertEquals(Integer.MAX_VALUE, packed.getCurrentMaxPackableValue()); + assertEquals(1, packed.get(42)); + assertEquals(0, packed.get(41)); + assertEquals(0, packed.get(43)); + + packed.set(0, 99); + packed.setMinBitsPerValue(5); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(5, packed.getMinBitsPerValue()); + assertEquals(32, packed.getHandle().length()); + assertEquals(99, packed.get(0)); + } + + public void testSetMinBitsPerValue_splitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(64) + .minBitsPerValue(1) + .build(); + packed.set(42, 1); + assertThrows(IllegalArgumentException.class, () -> packed.setMinBitsPerValue(0)); + assertThrows(IllegalArgumentException.class, () -> packed.setMinBitsPerValue(32)); + assertEquals(1, packed.getMinBitsPerValue()); + assertEquals(1, packed.getHandle().length()); + assertEquals(0, packed.getCurrentMinPackableValue()); + assertEquals(1, packed.getCurrentMaxPackableValue()); + packed.setMinBitsPerValue(31); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(31, packed.getMinBitsPerValue()); + assertEquals(31, packed.getHandle().length()); + assertEquals(0, packed.getCurrentMinPackableValue()); + assertEquals(Integer.MAX_VALUE, packed.getCurrentMaxPackableValue()); + assertEquals(1, packed.get(42)); + assertEquals(0, packed.get(41)); + assertEquals(0, packed.get(43)); + + packed.set(0, 99); + packed.setMinBitsPerValue(5); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(5, packed.getMinBitsPerValue()); + assertEquals(31, packed.getHandle().length()); + assertEquals(99, packed.get(0)); + } + + public void testGet_throws_outOfBounds() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(64) + .minBitsPerValue(1) + .build(); + assertThrows(IndexOutOfBoundsException.class, () -> packed.get(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> packed.get(64)); + } + + public void testSet_throws_outOfBounds() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(64) + .minBitsPerValue(1) + .build(); + assertThrows(IndexOutOfBoundsException.class, () -> packed.set(-1, 0)); + assertThrows(IndexOutOfBoundsException.class, () -> packed.set(64, 0)); + } + + public void testSet_throws_whenValueBelowValueOffset() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(64) + .minBitsPerValue(1) + .valueOffset(10) + .build(); + assertThrows(IllegalArgumentException.class, () -> packed.set(0, 1)); + } + + public void testSet_automaticallyResizesAsNeeded_splitValuesAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(64) + .minBitsPerValue(1) + .build(); + assertEquals(1, packed.getHandle().length()); + packed.set(5, 1); + assertEquals(1, packed.get(5)); + assertEquals(1, packed.getHandle().length()); + packed.set(5, 2); + assertEquals(2, packed.get(5)); + assertEquals(2, packed.getHandle().length()); + assertSame(packed.getHandle().getValue(), packed.longs()); + packed.set(5, 3); + assertEquals(3, packed.get(5)); + assertEquals(2, packed.getHandle().length()); + packed.set(5, 31); + assertEquals(31, packed.get(5)); + assertEquals(5, packed.getHandle().length()); + assertSame(packed.getHandle().getValue(), packed.longs()); + } + + public void testClear_noSplitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + assertEquals(37, packed.getHandle().length()); + packed.setMinBitsPerValue(5); + assertEquals(37, packed.getHandle().length()); + packed.clear(false); + assertEquals(37, packed.getHandle().length()); + assertEquals(0, packed.get(0)); + packed.clear(true); + assertEquals(22, packed.getHandle().length()); + assertSame(packed.getHandle().getValue(), packed.longs()); + + // check that bits per value and values per long didn't get broken + packed.set(57, 29); + assertEquals(29, packed.get(57)); + } + + public void testClear_splitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getSplitValuesAcrossLongsTestData())); + assertEquals(36, packed.getHandle().length()); + packed.setMinBitsPerValue(5); + assertEquals(36, packed.getHandle().length()); + packed.clear(false); + assertEquals(36, packed.getHandle().length()); + assertEquals(0, packed.get(0)); + packed.clear(true); + assertEquals(20, packed.getHandle().length()); + assertSame(packed.getHandle().getValue(), packed.longs()); + + // check that bits per value and values per long didn't get broken + packed.set(57, 29); + assertEquals(29, packed.get(57)); + } + + public void testContains_noSplitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + assertTrue(packed.contains(130)); + assertFalse(packed.contains(120)); + assertFalse(packed.contains(-1)); + assertFalse(packed.contains(512)); + + packed.setValueOffset(-65); + assertFalse(packed.contains(130)); + assertTrue(packed.contains(77)); + assertFalse(packed.contains(-66)); + assertFalse(packed.contains(512 - 65)); + } + + public void testContains_splitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getSplitValuesAcrossLongsTestData())); + assertTrue(packed.contains(79)); + assertFalse(packed.contains(90)); + assertFalse(packed.contains(-1)); + assertFalse(packed.contains(512)); + + packed.setValueOffset(-65); + assertFalse(packed.contains(79)); + assertTrue(packed.contains(-2)); + assertFalse(packed.contains(-66)); + assertFalse(packed.contains(512 - 65)); + } + + public void testCount_noSplitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + assertEquals(26, packed.count(130)); + assertEquals(0, packed.count(99)); + + packed.setValueOffset(-65); + assertEquals(26, packed.count(130-65)); + assertEquals(0, packed.count(99)); + } + + public void testCount_splitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getSplitValuesAcrossLongsTestData())); + assertEquals(14, packed.count(80)); + assertEquals(0, packed.count(99)); + + packed.setValueOffset(-65); + assertEquals(3, packed.count(-2)); + assertEquals(0, packed.count(-100)); + } + + public void testCount_predicate_noSplitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + assertEquals(192, packed.count(v -> v > 130)); + assertEquals(0, packed.count(v -> v < 99)); + + packed.setValueOffset(-65); + assertEquals(192, packed.count(v -> v > (130-65))); + assertEquals(0, packed.count(v -> v < -30)); + assertEquals(256, packed.count(v -> v > -30)); + } + + public void testCount_predicate_splitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getSplitValuesAcrossLongsTestData())); + assertEquals(119, packed.count(v -> v > 75)); + assertEquals(256, packed.count(v -> v < 99)); + assertEquals(0, packed.count(v -> v >= 99)); + + packed.setValueOffset(-65); + assertEquals(119, packed.count(v -> v > (75-65))); + assertEquals(0, packed.count(v -> v < -30)); + assertEquals(256, packed.count(v -> v > -30)); + assertEquals(11, packed.count(v -> v < 0)); + } + + public void testReplaceAll_noSplitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + assertThrows(IllegalArgumentException.class, () -> packed.replaceAll(-1, 0)); + assertThrows(IllegalArgumentException.class, () -> packed.replaceAll(0, -1)); + packed.replaceAll(130, 1300); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertTrue(packed.contains(1300)); + assertFalse(packed.contains(130)); + assertEquals(11, packed.getBitsPerValue()); + + + packed.setValueOffset(-65); + assertThrows(IllegalArgumentException.class, () -> packed.replaceAll(-66, 0)); + assertThrows(IllegalArgumentException.class, () -> packed.replaceAll(0, -66)); + packed.replaceAll(1235, 0); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertTrue(packed.contains(0)); + assertFalse(packed.contains(1235)); + } + + public void testReplaceAll_splitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getSplitValuesAcrossLongsTestData())); + assertThrows(IllegalArgumentException.class, () -> packed.replaceAll(-1, 0)); + assertThrows(IllegalArgumentException.class, () -> packed.replaceAll(0, -1)); + packed.replaceAll(80, 800); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertTrue(packed.contains(800)); + assertFalse(packed.contains(80)); + assertEquals(10, packed.getBitsPerValue()); + + packed.setValueOffset(-65); + assertThrows(IllegalArgumentException.class, () -> packed.replaceAll(-66, 0)); + assertThrows(IllegalArgumentException.class, () -> packed.replaceAll(0, -66)); + packed.replaceAll(-2, -60); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertTrue(packed.contains(-60)); + assertFalse(packed.contains(-2)); + } + + public void testRemap_noSplitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + assertThrows(IllegalArgumentException.class, () -> packed.remap(v -> v > 135 ? -1 : v)); + packed.remap(v -> v > 135 ? 0 : v); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(""" + 129 129 0 0 0 0 0 0 0 129 0 129 129 129 129 129 + 129 129 0 0 0 0 0 0 0 129 129 129 129 129 129 129 + 129 129 0 0 0 0 0 0 0 129 129 129 129 129 129 129 + 129 129 0 0 0 0 0 0 0 129 129 129 130 130 130 130 + 129 129 129 0 0 0 0 0 129 129 129 130 130 130 131 131 + 130 130 130 130 130 130 130 129 130 130 130 130 131 131 131 131 + 131 131 131 130 130 130 130 130 130 130 130 131 131 131 131 131 + 132 132 132 132 131 131 131 131 131 131 131 131 131 131 131 131 + 133 133 133 133 132 132 132 132 132 132 131 131 131 131 131 131 + 134 133 133 133 133 133 133 132 132 132 131 131 131 131 131 131 + 134 133 133 133 133 133 133 133 132 132 132 131 131 131 131 131 + 134 134 133 133 133 133 133 133 132 132 132 131 131 131 131 131 + 134 134 133 133 133 133 133 133 132 132 132 131 131 131 131 131 + 134 134 133 133 133 133 133 133 132 132 132 131 131 131 131 131 + 134 133 133 133 133 133 133 133 132 132 132 131 131 131 131 131 + 134 133 133 133 133 133 133 133 133 132 132 132 131 131 131 131""", + packed.toString2dGrid()); + + + packed.setValueOffset(-65); + assertThrows(IllegalArgumentException.class, () -> packed.remap(v -> v % 2 == 0 ? -999 : v)); + packed.remap(v -> v % 2 == 0 ? 999 : v); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(""" + 999 999 -65 -65 -65 -65 -65 -65 -65 999 -65 999 999 999 999 999 + 999 999 -65 -65 -65 -65 -65 -65 -65 999 999 999 999 999 999 999 + 999 999 -65 -65 -65 -65 -65 -65 -65 999 999 999 999 999 999 999 + 999 999 -65 -65 -65 -65 -65 -65 -65 999 999 999 65 65 65 65 + 999 999 999 -65 -65 -65 -65 -65 999 999 999 65 65 65 999 999 + 65 65 65 65 65 65 65 999 65 65 65 65 999 999 999 999 + 999 999 999 65 65 65 65 65 65 65 65 999 999 999 999 999 + 67 67 67 67 999 999 999 999 999 999 999 999 999 999 999 999 + 999 999 999 999 67 67 67 67 67 67 999 999 999 999 999 999 + 69 999 999 999 999 999 999 67 67 67 999 999 999 999 999 999 + 69 999 999 999 999 999 999 999 67 67 67 999 999 999 999 999 + 69 69 999 999 999 999 999 999 67 67 67 999 999 999 999 999 + 69 69 999 999 999 999 999 999 67 67 67 999 999 999 999 999 + 69 69 999 999 999 999 999 999 67 67 67 999 999 999 999 999 + 69 999 999 999 999 999 999 999 67 67 67 999 999 999 999 999 + 69 999 999 999 999 999 999 999 999 67 67 67 999 999 999 999""", + packed.toString2dGrid()); + } + + public void testRemap_splitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getSplitValuesAcrossLongsTestData())); + assertThrows(IllegalArgumentException.class, () -> packed.remap(v -> -v)); + packed.remap(v -> Math.min(v, 75)); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(""" + 70 69 69 69 71 73 73 73 71 71 71 70 64 64 63 63 + 70 70 70 69 71 71 73 71 71 73 71 71 64 64 64 63 + 75 71 70 70 71 71 71 71 73 73 73 71 65 64 64 64 + 75 72 71 71 71 70 70 71 72 73 71 71 71 71 71 71 + 75 72 72 72 71 71 70 70 71 71 71 71 71 73 71 71 + 75 75 75 75 75 75 71 71 70 70 69 71 73 73 73 71 + 74 75 75 75 75 75 72 75 75 75 75 75 71 73 72 71 + 74 75 75 75 75 75 73 75 75 75 75 75 75 75 71 71 + 75 75 75 75 75 75 73 75 75 75 75 75 75 75 75 68 + 75 75 75 75 75 75 74 75 75 75 75 75 75 75 75 69 + 75 75 75 75 75 75 74 75 75 75 75 75 75 75 75 70 + 75 75 75 75 75 75 75 75 75 74 74 75 75 75 75 71 + 75 75 75 75 75 75 75 75 75 75 74 74 74 73 72 71 + 75 75 75 75 75 75 75 75 75 75 75 74 74 73 73 72 + 75 75 75 75 75 75 75 75 75 75 75 75 75 75 75 72 + 75 75 75 75 75 75 75 75 75 75 75 75 75 75 75 73""", + packed.toString2dGrid()); + + packed.setValueOffset(-65); + assertThrows(IllegalArgumentException.class, () -> packed.remap(v -> v < 0 ? v * 100 : v)); + packed.remap(v -> v < 0 ? v * 10 : v); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals( + """ + 5 4 4 4 6 8 8 8 6 6 6 5 -10 -10 -20 -20 + 5 5 5 4 6 6 8 6 6 8 6 6 -10 -10 -10 -20 + 10 6 5 5 6 6 6 6 8 8 8 6 0 -10 -10 -10 + 10 7 6 6 6 5 5 6 7 8 6 6 6 6 6 6 + 10 7 7 7 6 6 5 5 6 6 6 6 6 8 6 6 + 10 10 10 10 10 10 6 6 5 5 4 6 8 8 8 6 + 9 10 10 10 10 10 7 10 10 10 10 10 6 8 7 6 + 9 10 10 10 10 10 8 10 10 10 10 10 10 10 6 6 + 10 10 10 10 10 10 8 10 10 10 10 10 10 10 10 3 + 10 10 10 10 10 10 9 10 10 10 10 10 10 10 10 4 + 10 10 10 10 10 10 9 10 10 10 10 10 10 10 10 5 + 10 10 10 10 10 10 10 10 10 9 9 10 10 10 10 6 + 10 10 10 10 10 10 10 10 10 10 9 9 9 8 7 6 + 10 10 10 10 10 10 10 10 10 10 10 9 9 8 8 7 + 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 7 + 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 8 + """, packed.toString2dGrid() + "\n"); + } + + public void testCompact_noSplitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .valueOffset(-65) + .minBitsPerValue(9) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + packed.remap(v -> v / 3); + assertFalse(packed.shouldCompact()); + packed.setMinBitsPerValue(1); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertTrue(packed.shouldCompact()); + String expect = packed.toString2dGrid(); + assertEquals(7, packed.getActualUsedBitsPerValue()); + assertEquals(9, packed.getBitsPerValue()); + packed.compact(); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(7, packed.getBitsPerValue()); + assertEquals(expect, packed.toString2dGrid()); + assertEquals(29, packed.getHandle().length()); + } + + public void testCompact_splitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getSplitValuesAcrossLongsTestData())); + packed.remap(v -> v / 3); + assertFalse(packed.shouldCompact()); + packed.setMinBitsPerValue(1); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertTrue(packed.shouldCompact()); + String expect = packed.toString2dGrid(); + assertEquals(5, packed.getActualUsedBitsPerValue()); + assertEquals(9, packed.getBitsPerValue()); + packed.compact(); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(5, packed.getBitsPerValue()); + assertEquals(expect, packed.toString2dGrid()); + assertEquals(20, packed.getHandle().length()); + } + + + public void testClone_noSplitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .valueOffset(-65) + .minBitsPerValue(9) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + + LongArrayTagPackedIntegers packed2 = packed.clone(); + assertEquals(packed.length, packed2.length); + assertEquals(packed.getActualUsedBitsPerValue(), packed2.getActualUsedBitsPerValue()); + assertEquals(packed.getBitsPerValue(), packed2.getBitsPerValue()); + assertEquals(packed.getCurrentMaxPackableValue(), packed2.getCurrentMaxPackableValue()); + assertEquals(packed.getCurrentMinPackableValue(), packed2.getCurrentMinPackableValue()); + assertEquals(packed.getPackingStrategy(), packed2.getPackingStrategy()); + assertEquals(packed.getHandle(), packed2.getHandle()); + assertNotSame(packed.getHandle(), packed2.getHandle()); + assertNotSame(packed.longs(), packed2.longs()); + assertNotSame(packed.getHandle().getValue(), packed2.getHandle().getValue()); + assertSame(packed2.getHandle().getValue(), packed2.longs()); + assertEquals(packed.getValueOffset(), packed2.getValueOffset()); + } + + + public void testClone_splitAcrossLongs() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .valueOffset(-1) + .minBitsPerValue(9) + .build(new LongArrayTag(getSplitValuesAcrossLongsTestData())); + + LongArrayTagPackedIntegers packed2 = packed.clone(); + assertEquals(packed.length, packed2.length); + assertEquals(packed.getActualUsedBitsPerValue(), packed2.getActualUsedBitsPerValue()); + assertEquals(packed.getBitsPerValue(), packed2.getBitsPerValue()); + assertEquals(packed.getCurrentMaxPackableValue(), packed2.getCurrentMaxPackableValue()); + assertEquals(packed.getCurrentMinPackableValue(), packed2.getCurrentMinPackableValue()); + assertEquals(packed.getPackingStrategy(), packed2.getPackingStrategy()); + assertEquals(packed.getHandle(), packed2.getHandle()); + assertNotSame(packed.getHandle(), packed2.getHandle()); + assertNotSame(packed.longs(), packed2.longs()); + assertNotSame(packed.getHandle().getValue(), packed2.getHandle().getValue()); + assertSame(packed2.getHandle().getValue(), packed2.longs()); + assertEquals(packed.getValueOffset(), packed2.getValueOffset()); + } + + + public void testToValueArray_takingArray() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .valueOffset(-65) + .minBitsPerValue(9) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + assertThrows(IllegalArgumentException.class, () -> packed.toArray(new int[0])); + assertThrows(IllegalArgumentException.class, () -> packed.toArray(new int[257])); + + int[] ints = new int[packed.length]; + assertSame(ints, packed.toArray(ints)); + for (int i = 0; i < ints.length; i++) { + assertEquals(packed.get(i), ints[i]); + } + } + + public void testToValueArray_takingArrayAndStartIndex() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .valueOffset(-65) + .minBitsPerValue(9) + .build(new LongArrayTag(getNoSplitValuesAcrossLongsTestData())); + assertThrows(IllegalArgumentException.class, () -> packed.toArray(new int[0], 0)); + assertThrows(IllegalArgumentException.class, () -> packed.toArray(new int[257], 2)); + assertThrows(IllegalArgumentException.class, () -> packed.toArray(new int[300], -1)); + + int[] ints = new int[packed.length * 2]; + assertSame(ints, packed.toArray(ints, 100)); + for (int i = 0; i < packed.length; i++) { + assertEquals(packed.get(i), ints[i + 100]); + } + } + + public void testSetFromValueArray() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(4) + .minBitsPerValue(1) + .build(); + assertThrows(IllegalArgumentException.class, () -> packed.setFromArray(new int[0])); + assertThrows(IllegalArgumentException.class, () -> packed.setFromArray(new int[5])); + + // should grow bits per value + final int[] given = new int[] {1, 7, 2, 0}; + packed.setFromArray(given); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(3, packed.getBitsPerValue()); + assertArrayEquals(given, packed.toArray()); + + // should shrink bits per value + final int[] given2 = new int[] {1, 3, 2, 0}; + packed.setFromArray(given2); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(2, packed.getBitsPerValue()); + assertArrayEquals(given2, packed.toArray()); + } + + public void testSetFromValueArray_takingStartIndex() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(4) + .minBitsPerValue(1) + .build(); + assertThrows(IllegalArgumentException.class, () -> packed.setFromArray(new int[0])); + assertThrows(IllegalArgumentException.class, () -> packed.setFromArray(new int[5])); + + // should grow bits per value + final int[] given = new int[] {1, 7, 2, 0, 5, 4, 9, 6, 0, -1, 1, -1}; + packed.setFromArray(given, 0); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(3, packed.getBitsPerValue()); + assertArrayEquals(new int[] {1, 7, 2, 0}, packed.toArray()); + + packed.setFromArray(given, 4); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(4, packed.getBitsPerValue()); + assertArrayEquals(new int[] {5, 4, 9, 6}, packed.toArray()); + + packed.setValueOffset(-1); + packed.setFromArray(given, 8); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertEquals(2, packed.getBitsPerValue()); + assertArrayEquals(new int[] {0, -1, 1, -1}, packed.toArray()); + + packed.setValueOffset(0); + assertThrows(IllegalArgumentException.class, () -> packed.setFromArray(given, 6)); + assertEquals(1, packed.get(0)); + assertEquals(2, packed.getBitsPerValue()); + } + + public void testIterator() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .minBitsPerValue(1) + .valueOffset(-1) + .build(new int[] {1, 7, 2, -1}); + ListIterator iter = packed.iterator(); +// System.out.println(packed.toString2dGrid()); + + assertThrows(NoSuchElementException.class, iter::previous); + assertFalse(iter.hasPrevious()); + assertTrue(iter.hasNext()); + assertEquals(0, iter.nextIndex()); + assertEquals(-1, iter.previousIndex()); + + assertEquals(1, (int) iter.next()); + assertTrue(iter.hasPrevious()); + assertTrue(iter.hasNext()); + assertEquals(1, iter.nextIndex()); + assertEquals(0, iter.previousIndex()); + + assertEquals(1, (int) iter.previous()); + assertFalse(iter.hasPrevious()); + assertTrue(iter.hasNext()); + assertEquals(0, iter.nextIndex()); + assertEquals(-1, iter.previousIndex()); + + assertEquals(1, (int) iter.next()); + assertTrue(iter.hasPrevious()); + assertTrue(iter.hasNext()); + assertEquals(1, iter.nextIndex()); + assertEquals(0, iter.previousIndex()); + + assertEquals(7, (int) iter.next()); + assertTrue(iter.hasPrevious()); + assertTrue(iter.hasNext()); + + assertEquals(2, (int) iter.next()); + assertTrue(iter.hasPrevious()); + assertTrue(iter.hasNext()); + + assertEquals(-1, (int) iter.next()); + assertTrue(iter.hasPrevious()); + assertFalse(iter.hasNext()); + assertThrows(NoSuchElementException.class, iter::next); + } + + public void testIterator_setValue() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .minBitsPerValue(1) + .valueOffset(-1) + .build(new int[] {1, 7, 2, -1}); + ListIterator iter = packed.iterator(); + assertThrows(IllegalStateException.class, () -> iter.set(2)); + iter.next(); + iter.set(5); + iter.next(); + iter.next(); + iter.previous(); + iter.set(42); + assertSame(packed.getHandle().getValue(), packed.longs()); + assertArrayEquals(new int[] {5, 7, 42, -1}, packed.toArray()); + iter.previous(); + iter.set(-1); + assertArrayEquals(new int[] {5, -1, 42, -1}, packed.toArray()); + assertThrows(IllegalArgumentException.class, () -> iter.set(-2)); + } + + public void testGetSet2d() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(SPLIT_VALUES_ACROSS_LONGS) + .length(256) + .minBitsPerValue(9) + .build(new LongArrayTag(getSplitValuesAcrossLongsTestData())); + assertEquals(16, packed.squareEdgeLength()); + assertEquals(-1, packed.cubeEdgeLength()); + assertEquals(79, packed.get2d(4, 6)); + packed.set2d(4, 6, 99); + assertEquals(99, packed.get2d(4, 6)); + assertEquals(""" + 70 69 69 69 71 73 73 73 71 71 71 70 64 64 63 63 + 70 70 70 69 71 71 73 71 71 73 71 71 64 64 64 63 + 77 71 70 70 71 71 71 71 73 73 73 71 65 64 64 64 + 77 72 71 71 71 70 70 71 72 73 71 71 71 71 71 71 + 77 72 72 72 71 71 70 70 71 71 71 71 71 73 71 71 + 76 78 78 78 78 77 71 71 70 70 69 71 73 73 73 71 + 74 78 79 80 99 78 72 78 78 78 78 77 71 73 72 71 + 74 78 80 80 80 78 73 78 78 80 78 78 75 75 71 71 + 75 78 79 80 79 78 73 78 80 80 80 78 77 76 75 68 + 76 78 78 78 78 77 74 78 79 80 78 78 77 77 75 69 + 76 76 76 75 75 75 74 78 78 78 78 78 77 75 75 70 + 77 76 76 76 78 79 79 79 79 74 74 75 75 75 75 71 + 77 77 77 76 79 80 81 80 79 75 74 74 74 73 72 71 + 78 78 77 77 79 81 81 81 79 75 75 74 74 73 73 72 + 78 78 78 77 79 79 81 80 79 75 79 79 79 79 79 72 + 79 79 78 78 78 79 79 79 79 76 79 80 81 79 79 73""", packed.toString2dGrid()); + } + + public void testGetSet3d() { + LongArrayTagPackedIntegers packed = LongArrayTagPackedIntegers.builder() + .packingStrategy(NO_SPLIT_VALUES_ACROSS_LONGS) + .length(64) + .minBitsPerValue(1) + .initializeForStoring(5) + .build(new LongArrayTag(3785436329631921288L, 3932602464921073299L, 2797418351439529682L, 4L)); + + assertEquals(8, packed.squareEdgeLength()); + assertEquals(4, packed.cubeEdgeLength()); + assertEquals(4, packed.get3d(2, 1, 3)); + packed.set3d(2, 1, 3, 6); + assertEquals(6, packed.get3d(2, 1, 3)); + assertEquals(""" + Y=0 + 0 1 2 2 + 3 3 4 4 + 3 4 4 4 + 4 4 4 4 + Y=1 + 0 1 2 2 + 3 3 2 2 + 3 3 4 4 + 3 4 6 4 + Y=2 + 5 1 2 2 + 3 3 2 2 + 3 3 2 2 + 3 3 4 4 + Y=3 + 5 5 5 2 + 5 5 2 2 + 3 3 2 2 + 3 3 2 4""", packed.toString3dGrid()); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/util/PalettizedCuboidTest.java b/src/test/java/io/github/ensgijs/nbt/mca/util/PalettizedCuboidTest.java new file mode 100644 index 00000000..9d5f45f7 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/util/PalettizedCuboidTest.java @@ -0,0 +1,933 @@ +package io.github.ensgijs.nbt.mca.util; + +import io.github.ensgijs.nbt.mca.DataVersion; +import io.github.ensgijs.nbt.tag.CompoundTag; +import io.github.ensgijs.nbt.tag.ListTag; +import io.github.ensgijs.nbt.tag.LongArrayTag; +import io.github.ensgijs.nbt.tag.StringTag; +import io.github.ensgijs.nbt.NbtTestCase; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.ConcurrentModificationException; +import java.util.NoSuchElementException; + +import static org.junit.Assert.assertArrayEquals; + +public class PalettizedCuboidTest extends NbtTestCase { + + public void testCalculateBitMask_throwsIllegalArgumentExceptionAppropriately() { + assertThrowsException(() -> PalettizedCuboid.calculateBitMask(-1), IllegalArgumentException.class); + assertThrowsException(() -> PalettizedCuboid.calculateBitMask(32), IllegalArgumentException.class); + } + + public void testCalculateBitMask_typicalUsage() { + assertEquals(0x1, PalettizedCuboid.calculateBitMask(1)); + assertEquals(0x3, PalettizedCuboid.calculateBitMask(2)); + assertEquals(0x7, PalettizedCuboid.calculateBitMask(3)); + assertEquals(0xf, PalettizedCuboid.calculateBitMask(4)); + assertEquals(0x1f, PalettizedCuboid.calculateBitMask(5)); + assertEquals(0x3f, PalettizedCuboid.calculateBitMask(6)); + assertEquals(0x7f, PalettizedCuboid.calculateBitMask(7)); + assertEquals(0xff, PalettizedCuboid.calculateBitMask(8)); + assertEquals(0xffff, PalettizedCuboid.calculateBitMask(16)); + assertEquals(0x7fffffff, PalettizedCuboid.calculateBitMask(31)); + } + + public void testCalculatePowerOfTwoExponent() { + assertEquals(0, PalettizedCuboid.calculatePowerOfTwoExponent(0, true)); + assertEquals(0, PalettizedCuboid.calculatePowerOfTwoExponent(1, true)); + assertEquals(1, PalettizedCuboid.calculatePowerOfTwoExponent(2, true)); + assertEquals(2, PalettizedCuboid.calculatePowerOfTwoExponent(4, true)); + assertEquals(3, PalettizedCuboid.calculatePowerOfTwoExponent(8, true)); + assertEquals(4, PalettizedCuboid.calculatePowerOfTwoExponent(16, true)); + + assertThrowsException(() -> PalettizedCuboid.calculatePowerOfTwoExponent(7, true), IllegalArgumentException.class); + + assertEquals(0, PalettizedCuboid.calculatePowerOfTwoExponent(0, false)); + assertEquals(0, PalettizedCuboid.calculatePowerOfTwoExponent(1, false)); + assertEquals(1, PalettizedCuboid.calculatePowerOfTwoExponent(2, false)); + assertEquals(2, PalettizedCuboid.calculatePowerOfTwoExponent(4, false)); + assertEquals(3, PalettizedCuboid.calculatePowerOfTwoExponent(8, false)); + assertEquals(4, PalettizedCuboid.calculatePowerOfTwoExponent(16, false)); + + assertEquals(2, PalettizedCuboid.calculatePowerOfTwoExponent(3, false)); + assertEquals(3, PalettizedCuboid.calculatePowerOfTwoExponent(5, false)); + assertEquals(3, PalettizedCuboid.calculatePowerOfTwoExponent(7, false)); + assertEquals(4, PalettizedCuboid.calculatePowerOfTwoExponent(9, false)); + assertEquals(4, PalettizedCuboid.calculatePowerOfTwoExponent(15, false)); + assertEquals(5, PalettizedCuboid.calculatePowerOfTwoExponent(17, false)); + assertEquals(6, PalettizedCuboid.calculatePowerOfTwoExponent(60, false)); + assertEquals(7, PalettizedCuboid.calculatePowerOfTwoExponent(66, false)); + } + + public void testCtor_initFilledCuboid() { + StringTag fillTag = new StringTag("void"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(4, fillTag); + assertEquals(4 * 4 * 4, cuboid.size()); + assertEquals(4, cuboid.cubeEdgeLength()); + assertEquals(1, cuboid.paletteSize()); + assertTrue(cuboid.packedData.allMatch(0)); + assertNotSame(fillTag, cuboid.palette.get(0)); + assertEquals(fillTag, cuboid.palette.get(0)); + } + + public void testCtr_throwsAppropriatelyWhenGivenNullFill() { + assertThrowsException(() -> new PalettizedCuboid<>(4, null), NullPointerException.class); + assertThrowsNoException(() -> new PalettizedCuboid<>(4, StringTag.class, null, true)); + } + + public void testCtor_initFromValueArray_happyCase() { + StringTag bedrockTag = new StringTag("bedrock"); + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + StringTag[] tags = new StringTag[2 * 2 * 2]; + Arrays.fill(tags, airTag); + tags[0] = bedrockTag; + tags[6] = stoneTag; + tags[7] = stoneTag; + PalettizedCuboid cuboid = new PalettizedCuboid<>(tags); + + assertEquals(tags.length, cuboid.size()); + assertEquals(2, cuboid.cubeEdgeLength()); + assertEquals(3, cuboid.paletteSize()); + assertEquals(bedrockTag, cuboid.palette.get(0)); + assertNotSame(bedrockTag, cuboid.palette.get(0)); + assertEquals(airTag, cuboid.palette.get(1)); + assertNotSame(airTag, cuboid.palette.get(1)); + assertEquals(stoneTag, cuboid.palette.get(2)); + assertNotSame(stoneTag, cuboid.palette.get(2)); + assertEquals(1, cuboid.packedData.count(0)); // bedrock + assertEquals(5, cuboid.packedData.count(1)); // air + assertEquals(2, cuboid.packedData.count(2)); // stone + } + + public void testCtor_initFromValueArray_notACubicLengthThrows() { + StringTag airTag = new StringTag("air"); + StringTag[] tags = new StringTag[2 * 2 * 2 + 1]; + Arrays.fill(tags, airTag); + assertThrowsException(() -> new PalettizedCuboid<>(tags), + IllegalArgumentException.class, + s -> s.equals("the cube root of 9 is not an integer!")); + } + + public void testCtor_initFromValueArray_notAPowerOfTwoCubicLengthThrows() { + StringTag airTag = new StringTag("air"); + StringTag[] tags = new StringTag[3 * 3 * 3]; + Arrays.fill(tags, airTag); + assertThrowsException(() -> new PalettizedCuboid<>(tags), + IllegalArgumentException.class, + s -> s.equals("3 isn't a power of two!")); + } + + public void testCtor_initFromValueArray_throwsWhenArrayContainsNulls() { + StringTag airTag = new StringTag("air"); + StringTag[] tags = new StringTag[2 * 2 * 2]; + Arrays.fill(tags, airTag); + tags[7] = null; + assertThrowsException(() -> new PalettizedCuboid<>(tags), + IllegalArgumentException.class, + s -> s.equals("values must not contain nulls!")); + + } + + public void testContains() { + StringTag airTag = new StringTag("air"); + StringTag[] tags = new StringTag[2 * 2 * 2]; + Arrays.fill(tags, airTag); + PalettizedCuboid cuboid = new PalettizedCuboid<>(tags); + + assertTrue(cuboid.contains(airTag)); + assertFalse(cuboid.contains(new StringTag("grass"))); + } + + public void testCountIf() { + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + StringTag[] tags = new StringTag[2 * 2 * 2]; + Arrays.fill(tags, airTag); + tags[0] = new StringTag("a"); + tags[1] = new StringTag("b"); + tags[2] = new StringTag("c"); + tags[3] = new StringTag("d"); + tags[4] = new StringTag("e"); + tags[6] = stoneTag; + tags[7] = stoneTag; + PalettizedCuboid cuboid = new PalettizedCuboid<>(tags); + + assertEquals(0, cuboid.countIf(new StringTag("lava")::equals)); // exercises shortcut case + assertEquals(1, cuboid.countIf(airTag::equals)); // exercises singleton case + assertEquals(7, cuboid.countIf(e -> !e.equals(airTag))); // exercises hashset case + assertEquals(4, cuboid.countIf(e -> e.getValue().charAt(0) <= 'c')); // exercises array list case (4 cuz air) + + assertThrowsException(() -> cuboid.countIf(e -> {e.setValue("boom"); return false;}), + PalettizedCuboid.PaletteCorruptedException.class); + + assertThrowsException(() -> cuboid.countIf(e -> {cuboid.set(0, new StringTag("zap")); return false;}), + ConcurrentModificationException.class); + } + + public void testToArray() { + StringTag bedrockTag = new StringTag("bedrock"); + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + StringTag[] tags = new StringTag[2 * 2 * 2]; + Arrays.fill(tags, airTag); + tags[0] = bedrockTag; + tags[6] = stoneTag; + tags[7] = stoneTag; + PalettizedCuboid cuboid = new PalettizedCuboid<>(tags); + + StringTag[] arr = cuboid.toArray(); + assertEquals(cuboid.size(), arr.length); + assertNotSame(cuboid.palette.get(0), arr[0]); + assertEquals(cuboid.palette.get(0), arr[0]); + assertNotSame(bedrockTag, arr[0]); + + assertEquals(bedrockTag, arr[0]); + assertEquals(airTag, arr[1]); + assertEquals(airTag, arr[2]); + assertEquals(airTag, arr[3]); + assertEquals(airTag, arr[4]); + assertEquals(airTag, arr[5]); + assertEquals(stoneTag, arr[6]); + assertEquals(stoneTag, arr[7]); + assertNotSame(arr[6], arr[7]); + } + + public void testToArrayByRef() { + StringTag bedrockTag = new StringTag("bedrock"); + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + StringTag[] tags = new StringTag[2 * 2 * 2]; + Arrays.fill(tags, airTag); + tags[0] = bedrockTag; + tags[6] = stoneTag; + tags[7] = stoneTag; + PalettizedCuboid cuboid = new PalettizedCuboid<>(tags); + + StringTag[] arr = cuboid.toArrayByRef(); + assertEquals(cuboid.size(), arr.length); + assertSame(cuboid.palette.get(0), arr[0]); + assertNotSame(bedrockTag, arr[0]); + + assertEquals(bedrockTag, arr[0]); + assertEquals(airTag, arr[1]); + assertEquals(airTag, arr[2]); + assertEquals(airTag, arr[3]); + assertEquals(airTag, arr[4]); + assertEquals(airTag, arr[5]); + assertEquals(stoneTag, arr[6]); + assertEquals(stoneTag, arr[7]); + assertSame(arr[6], arr[7]); + } + + public void testReplace() { + StringTag bedrockTag = new StringTag("bedrock"); + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + StringTag[] tags = new StringTag[2 * 2 * 2]; + Arrays.fill(tags, airTag); + tags[0] = bedrockTag; + tags[6] = stoneTag; + tags[7] = stoneTag; + PalettizedCuboid cuboid = new PalettizedCuboid<>(tags); + assertEquals(3, cuboid.palette.size()); + assertEquals(2, cuboid.packedData.get(7)); // validate test assumption + + assertTrue(cuboid.replace(stoneTag, bedrockTag)); + assertNotSame(bedrockTag, cuboid.getByRef(7)); + assertEquals(bedrockTag, cuboid.getByRef(7)); + assertEquals(3, cuboid.countIf(bedrockTag::equals)); + + assertEquals(3, cuboid.palette.size()); + assertEquals(1, cuboid.palette.stream().filter(bedrockTag::equals).count()); + assertTrue(cuboid.palette.contains(bedrockTag)); + assertTrue(cuboid.palette.contains(airTag)); + assertFalse(cuboid.palette.contains(stoneTag)); + assertEquals(0, cuboid.packedData.get(7)); // validate data remapped to exiting palette index + + assertFalse(cuboid.replace(stoneTag, bedrockTag)); // check not found case + assertFalse(cuboid.replace(airTag, airTag)); // check old == new case reports no changes + } + + public void testReplaceAll() { + StringTag bedrockTag = new StringTag("bedrock"); + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + StringTag lavaTag = new StringTag("lava"); + StringTag[] tags = new StringTag[2 * 2 * 2]; + Arrays.fill(tags, airTag); + tags[0] = bedrockTag; + tags[6] = stoneTag; + tags[7] = stoneTag; + PalettizedCuboid cuboid = new PalettizedCuboid<>(tags); + assertEquals(2, cuboid.packedData.get(7)); // validate test assumption + + assertTrue(cuboid.replaceAll(new StringTag[] {stoneTag, bedrockTag}, lavaTag)); + assertNotSame(lavaTag, cuboid.getByRef(7)); + assertEquals(lavaTag, cuboid.getByRef(7)); + assertEquals(3, cuboid.countIf(lavaTag::equals)); + + assertEquals(4, cuboid.palette.size()); + assertFalse(cuboid.palette.contains(bedrockTag)); + assertTrue(cuboid.palette.contains(airTag)); + assertFalse(cuboid.palette.contains(stoneTag)); + assertTrue(cuboid.palette.contains(lavaTag)); + assertEquals(3, cuboid.packedData.get(7)); // validate data remapped to exiting palette index + + assertFalse(cuboid.replaceAll(Collections.emptyList(), bedrockTag)); // check empty case + assertFalse(cuboid.replaceAll(Collections.singleton(stoneTag), bedrockTag)); // check not found case + assertFalse(cuboid.replaceAll(new StringTag[] {airTag}, airTag)); // check old == new case reports no changes + } + + public void testReplaceIf() { + StringTag bedrockTag = new StringTag("bedrock"); + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + StringTag lavaTag = new StringTag("lava"); + StringTag[] tags = new StringTag[2 * 2 * 2]; + Arrays.fill(tags, airTag); + tags[0] = bedrockTag; + tags[6] = stoneTag; + tags[7] = stoneTag; + PalettizedCuboid cuboid = new PalettizedCuboid<>(tags); + assertEquals(2, cuboid.packedData.get(7)); // validate test assumption + + assertTrue(cuboid.replaceIf(e -> e.getValue().contains("o"), lavaTag)); + assertNotSame(lavaTag, cuboid.getByRef(7)); + assertEquals(lavaTag, cuboid.getByRef(7)); + assertEquals(3, cuboid.countIf(lavaTag::equals)); + + assertEquals(4, cuboid.palette.size()); + assertFalse(cuboid.palette.contains(bedrockTag)); + assertTrue(cuboid.palette.contains(airTag)); + assertFalse(cuboid.palette.contains(stoneTag)); + assertTrue(cuboid.palette.contains(lavaTag)); + assertEquals(3, cuboid.packedData.get(7)); // validate data remapped to exiting palette index + + assertFalse(cuboid.replace(stoneTag, bedrockTag)); // check not found case + assertFalse(cuboid.replaceAll(new StringTag[] {airTag}, airTag)); // check old == new case reports no changes + + assertThrowsException(() -> cuboid.replaceIf(e -> {e.setValue("bad"); return true;}, new StringTag("ok")), + PalettizedCuboid.PaletteCorruptedException.class); + + assertThrowsException(() -> cuboid.replaceIf(e -> {cuboid.set(0, new StringTag("zap")); return true;}, new StringTag("good")), + ConcurrentModificationException.class); + } + + public void testRetainAll() { + StringTag bedrockTag = new StringTag("bedrock"); + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + StringTag lavaTag = new StringTag("lava"); + StringTag[] tags = new StringTag[2 * 2 * 2]; + Arrays.fill(tags, airTag); + tags[0] = bedrockTag; + tags[6] = stoneTag; + tags[7] = stoneTag; + PalettizedCuboid cuboid = new PalettizedCuboid<>(tags); + assertEquals(2, cuboid.packedData.get(7)); // validate test assumption + + assertTrue(cuboid.retainAll(new StringTag[] {airTag}, lavaTag)); + assertNotSame(lavaTag, cuboid.getByRef(7)); + assertEquals(lavaTag, cuboid.getByRef(7)); + assertEquals(3, cuboid.countIf(lavaTag::equals)); + + assertEquals(4, cuboid.palette.size()); + assertFalse(cuboid.palette.contains(bedrockTag)); + assertTrue(cuboid.palette.contains(airTag)); + assertFalse(cuboid.palette.contains(stoneTag)); + assertTrue(cuboid.palette.contains(lavaTag)); + assertEquals(3, cuboid.packedData.get(7)); // validate data remapped to exiting palette index + + assertFalse(cuboid.replace(stoneTag, bedrockTag)); // check not found case + assertFalse(cuboid.replaceAll(new StringTag[] {airTag}, airTag)); // check old == new case reports no changes + } + + public void testFill() { + StringTag bedrockTag = new StringTag("bedrock"); + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + StringTag lavaTag = new StringTag("lava"); + StringTag[] tags = new StringTag[2 * 2 * 2]; + Arrays.fill(tags, airTag); + tags[0] = bedrockTag; + tags[6] = stoneTag; + tags[7] = stoneTag; + PalettizedCuboid cuboid = new PalettizedCuboid<>(tags); + cuboid.fill(lavaTag); + assertEquals(8, cuboid.countIf(lavaTag::equals)); + assertEquals(1, cuboid.paletteSize()); + assertNotSame(lavaTag, cuboid.palette.get(0)); + assertEquals(lavaTag, cuboid.palette.get(0)); + } + + public void testIndexOfXyzLiterals() { + PalettizedCuboid cuboid = new PalettizedCuboid<>( + 16, new StringTag("air")); + assertEquals(4096, cuboid.size()); + assertEquals(0, cuboid.indexOf(0, 0, 0)); + assertEquals(4095, cuboid.indexOf(15, 15, 15)); + assertEquals(0, cuboid.indexOf(16, 16, 16)); + + assertEquals(834, cuboid.indexOf(2, 3, 4)); + assertEquals(cuboid.indexOf(2, 3, 4), cuboid.indexOf(16 + 2, 16 + 3, 16 + 4)); + } + + public void testIndexOfIntPointXYZ() { + PalettizedCuboid cuboid = new PalettizedCuboid<>( + 16, new StringTag("air")); + assertEquals(4096, cuboid.size()); + assertEquals(0, cuboid.indexOf(new IntPointXYZ(0, 0, 0))); + assertEquals(4095, cuboid.indexOf(new IntPointXYZ(15, 15, 15))); + assertEquals(0, cuboid.indexOf(new IntPointXYZ(16, 16, 16))); + + assertEquals(834, cuboid.indexOf(new IntPointXYZ(2, 3, 4))); + assertEquals(cuboid.indexOf(2, 3, 4), cuboid.indexOf(new IntPointXYZ(16 + 2, 16 + 3, 16 + 4))); + } + + public void testXyzOf_xyzLiterals() { + PalettizedCuboid cuboid = new PalettizedCuboid<>( + 16, new StringTag("air")); + assertEquals(new IntPointXYZ(0, 0, 0), cuboid.xyzOf(16, 16, 16)); + assertEquals(new IntPointXYZ(1, 6, 4), cuboid.xyzOf(65, 70, 84)); + } + + public void testXyzOf_index() { + PalettizedCuboid cuboid = new PalettizedCuboid<>( + 16, new StringTag("air")); + assertEquals(new IntPointXYZ(0, 0, 0), cuboid.xyzOf(0)); + assertEquals(new IntPointXYZ(2, 3, 4), cuboid.xyzOf(834)); + } + + public void testGet() { + StringTag airTag = new StringTag("air"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, airTag); + assertEquals(airTag, cuboid.get(0)); + assertNotSame(airTag, cuboid.get(0)); + assertNotSame(cuboid.palette.get(0), cuboid.get(0)); + } + + public void testGetByRef() { + StringTag airTag = new StringTag("air"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, airTag); + assertEquals(airTag, cuboid.getByRef(0)); + assertNotSame(airTag, cuboid.getByRef(0)); + assertSame(cuboid.palette.get(0), cuboid.getByRef(10, 11, 12)); + } + + public void testSet() { + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + StringTag lavaTag = new StringTag("lava"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, airTag); + cuboid.set(0, 0, 0, lavaTag); + cuboid.set(0, 1, 0, lavaTag); + assertEquals(6, cuboid.countIf(airTag::equals)); + assertEquals(lavaTag, cuboid.get(0, 0, 0)); + assertEquals(lavaTag, cuboid.get(0, 1, 0)); + assertNotSame(lavaTag, cuboid.get(0, 0, 0)); + assertNotSame(lavaTag, cuboid.get(0, 1, 0)); + + cuboid.set(0, stoneTag); + assertEquals(stoneTag, cuboid.get(0, 0, 0)); + // note that using set to replace all of one palette key will NOT remove that key from the palette + // optimizePalette will clean those up - tested in #testOptimizePalette + } + + public void testSet_throwsWhenIndexOutOfBounds() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("air")); + assertThrowsException(() -> cuboid.set(-1, new StringTag("bam")), IndexOutOfBoundsException.class); + assertThrowsException(() -> cuboid.set(8, new StringTag("bam")), IndexOutOfBoundsException.class); + } + + public void testSetRange_fullVolume_isTheSameAsFill() { + StringTag airTag = new StringTag("air"); + StringTag lavaTag = new StringTag("lava"); + StringTag stoneTag = new StringTag("stone"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(4, airTag); + cuboid.set(0, 0, 0, lavaTag); + cuboid.set(1, 1, 1, lavaTag); + cuboid.set(2, 2, 2, stoneTag); + cuboid.set(3, 3, 3, lavaTag); + + cuboid.set(IntPointXYZ.XYZ(0, 0, 0), stoneTag, IntPointXYZ.XYZ(3, 3, 3)); // exercise the wrapped call at least once + assertEquals(1, cuboid.paletteSize()); + assertEquals(64, cuboid.countIf(stoneTag::equals)); + } + + public void testSetRange_fullVolume_XZPlainFill() { + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(16, airTag); + + cuboid.set(0, 6, 0, stoneTag, 15, 8, 15); + assertEquals(16 * 16 * 3, cuboid.countIf(stoneTag::equals)); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + while (iter.hasNext()) { + iter.next(); + int y = iter.currentY(); + if (y < 6 || y > 8) { + assertEquals(iter.current(), airTag); + } else { + assertEquals(iter.current(), stoneTag); + } + } + } + + public void testSetRange_requiresElementArg() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("air")); + assertThrowsException(() -> cuboid.set(0, 0, 0, null, 0, 0,0), IllegalArgumentException.class); + } + + public void testSetRange_throwsIfCoordsAreOutOfBounds() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("air")); + + assertThrowsException(() -> cuboid.set(0, 0, -6, new StringTag("air"), 0, 0, 0), IndexOutOfBoundsException.class); + assertThrowsException(() -> cuboid.set(0, -6, 0, new StringTag("air"), 0, 0, 0), IndexOutOfBoundsException.class); + assertThrowsException(() -> cuboid.set(-6, 0, 0, new StringTag("air"), 0, 0, 0), IndexOutOfBoundsException.class); + assertThrowsException(() -> cuboid.set(0, 0, 0, new StringTag("air"), 3, 0, 0), IndexOutOfBoundsException.class); + assertThrowsException(() -> cuboid.set(0, 0, 0, new StringTag("air"), 0, 3, 0), IndexOutOfBoundsException.class); + assertThrowsException(() -> cuboid.set(0, 0, 0, new StringTag("air"), 0, 0, 3), IndexOutOfBoundsException.class); + } + + public void testSetRange_fullVolume_singleVoxel() { + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(16, airTag); + + cuboid.set(0, 6, 0, stoneTag, 0, 6, 0); + assertEquals(1, cuboid.countIf(stoneTag::equals)); + assertEquals(stoneTag, cuboid.get(0, 6, 0)); + } + + public void testSetRange_fullVolume_cuboid() { + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(16, airTag); + + cuboid.set(14, 14, 14, stoneTag, 1, 1, 1); // require swapping all coords + assertEquals(14 * 14 * 14, cuboid.countIf(stoneTag::equals)); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + assertEquals(airTag, cuboid.get(0, 0, 0)); + assertEquals(airTag, cuboid.get(0, 0, 15)); + assertEquals(airTag, cuboid.get(0, 15, 0)); + assertEquals(airTag, cuboid.get(0, 15, 15)); + assertEquals(airTag, cuboid.get(15, 0, 0)); + assertEquals(airTag, cuboid.get(15, 0, 15)); + assertEquals(airTag, cuboid.get(15, 15, 0)); + assertEquals(airTag, cuboid.get(15, 15, 15)); + + assertEquals(stoneTag, cuboid.get(1, 1, 1)); + assertEquals(stoneTag, cuboid.get(1, 1, 14)); + assertEquals(stoneTag, cuboid.get(1, 14, 1)); + assertEquals(stoneTag, cuboid.get(1, 14, 14)); + assertEquals(stoneTag, cuboid.get(14, 1, 1)); + assertEquals(stoneTag, cuboid.get(14, 1, 14)); + assertEquals(stoneTag, cuboid.get(14, 14, 1)); + assertEquals(stoneTag, cuboid.get(14, 14, 14)); + } + + public void testOptimizePalette() { + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + StringTag lavaTag = new StringTag("lava"); + StringTag bedrockTag = new StringTag("bedrock"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, airTag); + cuboid.set(1, 0, 0, stoneTag); + cuboid.set(1, 1, 0, stoneTag); + cuboid.set(1, 1, 1, lavaTag); + cuboid.set(0, 0, 0, bedrockTag); + + assertArrayEquals(new int[] {3, 1, 0, 0, 0, 1, 0, 2}, cuboid.packedData.toArray()); + + // test what should be a no-op + int[] originalData = cuboid.packedData.toArray(); + StringTag[] originalPalette = new StringTag[cuboid.palette.size()]; + for (int i = 0; i < originalPalette.length; i++) { + originalPalette[i] = cuboid.palette.get(i).clone(); + } + cuboid.optimizePalette(); + assertArrayEquals(originalData, cuboid.packedData.toArray()); + assertArrayEquals(originalPalette, cuboid.palette.toArray()); + assertEquals(bedrockTag, cuboid.get(0)); + + // start mutating + cuboid.replace(stoneTag, airTag); + cuboid.set(1, 1, 1, airTag); + assertEquals(4, cuboid.paletteSize()); + + cuboid.optimizePalette(); // id, null, null, id + assertEquals(2, cuboid.paletteSize()); + assertEquals(airTag, cuboid.palette.get(0)); + assertEquals(bedrockTag, cuboid.palette.get(1)); + assertArrayEquals(new int[] {1, 0, 0, 0, 0, 0, 0, 0}, cuboid.packedData.toArray()); + + // test trailing null in palette + cuboid.set(0, 0, 0, airTag); + cuboid.optimizePalette(); // id, null + assertEquals(1, cuboid.paletteSize()); + assertEquals(airTag, cuboid.palette.get(0)); + assertArrayEquals(new int[] {0, 0, 0, 0, 0, 0, 0, 0}, cuboid.packedData.toArray()); + } + + public void testClone() { + StringTag airTag = new StringTag("air"); + StringTag stoneTag = new StringTag("stone"); + StringTag lavaTag = new StringTag("lava"); + StringTag bedrockTag = new StringTag("bedrock"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, airTag); + cuboid.set(1, 0, 0, stoneTag); + cuboid.set(1, 1, 0, stoneTag); + cuboid.set(1, 1, 1, lavaTag); + cuboid.set(0, 0, 0, bedrockTag); + + assertEquals(bedrockTag, cuboid.get(0)); + PalettizedCuboid cuboid2 = cuboid.clone(); + + assertNotSame(cuboid, cuboid2); + assertEquals(cuboid.size(), cuboid2.size()); + assertEquals(cuboid.cubeEdgeLength(), cuboid2.cubeEdgeLength()); + assertEquals(cuboid.paletteSize(), cuboid2.paletteSize()); + + assertArrayEquals(cuboid.packedData.toArray(), cuboid2.packedData.toArray()); + assertEquals(bedrockTag, cuboid.get(0)); + assertEquals(bedrockTag, cuboid2.get(0)); + + assertEquals(cuboid.palette.get(0), cuboid2.palette.get(0)); + assertNotSame(cuboid.palette.get(0), cuboid2.palette.get(0)); + } + + public void testCompoundTag_toAndFrom_moreThanOnePaletteIdInUse() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(4, new StringTag("desert")); + cuboid.set(0, 0, 0, new StringTag("beach")); + cuboid.set(0, 1, 0, new StringTag("beach")); + cuboid.set(1, 1, 0, new StringTag("flower_forest")); + cuboid.set(1, 0, 1, new StringTag("beach")); + cuboid.set(0, 2, 2, new StringTag("flower_forest")); + cuboid.set(2, 3, 3, new StringTag("river")); + cuboid.set(3, 3, 2, new StringTag("jumaji")); + cuboid.set(3, 3, 3, new StringTag("jumaji")); + + CompoundTag serializedTag = cuboid.toCompoundTag(); + assertNotNull(serializedTag); + assertTrue(serializedTag.containsKey("palette")); + assertTrue(serializedTag.containsKey("data")); + assertTrue(serializedTag.get("palette") instanceof ListTag); + assertTrue(serializedTag.get("data") instanceof LongArrayTag); + +// System.out.println(JsonPrettyPrinter.prettyPrintJson(serializedTag.toString())); + PalettizedCuboid cuboid2 = PalettizedCuboid.fromCompoundTag(serializedTag, 4); + assertNotNull(cuboid2); + assertEquals(cuboid.size(), cuboid2.size()); + assertEquals(cuboid.cubeEdgeLength(), cuboid2.cubeEdgeLength()); + assertArrayEquals(cuboid.packedData.toArray(), cuboid2.packedData.toArray()); + assertEquals(cuboid.paletteSize(), cuboid2.paletteSize()); + for (int i = 0; i < cuboid.palette.size(); i++) { + assertEquals(cuboid.palette.get(i), cuboid2.palette.get(i)); + } + } + + + public void testToCompoundTag_singleElement() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(4, new StringTag("dripstone_caves")); + CompoundTag tag = cuboid.toCompoundTag(); + assertEquals(1, tag.size()); + assertEquals(1, tag.getListTag("palette").size()); + assertEquals(new StringTag("dripstone_caves"), tag.getListTag("palette").get(0)); + } + + + public void testToCompoundTag_minimumBitsPerIndex() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(16, new StringTag("air")); + cuboid.set(42, new StringTag("answers")); + + // when not providing a minimum bits per index, for a 16^3 cuboid, 4 bits per index is assumed + CompoundTag tag = cuboid.toCompoundTag(); + assertEquals(2, tag.size()); + assertEquals(2, tag.getListTag("palette").size()); + assertEquals(256, tag.getLongArrayTag("data").length()); + assertEquals(new StringTag("air"), tag.getListTag("palette").get(0)); + assertEquals(new StringTag("answers"), tag.getListTag("palette").get(1)); + + // we can override this detected default + tag = cuboid.toCompoundTag(0, 1); + assertEquals(2, tag.size()); + assertEquals(2, tag.getListTag("palette").size()); + assertEquals(64, tag.getLongArrayTag("data").length()); + assertEquals(new StringTag("air"), tag.getListTag("palette").get(0)); + assertEquals(new StringTag("answers"), tag.getListTag("palette").get(1)); + } + + public void testFromCompoundTag_returnsNullWhenGivenNullTag() { + assertNull(PalettizedCuboid.fromCompoundTag(null, 4)); + } + + public void testFromCompoundTag_singleElement() { + ListTag paletteTag = new ListTag<>(StringTag.class); + CompoundTag rootTag = new CompoundTag(); + rootTag.put("palette", paletteTag); + paletteTag.add(new StringTag("dripstone_caves")); + PalettizedCuboid cuboid = PalettizedCuboid.fromCompoundTag(rootTag, 4); + assertNotNull(cuboid); + assertEquals(64, cuboid.size()); + assertEquals(1, cuboid.paletteSize()); + assertEquals(64, cuboid.countIf(e -> e.getValue().equals("dripstone_caves"))); + } + + public void testFromCompoundTag_missingDataTagThrows() { + ListTag paletteTag = new ListTag<>(StringTag.class); + CompoundTag rootTag = new CompoundTag(); + rootTag.put("palette", paletteTag); + paletteTag.add(new StringTag("a")); + paletteTag.add(new StringTag("b")); + assertThrowsException(() -> PalettizedCuboid.fromCompoundTag(rootTag, 4), IllegalArgumentException.class); + } + + public void testIterator_happyCase() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("xxxx")); + cuboid.set(0, 0, 0, new StringTag("000")); + cuboid.set(1, 0, 0, new StringTag("100")); + cuboid.set(0, 0, 1, new StringTag("001")); + cuboid.set(1, 0, 1, new StringTag("101")); + cuboid.set(0, 1, 0, new StringTag("010")); + cuboid.set(1, 1, 0, new StringTag("110")); + cuboid.set(0, 1, 1, new StringTag("011")); + cuboid.set(1, 1, 1, new StringTag("111")); + + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + assertTrue(iter.hasNext()); + assertEquals("000", iter.next().getValue()); + assertEquals("000", iter.current().getValue()); + assertEquals(0, iter.currentIndex()); + assertEquals(IntPointXYZ.XYZ(0, 0, 0), iter.currentXYZ()); + assertTrue(iter.hasNext()); + assertEquals("100", iter.next().getValue()); + assertEquals(1, iter.currentIndex()); + assertTrue(iter.hasNext()); + assertEquals("001", iter.next().getValue()); + assertEquals(0, iter.currentX()); + assertTrue(iter.hasNext()); + assertEquals("101", iter.next().getValue()); + assertEquals(IntPointXYZ.XYZ(1, 0, 1), iter.currentXYZ()); + assertEquals(1, iter.currentX()); + assertEquals(0, iter.currentY()); + assertEquals(1, iter.currentZ()); + assertTrue(iter.hasNext()); + assertEquals("010", iter.next().getValue()); + assertEquals(1, iter.currentY()); + assertEquals(0, iter.currentZ()); + assertTrue(iter.hasNext()); + assertEquals("110", iter.next().getValue()); + assertTrue(iter.hasNext()); + assertEquals("011", iter.next().getValue()); + assertTrue(iter.hasNext()); + assertEquals("111", iter.next().getValue()); + assertEquals("111", iter.current().getValue()); + assertFalse(iter.hasNext()); + } + + public void testIterator_nextXYZ() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("xxxx")); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + assertEquals(IntPointXYZ.XYZ(0, 0, 0), iter.nextXYZ()); + assertEquals(IntPointXYZ.XYZ(1, 0, 0), iter.nextXYZ()); + assertEquals(IntPointXYZ.XYZ(0, 0, 1), iter.nextXYZ()); + assertEquals(IntPointXYZ.XYZ(1, 0, 1), iter.nextXYZ()); + assertEquals(IntPointXYZ.XYZ(0, 1, 0), iter.nextXYZ()); + assertEquals(IntPointXYZ.XYZ(1, 1, 0), iter.nextXYZ()); + assertEquals(IntPointXYZ.XYZ(0, 1, 1), iter.nextXYZ()); + assertEquals(IntPointXYZ.XYZ(1, 1, 1), iter.nextXYZ()); + assertFalse(iter.hasNext()); + } + + public void testIterator_current_ThrowsNoSuchElementExceptionIfNextHasNeverBeenCalled() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("xxxx")); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + assertThrowsException(iter::current, NoSuchElementException.class); + } + + public void testIterator_filtered_current_ThrowsNoSuchElementExceptionIfNextHasNeverBeenCalled() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("xxxx")); + StringTag tag = new StringTag("abba"); + cuboid.set(1, tag); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(tag::equals); + assertThrowsException(iter::current, NoSuchElementException.class); + } + + public void testIterator_currentIndex_ThrowsNoSuchElementExceptionIfNextHasNeverBeenCalled() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("xxxx")); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + assertThrowsException(iter::currentIndex, NoSuchElementException.class); + } + + public void testIterator_currentXYZ_ThrowsNoSuchElementExceptionIfNextHasNeverBeenCalled() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("xxxx")); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + assertThrowsException(iter::currentXYZ, NoSuchElementException.class); + assertThrowsException(iter::currentX, NoSuchElementException.class); + assertThrowsException(iter::currentY, NoSuchElementException.class); + assertThrowsException(iter::currentZ, NoSuchElementException.class); + } + + public void testIterator_next_ThrowsNoSuchElementExceptionIfThereAreNoMoreElements() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("xxxx")); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + while (iter.hasNext()) iter.next(); + assertThrowsException(iter::next, NoSuchElementException.class); + } + + public void testIterator_filtered_next_ThrowsNoSuchElementExceptionIfThereAreNoMoreElements() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("xxxx")); + StringTag tag = new StringTag("abba"); + cuboid.set(1, tag); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(tag::equals); + while (iter.hasNext()) iter.next(); + assertThrowsException(iter::next, NoSuchElementException.class); + } + + public void testIterator_PaletteCorruptedExceptionThrown() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("xxxx")); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + iter.next().setValue("boom"); + assertThrowsException(iter::hasNext, PalettizedCuboid.PaletteCorruptedException.class); + } + + public void testIterator_ConcurrentModificationExceptionThrown() { + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, new StringTag("xxxx")); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + cuboid.set(0, 1, 0, new StringTag("010")); + assertThrowsException(iter::hasNext, ConcurrentModificationException.class); + } + + public void testIterator_set_canSafelyModify() { + StringTag fillTag = new StringTag("xxxx"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, fillTag); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + iter.next(); + iter.set(new StringTag("a")); + iter.next(); + iter.next(); + iter.set(new StringTag("b")); + assertTrue(iter.hasNext()); + assertEquals(6, cuboid.countIf(fillTag::equals)); + assertTrue(cuboid.contains(new StringTag("a"))); + assertTrue(cuboid.contains(new StringTag("b"))); + } + + public void testIterator_set_throwsIfCalledBeforeNext() { + StringTag fillTag = new StringTag("xxxx"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, fillTag); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + assertThrowsException(() -> iter.set(new StringTag("a")), NoSuchElementException.class); + } + + public void testIterator_set_detectsCommonPaletteCorruptionCase() { + StringTag fillTag = new StringTag("xxxx"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, fillTag); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + StringTag tag = iter.next(); + tag.setValue("boom"); + assertThrowsException(() -> iter.set(tag), PalettizedCuboid.PaletteCorruptedException.class); + } + + public void testIterator_set_detectsConcurrentModificationCase() { + StringTag fillTag = new StringTag("xxxx"); + PalettizedCuboid cuboid = new PalettizedCuboid<>(2, fillTag); + PalettizedCuboid.CursorIterator iter = cuboid.iterator(); + iter.next(); + cuboid.set(0, 1, 0, new StringTag("pop")); + assertThrowsException(() -> iter.set(new StringTag("a")), ConcurrentModificationException.class); + } + + + // Interesting because bit packing has no waste on the per-long level, 4 bits to encode size 14 palette data. + public void testFromCompoundTag_blockStates_1_20_4__14entries() { + CompoundTag tag = (CompoundTag) deserializeFromFile("mca_palettes/block_states-1.20.4-14entries.snbt").getTag(); +// System.out.println(JsonPrettyPrinter.prettyPrintJson(tag.toString())); + PalettizedCuboid cuboid = PalettizedCuboid.fromCompoundTag(tag, 16, DataVersion.JAVA_1_20_4.id()); + assertNotNull(cuboid); + assertEquals(4096, cuboid.size()); + assertEquals(14, cuboid.paletteSize()); + assertEquals(2, cuboid.countIf(e -> e.getString("Name").equals("minecraft:glow_lichen"))); + PalettizedCuboid.CursorIterator iter = + cuboid.iterator(e -> e.getString("Name").equals("minecraft:glow_lichen")); + assertTrue(iter.hasNext()); + iter.next(); + assertEquals(cuboid.xyzOf(8, 33, 15), iter.currentXYZ()); + assertTrue(iter.hasNext()); + iter.next(); + assertEquals(cuboid.xyzOf(9, 33, 15), iter.currentXYZ()); + assertFalse(iter.hasNext()); + } + + // Interesting because only 3 bits are required to encode size 6 palette data, but it actually uses 4 bits. + public void testFromCompoundTag_blockStates_1_20_4__6entries() { + CompoundTag tag = (CompoundTag) deserializeFromFile("mca_palettes/block_states-1.20.4-6entries.snbt").getTag(); +// System.out.println(JsonPrettyPrinter.prettyPrintJson(tag.toString())); + PalettizedCuboid cuboid = PalettizedCuboid.fromCompoundTag(tag, 16, DataVersion.JAVA_1_20_4.id()); + assertNotNull(cuboid); + assertEquals(4096, cuboid.size()); + assertEquals(6, cuboid.paletteSize()); + assertEquals(1, cuboid.countIf(e -> e.getString("Name").equals("minecraft:dripstone_block"))); + assertEquals(62, cuboid.countIf(e -> e.getString("Name").equals("minecraft:coal_ore"))); + + } + + public void testFromCompoundTag_blockStates_1_20_4__72entries() throws IOException { + // interesting because it's the largest chunk section found in a random world's region file. + CompoundTag tag = (CompoundTag) deserializeFromFile("mca_palettes/block_states-1.20.4-r.0.0_X6Y-3Z23_72entries.snbt").getTag(); +// System.out.println(JsonPrettyPrinter.prettyPrintJson(tag.toString())); + PalettizedCuboid cuboid = PalettizedCuboid.fromCompoundTag(tag, 16, DataVersion.JAVA_1_20_4.id()); + assertNotNull(cuboid); + assertEquals(4096, cuboid.size()); + assertEquals(72, cuboid.paletteSize()); + PalettizedCuboid.CursorIterator iter = + cuboid.iterator(e -> e.getString("Name").equals("minecraft:sticky_piston")); + assertTrue(iter.hasNext()); + IntPointXYZ subChunkLocation = new IntPointXYZ(6, -3, 23); + IntPointXYZ blockAbsoluteLocation = subChunkLocation.transformChunkSectionToBlock().add(iter.nextXYZ()); + assertEquals(new IntPointXYZ(99, -48, 372), blockAbsoluteLocation); + assertFalse(iter.hasNext()); + assertEquals("west", iter.current().getCompoundTag("Properties").getString("facing")); + assertTrue(iter.current().getCompoundTag("Properties").getBoolean("extended")); + } + + public void testFromCompoundTag_biomes_1_20_4__6entries() throws IOException { + // interesting because it highlights the need to compute bits per index from palette size and not from the + // number of longs - computing from number of longs gives the wrong answer because this sample overflows + // 3 longs by as single record. + CompoundTag tag = (CompoundTag) deserializeFromFile("mca_palettes/biomes-1.20.4-r.0.0_X21Y-3Z3_6entries.snbt").getTag(); +// System.out.println(JsonPrettyPrinter.prettyPrintJson(tag.toString())); + PalettizedCuboid cuboid = PalettizedCuboid.fromCompoundTag(tag, 4, DataVersion.JAVA_1_20_4.id()); + assertNotNull(cuboid); + assertEquals(64, cuboid.size()); + assertEquals(6, cuboid.paletteSize()); + assertEquals(6, cuboid.countIf(e -> e.getValue().equals("minecraft:dripstone_caves"))); + PalettizedCuboid.CursorIterator iter = + cuboid.iterator(e -> e.getValue().equals("minecraft:dripstone_caves")); + assertEquals(IntPointXYZ.XYZ(0, 2, 0), iter.nextXYZ()); + assertEquals(IntPointXYZ.XYZ(0, 3, 0), iter.nextXYZ()); + assertEquals(IntPointXYZ.XYZ(1, 3, 0), iter.nextXYZ()); + assertEquals(IntPointXYZ.XYZ(2, 3, 0), iter.nextXYZ()); + assertEquals(IntPointXYZ.XYZ(0, 3, 1), iter.nextXYZ()); + assertEquals(IntPointXYZ.XYZ(1, 3, 1), iter.nextXYZ()); + assertFalse(iter.hasNext()); + } + + public void testFromCompoundTag_biomes_1_20_4__2entries() throws IOException { + // interesting because entry count is exactly a power of 2 + CompoundTag tag = (CompoundTag) deserializeFromFile("mca_palettes/biomes-1.20.4-r.-3.-3_X-91Y-2Z-87_2entries.snbt").getTag(); +// System.out.println(JsonPrettyPrinter.prettyPrintJson(tag.toString())); + PalettizedCuboid cuboid = PalettizedCuboid.fromCompoundTag(tag, 4, DataVersion.JAVA_1_20_4.id()); +// System.out.println(cuboid); + + assertNotNull(cuboid); + assertEquals(64, cuboid.size()); + assertEquals(2, cuboid.paletteSize()); + assertEquals(24, cuboid.countIf(e -> e.getValue().equals("minecraft:dripstone_caves"))); + assertEquals(64 - 24, cuboid.countIf(e -> e.getValue().equals("minecraft:savanna"))); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/util/RegionBoundingRectangleTest.java b/src/test/java/io/github/ensgijs/nbt/mca/util/RegionBoundingRectangleTest.java new file mode 100644 index 00000000..8eb047f1 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/util/RegionBoundingRectangleTest.java @@ -0,0 +1,147 @@ +package io.github.ensgijs.nbt.mca.util; + +import junit.framework.TestCase; + +import java.util.List; + +import static io.github.ensgijs.nbt.mca.util.IntPointXZ.XZ; +import static org.junit.Assert.assertThrows; + +public class RegionBoundingRectangleTest extends TestCase { + + public void testContainsBlock() { + RegionBoundingRectangle rbr = new RegionBoundingRectangle(1, -1); // [512..1024), [-512..0) | [32..64), [-32..0) + + assertEquals(512, rbr.getMinBlockX()); + assertEquals(-512, rbr.getMinBlockZ()); + assertEquals(1024, rbr.getMaxBlockX()); + assertEquals(0, rbr.getMaxBlockZ()); + assertEquals(1, rbr.getWidthRegionXZ()); + assertEquals(32, rbr.getWidthChunkXZ()); + assertEquals(512, rbr.getWidthBlockXZ()); + + assertTrue(rbr.containsBlock(512, -512)); + assertTrue(rbr.containsBlock(1023, -1)); + assertFalse(rbr.containsBlock(511, -512)); + assertFalse(rbr.containsBlock(1023, 0)); + + rbr = new RegionBoundingRectangle(0, 0, 10); // [512..1024), [-512..0) | [32..64), [-32..0) + + assertEquals(0, rbr.getMinBlockX()); + assertEquals(0, rbr.getMinBlockZ()); + assertEquals(5120, rbr.getMaxBlockX()); + assertEquals(5120, rbr.getMaxBlockZ()); + assertEquals(10, rbr.getWidthRegionXZ()); + assertEquals(10 * 32, rbr.getWidthChunkXZ()); + assertEquals(10 * 512, rbr.getWidthBlockXZ()); + } + + public void testContainsChunk() { + RegionBoundingRectangle rbr = new RegionBoundingRectangle(1, -1); // [512..1024), [-512..0) | [32..64), [-32..0) + assertTrue(rbr.containsChunk(32, -32)); + assertTrue(rbr.containsChunk(63, -1)); + assertFalse(rbr.containsChunk(31, -32)); + assertFalse(rbr.containsChunk(63, 0)); + } + + public void testContainsRegion() { + RegionBoundingRectangle rbr = new RegionBoundingRectangle(0, 0, 5); // [512..1024), [-512..0) | [32..64), [-32..0) + assertTrue(rbr.containsRegion(0, 0)); + assertTrue(rbr.containsRegion(4, 4)); + assertFalse(rbr.containsRegion(-1, 2)); + assertFalse(rbr.containsRegion(2, 5)); + } + + public void testForChunk() { + RegionBoundingRectangle rbr = RegionBoundingRectangle.forChunk(42, -7); + assertEquals(1, rbr.getMinRegionX()); + assertEquals(-1, rbr.getMinRegionZ()); + assertEquals(1, rbr.getWidthRegionXZ()); + assertEquals(1024, rbr.getMaxBlockX()); + assertEquals(512, rbr.getMinBlockX()); + assertEquals(0, rbr.getMaxBlockZ()); + assertEquals(-512, rbr.getMinBlockZ()); + + rbr = RegionBoundingRectangle.forChunk(32, -32); + assertEquals(1, rbr.getMinRegionX()); + assertEquals(-1, rbr.getMinRegionZ()); + assertEquals(1, rbr.getWidthRegionXZ()); + + rbr = RegionBoundingRectangle.forChunk(63, -1); + assertEquals(1, rbr.getMinRegionX()); + assertEquals(-1, rbr.getMinRegionZ()); + assertEquals(1, rbr.getWidthRegionXZ()); + } + + public void testWORLD_REGION_BOUNDS() { + assertEquals(-58594, RegionBoundingRectangle.MAX_WORLD_REGION_BOUNDS.getMinRegionX()); + assertEquals(-58594, RegionBoundingRectangle.MAX_WORLD_REGION_BOUNDS.getMinRegionZ()); + assertEquals(58594, RegionBoundingRectangle.MAX_WORLD_REGION_BOUNDS.getMaxRegionX()); + assertEquals(58594, RegionBoundingRectangle.MAX_WORLD_REGION_BOUNDS.getMaxRegionZ()); + assertTrue(RegionBoundingRectangle.MAX_WORLD_REGION_BOUNDS.containsRegion(-58594, -58594)); + assertTrue(RegionBoundingRectangle.MAX_WORLD_REGION_BOUNDS.containsRegion(58593, 58593)); + assertFalse(RegionBoundingRectangle.MAX_WORLD_REGION_BOUNDS.containsRegion(58594, 58594)); + } + + public void testAsChunkBounds() { + RegionBoundingRectangle rbr = new RegionBoundingRectangle(4887, 6639); + assertEquals(32, rbr.getWidthChunkXZ()); + assertEquals(512, rbr.getWidthBlockXZ()); + assertEquals(4887 * 32, rbr.getMinChunkX()); + assertEquals(6639 * 32, rbr.getMinChunkZ()); + assertEquals(4888 * 32, rbr.getMaxChunkX()); + assertEquals(6640 * 32, rbr.getMaxChunkZ()); + + ChunkBoundingRectangle cbr = rbr.asChunkBounds(); + assertEquals(32, cbr.getWidthChunkXZ()); + assertEquals(512, cbr.getWidthBlockXZ()); + assertEquals(4887 * 32, cbr.getMinChunkX()); + assertEquals(6639 * 32, cbr.getMinChunkZ()); + assertEquals(4888 * 32, cbr.getMaxChunkX()); + assertEquals(6640 * 32, cbr.getMaxChunkZ()); + } + + public void testAsBlockBounds() { + RegionBoundingRectangle rbr = new RegionBoundingRectangle(4887, 6639); + BlockAlignedBoundingRectangle bbr = rbr.asBlockBounds(); + assertEquals(512, bbr.getWidthBlockXZ()); + assertEquals(4887 * 32 * 16, bbr.getMinBlockX()); + assertEquals(6639 * 32 * 16, bbr.getMinBlockZ()); + assertEquals(4888 * 32 * 16, bbr.getMaxBlockX()); + assertEquals(6640 * 32 * 16, bbr.getMaxBlockZ()); + } + + public void testOf() { + RegionBoundingRectangle br = RegionBoundingRectangle.of(List.of( + XZ(-5, 4) + )); + assertEquals(new RegionBoundingRectangle(-5, 4, 1), br); + + br = RegionBoundingRectangle.of(List.of( + XZ(-5, 4), + XZ(-4, -1) + )); + assertEquals(new RegionBoundingRectangle(-5, -1, 6), br); + } + + public void testGrow() { + RegionBoundingRectangle cbr = new RegionBoundingRectangle(3, 4, 5); + RegionBoundingRectangle cbr2 = cbr.growRegions(2); + assertEquals(new RegionBoundingRectangle(3 - 2, 4 - 2, 5 + 2 * 2), cbr2); + assertEquals(cbr.getCenterX(), cbr2.getCenterX(), 1e-6); + assertEquals(cbr.getCenterZ(), cbr2.getCenterZ(), 1e-6); + + assertThrows(IllegalArgumentException.class, () -> cbr2.growRegions(-1)); + } + + public void testShrink() { + RegionBoundingRectangle cbr = new RegionBoundingRectangle(3, 4, 5); + RegionBoundingRectangle cbr2 = cbr.shrinkRegions(2); + assertEquals(new RegionBoundingRectangle(3 + 2, 4 + 2, 5 - 2 * 2), cbr2); + assertEquals(cbr.getCenterX(), cbr2.getCenterX(), 1e-6); + assertEquals(cbr.getCenterZ(), cbr2.getCenterZ(), 1e-6); + + assertThrows(IllegalArgumentException.class, () -> cbr2.shrinkRegions(-1)); + assertNull(cbr2.shrinkRegions(1)); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/mca/util/VersionAwareTest.java b/src/test/java/io/github/ensgijs/nbt/mca/util/VersionAwareTest.java new file mode 100644 index 00000000..4d09e44e --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/mca/util/VersionAwareTest.java @@ -0,0 +1,27 @@ +package io.github.ensgijs.nbt.mca.util; + +import junit.framework.TestCase; + +public class VersionAwareTest extends TestCase { + + public void testSanity() { + VersionAware va = new VersionAware<>(); + assertNull(va.get(0)); + va.register(0, "Zero"); + assertEquals("Zero", va.get(0)); + assertEquals("Zero", va.get(Integer.MAX_VALUE)); + assertNull(va.get(-1)); + + va.register(10, "Ten"); + va.register(100, "Hundred"); + + assertEquals("Zero", va.get(0)); + assertEquals("Zero", va.get(1)); + assertEquals("Zero", va.get(9)); + assertEquals("Ten", va.get(10)); + assertEquals("Ten", va.get(11)); + assertEquals("Ten", va.get(99)); + assertEquals("Hundred", va.get(100)); + assertEquals("Hundred", va.get(Integer.MAX_VALUE)); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/query/NbtPathTest.java b/src/test/java/io/github/ensgijs/nbt/query/NbtPathTest.java new file mode 100644 index 00000000..d5b30807 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/query/NbtPathTest.java @@ -0,0 +1,223 @@ +package io.github.ensgijs.nbt.query; + +import io.github.ensgijs.nbt.tag.*; +import io.github.ensgijs.nbt.NbtTestCase; +import io.github.ensgijs.nbt.io.TextNbtParser; +import org.junit.Assert; + +public class NbtPathTest extends NbtTestCase { + + private void assertParse(String path) { + assertEquals(path, NbtPath.of(path).toString()); + } + + private void assertParse(String expected, String path) { + assertEquals(expected, NbtPath.of(path).toString()); + } + + private void assertParseThrows(String path) { + assertThrowsIllegalArgumentException(() -> NbtPath.of(path)); + } + + public void testParsingAndToString() { + assertParse("a"); + assertParse("a.b"); + assertParse("a[0].b"); + assertParse("a.b[7]"); + assertParse("a[0].b[2].c"); + assertParse("[42]"); + assertParse("[42].thing"); + assertParse("[42][19].thing.whatever.z[0].a.b"); + assertParse("4.baz"); // numbers can be names too + + assertParse(""); + // a leading dot is OK + assertParse("", "."); + assertParse("foo", ".foo"); + // but two leading dots aren't + assertParseThrows(".."); + assertParseThrows("..b"); + assertParseThrows("..bob"); + + assertParse("[1]"); + assertParseThrows(".[0]"); + assertParseThrows("a..b"); + assertParseThrows("foo."); + assertParseThrows("foo.bar."); + assertParseThrows("foo.bar.[0]"); + + assertParseThrows("a[]"); + assertParseThrows("[]"); + assertParseThrows("[z]"); + assertParseThrows("[zap]"); + assertParseThrows("[-2]"); + assertParseThrows("[-]"); + assertParseThrows("a[0"); + assertParseThrows("a[0]b[1]"); + assertParseThrows("a]0"); + assertParseThrows("a]0["); + assertParseThrows("a[0]]"); + } + + public void testUsageSanity() { + CompoundTag root = new CompoundTag(); + ListTag teamTag = new ListTag<>(CompoundTag.class); + + CompoundTag tmp = new CompoundTag(); + tmp.putString("name", "Bob"); + tmp.putBoolean("boolT", true); + tmp.putBoolean("boolF", false); + tmp.putByte("byte", (byte) -42); + tmp.putShort("short", (short) 12345); + tmp.putInt("int", 1001); + tmp.putLong("long", 99988765454785765L); + tmp.putFloat("float", 0.35f); + tmp.putDouble("double", 9.78d); + tmp.putLongArray("history", new long[]{43L, 56L, 89L}); + teamTag.add(tmp); + + tmp = new CompoundTag(); + tmp.putString("name", "Jebbb"); + tmp.putLongArray("history", new long[]{12L, 11L, 15L}); + teamTag.add(tmp); + + root.put("team", teamTag); + root.putInt("version", 1234); + + assertNull(NbtPath.of("xyz").get(root)); // doesnt exist + assertNotNull(NbtPath.of("team").get(root)); // exists + assertNull(NbtPath.of("team[6]").get(root)); // index doesn't exist + + assertSame(tmp, NbtPath.of("[1]").get(teamTag)); // can use a ListTag as root + + // value getters + assertEquals("Bob", NbtPath.of("team[0].name").getString(root)); + assertTrue(NbtPath.of("team[0].boolT").getBoolean(root)); + assertFalse(NbtPath.of("team[0].boolF").getBoolean(root)); + assertEquals((byte) -42, NbtPath.of("team[0].byte").getByte(root)); + assertEquals((short) 12345, NbtPath.of("team[0].short").getShort(root)); + assertEquals(1001 , NbtPath.of("team[0].int").getInt(root)); + assertEquals(99988765454785765L , NbtPath.of("team[0].long").getLong(root)); + assertEquals(0.35f, NbtPath.of("team[0].float").getFloat(root), 1e-6); + assertEquals(9.78d, NbtPath.of("team[0].double").getDouble(root), 1e-6); + + // missing value getters + assertNull(NbtPath.of("team[0].whatever").getString(root)); + assertNull(NbtPath.of("team[0].whatever.something.else").getString(root)); + assertFalse(NbtPath.of("team[0].whatever").getBoolean(root)); + assertFalse(NbtPath.of("team[0].whatever.something.else").getBoolean(root)); + assertEquals((byte) 0, NbtPath.of("team[0].whatever").getByte(root)); + assertEquals((byte) 0, NbtPath.of("team[0].whatever.something.else").getByte(root)); + assertEquals((short) 0, NbtPath.of("team[0].whatever").getShort(root)); + assertEquals((short) 0, NbtPath.of("team[0].whatever.something.else").getShort(root)); + assertEquals(0, NbtPath.of("team[0].whatever").getInt(root)); + assertEquals(0, NbtPath.of("team[0].whatever.something.else").getInt(root)); + assertEquals(0L, NbtPath.of("team[0].whatever").getLong(root)); + assertEquals(0L, NbtPath.of("team[0].whatever.something.else").getLong(root)); + assertEquals(0f, NbtPath.of("team[0].whatever").getFloat(root)); + assertEquals(0f, NbtPath.of("team[0].whatever.something.else").getFloat(root)); + assertEquals(0d, NbtPath.of("team[0].whatever").getDouble(root)); + assertEquals(0d, NbtPath.of("team[0].whatever.something.else").getDouble(root)); + + assertEquals(15L, NbtPath.of("team[1].history[2]").getLong(root)); // getting a raw long from a LongArray + + // get - throws when not the right type + assertThrowsException(() -> NbtPath.of("team[0].long").getString(root), ClassCastException.class); + assertThrowsException(() -> NbtPath.of("team[0].name").getByte(root), ClassCastException.class); + assertThrowsException(() -> NbtPath.of("team[0].name").getShort(root), ClassCastException.class); + assertThrowsException(() -> NbtPath.of("team[0].name").getInt(root), ClassCastException.class); + assertThrowsException(() -> NbtPath.of("team[0].name").getLong(root), ClassCastException.class); + assertThrowsException(() -> NbtPath.of("team[0].name").getFloat(root), ClassCastException.class); + assertThrowsException(() -> NbtPath.of("team[0].name").getDouble(root), ClassCastException.class); + + + // size function + assertEquals(2, NbtPath.of("team").size(root)); // size of list tag + assertEquals(3, NbtPath.of("team[1].history").size(root)); // size of long array + assertEquals(0, NbtPath.of("meme").size(root)); // no-existing tag size + assertEquals(5, NbtPath.of("team[1].name").size(root)); // string size + assertEquals(2, NbtPath.of("").size(root)); // size of compound tag + + // can't get these sizes + assertThrowsIllegalArgumentException(() -> NbtPath.of("team[0].byte").size(root)); + assertThrowsIllegalArgumentException(() -> NbtPath.of("team[0].short").size(root)); + assertThrowsIllegalArgumentException(() -> NbtPath.of("team[0].int").size(root)); + assertThrowsIllegalArgumentException(() -> NbtPath.of("team[0].long").size(root)); + assertThrowsIllegalArgumentException(() -> NbtPath.of("team[0].float").size(root)); + assertThrowsIllegalArgumentException(() -> NbtPath.of("team[0].double").size(root)); + + // exists function + assertTrue(NbtPath.of("team").exists(root)); + assertTrue(NbtPath.of("team[1].history").exists(root)); + assertFalse(NbtPath.of("meme").exists(root)); + assertTrue(NbtPath.of("").exists(root)); + assertTrue(NbtPath.of("team[1].history[0]").exists(root)); + assertFalse(NbtPath.of("team[1].history[99]").exists(root)); + + assertFalse(NbtPath.of("team[1].abc").exists(root)); + assertFalse(NbtPath.of("team[100].abc").exists(root)); + } + + public void testPutTag() { + // throws when create parents is FALSE + assertThrowsIllegalArgumentException(() -> NbtPath.of("team.alpha").putTag(new CompoundTag(), new StringTag("STOP"), false)); + + final CompoundTag root = new CompoundTag(); + // now try with create parents - and verify that no previous tag was replaced + assertNull(NbtPath.of("team.alpha").putTag(root, new StringTag("GO GO"), true)); + assertEquals("GO GO", NbtPath.of("team.alpha").getString(root)); + // can't treat a compound as a list + assertThrowsIllegalArgumentException(() -> NbtPath.of("team[0]").getTag(root)); + + // check that the previous tag is returned - and the new value is set + StringTag oldTag = NbtPath.of("team.alpha").putTag(root, new StringTag("Weeee")); + assertNotNull(oldTag); + assertEquals("GO GO", oldTag.getValue()); + assertEquals("Weeee", NbtPath.of("team.alpha").getString(root)); + + // now test removal + assertNull(NbtPath.of("team.beta").putTag(root, null)); // doesn't exist - should be FINE + oldTag = NbtPath.of("team.alpha").putTag(root, null); + assertNotNull(oldTag); + assertEquals("Weeee", oldTag.getValue()); + assertNull(NbtPath.of("team.alpha").getTag(root)); // verify it's removed + + root.put("teams", new ListTag<>(CompoundTag.class)); + root.getListTag("teams").asCompoundTagList().add(new CompoundTag()); + // this works because we initialized list element [0] first + NbtPath.of("teams[0].name").putTag(root, new StringTag("alpha")); + assertEquals("alpha", NbtPath.of("teams[0].name").getString(root)); + + // should create stats for us (not the first case - where we don't create parents) + assertThrowsIllegalArgumentException(() -> NbtPath.of("teams[0].stats.wins").putTag(root, new IntTag(1))); + NbtPath.of("teams[0].stats.wins").putTag(root, new IntTag(1), true); + + // list element [1] doesn't exist + assertThrowsIllegalArgumentException(() -> NbtPath.of("teams[1].name").putTag(root, new StringTag("beta"))); + + // cannot add tag list (at this time... i mean really, it doesn't make sense as the index would need to be + // put in the path string - at that point trying to use this type of solution would just be bad practice) + assertThrowsIllegalArgumentException(() -> NbtPath.of("zom.lom[0]").putTag(root, new CompoundTag())); + + // can't add a keyed field to a TagList (requires an index) + assertThrowsUnsupportedOperationException(() -> NbtPath.of("teams.broken").putTag(root, new ByteTag(true))); + assertThrowsUnsupportedOperationException(() -> NbtPath.of("teams.broken").putTag(root, new ByteTag(true), true)); + + + NbtPath.of("long").putTag(root, new LongTag(19)); + // can't get a child of a primitive tag + assertNull(NbtPath.of("long.foo").getTag(root)); + // can't treat a primitive as a list + assertThrowsIllegalArgumentException(() -> NbtPath.of("long[0]").getTag(root)); + + // can't add a child to a primitive tag + assertThrowsUnsupportedOperationException(() -> NbtPath.of("long.foo.bar").putTag(root, new CompoundTag(), true)); + } + + public void testGetTypedArrayTags() { + CompoundTag root = TextNbtParser.parseInline("{stuff:{ints:[I;11223344,5,7],bytes:[B;-100,50],longs:[L;2490852214246277761,17616930435]}}"); + Assert.assertArrayEquals(new int[] {11223344, 5, 7}, NbtPath.of("stuff.ints").getIntArray(root)); + Assert.assertArrayEquals(new byte[] {(byte) -100, (byte) 50}, NbtPath.of("stuff.bytes").getByteArray(root)); + Assert.assertArrayEquals(new long[] {2490852214246277761L, 17616930435L}, NbtPath.of("stuff.longs").getLongArray(root)); + } +} diff --git a/src/test/java/net/querz/nbt/tag/ByteArrayTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/ByteArrayTagTest.java similarity index 94% rename from src/test/java/net/querz/nbt/tag/ByteArrayTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/ByteArrayTagTest.java index 1d10e033..1b9a203a 100644 --- a/src/test/java/net/querz/nbt/tag/ByteArrayTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/ByteArrayTagTest.java @@ -1,10 +1,10 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; import java.util.Arrays; -public class ByteArrayTagTest extends NBTTestCase { +public class ByteArrayTagTest extends NbtTestCase { public void testCreate() { ByteArrayTag t = new ByteArrayTag(new byte[]{Byte.MIN_VALUE, 0, Byte.MAX_VALUE}); @@ -56,7 +56,7 @@ public void testCompareTo() { ByteArrayTag t3 = new ByteArrayTag(new byte[]{Byte.MAX_VALUE, 0, Byte.MIN_VALUE}); ByteArrayTag t4 = new ByteArrayTag(new byte[]{0, Byte.MIN_VALUE}); assertEquals(0, t.compareTo(t2)); - assertEquals(0, t.compareTo(t3)); + assertTrue(0 > t.compareTo(t3)); assertTrue(0 < t.compareTo(t4)); assertTrue(0 > t4.compareTo(t)); assertThrowsRuntimeException(() -> t.compareTo(null), NullPointerException.class); diff --git a/src/test/java/net/querz/nbt/tag/ByteTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/ByteTagTest.java similarity index 94% rename from src/test/java/net/querz/nbt/tag/ByteTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/ByteTagTest.java index ff9451d0..179049e8 100644 --- a/src/test/java/net/querz/nbt/tag/ByteTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/ByteTagTest.java @@ -1,10 +1,10 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; import java.util.Arrays; -public class ByteTagTest extends NBTTestCase { +public class ByteTagTest extends NbtTestCase { public void testCreate() { ByteTag t = new ByteTag(Byte.MAX_VALUE); diff --git a/src/test/java/net/querz/nbt/tag/CompoundTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/CompoundTagTest.java similarity index 62% rename from src/test/java/net/querz/nbt/tag/CompoundTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/CompoundTagTest.java index e08426f4..38afae01 100644 --- a/src/test/java/net/querz/nbt/tag/CompoundTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/CompoundTagTest.java @@ -1,16 +1,16 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.io.MaxDepthReachedException; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.io.MaxDepthReachedException; +import io.github.ensgijs.nbt.io.NamedTag; +import io.github.ensgijs.nbt.NbtTestCase; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.Map; +import java.util.*; +import java.util.stream.Collectors; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertNotEquals; -public class CompoundTagTest extends NBTTestCase { +public class CompoundTagTest extends NbtTestCase { private CompoundTag createCompoundTag() { CompoundTag ct = new CompoundTag(); @@ -27,9 +27,9 @@ public void testStringConversion() { assertEquals("{\"type\":\"CompoundTag\"," + "\"value\":{" + "\"b\":{\"type\":\"ByteTag\",\"value\":127}," + - "\"str\":{\"type\":\"StringTag\",\"value\":\"foo\"}," + - "\"list\":{\"type\":\"ListTag\"," + - "\"value\":{\"type\":\"ByteTag\",\"list\":[123]}}}}", ct.toString()); + "\"list\":{\"type\":\"ListTag\",\"value\":{\"type\":\"ByteTag\",\"list\":[123]}}," + + "\"str\":{\"type\":\"StringTag\",\"value\":\"foo\"}" + + "}}", ct.toString()); } public void testEquals() { @@ -263,7 +263,7 @@ public void testCompareTo() { CompoundTag co = new CompoundTag(); co.putInt("three", 3); co.putInt("four", 4); - assertEquals(0, ci.compareTo(co)); + assertTrue(0 < ci.compareTo(co)); co.putInt("five", 5); assertEquals(-1, ci.compareTo(co)); co.remove("five"); @@ -297,9 +297,9 @@ public void testRecursion() { public void testEntrySet() { CompoundTag e = new CompoundTag(); e.putInt("int", 123); - for (Map.Entry> en : e.entrySet()) { - assertThrowsRuntimeException(() -> en.setValue(null), NullPointerException.class); - assertThrowsNoRuntimeException(() -> en.setValue(new IntTag(321))); + for (NamedTag en : e) { + assertThrowsIllegalArgumentException(() -> en.setTag(null)); + assertThrowsNoRuntimeException(() -> en.setTag(new IntTag(321))); } assertEquals(1, e.size()); assertEquals(321, e.getInt("int")); @@ -309,8 +309,14 @@ public void testContains() { CompoundTag ct = createCompoundTag(); assertEquals(3, ct.size()); assertTrue(ct.containsKey("b")); + assertTrue(ct.containsKey("b", ByteTag.class)); + assertTrue(ct.containsKey("b", NumberTag.class)); assertTrue(ct.containsKey("str")); + assertTrue(ct.containsKey("str", StringTag.class)); + assertFalse(ct.containsKey("str", ByteTag.class)); assertTrue(ct.containsKey("list")); + assertTrue(ct.containsKey("list", ListTag.class)); + assertFalse(ct.containsKey("list", ByteTag.class)); assertFalse(ct.containsKey("invalid")); assertTrue(ct.containsValue(new StringTag("foo"))); ListTag l = new ListTag<>(ByteTag.class); @@ -338,22 +344,22 @@ public void testIterator() { assertFalse(ct.containsKey("str")); assertThrowsRuntimeException(() -> ct.keySet().add("str"), UnsupportedOperationException.class); ct.putString("str", "foo"); - for (Map.Entry> e : ct.entrySet()) { - assertNotNull(e.getKey()); - assertNotNull(e.getValue()); - assertThrowsRuntimeException(() -> e.setValue(null), NullPointerException.class); - if (e.getKey().equals("str")) { - assertThrowsNoRuntimeException(() -> e.setValue(new StringTag("bar"))); + for (NamedTag e : ct) { + assertNotNull(e.getName()); + assertNotNull(e.getTag()); + assertThrowsIllegalArgumentException(() -> e.setTag(null)); + if (e.getName().equals("str")) { + assertThrowsNoRuntimeException(() -> e.setTag(new StringTag("bar"))); } } assertTrue(ct.containsKey("str")); assertEquals("bar", ct.getString("str")); - for (Map.Entry> e : ct) { - assertNotNull(e.getKey()); - assertNotNull(e.getValue()); - assertThrowsRuntimeException(() -> e.setValue(null), NullPointerException.class); - if (e.getKey().equals("str")) { - assertThrowsNoRuntimeException(() -> e.setValue(new StringTag("foo"))); + for (NamedTag e : ct) { + assertNotNull(e.getName()); + assertNotNull(e.getTag()); + assertThrowsIllegalArgumentException(() -> e.setTag(null)); + if (e.getName().equals("str")) { + assertThrowsNoRuntimeException(() -> e.setTag(new StringTag("foo"))); } } assertTrue(ct.containsKey("str")); @@ -365,6 +371,13 @@ public void testIterator() { assertEquals(3, ct.size()); } + public void testStream() { + CompoundTag ct = createCompoundTag(); + List keys = ct.stream().map(NamedTag::getName).collect(Collectors.toList()); + assertEquals(ct.size(), keys.size()); + assertTrue(keys.containsAll(Arrays.asList("b", "str", "list"))); + } + public void testPutIfNotNull() { CompoundTag ct = new CompoundTag(); assertEquals(0, ct.size()); @@ -373,4 +386,169 @@ public void testPutIfNotNull() { assertEquals(1, ct.size()); assertEquals("bar", ct.getString("foo")); } + + public void testDefaultValueGetters() { + CompoundTag ct = new CompoundTag(); + assertTrue(ct.getBoolean("name", true)); + assertFalse(ct.getBoolean("name", false)); + assertEquals((byte)-7, ct.getByte("name", (byte) -7)); + assertEquals((short)13456, ct.getShort("name", (short) 13456)); + assertEquals(13456789, ct.getInt("name", 13456789)); + assertEquals(13456789101112L, ct.getLong("name", 13456789101112L)); + assertEquals(1.23456f, ct.getFloat("name", 1.23456f), 0.5e-5f); + assertEquals(1.234567981019, ct.getDouble("name", 1.234567981019), 0.5e-12f); + assertEquals("hello world", ct.getString("name", "hello world")); + } + + public void testPutGetFloatArrayAsTagList() { + CompoundTag tag = new CompoundTag(); + float[] values = new float[] {-1.1f, 0f, 1.1f}; + tag.putFloatArrayAsTagList("name", values); + assertTrue(tag.containsKey("name")); + assertArrayEquals(values, tag.getFloatTagListAsArray("name"), 1e-5f); + + // put singleton + tag = new CompoundTag(); + tag.putFloatArrayAsTagList("name", 42.7f); + assertTrue(tag.containsKey("name")); + assertArrayEquals(new float[]{42.7f}, tag.getFloatTagListAsArray("name"), 1e-5f); + + // put empty should create empty + values = new float[0]; + tag = new CompoundTag(); + tag.putFloatArrayAsTagList("name", values); + assertTrue(tag.containsKey("name")); + assertEquals(0, tag.getFloatTagListAsArray("name").length); + + // put null should remove tag + values = null; + tag = new CompoundTag(); + tag.putFloatArrayAsTagList("name", values); + assertFalse(tag.containsKey("name")); + } + + public void testPutGetDoubleArrayAsTagList() { + CompoundTag tag = new CompoundTag(); + double[] values = new double[] {-1.1f, 0f, 1.1f}; + tag.putDoubleArrayAsTagList("name", values); + assertTrue(tag.containsKey("name")); + assertArrayEquals(values, tag.getDoubleTagListAsArray("name"), 1e-5f); + + // put singleton + tag = new CompoundTag(); + tag.putDoubleArrayAsTagList("name", 42.7); + assertTrue(tag.containsKey("name")); + assertArrayEquals(new double[]{42.7}, tag.getDoubleTagListAsArray("name"), 1e-5f); + + // put empty should create empty + values = new double[0]; + tag = new CompoundTag(); + tag.putDoubleArrayAsTagList("name", values); + assertTrue(tag.containsKey("name")); + assertEquals(0, tag.getDoubleTagListAsArray("name").length); + + // put null should remove tag + values = null; + tag = new CompoundTag(); + tag.putDoubleArrayAsTagList("name", values); + assertFalse(tag.containsKey("name")); + } + + public void testPutGetStringsAsTagList() { + CompoundTag tag = new CompoundTag(); + List values = Arrays.asList("abba", "dabba"); + + tag.putStringsAsTagList("name", values); + assertTrue(tag.containsKey("name")); + assertEquals(values, tag.getStringTagListValues("name")); + + // put empty should create empty + values = new ArrayList<>(); + tag = new CompoundTag(); + tag.putStringsAsTagList("name", values); + assertTrue(tag.containsKey("name")); + assertEquals(0, tag.getStringTagListValues("name").size()); + + // put null should remove tag + values = null; + tag = new CompoundTag(); + tag.putStringsAsTagList("name", values); + assertFalse(tag.containsKey("name")); + } + + public void testPutValue() { + CompoundTag t = new CompoundTag(); + + t.putInt("nuke-me", 42); + t.putValue("nuke-me", null); + assertFalse(t.containsKey("nuke-me")); + + CompoundTag t2 = new CompoundTag(); + t.putValue("key_tag", t2); + assertSame(t2, t.get("key_tag")); + + NamedTag t3 = new NamedTag("frank", new StringTag("smith")); + t.putValue("key_tag3", t3); + assertSame(t3.getTag(), t.get("key_tag3")); + + t.putValue("key_string", "it's magic"); + assertEquals(new StringTag("it's magic"), t.get("key_string")); + + t.putValue("key_bool0", false); + assertFalse(t.getBoolean("key_bool0")); + t.putValue("key_bool1", true); + assertTrue(t.getBoolean("key_bool1")); + + + t.putValue("key_byte", (byte) 2); + assertEquals(new ByteTag((byte) 2), t.get("key_byte")); + + t.putValue("key_short", (short) 4); + assertEquals(new ShortTag((short) 4), t.get("key_short")); + + t.putValue("key_int", 5); + assertEquals(new IntTag(5), t.get("key_int")); + + t.putValue("key_long", 6L); + assertEquals(new LongTag(6L), t.get("key_long")); + + t.putValue("key_float", 7.8f); + assertEquals(new FloatTag(7.8f), t.get("key_float")); + + t.putValue("key_double", 8.9); + assertEquals(new DoubleTag(8.9), t.get("key_double")); + + + byte[] ab = new byte[]{(byte) 3, (byte) 7}; + t.putValue("key_byte_array", ab); + assertArrayEquals(ab, t.getByteArray("key_byte_array")); + + int[] ai = new int[]{11, 13}; + t.putValue("key_int_array", ai); + assertArrayEquals(ai, t.getIntArray("key_int_array")); + + long[] al = new long[]{17, 19}; + t.putValue("key_long_array", al); + assertArrayEquals(al, t.getLongArray("key_long_array")); + + float[] af = new float[]{21.5f, 23.5f}; + t.putValue("key_float_array", af); + float[] af2 = t.getFloatTagListAsArray("key_float_array"); + assertEquals(af.length, af2.length); + assertEquals(af[0], af2[0], 0.001); + assertEquals(af[1], af2[1], 0.001); + + double[] ad = new double[]{27.4f, 29.8f}; + t.putValue("key_double_array", ad); + double[] ad2 = t.getDoubleTagListAsArray("key_double_array"); + assertEquals(ad.length, ad2.length); + assertEquals(ad[0], ad2[0], 0.001); + assertEquals(ad[1], ad2[1], 0.001); + + String[] as = new String[]{"bill", "bob", "jeb", "val"}; + t.putValue("key_string_array", as); + assertEquals(List.of(as), t.getStringTagListValues("key_string_array")); + + assertThrowsException(() -> t.putValue("boom", new Object()), IllegalArgumentException.class); + } } diff --git a/src/test/java/net/querz/nbt/tag/DoubleTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/DoubleTagTest.java similarity index 93% rename from src/test/java/net/querz/nbt/tag/DoubleTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/DoubleTagTest.java index 024fca99..a0665087 100644 --- a/src/test/java/net/querz/nbt/tag/DoubleTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/DoubleTagTest.java @@ -1,11 +1,11 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; import static org.junit.Assert.assertNotEquals; import java.util.Arrays; -public class DoubleTagTest extends NBTTestCase { +public class DoubleTagTest extends NbtTestCase { public void testCreate() { DoubleTag t = new DoubleTag(Double.MAX_VALUE); diff --git a/src/test/java/net/querz/nbt/tag/EndTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/EndTagTest.java similarity index 72% rename from src/test/java/net/querz/nbt/tag/EndTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/EndTagTest.java index 0044f78d..9e110034 100644 --- a/src/test/java/net/querz/nbt/tag/EndTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/EndTagTest.java @@ -1,8 +1,8 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; -public class EndTagTest extends NBTTestCase { +public class EndTagTest extends NbtTestCase { public void testStringConversion() { EndTag e = EndTag.INSTANCE; diff --git a/src/test/java/net/querz/nbt/tag/FloatTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/FloatTagTest.java similarity index 93% rename from src/test/java/net/querz/nbt/tag/FloatTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/FloatTagTest.java index 006b40bf..8ebd4d54 100644 --- a/src/test/java/net/querz/nbt/tag/FloatTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/FloatTagTest.java @@ -1,11 +1,11 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; import static org.junit.Assert.assertNotEquals; import java.util.Arrays; -public class FloatTagTest extends NBTTestCase { +public class FloatTagTest extends NbtTestCase { public void testCreate() { FloatTag t = new FloatTag(Float.MAX_VALUE); diff --git a/src/test/java/net/querz/nbt/tag/IntArrayTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/IntArrayTagTest.java similarity index 86% rename from src/test/java/net/querz/nbt/tag/IntArrayTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/IntArrayTagTest.java index 1458a732..9c51906c 100644 --- a/src/test/java/net/querz/nbt/tag/IntArrayTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/IntArrayTagTest.java @@ -1,10 +1,10 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; import java.util.Arrays; -public class IntArrayTagTest extends NBTTestCase { +public class IntArrayTagTest extends NbtTestCase { public void testCreate() { IntArrayTag t = new IntArrayTag(new int[]{Integer.MIN_VALUE, 0, Integer.MAX_VALUE}); @@ -56,9 +56,15 @@ public void testCompareTo() { IntArrayTag t3 = new IntArrayTag(new int[]{Integer.MAX_VALUE, 0, Integer.MIN_VALUE}); IntArrayTag t4 = new IntArrayTag(new int[]{0, Integer.MIN_VALUE}); assertEquals(0, t.compareTo(t2)); - assertEquals(0, t.compareTo(t3)); + assertTrue(0 > t.compareTo(t3)); assertTrue(0 < t.compareTo(t4)); assertTrue(0 > t4.compareTo(t)); assertThrowsRuntimeException(() -> t.compareTo(null), NullPointerException.class); } + + public void testStream() { + IntArrayTag tag = new IntArrayTag(new int[] {12, 7, 42, -69, 148, -187876}); + assertEquals(-187876, tag.stream().min().getAsInt()); + assertEquals(148, tag.stream().max().getAsInt()); + } } diff --git a/src/test/java/net/querz/nbt/tag/IntTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/IntTagTest.java similarity index 92% rename from src/test/java/net/querz/nbt/tag/IntTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/IntTagTest.java index 6c15fa42..6fb418d3 100644 --- a/src/test/java/net/querz/nbt/tag/IntTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/IntTagTest.java @@ -1,10 +1,10 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; import java.util.Arrays; -public class IntTagTest extends NBTTestCase { +public class IntTagTest extends NbtTestCase { public void testCreate() { IntTag t = new IntTag(Integer.MAX_VALUE); diff --git a/src/test/java/net/querz/nbt/tag/ListTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/ListTagTest.java similarity index 65% rename from src/test/java/net/querz/nbt/tag/ListTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/ListTagTest.java index af2b72fb..50efb026 100644 --- a/src/test/java/net/querz/nbt/tag/ListTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/ListTagTest.java @@ -1,18 +1,24 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; +import io.github.ensgijs.nbt.io.MaxDepthReachedException; import junit.framework.TestCase; -import net.querz.io.MaxDepthReachedException; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; + +import java.util.*; +import java.util.stream.Collectors; -import java.util.Arrays; -import java.util.Comparator; import static org.junit.Assert.assertNotEquals; -public class ListTagTest extends NBTTestCase { +public class ListTagTest extends NbtTestCase { public void testCreateInvalidList() { assertThrowsException(() -> new ListTag<>(EndTag.class), IllegalArgumentException.class); - assertThrowsException(() -> new ListTag<>(null), NullPointerException.class); + assertThrowsException(() -> new ListTag<>((Class) null), NullPointerException.class); + assertThrowsException(() -> new ListTag<>((List) null), NullPointerException.class); + + List nullContaining = new ArrayList<>(); + nullContaining.add(null); + assertThrowsException(() -> new ListTag<>(nullContaining), NullPointerException.class); } private ListTag createListTag() { @@ -23,6 +29,15 @@ private ListTag createListTag() { return bl; } + public void testIsEmpty() { + ListTag tag = new ListTag<>(IntTag.class); + assertTrue(tag.isEmpty()); + tag.addInt(1); + assertFalse(tag.isEmpty()); + tag.clear(); + assertTrue(tag.isEmpty()); + } + public void testStringConversion() { ListTag bl = createListTag(); assertTrue(3 == bl.size()); @@ -193,7 +208,7 @@ public void testCasting() { ListTag> lis = new ListTag<>(ListTag.class); lis.add(new ListTag<>(IntTag.class)); - assertThrowsNoRuntimeException(lis::asListTagList); + assertThrowsNoRuntimeException(() -> lis.asListTagList()); assertThrowsRuntimeException(lis::asCompoundTagList, ClassCastException.class); ListTag lco = new ListTag<>(CompoundTag.class); @@ -215,7 +230,7 @@ public void testCompareTo() { ListTag lo = new ListTag<>(IntTag.class); lo.addInt(3); lo.addInt(4); - assertEquals(0, li.compareTo(lo)); + assertTrue(0 > li.compareTo(lo)); lo.addInt(5); assertEquals(-1, li.compareTo(lo)); lo.remove(2); @@ -274,6 +289,46 @@ public void testIterator() { l.forEach(TestCase::assertNotNull); } + public void testListIterator() { + ListTag l = new ListTag<>(IntTag.class); + l.addInt(1); + l.addInt(2); + l.addInt(3); + l.addInt(4); + + ListIterator iter = l.listIterator(1); + assertTrue(iter.hasNext()); + assertTrue(iter.hasPrevious()); + assertEquals(0, iter.previousIndex()); + assertEquals(1, iter.nextIndex()); + assertEquals(2, iter.next().asInt()); + iter.set(new IntTag(44)); + assertEquals(44, l.get(1).asInt()); + assertEquals(44, iter.previous().asInt()); + assertTrue(iter.hasPrevious()); + iter.previous(); + assertFalse(iter.hasPrevious()); + iter.next(); + iter.next(); + assertEquals(1, iter.previousIndex()); + assertEquals(2, iter.nextIndex()); + iter.next(); + iter.next(); + assertFalse(iter.hasNext()); + assertTrue(iter.hasPrevious()); + assertEquals(4, iter.previous().asInt()); + iter.add(new IntTag(5)); + assertEquals(5, iter.previous().asInt()); + assertEquals(3, iter.previous().asInt()); + iter.remove(); + + assertEquals(4, l.size()); + assertEquals(1, l.get(0).asInt()); + assertEquals(44, l.get(1).asInt()); + assertEquals(5, l.get(2).asInt()); + assertEquals(4, l.get(3).asInt()); + } + public void testSet() { ListTag l = createListTag(); l.set(1, new ByteTag((byte) 5)); @@ -292,6 +347,69 @@ public void testAddAll() { assertEquals(7, l.size()); assertEquals(9, l.get(1).asByte()); assertEquals(11, l.get(2).asByte()); + + assertThrowsException(() -> l.addAll(null), NullPointerException.class); + assertThrowsException(() -> l.addAll(0, null), NullPointerException.class); + + ListTag uncheckedListTag = ListTag.createUnchecked(EndTag.class); + uncheckedListTag.addAll(Arrays.asList(new ByteTag((byte) 9), new ByteTag((byte) 11))); + assertSame(ByteTag.class, uncheckedListTag.getTypeClass()); + assertEquals(2, uncheckedListTag.size()); + + uncheckedListTag = ListTag.createUnchecked(EndTag.class); + uncheckedListTag.addAll(0, Arrays.asList(new IntTag(9), new IntTag(11))); + assertSame(IntTag.class, uncheckedListTag.getTypeClass()); + assertEquals(2, uncheckedListTag.size()); + + // null element in given list + List badSeed = new ArrayList<>(); + badSeed.add(new StringTag("bang")); + badSeed.add(null); + assertThrowsException(() -> new ListTag<>(StringTag.class).addAll(badSeed), NullPointerException.class); + assertThrowsException(() -> new ListTag<>(StringTag.class).addAll(0, badSeed), NullPointerException.class); + + // wrong type in given strongly typed list is blocked at compile time - but mixed types is an interesting edge case + List everythingGoes = new ArrayList(); + everythingGoes.add(new StringTag("bang")); + everythingGoes.add(new IntTag(24)); + assertThrowsException(() -> new ListTag<>(IntTag.class).addAll(everythingGoes), ClassCastException.class); + assertThrowsException(() -> ListTag.createUnchecked(null).addAll(everythingGoes), ClassCastException.class); + } + + public void testConstructorUsingList() { + ListTag list = new ListTag<>(Arrays.asList(new ByteTag((byte) 9), new ByteTag((byte) 11))); + assertSame(ByteTag.class, list.getTypeClass()); + assertEquals(2, list.size()); + assertEquals(9, list.get(0).asInt()); + + assertThrowsException(() -> new ListTag((List) null), NullPointerException.class); + list = new ListTag<>(Arrays.asList(new ByteTag((byte) 9), new ByteTag((byte) 11))); + assertSame(ByteTag.class, list.getTypeClass()); + assertEquals(2, list.size()); + assertEquals(9, list.get(0).asInt()); + + // null element in given list + List badSeed = new ArrayList<>(); + badSeed.add(new StringTag("bang")); + badSeed.add(null); + assertThrowsException(() -> new ListTag<>(badSeed), NullPointerException.class); + + // wrong type in given strongly typed list is blocked at compile time - but mixed types is an interesting edge case + List everythingGoes = new ArrayList(); + everythingGoes.add(new StringTag("bang")); + everythingGoes.add(new IntTag(24)); + assertThrowsException(() -> new ListTag<>(everythingGoes), ClassCastException.class); + + // trying to use a type that doesn't extend Tag + List badfood = new ArrayList(); + badfood.add("badfood"); + assertThrowsException(() -> new ListTag(badfood), ClassCastException.class); + assertThrowsException(() -> new ListTag(badfood), ClassCastException.class); + List badfood2 = new ArrayList(); + badfood2.add(new StringTag("ok")); + badfood2.add("badfood"); + assertThrowsException(() -> new ListTag(badfood2), ClassCastException.class); + assertThrowsException(() -> new ListTag(badfood2), ClassCastException.class); } public void testIndexOf() { @@ -349,4 +467,47 @@ public void testAdd() { assertEquals(1, la.size()); assertTrue(Arrays.equals(new long[] {Long.MIN_VALUE, 0, Long.MAX_VALUE}, la.get(0).getValue())); } + + public void testStream() { + ListTag tag = new ListTag<>(StringTag.class); + tag.addString("A"); + tag.addString("quick"); + tag.addString("brown"); + tag.addString("fox"); + tag.addString("jumps"); + tag.addString("over"); + tag.addString("the"); + tag.addString("lazy"); + tag.addString("dog"); + + assertEquals("A quick brown fox jumps over the lazy dog", + tag.stream().map(StringTag::getValue).collect(Collectors.joining(" "))); + } + + public void testSubList() { + ListTag tag = new ListTag<>(StringTag.class); + tag.addString("A"); + tag.addString("quick"); + tag.addString("brown"); + tag.addString("fox"); + tag.addString("jumps"); + tag.addString("over"); + tag.addString("the"); + tag.addString("lazy"); + tag.addString("dog"); + + var subList = tag.subList(2, 5); + assertEquals("brown", subList.get(0).getValue()); + assertEquals("fox", subList.get(1).getValue()); + assertEquals("jumps", subList.get(2).getValue()); + subList.set(1, new StringTag("dog")); + assertEquals("dog", subList.get(1).getValue()); + assertEquals("dog", tag.get(3).getValue()); + subList.remove(0); + assertEquals("A quick dog jumps over the lazy dog", + tag.stream().map(StringTag::getValue).collect(Collectors.joining(" "))); + subList.add(1, new StringTag("almost")); + assertEquals("A quick dog almost jumps over the lazy dog", + tag.stream().map(StringTag::getValue).collect(Collectors.joining(" "))); + } } diff --git a/src/test/java/net/querz/nbt/tag/LongArrayTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/LongArrayTagTest.java similarity index 85% rename from src/test/java/net/querz/nbt/tag/LongArrayTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/LongArrayTagTest.java index dfa2a3f3..31471608 100644 --- a/src/test/java/net/querz/nbt/tag/LongArrayTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/LongArrayTagTest.java @@ -1,10 +1,10 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; import java.util.Arrays; -public class LongArrayTagTest extends NBTTestCase { +public class LongArrayTagTest extends NbtTestCase { public void testCreate() { LongArrayTag t = new LongArrayTag(new long[]{Long.MIN_VALUE, 0, Long.MAX_VALUE}); @@ -56,9 +56,15 @@ public void testCompareTo() { LongArrayTag t3 = new LongArrayTag(new long[]{Long.MAX_VALUE, 0, Long.MIN_VALUE}); LongArrayTag t4 = new LongArrayTag(new long[]{0, Long.MIN_VALUE}); assertEquals(0, t.compareTo(t2)); - assertEquals(0, t.compareTo(t3)); + assertTrue(0 > t.compareTo(t3)); assertTrue(0 < t.compareTo(t4)); assertTrue(0 > t4.compareTo(t)); assertThrowsRuntimeException(() -> t.compareTo(null), NullPointerException.class); } + + public void testStream() { + LongArrayTag tag = new LongArrayTag(new long[] {12, 7, 42, -69, 148, -1878769999999L}); + assertEquals(-1878769999999L, tag.stream().min().getAsLong()); + assertEquals(148, tag.stream().max().getAsLong()); + } } diff --git a/src/test/java/net/querz/nbt/tag/LongTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/LongTagTest.java similarity index 92% rename from src/test/java/net/querz/nbt/tag/LongTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/LongTagTest.java index 86ef1ff2..e21e1df2 100644 --- a/src/test/java/net/querz/nbt/tag/LongTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/LongTagTest.java @@ -1,10 +1,10 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; import java.util.Arrays; -public class LongTagTest extends NBTTestCase { +public class LongTagTest extends NbtTestCase { public void testCreate() { LongTag t = new LongTag(Long.MAX_VALUE); diff --git a/src/test/java/net/querz/nbt/tag/ShortTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/ShortTagTest.java similarity index 93% rename from src/test/java/net/querz/nbt/tag/ShortTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/ShortTagTest.java index 9668c248..8e199a94 100644 --- a/src/test/java/net/querz/nbt/tag/ShortTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/ShortTagTest.java @@ -1,10 +1,10 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; import java.util.Arrays; -public class ShortTagTest extends NBTTestCase { +public class ShortTagTest extends NbtTestCase { public void testCreate() { ShortTag t = new ShortTag(Short.MAX_VALUE); diff --git a/src/test/java/net/querz/nbt/tag/StringTagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/StringTagTest.java similarity index 94% rename from src/test/java/net/querz/nbt/tag/StringTagTest.java rename to src/test/java/io/github/ensgijs/nbt/tag/StringTagTest.java index e008a55a..021dbcae 100644 --- a/src/test/java/net/querz/nbt/tag/StringTagTest.java +++ b/src/test/java/io/github/ensgijs/nbt/tag/StringTagTest.java @@ -1,10 +1,10 @@ -package net.querz.nbt.tag; +package io.github.ensgijs.nbt.tag; -import net.querz.NBTTestCase; +import io.github.ensgijs.nbt.NbtTestCase; import java.util.Arrays; -public class StringTagTest extends NBTTestCase { +public class StringTagTest extends NbtTestCase { public void testStringConversion() { StringTag t = new StringTag("foo"); diff --git a/src/test/java/io/github/ensgijs/nbt/tag/TagTest.java b/src/test/java/io/github/ensgijs/nbt/tag/TagTest.java new file mode 100644 index 00000000..80c08008 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/tag/TagTest.java @@ -0,0 +1,111 @@ +package io.github.ensgijs.nbt.tag; + +import io.github.ensgijs.nbt.NbtTestCase; +import io.github.ensgijs.nbt.io.NamedTag; +import io.github.ensgijs.nbt.io.TextNbtHelpers; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNotEquals; + +public class TagTest extends NbtTestCase { + + public void testAsTag() { + assertNull(Tag.asTag(null)); + + CompoundTag t2 = new CompoundTag(); + assertSame(t2, Tag.asTag(t2)); + + NamedTag t3 = new NamedTag("frank", new StringTag("smith")); + assertSame(t3.getTag(), Tag.asTag(t3)); + + assertEquals(new StringTag("it's magic"), Tag.asTag("it's magic")); + + assertEquals(new ByteTag(false), Tag.asTag(false)); + assertEquals(new ByteTag(true), Tag.asTag(true)); + + + assertEquals(new ByteTag((byte) 2), Tag.asTag((byte) 2)); + + assertEquals(new ShortTag((short) 4), Tag.asTag((short) 4)); + + assertEquals(new IntTag(5), Tag.asTag(5)); + + assertEquals(new LongTag(6L), Tag.asTag(6L)); + + assertEquals(new FloatTag(7.8f), Tag.asTag(7.8f)); + + assertEquals(new DoubleTag(8.9), Tag.asTag(8.9)); + + + byte[] ab = new byte[]{(byte) 3, (byte) 7}; + assertEquals(new ByteArrayTag(ab), Tag.asTag(ab)); + + int[] ai = new int[]{11, 13}; + assertEquals(new IntArrayTag(ai), Tag.asTag(ai)); + + long[] al = new long[]{17, 19}; + assertEquals(new LongArrayTag(al), Tag.asTag(al)); + + final float[] af = new float[]{21.5f, 23.5f}; + ListTag afTag = (ListTag) Tag.asTag(af); + assertEquals(af.length, afTag.size()); + assertEquals(af[0], afTag.get(0).getValue(), 0.001); + assertEquals(af[1], afTag.get(1).getValue(), 0.001); + + afTag = (ListTag) Tag.asTag(List.of(21.5f, 23.5f)); + assertEquals(af.length, afTag.size()); + assertEquals(af[0], afTag.get(0).getValue(), 0.001); + assertEquals(af[1], afTag.get(1).getValue(), 0.001); + + final double[] ad = new double[]{27.4, 29.8}; + ListTag dfTag = (ListTag) Tag.asTag(ad); + assertEquals(ad.length, dfTag.size()); + assertEquals(ad[0], dfTag.get(0).getValue(), 0.001); + assertEquals(ad[1], dfTag.get(1).getValue(), 0.001); + + dfTag = (ListTag) Tag.asTag(List.of(27.4, 29.8)); + assertEquals(ad.length, dfTag.size()); + assertEquals(ad[0], dfTag.get(0).getValue(), 0.001); + assertEquals(ad[1], dfTag.get(1).getValue(), 0.001); + + final String[] as = new String[]{"bill", "bob", "jeb", "val"}; + ListTag asTag = (ListTag) Tag.asTag(as); + assertEquals(as.length, asTag.size()); + assertEquals(as[0], asTag.get(0).getValue()); + assertEquals(as[1], asTag.get(1).getValue()); + assertEquals(as[2], asTag.get(2).getValue()); + assertEquals(as[3], asTag.get(3).getValue()); + + asTag = (ListTag) Tag.asTag(List.of("bill", "bob", "jeb", "val")); + assertEquals(as.length, asTag.size()); + assertEquals(as[0], asTag.get(0).getValue()); + assertEquals(as[1], asTag.get(1).getValue()); + assertEquals(as[2], asTag.get(2).getValue()); + assertEquals(as[3], asTag.get(3).getValue()); + + final Map rawMap = Map.of( + "key_int", 42, + "key_float_array", new float[] {12.7f, 99.8f}, + "key_compound", Map.of("one", 1, "str", "thing") + ); + + assertEquals(""" + { + key_compound: { + one: 1, + str: thing + }, + key_float_array: [ + 12.7f, + 99.8f + ], + key_int: 42 + }""", TextNbtHelpers.toTextNbt(Tag.asTag(rawMap), true)); + + assertThrowsException(() -> Tag.asTag(new Object()), IllegalArgumentException.class); + } +} diff --git a/src/test/java/io/github/ensgijs/nbt/util/JsonPrettyPrinterTest.java b/src/test/java/io/github/ensgijs/nbt/util/JsonPrettyPrinterTest.java new file mode 100644 index 00000000..064fd633 --- /dev/null +++ b/src/test/java/io/github/ensgijs/nbt/util/JsonPrettyPrinterTest.java @@ -0,0 +1,105 @@ +package io.github.ensgijs.nbt.util; + +import junit.framework.TestCase; +import static io.github.ensgijs.nbt.util.JsonPrettyPrinter.prettyPrintJson; + +public class JsonPrettyPrinterTest extends TestCase { + public void testFormatObject_strict() { + String json = "{\"hello\":\"world\",\"stuff\":[42.5e+2,\"things\"]}"; + String expected = "{\n" + + " \"hello\": \"world\",\n" + + " \"stuff\": [\n" + + " 42.5e+2,\n" + + " \"things\"\n" + + " ]\n" + + "}"; + assertEquals(expected, prettyPrintJson(json)); + } + + public void testFormatObject_lose() { + String json = "{hello:\"world\",stuff:[-42.5e-2,\"thang'z\", 'mo\"oo']}"; + String expected = "{\n" + + " hello: \"world\",\n" + + " stuff: [\n" + + " -42.5e-2,\n" + + " \"thang'z\",\n" + + " 'mo\"oo'\n" + + " ]\n" + + "}"; + assertEquals(expected, prettyPrintJson(json)); + } + + public void testFormatArray() { + String json = "[0,\"one\",\"o\":{},\"a\":[]]"; + String expected = "[\n" + + " 0,\n" + + " \"one\",\n" + + " \"o\": {},\n" + + " \"a\": []\n" + + "]"; + assertEquals(expected, prettyPrintJson(json)); + } + + public void testFormatLongSNBTArray() { + String json = "[L;0, 42, 99]"; + String expected = "[L;\n" + + " 0,\n" + + " 42,\n" + + " 99\n" + + "]"; + assertEquals(expected, prettyPrintJson(json)); + } + public void testFormatEmptyLongSNBTArray() { + String json = "[L;]"; + String expected = "[L;]"; + assertEquals(expected, prettyPrintJson(json)); + } + + public void testFormatIntSNBTArray() { + String json = "[I;0, 42, 99]"; + String expected = "[I;\n" + + " 0,\n" + + " 42,\n" + + " 99\n" + + "]"; + assertEquals(expected, prettyPrintJson(json)); + } + public void testFormatEmptyIntSNBTArray() { + String json = "[I;]"; + String expected = "[I;]"; + assertEquals(expected, prettyPrintJson(json)); + } + + public void testFormatByteSNBTArray() { + String json = "[B;0, 42, 99]"; + String expected = "[B;\n" + + " 0,\n" + + " 42,\n" + + " 99\n" + + "]"; + assertEquals(expected, prettyPrintJson(json)); + } + public void testFormatEmptyByteSNBTArray() { + String json = "[B;]"; + String expected = "[B;]"; + assertEquals(expected, prettyPrintJson(json)); + } + + + public void testFormatJustAString() { + String json = "\"hello world\""; + assertEquals(json, prettyPrintJson(json)); + json = "'hello world'"; + assertEquals(json, prettyPrintJson(json)); + } + + public void testFormatStringContainingNewline() { + String json = "\"hello\nworld\""; + assertEquals("\"hello\\nworld\"", prettyPrintJson(json)); + } + + public void testFormatJustANumber() { + String json = "42"; + assertEquals(json, prettyPrintJson(json)); + } +} diff --git a/src/test/java/net/querz/mca/MCAFileTest.java b/src/test/java/net/querz/mca/MCAFileTest.java deleted file mode 100644 index dc9c130d..00000000 --- a/src/test/java/net/querz/mca/MCAFileTest.java +++ /dev/null @@ -1,454 +0,0 @@ -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.util.Arrays; - -public class MCAFileTest extends MCATestCase { - - public void testGetChunkIndex() { - assertEquals(0, MCAFile.getChunkIndex(0, 0)); - assertEquals(0, MCAFile.getChunkIndex(32, 32)); - assertEquals(0, MCAFile.getChunkIndex(-32, -32)); - assertEquals(0, MCAFile.getChunkIndex(0, 32)); - assertEquals(0, MCAFile.getChunkIndex(-32, 32)); - assertEquals(1023, MCAFile.getChunkIndex(31, 31)); - assertEquals(1023, MCAFile.getChunkIndex(-1, -1)); - assertEquals(1023, MCAFile.getChunkIndex(63, 63)); - assertEquals(632, MCAFile.getChunkIndex(24, -13)); - int i = 0; - for (int cz = 0; cz < 32; cz++) { - for (int cx = 0; cx < 32; cx++) { - assertEquals(i++, MCAFile.getChunkIndex(cx, cz)); - } - } - } - - public void testChangeData() { - MCAFile mcaFile = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); - assertNotNull(mcaFile); - mcaFile.setChunk(0, null); - File tmpFile = getNewTmpFile("r.2.2.mca"); - Integer x = assertThrowsNoException(() -> MCAUtil.write(mcaFile, tmpFile, true)); - assertNotNull(x); - assertEquals(2, x.intValue()); - MCAFile again = assertThrowsNoException(() -> MCAUtil.read(tmpFile)); - assertNotNull(again); - for (int i = 0; i < 1024; i++) { - if (i != 512 && i != 1023) { - assertNull(again.getChunk(i)); - } else { - assertNotNull(again.getChunk(i)); - } - } - } - - public void testChangeLastUpdate() { - MCAFile from = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); - assertNotNull(from); - File tmpFile = getNewTmpFile("r.2.2.mca"); - assertThrowsNoException(() -> MCAUtil.write(from, tmpFile, true)); - MCAFile to = assertThrowsNoException(() -> MCAUtil.read(tmpFile)); - assertNotNull(to); - assertFalse(from.getChunk(0).getLastMCAUpdate() == to.getChunk(0).getLastMCAUpdate()); - assertFalse(from.getChunk(512).getLastMCAUpdate() == to.getChunk(512).getLastMCAUpdate()); - assertFalse(from.getChunk(1023).getLastMCAUpdate() == to.getChunk(1023).getLastMCAUpdate()); - assertTrue(to.getChunk(0).getLastMCAUpdate() == to.getChunk(512).getLastMCAUpdate()); - assertTrue(to.getChunk(0).getLastMCAUpdate() == to.getChunk(1023).getLastMCAUpdate()); - } - - public void testGetters() { - MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); - assertNotNull(f); - - assertThrowsRuntimeException(() -> f.getChunk(-1), IndexOutOfBoundsException.class); - assertThrowsRuntimeException(() -> f.getChunk(1024), IndexOutOfBoundsException.class); - assertNotNull(assertThrowsNoRuntimeException(() -> f.getChunk(0))); - assertNotNull(assertThrowsNoRuntimeException(() -> f.getChunk(1023))); - assertNotNull(assertThrowsNoRuntimeException(() -> f.getChunk(96, 64))); - assertNotNull(assertThrowsNoRuntimeException(() -> f.getChunk(95, 95))); - assertNull(assertThrowsNoRuntimeException(() -> f.getChunk(63, 64))); - assertNull(assertThrowsNoRuntimeException(() -> f.getChunk(95, 64))); - assertNotNull(assertThrowsNoRuntimeException(() -> f.getChunk(64, 96))); - assertNull(assertThrowsNoRuntimeException(() -> f.getChunk(64, 63))); - assertNull(assertThrowsNoRuntimeException(() -> f.getChunk(64, 95))); - //not loaded - - MCAFile u = new MCAFile(2, 2); - assertThrowsRuntimeException(() -> u.getChunk(-1), IndexOutOfBoundsException.class); - assertThrowsRuntimeException(() -> u.getChunk(1024), IndexOutOfBoundsException.class); - assertNull(assertThrowsNoRuntimeException(() -> u.getChunk(0))); - assertNull(assertThrowsNoRuntimeException(() -> u.getChunk(1023))); - - assertEquals(1628, f.getChunk(0).getDataVersion()); - assertEquals(1538048269, f.getChunk(0).getLastMCAUpdate()); - assertEquals(1205486986, f.getChunk(0).getLastUpdate()); - assertNotNull(f.getChunk(0).getBiomes()); - assertNull(f.getChunk(0).getHeightMaps()); - assertNull(f.getChunk(0).getCarvingMasks()); - assertEquals(ListTag.createUnchecked(null), f.getChunk(0).getEntities()); - assertNull(f.getChunk(0).getTileEntities()); - assertNull(f.getChunk(0).getTileTicks()); - assertNull(f.getChunk(0).getLiquidTicks()); - assertNull(f.getChunk(0).getLights()); - assertNull(f.getChunk(0).getLiquidsToBeTicked()); - assertNull(f.getChunk(0).getToBeTicked()); - assertNull(f.getChunk(0).getPostProcessing()); - assertNotNull(f.getChunk(0).getStructures()); - - assertNotNull(f.getChunk(0).getSection(0).getSkyLight()); - assertEquals(2048, f.getChunk(0).getSection(0).getSkyLight().length); - assertNotNull(f.getChunk(0).getSection(0).getBlockLight()); - assertEquals(2048, f.getChunk(0).getSection(0).getBlockLight().length); - assertNotNull(f.getChunk(0).getSection(0).getBlockStates()); - assertEquals(256, f.getChunk(0).getSection(0).getBlockStates().length); - } - - private Chunk createChunkWithPos() { - CompoundTag data = new CompoundTag(); - CompoundTag level = new CompoundTag(); - data.put("Level", level); - return new Chunk(data); - } - - public void testSetters() { - MCAFile f = new MCAFile(2, 2); - - assertThrowsNoRuntimeException(() -> f.setChunk(0, createChunkWithPos())); - assertEquals(createChunkWithPos().updateHandle(64, 64), f.getChunk(0).updateHandle(64, 64)); - assertThrowsRuntimeException(() -> f.setChunk(1024, createChunkWithPos()), IndexOutOfBoundsException.class); - assertThrowsNoRuntimeException(() -> f.setChunk(1023, createChunkWithPos())); - assertThrowsNoRuntimeException(() -> f.setChunk(0, null)); - assertNull(f.getChunk(0)); - assertThrowsNoRuntimeException(() -> f.setChunk(1023, createChunkWithPos())); - assertThrowsNoRuntimeException(() -> f.setChunk(1023, createChunkWithPos())); - - f.getChunk(1023).setDataVersion(1627); - assertEquals(1627, f.getChunk(1023).getDataVersion()); - f.getChunk(1023).setLastMCAUpdate(12345678); - assertEquals(12345678, f.getChunk(1023).getLastMCAUpdate()); - f.getChunk(1023).setLastUpdate(87654321); - assertEquals(87654321, f.getChunk(1023).getLastUpdate()); - f.getChunk(1023).setInhabitedTime(13243546); - assertEquals(13243546, f.getChunk(1023).getInhabitedTime()); - assertThrowsRuntimeException(() -> f.getChunk(1023).setBiomes(new int[255]), IllegalArgumentException.class); - assertThrowsNoRuntimeException(() -> f.getChunk(1023).setBiomes(new int[256])); - assertTrue(Arrays.equals(new int[256], f.getChunk(1023).getBiomes())); - f.getChunk(1023).setHeightMaps(getSomeCompoundTag()); - assertEquals(getSomeCompoundTag(), f.getChunk(1023).getHeightMaps()); - f.getChunk(1023).setCarvingMasks(getSomeCompoundTag()); - assertEquals(getSomeCompoundTag(), f.getChunk(1023).getCarvingMasks()); - f.getChunk(1023).setEntities(getSomeCompoundTagList()); - assertEquals(getSomeCompoundTagList(), f.getChunk(1023).getEntities()); - f.getChunk(1023).setTileEntities(getSomeCompoundTagList()); - assertEquals(getSomeCompoundTagList(), f.getChunk(1023).getTileEntities()); - f.getChunk(1023).setTileTicks(getSomeCompoundTagList()); - assertEquals(getSomeCompoundTagList(), f.getChunk(1023).getTileTicks()); - f.getChunk(1023).setLiquidTicks(getSomeCompoundTagList()); - assertEquals(getSomeCompoundTagList(), f.getChunk(1023).getLiquidTicks()); - f.getChunk(1023).setLights(getSomeListTagList()); - assertEquals(getSomeListTagList(), f.getChunk(1023).getLights()); - f.getChunk(1023).setLiquidsToBeTicked(getSomeListTagList()); - assertEquals(getSomeListTagList(), f.getChunk(1023).getLiquidsToBeTicked()); - f.getChunk(1023).setToBeTicked(getSomeListTagList()); - assertEquals(getSomeListTagList(), f.getChunk(1023).getToBeTicked()); - f.getChunk(1023).setPostProcessing(getSomeListTagList()); - assertEquals(getSomeListTagList(), f.getChunk(1023).getPostProcessing()); - f.getChunk(1023).setStructures(getSomeCompoundTag()); - assertEquals(getSomeCompoundTag(), f.getChunk(1023).getStructures()); - Section s = new Section(); - f.getChunk(1023).setSection(0, s); - assertEquals(s, f.getChunk(1023).getSection(0)); - assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(null), NullPointerException.class); - assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(new long[321]), IllegalArgumentException.class); - assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(new long[255]), IllegalArgumentException.class); - assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(new long[4097]), IllegalArgumentException.class); - assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(new long[320])); - assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(new long[4096])); - assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockStates(new long[256])); - assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockLight(new byte[2047]), IllegalArgumentException.class); - assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockLight(new byte[2049]), IllegalArgumentException.class); - assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockLight(new byte[2048])); - assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setBlockLight(null)); - assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setSkyLight(new byte[2047]), IllegalArgumentException.class); - assertThrowsRuntimeException(() -> f.getChunk(1023).getSection(0).setSkyLight(new byte[2049]), IllegalArgumentException.class); - assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setSkyLight(new byte[2048])); - assertThrowsNoRuntimeException(() -> f.getChunk(1023).getSection(0).setSkyLight(null)); - } - - public void testGetBiomeAt() { - 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); - 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(1024, biomes.length); - for (int i = 0; i < 1024; i++) { - assertTrue(i % 16 == 0 ? biomes[i] == 20 : biomes[i] == -1); - } - } - - public void testCleanupPaletteAndBlockStates() { - MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); - assertThrowsNoRuntimeException(f::cleanupPalettesAndBlockStates); - Chunk c = f.getChunk(0, 0); - Section s = c.getSection(0); - assertEquals(10, s.getPalette().size()); - for (int i = 11; i <= 15; i++) { - s.addToPalette(block("minecraft:" + i)); - } - assertEquals(15, s.getPalette().size()); - f.cleanupPalettesAndBlockStates(); - assertEquals(10, s.getPalette().size()); - assertEquals(256, s.updateHandle(0).getLongArray("BlockStates").length); - int y = 0; - for (int i = 11; i <= 17; i++) { - f.setBlockStateAt(1, y++, 1, block("minecraft:" + i), false); - } - assertEquals(17, s.getPalette().size()); - assertEquals(320, s.updateHandle(0).getLongArray("BlockStates").length); - f.cleanupPalettesAndBlockStates(); - assertEquals(17, s.getPalette().size()); - assertEquals(320, s.updateHandle(0).getLongArray("BlockStates").length); - f.setBlockStateAt(1, 0, 1, block("minecraft:bedrock"), false); - assertEquals(17, s.getPalette().size()); - assertEquals(320, s.updateHandle(0).getLongArray("BlockStates").length); - f.cleanupPalettesAndBlockStates(); - assertEquals(16, s.getPalette().size()); - assertEquals(256, s.updateHandle(0).getLongArray("BlockStates").length); - } - - public void testSetBlockDataAt() { - MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); - Section section = f.getChunk(0, 0).getSection(0); - assertEquals(10, section.getPalette().size()); - assertEquals(0b0001000100010001000100010001000100010001000100010001000100010001L, section.getBlockStates()[0]); - f.setBlockStateAt(0, 0, 0, block("minecraft:custom"), false); - assertEquals(11, section.getPalette().size()); - assertEquals(0b0001000100010001000100010001000100010001000100010001000100011010L, section.getBlockStates()[0]); - - //test "line break" - int y = 1; - for (int i = 12; i <= 17; i++) { - f.setBlockStateAt(0, y++, 0, block("minecraft:" + i), false); - } - assertEquals(17, section.getPalette().size()); - assertEquals(320, section.getBlockStates().length); - assertEquals(0b0001000010000100001000010000100001000010000100001000010000101010L, section.getBlockStates()[0]); - assertEquals(0b0010000100001000010000100001000010000100001000010000100001000010L, section.getBlockStates()[1]); - f.setBlockStateAt(12, 0, 0, block("minecraft:18"), false); - assertEquals(0b0001000010000100001000010000100001000010000100001000010000101010L, section.getBlockStates()[0]); - assertEquals(0b0010000100001000010000100001000010000100001000010000100001000011L, section.getBlockStates()[1]); - - //test chunkdata == null - assertNull(f.getChunk(1, 0)); - f.setBlockStateAt(17, 0, 0, block("minecraft:test"), false); - assertNotNull(f.getChunk(1, 0)); - 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()); - assertEquals(256, s.get(0).getLongArray("BlockStates").length); - assertEquals(0b0000000000000000000000000000000000000000000000000000000000010000L, s.get(0).getLongArray("BlockStates")[0]); - - //test section == null - assertNull(f.getChunk(66, 64)); - Chunk c = Chunk.newChunk(); - f.setChunk(66, 64, c); - assertNotNull(f.getChunk(66, 64)); - ListTag ss = f.getChunk(66, 64).updateHandle(66, 64).getCompoundTag("Level").getListTag("Sections").asCompoundTagList(); - assertEquals(0, ss.size()); - f.setBlockStateAt(33, 0, 0, block("minecraft:air"), false); - ss = f.getChunk(66, 64).updateHandle(66, 64).getCompoundTag("Level").getListTag("Sections").asCompoundTagList(); - assertEquals(1, ss.size()); - f.setBlockStateAt(33, 0, 0, block("minecraft:foo"), false); - ss = f.getChunk(66, 64).updateHandle(66, 64).getCompoundTag("Level").getListTag("Sections").asCompoundTagList(); - assertEquals(1, ss.size()); - assertEquals(2, ss.get(0).getListTag("Palette").size()); - assertEquals(256, s.get(0).getLongArray("BlockStates").length); - assertEquals(0b0000000000000000000000000000000000000000000000000000000000010000L, ss.get(0).getLongArray("BlockStates")[0]); - - //test force cleanup - ListTag sss = f.getChunk(31, 31).updateHandle(65, 65).getCompoundTag("Level").getListTag("Sections").asCompoundTagList(); - assertEquals(12, sss.get(0).getListTag("Palette").size()); - y = 0; - for (int i = 13; i <= 17; i++) { - f.setBlockStateAt(1008, y++, 1008, block("minecraft:" + i), false); - } - f.getChunk(31, 31).getSection(0).cleanupPaletteAndBlockStates(); - sss = f.getChunk(31, 31).updateHandle(65, 65).getCompoundTag("Level").getListTag("Sections").asCompoundTagList(); - assertEquals(17, sss.get(0).getListTag("Palette").size()); - assertEquals(320, sss.get(0).getLongArray("BlockStates").length); - f.setBlockStateAt(1008, 4, 1008, block("minecraft:16"), true); - sss = f.getChunk(31, 31).updateHandle(65, 65).getCompoundTag("Level").getListTag("Sections").asCompoundTagList(); - assertEquals(16, sss.get(0).getListTag("Palette").size()); - assertEquals(256, sss.get(0).getLongArray("BlockStates").length); - } - - public void testSetBlockDataAt2527() { - //test "line break" for DataVersion 2527 - MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); - Chunk p = f.getChunk(0, 0); - p.setDataVersion(3000); - Section section = f.getChunk(0, 0).getSection(0); - assertEquals(10, section.getPalette().size()); - assertEquals(0b0001000100010001000100010001000100010001000100010001000100010001L, section.getBlockStates()[0]); - f.setBlockStateAt(0, 0, 0, block("minecraft:custom"), false); - assertEquals(11, section.getPalette().size()); - assertEquals(0b0001000100010001000100010001000100010001000100010001000100011010L, section.getBlockStates()[0]); - int y = 1; - for (int i = 12; i <= 17; i++) { - f.setBlockStateAt(0, y++, 0, block("minecraft:" + i), false); - } - assertEquals(17, section.getPalette().size()); - assertEquals(342, section.getBlockStates().length); - } - - public void testGetBlockDataAt() { - MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); - assertEquals(block("minecraft:bedrock"), f.getBlockStateAt(0, 0, 0)); - assertNull(f.getBlockStateAt(16, 0, 0)); - assertEquals(block("minecraft:dirt"), f.getBlockStateAt(0, 62, 0)); - assertEquals(block("minecraft:dirt"), f.getBlockStateAt(15, 67, 15)); - assertNull(f.getBlockStateAt(3, 100, 3)); - } - - public void testGetChunkStatus() { - MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); - assertEquals("mobs_spawned", f.getChunk(0, 0).getStatus()); - } - - public void testSetChunkStatus() { - MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); - assertThrowsNoRuntimeException(() -> f.getChunk(0, 0).setStatus("base")); - assertEquals("base", f.getChunk(0, 0).updateHandle(64, 64).getCompoundTag("Level").getString("Status")); - assertNull(f.getChunk(1, 0)); - } - - public void testChunkInitReferences() { - CompoundTag t = new CompoundTag(); - assertThrowsRuntimeException(() -> new Chunk(null), NullPointerException.class); - assertThrowsRuntimeException(() -> new Chunk(t), IllegalArgumentException.class); - } - - public void testChunkInvalidCompressionType() { - assertThrowsException(() -> { - try (RandomAccessFile raf = new RandomAccessFile(getResourceFile("invalid_compression.dat"), "r")) { - Chunk c = new Chunk(0); - c.deserialize(raf); - } - }, IOException.class); - } - - public void testChunkInvalidDataTag() { - assertThrowsException(() -> { - try (RandomAccessFile raf = new RandomAccessFile(getResourceFile("invalid_data_tag.dat"), "r")) { - Chunk c = new Chunk(0); - c.deserialize(raf); - } - }, IOException.class); - } - - private void assertLoadFLag(Object field, long flags, long wantedFlag) { - if((flags & wantedFlag) != 0) { - assertNotNull(String.format("Should not be null. Flags=%08x, Wanted flag=%08x", flags, wantedFlag), field); - } else { - assertNull(String.format("Should be null. Flags=%08x, Wanted flag=%08x", flags, wantedFlag), field); - } - } - - private void assertPartialChunk(Chunk c, long loadFlags) { - assertLoadFLag(c.getBiomes(), loadFlags, BIOMES); - assertLoadFLag(c.getHeightMaps(), loadFlags, HEIGHTMAPS); - assertLoadFLag(c.getEntities(), loadFlags, ENTITIES); - assertLoadFLag(c.getCarvingMasks(), loadFlags, CARVING_MASKS); - assertLoadFLag(c.getLights(), loadFlags, LIGHTS); - assertLoadFLag(c.getPostProcessing(), loadFlags, POST_PROCESSING); - assertLoadFLag(c.getLiquidTicks(), loadFlags, LIQUID_TICKS); - assertLoadFLag(c.getLiquidsToBeTicked(), loadFlags, LIQUIDS_TO_BE_TICKED); - assertLoadFLag(c.getTileTicks(), loadFlags, TILE_TICKS); - assertLoadFLag(c.getTileEntities(), loadFlags, TILE_ENTITIES); - assertLoadFLag(c.getToBeTicked(), loadFlags, TO_BE_TICKED); - assertLoadFLag(c.getSection(0), loadFlags, BLOCK_LIGHTS|BLOCK_STATES|SKY_LIGHT); - if ((loadFlags & (BLOCK_LIGHTS|BLOCK_STATES|SKY_LIGHT)) != 0) { - Section s = c.getSection(0); - assertNotNull(String.format("Section is null. Flags=%08x", loadFlags), s); - assertLoadFLag(s.getBlockStates(), loadFlags, BLOCK_STATES); - assertLoadFLag(s.getBlockLight(), loadFlags, BLOCK_LIGHTS); - assertLoadFLag(s.getSkyLight(), loadFlags, SKY_LIGHT); - } - } - - public void testPartialLoad() { - long[] flags = new long[] { - BIOMES, - HEIGHTMAPS, - ENTITIES, - CARVING_MASKS, - LIGHTS, - POST_PROCESSING, - LIQUID_TICKS, - LIQUIDS_TO_BE_TICKED, - TILE_TICKS, - TILE_ENTITIES, - TO_BE_TICKED, - BLOCK_STATES, - BLOCK_LIGHTS, - SKY_LIGHT - }; - - MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.2.2.mca"))); - Chunk c = f.getChunk(0); - c.setCarvingMasks(getSomeCompoundTag()); - c.setEntities(getSomeCompoundTagList()); - c.setLights(getSomeListTagList()); - c.setTileEntities(getSomeCompoundTagList()); - c.setTileTicks(getSomeCompoundTagList()); - c.setLiquidTicks(getSomeCompoundTagList()); - c.setToBeTicked(getSomeListTagList()); - c.setLiquidsToBeTicked(getSomeListTagList()); - c.setHeightMaps(getSomeCompoundTag()); - c.setPostProcessing(getSomeListTagList()); - c.getSection(0).setBlockLight(new byte[2048]); - File tmp = this.getNewTmpFile("r.2.2.mca"); - assertThrowsNoException(() -> MCAUtil.write(f, tmp)); - - for (long flag : flags) { - MCAFile mcaFile = assertThrowsNoException(() -> MCAUtil.read(tmp, flag)); - c = mcaFile.getChunk(0, 0); - assertPartialChunk(c, flag); - assertThrowsException(() -> MCAUtil.write(mcaFile, getNewTmpFile("r.12.34.mca")), UnsupportedOperationException.class); - } - } - - public void test1_15GetBiomeAt() throws IOException { - MCAFile f = assertThrowsNoException(() -> MCAUtil.read(copyResourceToTmp("r.0.0.mca"))); - assertEquals(162, f.getBiomeAt(31, 0, 63)); - assertEquals(4, f.getBiomeAt(16, 0, 48)); - assertEquals(4, f.getBiomeAt(16, 0, 63)); - assertEquals(162, f.getBiomeAt(31, 0, 48)); - assertEquals(162, f.getBiomeAt(31, 100, 63)); - assertEquals(4, f.getBiomeAt(16, 100, 48)); - assertEquals(4, f.getBiomeAt(16, 100, 63)); - assertEquals(162, f.getBiomeAt(31, 100, 48)); - assertEquals(162, f.getBiomeAt(31, 106, 63)); - assertEquals(4, f.getBiomeAt(16, 106, 48)); - assertEquals(4, f.getBiomeAt(16, 106, 63)); - assertEquals(162, f.getBiomeAt(31, 106, 48)); - } -} diff --git a/src/test/java/net/querz/mca/MCAUtilTest.java b/src/test/java/net/querz/mca/MCAUtilTest.java deleted file mode 100644 index a63dde4e..00000000 --- a/src/test/java/net/querz/mca/MCAUtilTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package net.querz.mca; - -import java.io.File; - -public class MCAUtilTest extends MCATestCase { - - public void testLocationConversion() { - assertEquals(0, MCAUtil.blockToChunk(0)); - assertEquals(0, MCAUtil.blockToChunk(15)); - assertEquals(1, MCAUtil.blockToChunk(16)); - assertEquals(-1, MCAUtil.blockToChunk(-1)); - assertEquals(-1, MCAUtil.blockToChunk(-16)); - assertEquals(-2, MCAUtil.blockToChunk(-17)); - - assertEquals(0, MCAUtil.blockToRegion(0)); - assertEquals(0, MCAUtil.blockToRegion(511)); - assertEquals(1, MCAUtil.blockToRegion(512)); - assertEquals(-1, MCAUtil.blockToRegion(-1)); - assertEquals(-1, MCAUtil.blockToRegion(-512)); - assertEquals(-2, MCAUtil.blockToRegion(-513)); - - assertEquals(0, MCAUtil.chunkToRegion(0)); - assertEquals(0, MCAUtil.chunkToRegion(31)); - assertEquals(1, MCAUtil.chunkToRegion(32)); - assertEquals(-1, MCAUtil.chunkToRegion(-1)); - assertEquals(-1, MCAUtil.chunkToRegion(-32)); - assertEquals(-2, MCAUtil.chunkToRegion(-33)); - - assertEquals(0, MCAUtil.regionToChunk(0)); - assertEquals(32, MCAUtil.regionToChunk(1)); - assertEquals(-32, MCAUtil.regionToChunk(-1)); - assertEquals(-64, MCAUtil.regionToChunk(-2)); - - assertEquals(0, MCAUtil.regionToBlock(0)); - assertEquals(512, MCAUtil.regionToBlock(1)); - assertEquals(-512, MCAUtil.regionToBlock(-1)); - assertEquals(-1024, MCAUtil.regionToBlock(-2)); - - assertEquals(0, MCAUtil.chunkToBlock(0)); - assertEquals(16, MCAUtil.chunkToBlock(1)); - assertEquals(-16, MCAUtil.chunkToBlock(-1)); - assertEquals(-32, MCAUtil.chunkToBlock(-2)); - } - - public void testCreateNameFromLocation() { - assertEquals("r.0.0.mca", MCAUtil.createNameFromBlockLocation(0, 0)); - assertEquals("r.0.0.mca", MCAUtil.createNameFromBlockLocation(511, 511)); - assertEquals("r.1.0.mca", MCAUtil.createNameFromBlockLocation(512, 511)); - assertEquals("r.0.-1.mca", MCAUtil.createNameFromBlockLocation(511, -1)); - assertEquals("r.0.-1.mca", MCAUtil.createNameFromBlockLocation(511, -512)); - assertEquals("r.0.-2.mca", MCAUtil.createNameFromBlockLocation(511, -513)); - assertEquals("r.0.1.mca", MCAUtil.createNameFromBlockLocation(511, 512)); - assertEquals("r.-1.0.mca", MCAUtil.createNameFromBlockLocation(-1, 511)); - assertEquals("r.-1.0.mca", MCAUtil.createNameFromBlockLocation(-512, 511)); - assertEquals("r.-2.0.mca", MCAUtil.createNameFromBlockLocation(-513, 511)); - - assertEquals("r.0.0.mca", MCAUtil.createNameFromChunkLocation(0, 0)); - assertEquals("r.0.0.mca", MCAUtil.createNameFromChunkLocation(31, 31)); - assertEquals("r.1.0.mca", MCAUtil.createNameFromChunkLocation(32, 31)); - assertEquals("r.0.-1.mca", MCAUtil.createNameFromChunkLocation(31, -1)); - assertEquals("r.0.-1.mca", MCAUtil.createNameFromChunkLocation(31, -32)); - assertEquals("r.0.-2.mca", MCAUtil.createNameFromChunkLocation(31, -33)); - assertEquals("r.0.1.mca", MCAUtil.createNameFromChunkLocation(31, 32)); - assertEquals("r.-1.0.mca", MCAUtil.createNameFromChunkLocation(-1, 31)); - assertEquals("r.-1.0.mca", MCAUtil.createNameFromChunkLocation(-32, 31)); - assertEquals("r.-2.0.mca", MCAUtil.createNameFromChunkLocation(-33, 31)); - - assertEquals("r.0.0.mca", MCAUtil.createNameFromRegionLocation(0, 0)); - assertEquals("r.1.0.mca", MCAUtil.createNameFromRegionLocation(1, 0)); - assertEquals("r.0.-1.mca", MCAUtil.createNameFromRegionLocation(0, -1)); - assertEquals("r.0.-2.mca", MCAUtil.createNameFromRegionLocation(0, -2)); - assertEquals("r.0.1.mca", MCAUtil.createNameFromRegionLocation(0, 1)); - assertEquals("r.-1.0.mca", MCAUtil.createNameFromRegionLocation(-1, 0)); - assertEquals("r.-2.0.mca", MCAUtil.createNameFromRegionLocation(-2, 0)); - } - - public void testMakeMyCoverageGreatAgain() { - assertThrowsException(() -> MCAUtil.read((String) null), NullPointerException.class); - assertThrowsException(() -> MCAUtil.write(null, (String) null), NullPointerException.class); - assertThrowsException(() -> MCAUtil.write(null, (File) null), NullPointerException.class); - assertThrowsException(() -> MCAUtil.write(null, (String) null, false), NullPointerException.class); - assertThrowsException(() -> MCAUtil.read("r.a.b.mca"), IllegalArgumentException.class); - assertThrowsNoException(() -> new MCAFile(0, 0).serialize(null)); // empty MCAFile will not even attempt to write to file - - // 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); - } -} diff --git a/src/test/java/net/querz/nbt/io/NamedTagTest.java b/src/test/java/net/querz/nbt/io/NamedTagTest.java deleted file mode 100644 index 2dd2d11c..00000000 --- a/src/test/java/net/querz/nbt/io/NamedTagTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package net.querz.nbt.io; - -import net.querz.NBTTestCase; -import net.querz.nbt.tag.ByteTag; -import net.querz.nbt.tag.ShortTag; - -public class NamedTagTest extends NBTTestCase { - - public void testCreate() { - ByteTag t = new ByteTag(); - NamedTag n = new NamedTag("name", t); - assertEquals("name", n.getName()); - assertTrue(n.getTag() == t); - } - - public void testSet() { - ByteTag t = new ByteTag(); - NamedTag n = new NamedTag("name", t); - n.setName("blah"); - assertEquals("blah", n.getName()); - ShortTag s = new ShortTag(); - n.setTag(s); - assertTrue(n.getTag() == s); - } -} diff --git a/src/test/java/net/querz/nbt/io/SNBTParserTest.java b/src/test/java/net/querz/nbt/io/SNBTParserTest.java deleted file mode 100644 index 597d2026..00000000 --- a/src/test/java/net/querz/nbt/io/SNBTParserTest.java +++ /dev/null @@ -1,159 +0,0 @@ -package net.querz.nbt.io; - -import net.querz.NBTTestCase; -import net.querz.nbt.tag.*; -import java.util.Arrays; - -public class SNBTParserTest extends NBTTestCase { - - public void testParse() { - Tag t = assertThrowsNoException(() -> new SNBTParser("{abc: def, blah: 4b, blubb: \"string\", \"foo\": 2s}").parse()); - assertEquals(CompoundTag.class, t.getClass()); - CompoundTag c = (CompoundTag) t; - assertEquals(4, c.size()); - assertEquals("def", c.getString("abc")); - assertEquals((byte) 4, c.getByte("blah")); - assertEquals("string", c.getString("blubb")); - assertEquals((short) 2, c.getShort("foo")); - assertFalse(c.containsKey("invalid")); - - // ------------------------------------------------- number tags - - Tag tb = assertThrowsNoException(() -> new SNBTParser("16b").parse()); - assertEquals(ByteTag.class, tb.getClass()); - assertEquals((byte) 16, ((ByteTag) tb).asByte()); - - tb = assertThrowsNoException(() -> new SNBTParser("16B").parse()); - assertEquals(ByteTag.class, tb.getClass()); - assertEquals((byte) 16, ((ByteTag) tb).asByte()); - - assertThrowsException((() -> new SNBTParser("-129b").parse()), ParseException.class); - - Tag ts = assertThrowsNoException(() -> new SNBTParser("17s").parse()); - assertEquals(ShortTag.class, ts.getClass()); - assertEquals((short) 17, ((ShortTag) ts).asShort()); - - ts = assertThrowsNoException(() -> new SNBTParser("17S").parse()); - assertEquals(ShortTag.class, ts.getClass()); - assertEquals((short) 17, ((ShortTag) ts).asShort()); - - assertThrowsException((() -> new SNBTParser("-32769s").parse()), ParseException.class); - - Tag ti = assertThrowsNoException(() -> new SNBTParser("18").parse()); - assertEquals(IntTag.class, ti.getClass()); - assertEquals(18, ((IntTag) ti).asInt()); - - assertThrowsException((() -> new SNBTParser("-2147483649").parse()), ParseException.class); - - Tag tl = assertThrowsNoException(() -> new SNBTParser("19l").parse()); - assertEquals(LongTag.class, tl.getClass()); - assertEquals(19L, ((LongTag) tl).asLong()); - - tl = assertThrowsNoException(() -> new SNBTParser("19L").parse()); - assertEquals(LongTag.class, tl.getClass()); - assertEquals(19L, ((LongTag) tl).asLong()); - - assertThrowsException((() -> new SNBTParser("-9223372036854775809l").parse()), ParseException.class); - - Tag tf = assertThrowsNoException(() -> new SNBTParser("20.3f").parse()); - assertEquals(FloatTag.class, tf.getClass()); - assertEquals(20.3f, ((FloatTag) tf).asFloat()); - - tf = assertThrowsNoException(() -> new SNBTParser("20.3F").parse()); - assertEquals(FloatTag.class, tf.getClass()); - assertEquals(20.3f, ((FloatTag) tf).asFloat()); - - Tag td = assertThrowsNoException(() -> new SNBTParser("21.3d").parse()); - assertEquals(DoubleTag.class, td.getClass()); - assertEquals(21.3d, ((DoubleTag) td).asDouble()); - - td = assertThrowsNoException(() -> new SNBTParser("21.3D").parse()); - assertEquals(DoubleTag.class, td.getClass()); - assertEquals(21.3d, ((DoubleTag) td).asDouble()); - - td = assertThrowsNoException(() -> new SNBTParser("21.3").parse()); - assertEquals(DoubleTag.class, td.getClass()); - assertEquals(21.3d, ((DoubleTag) td).asDouble()); - - Tag tbo = assertThrowsNoException(() -> new SNBTParser("true").parse()); - assertEquals(ByteTag.class, tbo.getClass()); - assertEquals((byte) 1, ((ByteTag) tbo).asByte()); - - tbo = assertThrowsNoException(() -> new SNBTParser("false").parse()); - assertEquals(ByteTag.class, tbo.getClass()); - assertEquals((byte) 0, ((ByteTag) tbo).asByte()); - - // ------------------------------------------------- arrays - - Tag ba = assertThrowsNoException(() -> new SNBTParser("[B; -128,0, 127]").parse()); - assertEquals(ByteArrayTag.class, ba.getClass()); - assertEquals(3, ((ByteArrayTag) ba).length()); - assertTrue(Arrays.equals(new byte[]{-128, 0, 127}, ((ByteArrayTag) ba).getValue())); - - Tag ia = assertThrowsNoException(() -> new SNBTParser("[I; -2147483648, 0,2147483647]").parse()); - assertEquals(IntArrayTag.class, ia.getClass()); - assertEquals(3, ((IntArrayTag) ia).length()); - assertTrue(Arrays.equals(new int[]{-2147483648, 0, 2147483647}, ((IntArrayTag) ia).getValue())); - - Tag la = assertThrowsNoException(() -> new SNBTParser("[L; -9223372036854775808, 0, 9223372036854775807 ]").parse()); - assertEquals(LongArrayTag.class, la.getClass()); - assertEquals(3, ((LongArrayTag) la).length()); - assertTrue(Arrays.equals(new long[]{-9223372036854775808L, 0, 9223372036854775807L}, ((LongArrayTag) la).getValue())); - - // ------------------------------------------------- invalid arrays - - assertThrowsException((() -> new SNBTParser("[B; -129]").parse()), ParseException.class); - assertThrowsException((() -> new SNBTParser("[I; -2147483649]").parse()), ParseException.class); - assertThrowsException((() -> new SNBTParser("[L; -9223372036854775809]").parse()), ParseException.class); - assertThrowsException((() -> new SNBTParser("[B; 123b]").parse()), ParseException.class); - assertThrowsException((() -> new SNBTParser("[I; 123i]").parse()), ParseException.class); - assertThrowsException((() -> new SNBTParser("[L; 123l]").parse()), ParseException.class); - assertThrowsException((() -> new SNBTParser("[K; -129]").parse()), ParseException.class); - - // ------------------------------------------------- high level errors - - assertThrowsException(() -> new SNBTParser("{20:10} {blah:blubb}").parse(), ParseException.class); - - // ------------------------------------------------- string tag - - Tag st = assertThrowsNoException(() -> new SNBTParser("abc").parse()); - assertEquals(StringTag.class, st.getClass()); - assertEquals("abc", ((StringTag) st).getValue()); - - st = assertThrowsNoException(() -> new SNBTParser("\"abc\"").parse()); - assertEquals(StringTag.class, st.getClass()); - assertEquals("abc", ((StringTag) st).getValue()); - - st = assertThrowsNoException(() -> new SNBTParser("123a").parse()); - assertEquals(StringTag.class, st.getClass()); - assertEquals("123a", ((StringTag) st).getValue()); - - // ------------------------------------------------- list tag - - Tag lt = assertThrowsNoException(() -> new SNBTParser("[abc, \"def\", \"123\" ]").parse()); - assertEquals(ListTag.class, lt.getClass()); - assertEquals(StringTag.class, ((ListTag) lt).getTypeClass()); - assertEquals(3, ((ListTag) lt).size()); - assertEquals("abc", ((ListTag) lt).asStringTagList().get(0).getValue()); - assertEquals("def", ((ListTag) lt).asStringTagList().get(1).getValue()); - assertEquals("123", ((ListTag) lt).asStringTagList().get(2).getValue()); - - assertThrowsException(() -> new SNBTParser("[123, 456").parse(), ParseException.class); - assertThrowsException(() -> new SNBTParser("[123, 456d]").parse(), ParseException.class); - - // ------------------------------------------------- compound tag - - Tag ct = assertThrowsNoException(() -> new SNBTParser("{abc: def,\"key\": 123d, blah: [L;123, 456], blubb: [123, 456]}").parse()); - assertEquals(CompoundTag.class, ct.getClass()); - assertEquals(4, ((CompoundTag) ct).size()); - assertEquals("def", assertThrowsNoException(() -> ((CompoundTag) ct).getString("abc"))); - assertEquals(123D, assertThrowsNoException(() -> ((CompoundTag) ct).getDouble("key"))); - assertTrue(Arrays.equals(new long[]{123, 456}, assertThrowsNoException(() -> ((CompoundTag) ct).getLongArray("blah")))); - assertEquals(2, assertThrowsNoException(() -> ((CompoundTag) ct).getListTag("blubb")).size()); - assertEquals(IntTag.class, ((CompoundTag) ct).getListTag("blubb").getTypeClass()); - - assertThrowsException(() -> new SNBTParser("{abc: def").parse(), ParseException.class); - assertThrowsException(() -> new SNBTParser("{\"\":empty}").parse(), ParseException.class); - assertThrowsException(() -> new SNBTParser("{empty:}").parse(), ParseException.class); - } -} diff --git a/src/test/java/net/querz/nbt/io/SNBTWriterTest.java b/src/test/java/net/querz/nbt/io/SNBTWriterTest.java deleted file mode 100644 index 2553802e..00000000 --- a/src/test/java/net/querz/nbt/io/SNBTWriterTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package net.querz.nbt.io; - -import net.querz.NBTTestCase; -import net.querz.nbt.tag.*; - -import java.util.LinkedHashMap; - -public class SNBTWriterTest extends NBTTestCase { - - public void testWrite() { - - // write number tags - - assertEquals("127b", assertThrowsNoException(() -> SNBTUtil.toSNBT(new ByteTag(Byte.MAX_VALUE)))); - assertEquals("-32768s", assertThrowsNoException(() -> SNBTUtil.toSNBT(new ShortTag(Short.MIN_VALUE)))); - assertEquals("-2147483648", assertThrowsNoException(() -> SNBTUtil.toSNBT(new IntTag(Integer.MIN_VALUE)))); - assertEquals("-9223372036854775808l", assertThrowsNoException(() -> SNBTUtil.toSNBT(new LongTag(Long.MIN_VALUE)))); - assertEquals("123.456f", assertThrowsNoException(() -> SNBTUtil.toSNBT(new FloatTag(123.456F)))); - assertEquals("123.456d", assertThrowsNoException(() -> SNBTUtil.toSNBT(new DoubleTag(123.456D)))); - - // write array tags - - assertEquals("[B;-128,0,127]", assertThrowsNoException(() -> SNBTUtil.toSNBT(new ByteArrayTag(new byte[]{Byte.MIN_VALUE, 0, Byte.MAX_VALUE})))); - assertEquals("[I;-2147483648,0,2147483647]", assertThrowsNoException(() -> SNBTUtil.toSNBT(new IntArrayTag(new int[]{Integer.MIN_VALUE, 0, Integer.MAX_VALUE})))); - assertEquals("[L;-9223372036854775808,0,9223372036854775807]", assertThrowsNoException(() -> SNBTUtil.toSNBT(new LongArrayTag(new long[]{Long.MIN_VALUE, 0, Long.MAX_VALUE})))); - - // write string tag - - assertEquals("abc", assertThrowsNoException(() -> SNBTUtil.toSNBT(new StringTag("abc")))); - assertEquals("\"123\"", assertThrowsNoException(() -> SNBTUtil.toSNBT(new StringTag("123")))); - assertEquals("\"123.456\"", assertThrowsNoException(() -> SNBTUtil.toSNBT(new StringTag("123.456")))); - assertEquals("\"-123\"", assertThrowsNoException(() -> SNBTUtil.toSNBT(new StringTag("-123")))); - assertEquals("\"-1.23e14\"", assertThrowsNoException(() -> SNBTUtil.toSNBT(new StringTag("-1.23e14")))); - assertEquals("\"äöü\\\\\"", assertThrowsNoException(() -> SNBTUtil.toSNBT(new StringTag("äöü\\")))); - - // write list tag - - ListTag lt = new ListTag<>(StringTag.class); - lt.addString("blah"); - lt.addString("blubb"); - lt.addString("123"); - assertEquals("[blah,blubb,\"123\"]", assertThrowsNoException(() -> SNBTUtil.toSNBT(lt))); - - // write compound tag - CompoundTag ct = new CompoundTag(); - invokeSetValue(ct, new LinkedHashMap<>()); - ct.putString("key", "value"); - ct.putByte("byte", Byte.MAX_VALUE); - ct.putByteArray("array", new byte[]{Byte.MIN_VALUE, 0, Byte.MAX_VALUE}); - ListTag clt = new ListTag<>(StringTag.class); - clt.addString("foo"); - clt.addString("bar"); - ct.put("list", clt); - String ctExpected = "{key:value,byte:127b,array:[B;-128,0,127],list:[foo,bar]}"; - assertEquals(ctExpected, assertThrowsNoException(() -> SNBTUtil.toSNBT(ct))); - } -} diff --git a/src/test/java/net/querz/nbt/tag/NoNullEntrySetTest.java b/src/test/java/net/querz/nbt/tag/NoNullEntrySetTest.java deleted file mode 100644 index 74d628b2..00000000 --- a/src/test/java/net/querz/nbt/tag/NoNullEntrySetTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package net.querz.nbt.tag; - -import net.querz.NBTTestCase; - -import java.util.Arrays; -import java.util.Map; -import java.util.TreeMap; - -public class NoNullEntrySetTest extends NBTTestCase { - - public void testForwards() { - Map m = new TreeMap<>(); - m.put("foo", "bar"); - m.put("blah", "blubb"); - NonNullEntrySet s = new NonNullEntrySet<>(m.entrySet()); - assertEquals(2, s.size()); - assertFalse(s.isEmpty()); - assertTrue(s.contains(new TreeMap.SimpleEntry<>("foo", "bar"))); - assertFalse(s.contains(new TreeMap.SimpleEntry<>("bar", "foo"))); - assertTrue(s.containsAll(Arrays.asList(new TreeMap.SimpleEntry<>("foo", "bar"), new TreeMap.SimpleEntry<>("blah", "blubb")))); - assertFalse(s.containsAll(Arrays.asList(new TreeMap.SimpleEntry<>("foo", "bar"), new TreeMap.SimpleEntry<>("bar", "foo")))); - assertEquals(2, s.toArray().length); - assertEquals(2, s.toArray(new Object[0]).length); - assertThrowsRuntimeException(() -> s.add(new TreeMap.SimpleEntry<>("faz", "baz")), UnsupportedOperationException.class); - assertThrowsRuntimeException(() -> s.addAll(Arrays.asList(new TreeMap.SimpleEntry<>("fuz", "buz"), new TreeMap.SimpleEntry<>("faz", "baz"))), UnsupportedOperationException.class); - assertTrue(assertThrowsNoRuntimeException(() -> s.remove(new TreeMap.SimpleEntry<>("foo", "bar")))); - assertFalse(assertThrowsNoRuntimeException(() -> s.remove(new TreeMap.SimpleEntry<>("fuz", "baz")))); - assertEquals(1, m.size()); - m.put("foo", "bar"); - assertEquals(2, s.size()); - assertTrue(assertThrowsNoRuntimeException(() -> s.removeAll(Arrays.asList(new TreeMap.SimpleEntry<>("foo", "bar"), new TreeMap.SimpleEntry<>("faz", "baz"))))); - assertEquals(1, m.size()); - m.put("foo", "bar"); - assertTrue(assertThrowsNoRuntimeException(() -> s.retainAll(Arrays.asList(new TreeMap.SimpleEntry<>("foo", "bar"), new TreeMap.SimpleEntry<>("bar", "foo"))))); - m.put("blah", "blubb"); - for (Map.Entry e : s) { - assertNotNull(e.getKey()); - assertNotNull(e.getValue()); - assertThrowsRuntimeException(() -> e.setValue(null), NullPointerException.class); - assertThrowsNoRuntimeException(() -> e.setValue("kaz")); - switch (e.getKey()) { - case "foo": - assertEquals(4386, e.hashCode()); - assertTrue(e.equals(new TreeMap.SimpleEntry<>("foo", "kaz"))); - break; - case "blah": - assertEquals(3125269, e.hashCode()); - assertTrue(e.equals(new TreeMap.SimpleEntry<>("blah", "kaz"))); - break; - default: - fail("unknown element in NoNullEntrySet"); - } - } - assertThrowsNoRuntimeException(s::clear); - assertEquals(0, m.size()); - } -} diff --git a/src/test/resources/1_12_2/region/r.0.0.mca b/src/test/resources/1_12_2/region/r.0.0.mca new file mode 100644 index 00000000..50c42697 Binary files /dev/null and b/src/test/resources/1_12_2/region/r.0.0.mca differ diff --git a/src/test/resources/1_13_0/region/r.0.0.mca b/src/test/resources/1_13_0/region/r.0.0.mca new file mode 100644 index 00000000..7a19f81f Binary files /dev/null and b/src/test/resources/1_13_0/region/r.0.0.mca differ diff --git a/src/test/resources/r.2.2.mca b/src/test/resources/1_13_1/region/r.2.2.mca similarity index 100% rename from src/test/resources/r.2.2.mca rename to src/test/resources/1_13_1/region/r.2.2.mca diff --git a/src/test/resources/1_13_2/region/r.-2.-2.mca b/src/test/resources/1_13_2/region/r.-2.-2.mca new file mode 100644 index 00000000..9695dfc1 Binary files /dev/null and b/src/test/resources/1_13_2/region/r.-2.-2.mca differ diff --git a/src/test/resources/1_14_4/poi/r.-1.0.mca b/src/test/resources/1_14_4/poi/r.-1.0.mca new file mode 100644 index 00000000..c979f83f Binary files /dev/null and b/src/test/resources/1_14_4/poi/r.-1.0.mca differ diff --git a/src/test/resources/1_14_4/region/r.-1.0.mca b/src/test/resources/1_14_4/region/r.-1.0.mca new file mode 100644 index 00000000..171d957b Binary files /dev/null and b/src/test/resources/1_14_4/region/r.-1.0.mca differ diff --git a/src/test/resources/1_15_2/poi/r.-1.0.mca b/src/test/resources/1_15_2/poi/r.-1.0.mca new file mode 100644 index 00000000..1ea4d282 Binary files /dev/null and b/src/test/resources/1_15_2/poi/r.-1.0.mca differ diff --git a/src/test/resources/1_15_2/region/r.-1.0.mca b/src/test/resources/1_15_2/region/r.-1.0.mca new file mode 100644 index 00000000..c01d14c2 Binary files /dev/null and b/src/test/resources/1_15_2/region/r.-1.0.mca differ diff --git a/src/test/resources/r.0.0.mca b/src/test/resources/1_15_2/region/r.0.0.mca similarity index 100% rename from src/test/resources/r.0.0.mca rename to src/test/resources/1_15_2/region/r.0.0.mca diff --git a/src/test/resources/1_16_5/poi/r.0.-1.mca b/src/test/resources/1_16_5/poi/r.0.-1.mca new file mode 100644 index 00000000..f8f46e48 Binary files /dev/null and b/src/test/resources/1_16_5/poi/r.0.-1.mca differ diff --git a/src/test/resources/1_16_5/region/r.0.-1.mca b/src/test/resources/1_16_5/region/r.0.-1.mca new file mode 100644 index 00000000..f33d8540 Binary files /dev/null and b/src/test/resources/1_16_5/region/r.0.-1.mca differ diff --git a/src/test/resources/1_17_1/entities/r.-3.-2.mca b/src/test/resources/1_17_1/entities/r.-3.-2.mca new file mode 100644 index 00000000..f0d92928 Binary files /dev/null and b/src/test/resources/1_17_1/entities/r.-3.-2.mca differ diff --git a/src/test/resources/1_17_1/poi/r.-3.-2.mca b/src/test/resources/1_17_1/poi/r.-3.-2.mca new file mode 100644 index 00000000..d986b6e5 Binary files /dev/null and b/src/test/resources/1_17_1/poi/r.-3.-2.mca differ 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..c80ecac2 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_1/entities/r.0.-2.mca b/src/test/resources/1_18_1/entities/r.0.-2.mca new file mode 100644 index 00000000..fd86a619 Binary files /dev/null and b/src/test/resources/1_18_1/entities/r.0.-2.mca differ diff --git a/src/test/resources/1_18_1/entities/r.8.1.mca b/src/test/resources/1_18_1/entities/r.8.1.mca new file mode 100644 index 00000000..0fa7f79b Binary files /dev/null and b/src/test/resources/1_18_1/entities/r.8.1.mca differ diff --git a/src/test/resources/1_18_1/poi/r.0.-2.mca b/src/test/resources/1_18_1/poi/r.0.-2.mca new file mode 100644 index 00000000..3a0b2bbc Binary files /dev/null and b/src/test/resources/1_18_1/poi/r.0.-2.mca differ diff --git a/src/test/resources/1_18_1/region/r.0.-2.mca b/src/test/resources/1_18_1/region/r.0.-2.mca new file mode 100644 index 00000000..e11bfceb Binary files /dev/null and b/src/test/resources/1_18_1/region/r.0.-2.mca differ diff --git a/src/test/resources/1_18_1/region/r.8.1.mca b/src/test/resources/1_18_1/region/r.8.1.mca new file mode 100644 index 00000000..dabf0d98 Binary files /dev/null and b/src/test/resources/1_18_1/region/r.8.1.mca differ diff --git a/src/test/resources/1_18_PRE1/entities/r.-2.-3.mca b/src/test/resources/1_18_PRE1/entities/r.-2.-3.mca new file mode 100644 index 00000000..18814167 Binary files /dev/null and b/src/test/resources/1_18_PRE1/entities/r.-2.-3.mca differ diff --git a/src/test/resources/1_18_PRE1/poi/r.-2.-3.mca b/src/test/resources/1_18_PRE1/poi/r.-2.-3.mca new file mode 100644 index 00000000..04e17bf0 Binary files /dev/null and b/src/test/resources/1_18_PRE1/poi/r.-2.-3.mca differ diff --git a/src/test/resources/1_18_PRE1/region/r.-2.-3.mca b/src/test/resources/1_18_PRE1/region/r.-2.-3.mca new file mode 100644 index 00000000..36c5d7a3 Binary files /dev/null and b/src/test/resources/1_18_PRE1/region/r.-2.-3.mca differ diff --git a/src/test/resources/1_20_4/entities/double_passengers.snbt b/src/test/resources/1_20_4/entities/double_passengers.snbt new file mode 100644 index 00000000..ca810381 --- /dev/null +++ b/src/test/resources/1_20_4/entities/double_passengers.snbt @@ -0,0 +1,429 @@ +{ + DataVersion: 3700, + Entities: [ + { + AbsorptionAmount: 0.0f, + Air: 300s, + ArmorDropChances: [ + 0.085f, + 0.085f, + 0.085f, + 0.085f + ], + ArmorItems: [ + {}, + {}, + {}, + {} + ], + AttackTick: 0, + Attributes: [ + { + Base: 0.3d, + Name: "minecraft:generic.movement_speed" + } + ], + Brain: { + memories: {} + }, + CanJoinRaid: 0b, + CanPickUpLoot: 0b, + DeathTime: 0s, + FallDistance: 0.0f, + FallFlying: 0b, + Fire: -1s, + HandDropChances: [ + 0.085f, + 0.085f + ], + HandItems: [ + {}, + {} + ], + Health: 100.0f, + HurtByTimestamp: 0, + HurtTime: 0s, + Invulnerable: 0b, + LeftHanded: 0b, + Motion: [ + 0.0d, + -0.0784000015258789d, + 0.0d + ], + OnGround: 1b, + Passengers: [ + { + AbsorptionAmount: 0.0f, + Air: 300s, + ArmorDropChances: [ + 0.085f, + 0.085f, + 0.085f, + 0.085f + ], + ArmorItems: [ + {}, + {}, + {}, + {} + ], + Attributes: [ + { + Base: 0.3499999940395355d, + Name: "minecraft:generic.movement_speed" + } + ], + Brain: { + memories: {} + }, + CanJoinRaid: 0b, + CanPickUpLoot: 1b, + DeathTime: 0s, + FallDistance: 0.0f, + FallFlying: 0b, + Fire: -1s, + HandDropChances: [ + 0.085f, + 0.085f + ], + HandItems: [ + { + Count: 1b, + id: "minecraft:crossbow", + tag: { + Damage: 0 + } + }, + {} + ], + Health: 24.0f, + HurtByTimestamp: 0, + HurtTime: 0s, + Inventory: [], + Invulnerable: 0b, + LeftHanded: 0b, + Motion: [ + 0.0d, + -0.0784000015258789d, + 0.0d + ], + OnGround: 0b, + Passengers: [ + { + AbsorptionAmount: 0.0f, + Air: 300s, + ArmorDropChances: [ + 0.085f, + 0.085f, + 0.085f, + 0.085f + ], + ArmorItems: [ + {}, + {}, + {}, + {} + ], + Attributes: [ + { + Base: 0.23000000417232513d, + Name: "minecraft:generic.movement_speed" + } + ], + Brain: { + memories: {} + }, + CanBreakDoors: 0b, + CanPickUpLoot: 0b, + DeathTime: 0s, + DrownedConversionTime: -1, + FallDistance: 0.0f, + FallFlying: 0b, + Fire: 157s, + HandDropChances: [ + 0.085f, + 0.085f + ], + HandItems: [ + {}, + {} + ], + Health: 14.0f, + HurtByTimestamp: 0, + HurtTime: 7s, + InWaterTime: -1, + Invulnerable: 0b, + IsBaby: 1b, + LeftHanded: 0b, + Motion: [ + 0.0d, + -0.0784000015258789d, + 0.0d + ], + OnGround: 0b, + PersistenceRequired: 0b, + PortalCooldown: 0, + Pos: [ + 11.435331581942496d, + 74.31250002980232d, + 6.967756238280857d + ], + Rotation: [ + 0.0f, + -25.155565f + ], + UUID: [I; + 162650337, + 1260538818, + -1740427671, + 2004001158 + ], + id: "minecraft:zombie" + } + ], + PatrolLeader: 0b, + Patrolling: 0b, + PersistenceRequired: 0b, + PortalCooldown: 0, + Pos: [ + 11.435331581942496d, + 72.66250002384186d, + 7.030256238280857d + ], + Rotation: [ + 0.0f, + 0.0f + ], + UUID: [I; + -9855309, + 1771589144, + -1107932188, + 451046003 + ], + Wave: 0, + id: "minecraft:pillager" + } + ], + PatrolLeader: 0b, + Patrolling: 0b, + PersistenceRequired: 0b, + PortalCooldown: 0, + Pos: [ + 11.435331581942496d, + 71.0d, + 7.030256238280857d + ], + RoarTick: 0, + Rotation: [ + 0.0f, + 0.0f + ], + StunTick: 0, + UUID: [I; + -28138968, + 955206211, + -1680062184, + -956722909 + ], + Wave: 0, + id: "minecraft:ravager" + }, + { + AbsorptionAmount: 0.0f, + Air: 300s, + ArmorDropChances: [ + 0.085f, + 0.085f, + 0.085f, + 0.085f + ], + ArmorItems: [ + {}, + {}, + {}, + {} + ], + Attributes: [ + { + Base: 16.0d, + Modifiers: [ + { + Amount: 0.006869090403130357d, + Name: "Random spawn bonus", + Operation: 1, + UUID: [I; + -782296959, + 1082084202, + -1138745496, + 1799387745 + ] + } + ], + Name: "minecraft:generic.follow_range" + }, + { + Base: 0.30000001192092896d, + Name: "minecraft:generic.movement_speed" + } + ], + Brain: { + memories: {} + }, + CanPickUpLoot: 0b, + DeathTime: 0s, + FallDistance: 0.0f, + FallFlying: 0b, + Fire: -1s, + HandDropChances: [ + 0.085f, + 0.085f + ], + HandItems: [ + {}, + {} + ], + Health: 16.0f, + HurtByTimestamp: 0, + HurtTime: 0s, + Invulnerable: 0b, + LeftHanded: 0b, + Motion: [ + 0.0d, + -0.0784000015258789d, + 0.0d + ], + OnGround: 1b, + PersistenceRequired: 0b, + PortalCooldown: 0, + Pos: [ + 12.124114279023473d, + -47.0d, + 15.745077035996138d + ], + Rotation: [ + 25.470459f, + 0.0f + ], + UUID: [I; + 289330613, + 1998930528, + -1880759828, + -892464562 + ], + id: "minecraft:spider" + }, + { + AbsorptionAmount: 0.0f, + Air: 300s, + ArmorDropChances: [ + 0.085f, + 0.085f, + 0.085f, + 0.085f + ], + ArmorItems: [ + {}, + {}, + {}, + {} + ], + Attributes: [ + { + Base: 0.07786879225751496d, + Name: "minecraft:zombie.spawn_reinforcements" + }, + { + Base: 35.0d, + Modifiers: [ + { + Amount: 0.049743735644221014d, + Name: "Random spawn bonus", + Operation: 1, + UUID: [I; + -604691912, + 1147553982, + -1697511381, + 237360885 + ] + } + ], + Name: "minecraft:generic.follow_range" + }, + { + Base: 0.0d, + Modifiers: [ + { + Amount: 0.002548257857959571d, + Name: "Random spawn bonus", + Operation: 0, + UUID: [I; + -38604889, + -855817857, + -1569138175, + 248874423 + ] + } + ], + Name: "minecraft:generic.knockback_resistance" + }, + { + Base: 0.23000000417232513d, + Name: "minecraft:generic.movement_speed" + } + ], + Brain: { + memories: {} + }, + CanBreakDoors: 0b, + CanPickUpLoot: 0b, + DeathTime: 0s, + DrownedConversionTime: -1, + FallDistance: 0.0f, + FallFlying: 0b, + Fire: -1s, + HandDropChances: [ + 0.085f, + 0.085f + ], + HandItems: [ + {}, + {} + ], + Health: 20.0f, + HurtByTimestamp: 0, + HurtTime: 0s, + InWaterTime: 0, + Invulnerable: 0b, + IsBaby: 0b, + LeftHanded: 0b, + Motion: [ + 0.0d, + -0.005d, + 0.0d + ], + OnGround: 1b, + PersistenceRequired: 0b, + PortalCooldown: 0, + Pos: [ + 2.5d, + -4.0d, + 2.5d + ], + Rotation: [ + 206.74269f, + 0.0f + ], + UUID: [I; + 1593312871, + 1490307227, + -1833698099, + -1435585598 + ], + id: "minecraft:drowned" + } + ], + Position: [I; + 0, + 0 + ] +} \ No newline at end of file diff --git a/src/test/resources/1_20_4/entities/double_passengers_moveto_10.10_expected.snbt b/src/test/resources/1_20_4/entities/double_passengers_moveto_10.10_expected.snbt new file mode 100644 index 00000000..caf4f08d --- /dev/null +++ b/src/test/resources/1_20_4/entities/double_passengers_moveto_10.10_expected.snbt @@ -0,0 +1,429 @@ +{ + DataVersion: 3700, + Entities: [ + { + AbsorptionAmount: 0.0f, + Air: 300s, + ArmorDropChances: [ + 0.085f, + 0.085f, + 0.085f, + 0.085f + ], + ArmorItems: [ + {}, + {}, + {}, + {} + ], + AttackTick: 0, + Attributes: [ + { + Base: 0.3d, + Name: "minecraft:generic.movement_speed" + } + ], + Brain: { + memories: {} + }, + CanJoinRaid: 0b, + CanPickUpLoot: 0b, + DeathTime: 0s, + FallDistance: 0.0f, + FallFlying: 0b, + Fire: -1s, + HandDropChances: [ + 0.085f, + 0.085f + ], + HandItems: [ + {}, + {} + ], + Health: 100.0f, + HurtByTimestamp: 0, + HurtTime: 0s, + Invulnerable: 0b, + LeftHanded: 0b, + Motion: [ + 0.0d, + -0.0784000015258789d, + 0.0d + ], + OnGround: 1b, + Passengers: [ + { + AbsorptionAmount: 0.0f, + Air: 300s, + ArmorDropChances: [ + 0.085f, + 0.085f, + 0.085f, + 0.085f + ], + ArmorItems: [ + {}, + {}, + {}, + {} + ], + Attributes: [ + { + Base: 0.3499999940395355d, + Name: "minecraft:generic.movement_speed" + } + ], + Brain: { + memories: {} + }, + CanJoinRaid: 0b, + CanPickUpLoot: 1b, + DeathTime: 0s, + FallDistance: 0.0f, + FallFlying: 0b, + Fire: -1s, + HandDropChances: [ + 0.085f, + 0.085f + ], + HandItems: [ + { + Count: 1b, + id: "minecraft:crossbow", + tag: { + Damage: 0 + } + }, + {} + ], + Health: 24.0f, + HurtByTimestamp: 0, + HurtTime: 0s, + Inventory: [], + Invulnerable: 0b, + LeftHanded: 0b, + Motion: [ + 0.0d, + -0.0784000015258789d, + 0.0d + ], + OnGround: 0b, + Passengers: [ + { + AbsorptionAmount: 0.0f, + Air: 300s, + ArmorDropChances: [ + 0.085f, + 0.085f, + 0.085f, + 0.085f + ], + ArmorItems: [ + {}, + {}, + {}, + {} + ], + Attributes: [ + { + Base: 0.23000000417232513d, + Name: "minecraft:generic.movement_speed" + } + ], + Brain: { + memories: {} + }, + CanBreakDoors: 0b, + CanPickUpLoot: 0b, + DeathTime: 0s, + DrownedConversionTime: -1, + FallDistance: 0.0f, + FallFlying: 0b, + Fire: 157s, + HandDropChances: [ + 0.085f, + 0.085f + ], + HandItems: [ + {}, + {} + ], + Health: 14.0f, + HurtByTimestamp: 0, + HurtTime: 7s, + InWaterTime: -1, + Invulnerable: 0b, + IsBaby: 1b, + LeftHanded: 0b, + Motion: [ + 0.0d, + -0.0784000015258789d, + 0.0d + ], + OnGround: 0b, + PersistenceRequired: 0b, + PortalCooldown: 0, + Pos: [ + 171.4353315819425d, + 74.31250002980232d, + 166.96775623828086d + ], + Rotation: [ + 0.0f, + -25.155565f + ], + UUID: [I; + 162650337, + 1260538818, + -1740427671, + 2004001158 + ], + id: "minecraft:zombie" + } + ], + PatrolLeader: 0b, + Patrolling: 0b, + PersistenceRequired: 0b, + PortalCooldown: 0, + Pos: [ + 171.4353315819425d, + 72.66250002384186d, + 167.03025623828086d + ], + Rotation: [ + 0.0f, + 0.0f + ], + UUID: [I; + -9855309, + 1771589144, + -1107932188, + 451046003 + ], + Wave: 0, + id: "minecraft:pillager" + } + ], + PatrolLeader: 0b, + Patrolling: 0b, + PersistenceRequired: 0b, + PortalCooldown: 0, + Pos: [ + 171.4353315819425d, + 71.0d, + 167.03025623828086d + ], + RoarTick: 0, + Rotation: [ + 0.0f, + 0.0f + ], + StunTick: 0, + UUID: [I; + -28138968, + 955206211, + -1680062184, + -956722909 + ], + Wave: 0, + id: "minecraft:ravager" + }, + { + AbsorptionAmount: 0.0f, + Air: 300s, + ArmorDropChances: [ + 0.085f, + 0.085f, + 0.085f, + 0.085f + ], + ArmorItems: [ + {}, + {}, + {}, + {} + ], + Attributes: [ + { + Base: 16.0d, + Modifiers: [ + { + Amount: 0.006869090403130357d, + Name: "Random spawn bonus", + Operation: 1, + UUID: [I; + -782296959, + 1082084202, + -1138745496, + 1799387745 + ] + } + ], + Name: "minecraft:generic.follow_range" + }, + { + Base: 0.30000001192092896d, + Name: "minecraft:generic.movement_speed" + } + ], + Brain: { + memories: {} + }, + CanPickUpLoot: 0b, + DeathTime: 0s, + FallDistance: 0.0f, + FallFlying: 0b, + Fire: -1s, + HandDropChances: [ + 0.085f, + 0.085f + ], + HandItems: [ + {}, + {} + ], + Health: 16.0f, + HurtByTimestamp: 0, + HurtTime: 0s, + Invulnerable: 0b, + LeftHanded: 0b, + Motion: [ + 0.0d, + -0.0784000015258789d, + 0.0d + ], + OnGround: 1b, + PersistenceRequired: 0b, + PortalCooldown: 0, + Pos: [ + 172.12411427902347d, + -47.0d, + 175.74507703599613d + ], + Rotation: [ + 25.470459f, + 0.0f + ], + UUID: [I; + 289330613, + 1998930528, + -1880759828, + -892464562 + ], + id: "minecraft:spider" + }, + { + AbsorptionAmount: 0.0f, + Air: 300s, + ArmorDropChances: [ + 0.085f, + 0.085f, + 0.085f, + 0.085f + ], + ArmorItems: [ + {}, + {}, + {}, + {} + ], + Attributes: [ + { + Base: 0.07786879225751496d, + Name: "minecraft:zombie.spawn_reinforcements" + }, + { + Base: 35.0d, + Modifiers: [ + { + Amount: 0.049743735644221014d, + Name: "Random spawn bonus", + Operation: 1, + UUID: [I; + -604691912, + 1147553982, + -1697511381, + 237360885 + ] + } + ], + Name: "minecraft:generic.follow_range" + }, + { + Base: 0.0d, + Modifiers: [ + { + Amount: 0.002548257857959571d, + Name: "Random spawn bonus", + Operation: 0, + UUID: [I; + -38604889, + -855817857, + -1569138175, + 248874423 + ] + } + ], + Name: "minecraft:generic.knockback_resistance" + }, + { + Base: 0.23000000417232513d, + Name: "minecraft:generic.movement_speed" + } + ], + Brain: { + memories: {} + }, + CanBreakDoors: 0b, + CanPickUpLoot: 0b, + DeathTime: 0s, + DrownedConversionTime: -1, + FallDistance: 0.0f, + FallFlying: 0b, + Fire: -1s, + HandDropChances: [ + 0.085f, + 0.085f + ], + HandItems: [ + {}, + {} + ], + Health: 20.0f, + HurtByTimestamp: 0, + HurtTime: 0s, + InWaterTime: 0, + Invulnerable: 0b, + IsBaby: 0b, + LeftHanded: 0b, + Motion: [ + 0.0d, + -0.005d, + 0.0d + ], + OnGround: 1b, + PersistenceRequired: 0b, + PortalCooldown: 0, + Pos: [ + 162.5d, + -4.0d, + 162.5d + ], + Rotation: [ + 206.74269f, + 0.0f + ], + UUID: [I; + 1593312871, + 1490307227, + -1833698099, + -1435585598 + ], + id: "minecraft:drowned" + } + ], + Position: [I; + 10, + 10 + ] +} \ No newline at end of file diff --git a/src/test/resources/1_20_4/entities/r.-3.-3.mca b/src/test/resources/1_20_4/entities/r.-3.-3.mca new file mode 100644 index 00000000..b502a549 Binary files /dev/null and b/src/test/resources/1_20_4/entities/r.-3.-3.mca differ diff --git a/src/test/resources/1_20_4/poi/r.-3.-3.mca b/src/test/resources/1_20_4/poi/r.-3.-3.mca new file mode 100644 index 00000000..acb37512 Binary files /dev/null and b/src/test/resources/1_20_4/poi/r.-3.-3.mca differ diff --git a/src/test/resources/1_20_4/region/r.-3.-3.mca b/src/test/resources/1_20_4/region/r.-3.-3.mca new file mode 100644 index 00000000..80b9ef48 Binary files /dev/null and b/src/test/resources/1_20_4/region/r.-3.-3.mca differ diff --git a/src/test/resources/1_9_4/region/r.2.-1.mca b/src/test/resources/1_9_4/region/r.2.-1.mca new file mode 100644 index 00000000..6108c9bb Binary files /dev/null and b/src/test/resources/1_9_4/region/r.2.-1.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..a3a0a5e4 --- /dev/null +++ b/src/test/resources/ABOUT_TEST_DATA.md @@ -0,0 +1,129 @@ +# MCA Palette Samples + +### 1.20.4 +* **biomes-1.20.4-r.0.0_X21Y-3Z3_6entries.snbt** - + interesting because it highlights the need to compute bits per index from palette size and not from the + number of longs - computing from number of longs gives the wrong answer because this sample overflows + 3 longs by as single record. +* **block_states-1.20.4-6entries.snbt** - + interesting because only 3 bits are required to encode size 6 palette data, but it actually uses 4 bits. +* **block_states-1.20.4-14entries.snbt** - + interesting because bit packing has no waste on the per-long level, 4 bits to encode size 14 palette data. +* **block_states-1.20.4-r.0.0_X6Y-3Z23_72entries.snbt** - + interesting because it's the largest chunk section found in a random world's region file having a + bits per index of 7. + +# Chunk Mover Samples + +### single_start_record_with_all_xz_fields +A hand curated structure start (single record) that is defined for two chunk locations so one can be read, +moved to the location of the other, and validated with a simple equality check. + +The POST_MOVE_CLIPPED file is the result of moving to chunk 0 0 which clips the structure on the r.0.0 region boundary. + +The POST_MOVE_NOCLIP file is the result of moving to chunk 0 0 without a clipping region defined. + +# Region Files + +## Older versions + +### 1.9.4 +has pigs in it, nothing special +Chunks: +- 88 -20 + +### 1.12.2 +has sheep about and various villager shoved in a 2x2 hole + +Chunks: +- 10 11 + +### 1.13.0 / r.0.0.mca +Chunk has villager, chicken, turtle eggs, bed, and multiple biomes - uhh no it doesn't! no entities, just a mineshaft reference. + +Chunks: +- 6 10 + +### 1.13.1 / r.2.2.mca +OG test region file used for most initial development / testing. Really, these are boring chunks and not good for much except the basics. + +Contains 3 chunks - all have "UpgradeData" tag with some "Indices" populated + +### 1.13.2 +has chickens, horses, and villagers + +Chunks: +- -42 -45 + +### 1.14.4 +has villagers, lecturn, bell, bed. The librarian is bound to the lecturn in the chunk + +Chunks: +- -1 16 + +### 1.15.2 / r.0.0.mca +OG test region file used exclusively for biome testing in MCAFileTest::test1_15GetBiomeAt + +### 1.15.2 / r.-1.0.mca +has a horse, villagers, lecturn, bell, bed (and half a bed). + +Chunks: +- -3 11 - has a reference to a village that resides in another region file in chunk 1 12 + + +### 1.16.5 +has an iron golumn, villagers, fletching table, bell +berry bushes + +Chunks: +- 4 -27 + +## 1.17.1 (DV 2730) + +### 1_17_1 / r.-3.2.mca (seed: -592955240269541309) +Village, with villager with POI of cartography table and bed as well as nether portal + +Chunks: +- -65 -42 + +## 1.18 +Vanilla world height from Y -64 to 320 + +Region chunk NBT data is not wrapped in the "Level" tag. + +### 1_18_PRE1 +villager, lush caves, beds, bell, workstations, chickens, cat, geode + +Chunks: +- -60 -69 + +### 1_18_1 / r.0.-2.mca +villager, cat, iron golem, bell + +Chunks: +- 19 -47 + +### 1_18_1 / r.8.1.mca +lush caves, loot minecart + +Chunks: +- 275 33 + +### 1_20_4 +### r.-3.-3.mca +pillager outpost and a section of a mineshaft - world seed: -4846182428012336372L + +Chunks: +- pillager outpost + - XZ(-94, -85) + - XZ(-94, -86) + - XZ(-95, -85) + - XZ(-95, -86) +- mineshaft "starts" data - runs under pillager outpost chunks + - XZ(-91, -87) + +Note that the POI file is the complete file for the chunk and contains various beehives scattered about through 6 chunks. +Poi chunks: -77 -84; -77 -73; -94 -71; -78 -70; -77 -68; -82 -67 + +### 1_20_4/entities/double_passengers +Has a baby zombie ridding a pillager riding a ravager and a few other mobs in the chunk. \ No newline at end of file diff --git a/src/test/resources/chunk_mover_samples/single_start_record_with_all_xz_fields.r.-3.-3.c.-95.-85.snbt b/src/test/resources/chunk_mover_samples/single_start_record_with_all_xz_fields.r.-3.-3.c.-95.-85.snbt new file mode 100644 index 00000000..8eef4d03 --- /dev/null +++ b/src/test/resources/chunk_mover_samples/single_start_record_with_all_xz_fields.r.-3.-3.c.-95.-85.snbt @@ -0,0 +1,107 @@ +{ + BB: [I; + -1498, + 34, + -1390, + -1444, + 69, + -1348 + ], + Children: [ + { + BB: [I; + -1454, + 34, + -1390, + -1444, + 39, + -1381 + ], + Entrances: [ + [I; + -1448, + 35, + -1390, + -1446, + 37, + -1389 + ], + [I; + -1451, + 35, + -1382, + -1447, + 37, + -1381 + ] + ], + GD: 0, + MST: 0, + O: -1, + id: "minecraft:msroom" + }, + { + BB: [I; + -1448, + 35, + -1405, + -1446, + 37, + -1391 + ], + GD: 1, + MST: 0, + Num: 3, + O: 2, + hps: 0b, + hr: 0b, + id: "minecraft:mscorridor", + sc: 0b + }, + { + BB: [I; + -1498, + 67, + -1354, + -1496, + 69, + -1348 + ], + GD: 0, + O: -1, + PosX: -1498, + PosY: 67, + PosZ: -1354, + ground_level_delta: 0, + id: "minecraft:jigsaw", + junctions: [ + { + delta_y: -1, + dest_proj: terrain_matching, + source_ground_y: 67, + source_x: -1497, + source_z: -1351 + } + ], + pool_element: { + element_type: "minecraft:legacy_single_pool_element", + location: "minecraft:pillager_outpost/feature_targets", + processors: { + processors: [] + }, + projection: rigid + }, + rotation: NONE + } + ], + Processed: [ + { + X: -1498, + Z: -1354 + } + ], + ChunkX: -95, + ChunkZ: -85, + id: "mashup", + references: 0 +} \ No newline at end of file diff --git a/src/test/resources/chunk_mover_samples/single_start_record_with_all_xz_fields.r.0.0.c.0.0_POST_MOVE_CLIPPED.snbt b/src/test/resources/chunk_mover_samples/single_start_record_with_all_xz_fields.r.0.0.c.0.0_POST_MOVE_CLIPPED.snbt new file mode 100644 index 00000000..f706fb64 --- /dev/null +++ b/src/test/resources/chunk_mover_samples/single_start_record_with_all_xz_fields.r.0.0.c.0.0_POST_MOVE_CLIPPED.snbt @@ -0,0 +1,57 @@ +{ + BB: [I; + 22, + 34, + 0, + 76, + 69, + 12 + ], + Children: [ + { + BB: [I; + 22, + 67, + 6, + 24, + 69, + 12 + ], + GD: 0, + O: -1, + PosX: 22, + PosY: 67, + PosZ: 6, + ground_level_delta: 0, + id: "minecraft:jigsaw", + junctions: [ + { + delta_y: -1, + dest_proj: terrain_matching, + source_ground_y: 67, + source_x: 23, + source_z: 9 + } + ], + pool_element: { + element_type: "minecraft:legacy_single_pool_element", + location: "minecraft:pillager_outpost/feature_targets", + processors: { + processors: [] + }, + projection: rigid + }, + rotation: NONE + } + ], + ChunkX: 0, + ChunkZ: 0, + Processed: [ + { + X: 22, + Z: 6 + } + ], + id: mashup, + references: 0 +} \ No newline at end of file diff --git a/src/test/resources/chunk_mover_samples/single_start_record_with_all_xz_fields.r.0.0.c.0.0_POST_MOVE_NOCLIP.snbt b/src/test/resources/chunk_mover_samples/single_start_record_with_all_xz_fields.r.0.0.c.0.0_POST_MOVE_NOCLIP.snbt new file mode 100644 index 00000000..ed36a0c4 --- /dev/null +++ b/src/test/resources/chunk_mover_samples/single_start_record_with_all_xz_fields.r.0.0.c.0.0_POST_MOVE_NOCLIP.snbt @@ -0,0 +1,107 @@ +{ + BB: [I; + 22, + 34, + -30, + 76, + 69, + 12 + ], + Children: [ + { + BB: [I; + 66, + 34, + -30, + 76, + 39, + -21 + ], + Entrances: [ + [I; + 72, + 35, + -30, + 74, + 37, + -29 + ], + [I; + 69, + 35, + -22, + 73, + 37, + -21 + ] + ], + GD: 0, + MST: 0, + O: -1, + id: "minecraft:msroom" + }, + { + BB: [I; + 72, + 35, + -45, + 74, + 37, + -31 + ], + GD: 1, + MST: 0, + Num: 3, + O: 2, + hps: 0b, + hr: 0b, + id: "minecraft:mscorridor", + sc: 0b + }, + { + BB: [I; + 22, + 67, + 6, + 24, + 69, + 12 + ], + GD: 0, + O: -1, + PosX: 22, + PosY: 67, + PosZ: 6, + ground_level_delta: 0, + id: "minecraft:jigsaw", + junctions: [ + { + delta_y: -1, + dest_proj: terrain_matching, + source_ground_y: 67, + source_x: 23, + source_z: 9 + } + ], + pool_element: { + element_type: "minecraft:legacy_single_pool_element", + location: "minecraft:pillager_outpost/feature_targets", + processors: { + processors: [] + }, + projection: rigid + }, + rotation: NONE + } + ], + ChunkX: 0, + ChunkZ: 0, + Processed: [ + { + X: 22, + Z: 6 + } + ], + id: mashup, + references: 0 +} \ No newline at end of file diff --git a/src/test/resources/chunk_mover_samples/single_start_record_with_all_xz_fields.r.0.0.c.1.11.snbt b/src/test/resources/chunk_mover_samples/single_start_record_with_all_xz_fields.r.0.0.c.1.11.snbt new file mode 100644 index 00000000..1d98b86c --- /dev/null +++ b/src/test/resources/chunk_mover_samples/single_start_record_with_all_xz_fields.r.0.0.c.1.11.snbt @@ -0,0 +1,107 @@ +{ + BB: [I; + 38, + 34, + 146, + 92, + 69, + 188 + ], + Children: [ + { + BB: [I; + 82, + 34, + 146, + 92, + 39, + 155 + ], + Entrances: [ + [I; + 88, + 35, + 146, + 90, + 37, + 147 + ], + [I; + 85, + 35, + 154, + 89, + 37, + 155 + ] + ], + GD: 0, + MST: 0, + O: -1, + id: "minecraft:msroom" + }, + { + BB: [I; + 88, + 35, + 131, + 90, + 37, + 145 + ], + GD: 1, + MST: 0, + Num: 3, + O: 2, + hps: 0b, + hr: 0b, + id: "minecraft:mscorridor", + sc: 0b + }, + { + BB: [I; + 38, + 67, + 182, + 40, + 69, + 188 + ], + GD: 0, + O: -1, + PosX: 38, + PosY: 67, + PosZ: 182, + ground_level_delta: 0, + id: "minecraft:jigsaw", + junctions: [ + { + delta_y: -1, + dest_proj: terrain_matching, + source_ground_y: 67, + source_x: 39, + source_z: 185 + } + ], + pool_element: { + element_type: "minecraft:legacy_single_pool_element", + location: "minecraft:pillager_outpost/feature_targets", + processors: { + processors: [] + }, + projection: rigid + }, + rotation: NONE + } + ], + ChunkX: 1, + ChunkZ: 11, + Processed: [ + { + X: 38, + Z: 182 + } + ], + id: mashup, + references: 0 +} diff --git a/src/test/resources/mca_palettes/biomes-1.20.4-r.-3.-3_X-91Y-2Z-87_2entries.snbt b/src/test/resources/mca_palettes/biomes-1.20.4-r.-3.-3_X-91Y-2Z-87_2entries.snbt new file mode 100644 index 00000000..921ca8fe --- /dev/null +++ b/src/test/resources/mca_palettes/biomes-1.20.4-r.-3.-3_X-91Y-2Z-87_2entries.snbt @@ -0,0 +1,9 @@ +biomes: { + data: [L; + 5764327143275102208 + ], + palette: [ + "minecraft:savanna", + "minecraft:dripstone_caves" + ] +} \ No newline at end of file diff --git a/src/test/resources/mca_palettes/biomes-1.20.4-r.0.0_X21Y-3Z3_6entries.snbt b/src/test/resources/mca_palettes/biomes-1.20.4-r.0.0_X21Y-3Z3_6entries.snbt new file mode 100644 index 00000000..ecf0a9a1 --- /dev/null +++ b/src/test/resources/mca_palettes/biomes-1.20.4-r.0.0_X21Y-3Z3_6entries.snbt @@ -0,0 +1,16 @@ +{ + palette: [ + "minecraft:snowy_taiga", + "minecraft:snowy_plains", + "minecraft:snowy_slopes", + "minecraft:grove", + "minecraft:deep_dark", + "minecraft:dripstone_caves" + ], + data: [L; + 3785436329631921288, + 3932602464921073299, + 2797418351439529682, + 4 + ] +} \ No newline at end of file diff --git a/src/test/resources/mca_palettes/block_states-1.20.4-14entries.snbt b/src/test/resources/mca_palettes/block_states-1.20.4-14entries.snbt new file mode 100644 index 00000000..e4416dcb --- /dev/null +++ b/src/test/resources/mca_palettes/block_states-1.20.4-14entries.snbt @@ -0,0 +1,333 @@ +{ + data: [L; + 4919129411944587520, + 4909524073787581781, + 4765409325459396176, + 2459566302555362677, + 2459564996885287185, + 2459551871464968465, + 4765178276883796241, + 4919431994033639697, + 5797633937477013777, + 6158484858727371025, + 1537228811238707473, + 1230983898147655953, + 1230983898147934481, + 1229858006831007061, + 1229857998241092949, + 1229787629496915285, + 4919132190789542161, + 4919094296290857233, + 4909524513580978454, + 4765409325512724743, + 4765408225947357457, + 4909505339995525393, + 4918231059907285265, + 4702902969347150097, + 4688552285046903057, + 4688552147809276177, + 4688622168744988945, + 4904795294170943761, + 4904795302798627089, + 4904724903956386065, + 4904795302765360469, + 4904725364054775125, + 4919130580176814353, + 4919130580174573841, + 4919129480662946065, + 4919094250473341969, + 4919093150968202308, + 4919075464293139524, + 4918231035068040260, + 4689391877123215633, + 4905564659111760145, + 4918231033114071313, + 4918231033063739665, + 4904720990095872273, + 4905577510727651601, + 4919140329247019281, + 4919131739316884753, + 4919131739367232897, + 4919130580176815169, + 4919130580176683281, + 4919131679686201617, + 4919131752989017105, + 4919131752989213764, + 4919128248295900228, + 4918231033922733124, + 4919128234605691972, + 4919075457997227076, + 4918231033117426756, + 4918231033117426756, + 4919075664205988932, + 4905568163858760776, + 4919131740058454152, + 4919131739253147780, + 4919131739303479361, + 4919131679688442945, + 4919131679689557012, + 4919131748422652225, + 4919131752989017116, + 4919131752989213764, + 4919128248295900228, + 4919075458852865092, + 4919128247490593860, + 4919075457997227076, + 4919075458047558724, + 4918231046807635012, + 4919075677090890820, + 4905568163858760772, + 4919131740053980228, + 4919131752138048580, + 4919131739299284033, + 4919131748409033796, + 4919131752989213764, + 4919131752989213764, + 4919131752989017116, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752183907396, + 4919131739248673860, + 4919131546025477188, + 4921823151295579204, + 4921833253058659396, + 4908322440486339652, + 4919299978268263492, + 4919142266213516356, + 4919131752989213761, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4921823357454048580, + 4921833253058698564, + 4921833252253355332, + 4919299978268263492, + 4919142267069154372, + 4919131752989213764, + 4962197424425944132, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989252932, + 4919131752989253076, + 4919131752989253076, + 4919131752989216221, + 4919131752989216212, + 4919131752997602372, + -2459566536201583548, + -2502631589163023292, + 4919142305723860036, + 4919132410119210052, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989216077, + 4919131752989216221, + 4919131752989216221, + 4919131752989216221, + 4919131752989213917, + -2459565917726292924, + -2502631550508317628, + 4919300635398259780, + 4919142305723860036, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213917, + 4919131752989213917, + 4919131752989213773, + 4919131752989213917, + 4919131752989216221, + 4919131752989213917, + 4919131752989213917, + 4921833253058659396, + 4919300596743554116, + 4919300635398259780, + 4919132371464504388, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989230301, + 4919131752993687693, + 4919131752993687693, + 4919131752989230157, + 4919131752989213773, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752993687688, + 4919131753060796552, + 4919131753060796552, + 4919131752993687688, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919130580177929284, + 4919130580177929284, + 4919130580176880708, + 4919131679688442948, + 4919131752702886980, + 4919131752702886980, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752993687688, + 4919131753060796552, + 4919131753060796552, + 4919131752993687688, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4918831513014125636, + 4919112987990770756, + 4919112987990766660, + 4919130580176811076, + 4919131679688438852, + 4919131748407915588, + 4919131748407915588, + 4919131752971318340, + 4919131752989213764, + 4919131752989493384, + 4919131752993687688, + 4919131752993687688, + 4919131752989493384, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4918831513014060100, + 4918831513014060100, + 4919112987990766660, + 4919112987990766404, + 4919130580176810820, + 4919131679688438596, + 4919131748407915332, + 4919131752702882612, + 4919131752988095300, + 4919131752989209668, + 4919131752989231240, + 4919131752989231240, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4914327913387313284, + 4918831513014056004, + 4918831513014055748, + 4919112987990766404, + 4919112987990766404, + 4919131679688438580, + 4919131679688438580, + 4919131748407915316, + 4919131752971318068, + 4910122354711147332, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764, + 4919131752989213764 + ], + palette: [ + { + Properties: { + vertical_direction: down, + thickness: frustum, + waterlogged: false + }, + Name: "minecraft:pointed_dripstone" + }, + { + Name: "minecraft:dripstone_block" + }, + { + Name: "minecraft:diorite" + }, + { + Name: "minecraft:andesite" + }, + { + Name: "minecraft:stone" + }, + { + Name: "minecraft:air" + }, + { + Properties: { + vertical_direction: down, + thickness: base, + waterlogged: false + }, + Name: "minecraft:pointed_dripstone" + }, + { + Properties: { + vertical_direction: down, + thickness: tip, + waterlogged: false + }, + Name: "minecraft:pointed_dripstone" + }, + { + Name: "minecraft:dirt" + }, + { + Properties: { + vertical_direction: down, + thickness: middle, + waterlogged: false + }, + Name: "minecraft:pointed_dripstone" + }, + { + Name: "minecraft:gravel" + }, + { + Properties: { + east: false, + waterlogged: false, + south: false, + north: false, + west: false, + up: true, + down: false + }, + Name: "minecraft:glow_lichen" + }, + { + Name: "minecraft:iron_ore" + }, + { + Name: "minecraft:copper_ore" + } + ] +} \ No newline at end of file diff --git a/src/test/resources/mca_palettes/block_states-1.20.4-6entries.snbt b/src/test/resources/mca_palettes/block_states-1.20.4-6entries.snbt new file mode 100644 index 00000000..67fc146d --- /dev/null +++ b/src/test/resources/mca_palettes/block_states-1.20.4-6entries.snbt @@ -0,0 +1,280 @@ +{ + data: [L; + 1152921504606846976, + 2305843009213693952, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1152921504606846976, + 1224979098644774912, + 1224979099450081280, + 1229482699127783424, + 1229764173248856064, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1152921504606846976, + 1224979098644774912, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 262144, + 16384, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1365, + 1365, + 5, + 0, + 0, + 0, + 0, + 0, + 768, + 819, + 3, + 0, + 0, + 0, + 0, + 1360, + 21845, + 21845, + 1365, + 5, + 0, + 0, + 0, + 0, + 768, + 819, + 51, + 3, + 0, + 0, + 0, + 1360, + 21845, + 21845, + 1365, + 5, + 0, + 0, + 0, + 0, + 0, + 3, + 51, + 3, + 0, + 0, + 0, + 0, + 1365, + 1365, + 5, + 0, + 0, + 3504693313536, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3517578215424, + 3518383521792, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3518383521792, + 219848638464, + 12884901888, + 0, + 0, + 209664, + 209664, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 5764607523034234880, + 0, + 12884901888, + 0, + 0, + 0, + 209715, + 3355440, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 5764607523034234880, + 0, + 0, + 0, + 0, + 0, + 13107, + 13104, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + palette: [ + { + Name: "minecraft:stone" + }, + { + Name: "minecraft:air" + }, + { + Name: "minecraft:dripstone_block" + }, + { + Name: "minecraft:coal_ore" + }, + { + Name: "minecraft:iron_ore" + }, + { + Name: "minecraft:dirt" + } + ] +} \ No newline at end of file diff --git a/src/test/resources/mca_palettes/block_states-1.20.4-r.0.0_X6Y-3Z23_72entries.snbt b/src/test/resources/mca_palettes/block_states-1.20.4-r.0.0_X6Y-3Z23_72entries.snbt new file mode 100644 index 00000000..bd4f1433 --- /dev/null +++ b/src/test/resources/mca_palettes/block_states-1.20.4-r.0.0_X6Y-3Z23_72entries.snbt @@ -0,0 +1,1102 @@ +{ + palette: [ + { + Name: "minecraft:air" + }, + { + Name: "minecraft:cracked_deepslate_bricks" + }, + { + Name: "minecraft:deepslate_bricks" + }, + { + Name: "minecraft:sculk" + }, + { + Name: "minecraft:gray_wool" + }, + { + Properties: { + facing: south, + waterlogged: false, + half: top, + shape: straight + }, + Name: "minecraft:deepslate_tile_stairs" + }, + { + Properties: { + east: false, + waterlogged: false, + south: false, + north: true, + west: false, + up: false, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + facing: west, + short: false, + type: sticky + }, + Name: "minecraft:piston_head" + }, + { + Properties: { + facing: west, + extended: true + }, + Name: "minecraft:sticky_piston" + }, + { + Name: "minecraft:deepslate_tiles" + }, + { + Name: "minecraft:cracked_deepslate_tiles" + }, + { + Properties: { + facing: south, + delay: "1", + locked: false, + powered: true + }, + Name: "minecraft:repeater" + }, + { + Properties: { + west: false, + east: false, + waterlogged: false, + south: true, + north: true + }, + Name: "minecraft:glass_pane" + }, + { + Properties: { + east: false, + waterlogged: false, + south: false, + north: false, + west: false, + up: true, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + west: side, + east: none, + power: "13", + south: side, + north: side + }, + Name: "minecraft:redstone_wire" + }, + { + Properties: { + west: false, + east: false, + waterlogged: false, + south: false, + north: true + }, + Name: "minecraft:glass_pane" + }, + { + Properties: { + west: up, + east: side, + power: "14", + south: none, + north: side + }, + Name: "minecraft:redstone_wire" + }, + { + Properties: { + west: side, + east: side, + power: "15", + south: none, + north: none + }, + Name: "minecraft:redstone_wire" + }, + { + Properties: { + west: none, + east: side, + power: "0", + south: side, + north: none + }, + Name: "minecraft:redstone_wire" + }, + { + Properties: { + west: side, + east: side, + power: "0", + south: none, + north: none + }, + Name: "minecraft:redstone_wire" + }, + { + Properties: { + west: side, + east: none, + power: "0", + south: side, + north: none + }, + Name: "minecraft:redstone_wire" + }, + { + Properties: { + west: none, + east: none, + power: "0", + south: side, + north: side + }, + Name: "minecraft:redstone_wire" + }, + { + Properties: { + west: false, + east: true, + waterlogged: false, + south: true, + north: true + }, + Name: "minecraft:glass_pane" + }, + { + Properties: { + west: none, + east: side, + power: "0", + south: none, + north: side + }, + Name: "minecraft:redstone_wire" + }, + { + Properties: { + west: true, + east: false, + waterlogged: false, + south: true, + north: true + }, + Name: "minecraft:glass_pane" + }, + { + Properties: { + facing: north, + face: floor, + powered: false + }, + Name: "minecraft:lever" + }, + { + Properties: { + east: false, + waterlogged: false, + south: false, + north: false, + west: true, + up: false, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + west: true, + east: true, + waterlogged: false, + south: false, + north: false + }, + Name: "minecraft:glass_pane" + }, + { + Properties: { + east: false, + waterlogged: false, + south: true, + north: true, + west: false, + up: true, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + east: false, + waterlogged: false, + south: true, + north: true, + west: false, + up: false, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + east: true, + waterlogged: false, + south: false, + north: false, + west: true, + up: true, + down: true + }, + Name: "minecraft:sculk_vein" + }, + { + Name: "minecraft:chiseled_deepslate" + }, + { + Properties: { + east: true, + waterlogged: false, + south: false, + north: false, + west: false, + up: true, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + west: up, + east: side, + power: "13", + south: none, + north: none + }, + Name: "minecraft:redstone_wire" + }, + { + Properties: { + east: false, + waterlogged: false, + south: false, + north: true, + west: false, + up: true, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + east: false, + waterlogged: false, + south: false, + north: false, + west: true, + up: true, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + waterlogged: false, + hanging: true + }, + Name: "minecraft:soul_lantern" + }, + { + Properties: { + facing: south, + waterlogged: false, + half: bottom, + shape: straight + }, + Name: "minecraft:polished_deepslate_stairs" + }, + { + Properties: { + east: true, + waterlogged: false, + south: false, + north: false, + west: false, + up: false, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + facing: north, + waterlogged: false, + half: bottom, + shape: straight + }, + Name: "minecraft:polished_deepslate_stairs" + }, + { + Properties: { + waterlogged: false, + type: bottom + }, + Name: "minecraft:deepslate_brick_slab" + }, + { + Properties: { + west: side, + east: side, + power: "10", + south: none, + north: none + }, + Name: "minecraft:redstone_wire" + }, + { + Properties: { + west: side, + east: side, + power: "11", + south: none, + north: none + }, + Name: "minecraft:redstone_wire" + }, + { + Properties: { + west: side, + east: side, + power: "12", + south: none, + north: none + }, + Name: "minecraft:redstone_wire" + }, + { + Properties: { + facing: west, + waterlogged: false, + half: top, + shape: straight + }, + Name: "minecraft:deepslate_tile_stairs" + }, + { + Properties: { + west: true, + east: true, + waterlogged: false, + south: false, + north: true + }, + Name: "minecraft:glass_pane" + }, + { + Properties: { + west: none, + east: none, + waterlogged: false, + up: true, + south: none, + north: tall + }, + Name: "minecraft:polished_deepslate_wall" + }, + { + Properties: { + east: false, + waterlogged: false, + south: true, + north: false, + west: false, + up: false, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + axis: y + }, + Name: "minecraft:deepslate" + }, + { + Properties: { + east: false, + waterlogged: false, + south: false, + north: false, + west: false, + up: false, + down: true + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + east: true, + waterlogged: false, + south: false, + north: false, + west: true, + up: false, + down: true + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + sculk_sensor_phase: inactive, + waterlogged: false, + power: "0" + }, + Name: "minecraft:sculk_sensor" + }, + { + Name: "minecraft:polished_deepslate" + }, + { + Properties: { + candles: "1", + waterlogged: false, + lit: true + }, + Name: "minecraft:candle" + }, + { + Properties: { + candles: "2", + waterlogged: false, + lit: true + }, + Name: "minecraft:candle" + }, + { + Properties: { + candles: "4", + waterlogged: false, + lit: true + }, + Name: "minecraft:candle" + }, + { + Name: "minecraft:cobbled_deepslate" + }, + { + Properties: { + east: false, + waterlogged: false, + south: true, + north: false, + west: false, + up: false, + down: true + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + candles: "3", + waterlogged: false, + lit: false + }, + Name: "minecraft:candle" + }, + { + Properties: { + candles: "1", + waterlogged: false, + lit: false + }, + Name: "minecraft:candle" + }, + { + Properties: { + candles: "2", + waterlogged: false, + lit: false + }, + Name: "minecraft:candle" + }, + { + Name: "minecraft:soul_fire" + }, + { + Properties: { + east: false, + waterlogged: false, + south: true, + north: false, + west: false, + up: true, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + lit: false + }, + Name: "minecraft:redstone_lamp" + }, + { + Properties: { + east: false, + waterlogged: false, + south: true, + north: false, + west: true, + up: false, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + facing: south, + waterlogged: false, + half: top, + shape: straight + }, + Name: "minecraft:polished_deepslate_stairs" + }, + { + Properties: { + sculk_sensor_phase: inactive, + waterlogged: true, + power: "0" + }, + Name: "minecraft:sculk_sensor" + }, + { + Properties: { + facing: north, + waterlogged: false, + half: top, + shape: straight + }, + Name: "minecraft:polished_deepslate_stairs" + }, + { + Properties: { + east: true, + waterlogged: false, + south: true, + north: true, + west: true, + up: false, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + east: false, + waterlogged: false, + south: true, + north: false, + west: false, + up: true, + down: true + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + east: true, + waterlogged: false, + south: false, + north: false, + west: true, + up: true, + down: false + }, + Name: "minecraft:sculk_vein" + }, + { + Properties: { + east: true, + waterlogged: false, + south: false, + north: false, + west: false, + up: false, + down: true + }, + Name: "minecraft:sculk_vein" + } + ], + data: [L; + 144682605065797632, + 8831262310657, + 290499906672591360, + 144115189158052356, + 145245555021349122, + 8830463279362, + 361422667800133760, + 654187739694006533, + 5673826031649929, + 944093669569331200, + 214669, + 247552, + 1155173304420533888, + 1379874520684298257, + 5110841699469843, + 1513209474840526848, + 138521444352, + 101155069755392, + 109951187946518, + 300615275126980608, + 426500, + 435750064544336275, + 0, + 1960874369989214208, + 15318499077442971, + 0, + 0, + 0, + 144682605063700480, + 2091482325882781954, + 290499906672591235, + 290499906727117316, + 145245520930029697, + 377752716776079746, + 363111586121990274, + 726250486071591173, + 73187961253971081, + 864691128455168258, + 73192359298371597, + 52776558133250, + 289360743636189571, + 73746893796557316, + 720576009371435393, + 3527683468313738, + 290578932988642048, + 4484, + 1875749388144345088, + 0, + 114487730733056, + 0, + 12681996, + 1947243888884318208, + 14129157295515, + 1236951027075, + 20266198323167232, + 0, + 0, + 2666130989413646336, + 2270396278067877, + 290501074895307268, + 2832201215671075332, + 435896138260807719, + 73192324670194438, + 2884005909135114497, + 149784581703353482, + 439839939371237505, + 145250230107897869, + 57185348403458, + 73187961253971072, + 648519835171029379, + 145254385474109833, + 654192138284171266, + 1171721487625, + 217875136433423526, + 216172785603444780, + 24783095980949764, + 290495370104931968, + 936750235133198724, + 219576973997899789, + 936748722585387395, + 3268123326758830080, + 119675774042541, + 3489660974, + 7318349395230720, + 27262976, + 1664, + 458805208470781952, + 288232025419153967, + 2471853008410752, + 0, + 206171013888, + 144686969021038848, + 145249918704730152, + 3697464159424758018, + 145249953338376192, + 1374930632961, + 145249953336279297, + 217298682104529154, + 144687003382857858, + 73192393928655105, + 73750911205277954, + 290499803322794242, + 73759741658022146, + 145258749964108161, + 217307547644756098, + 144686969564233987, + 145817301067809153, + 216740199374307713, + 144682639964553603, + 73759707296186498, + 72629409347059969, + 145249987966583171, + 216740199105855875, + 4467577635203, + 0, + 0, + 27597741857177652, + 802865, + 0, + 73187926351020032, + 5121, + 2883434094371274801, + 3559748059761999872, + 175991118921903, + 144693028949327872, + 3386706930522145026, + 144682605144195072, + 217870497327644930, + 72629409344930179, + 145817335966498946, + 145245555294011906, + 289365175769792642, + 145245555289784863, + 290499837414113409, + 145249953336295680, + 145249953334182274, + 217307547105771778, + 144682605067911426, + 144691401427288322, + 145249953336295682, + 145249953604714754, + 217874895644672258, + 258, + 0, + 13958643712, + 384, + 0, + 0, + 0, + 0, + 0, + 239315577733120, + 0, + 176233628123136, + 3819052484010180608, + 1374931524533, + 73435797503613105, + 215504362963202, + 73192359298383872, + 3558623843594359809, + 145245563074412803, + 145804037936994984, + 3892244843037999361, + 145249987154942647, + 653624824373068034, + 145249953070072969, + 653624790015427721, + 653589328869490954, + 221279225787761034, + 217874930004443523, + 311672587401, + 0, + 3746994889972252672, + 103079215104, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 17587787997904896, + 0, + 255170449506304, + 27584547717644288, + 5111808, + 1683627180032, + 122585346, + 175640385571045376, + 990523, + 6272, + 3530822107858468864, + 3558406655576113152, + 3558623846187828361, + 27584560974551217, + 4430123576756011008, + 13282270909, + 0, + 1015808, + 2305843009345814528, + 4543, + 19140298416324608, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 875136, + 3891110078048108544, + 53, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 206708186021888, + 0, + 4066998693415387392, + 145524502255508536, + 175888615368170497, + 4683743613006414081, + 36730319797231775, + 2271076451543416832, + 115432143552512, + 295752253308928, + 141025664, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 770095, + 247706844587081728, + 4066754930823535672, + 4066998678806610360, + 217870497325531394, + 1795136946176, + 53123, + 4917534968967597952, + 3746994889972256031, + 1825292381329951, + 0, + 1610612736, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 99342208, + 144116802983559168, + 73192377377931521, + 145245486838733825, + 4036360031382290690, + 6340994, + 1683627180032, + 0, + 13154140160, + 0, + 6272, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6016, + 145817335966466095, + 217305019249082626, + 73187961251856512, + 13312369492225, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1614907703296, + 217861644590514176, + 654187774326391171, + 653629219990968248, + 4066998680700470410, + 573699, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3386706919782612992, + 1614907703296, + 39685497864239, + 216746935779427593, + 4066790048369000842, + 8865625021496, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 98572160, + 722264790239549568, + 32296483598418177, + 217908051980582912, + 129, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 206708186021888, + 0, + 1702147622174720, + 0, + 1924688560512, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6016, + 650207196201615360, + 49539, + 217874929192796160, + 3 + ] +} \ No newline at end of file diff --git a/src/test/resources/text_nbt_samples/empty_file-with_bom.snbt b/src/test/resources/text_nbt_samples/empty_file-with_bom.snbt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/test/resources/text_nbt_samples/empty_file-with_bom.snbt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/test/resources/text_nbt_samples/empty_file-with_bom.snbt.gz b/src/test/resources/text_nbt_samples/empty_file-with_bom.snbt.gz new file mode 100644 index 00000000..5da1bf24 Binary files /dev/null and b/src/test/resources/text_nbt_samples/empty_file-with_bom.snbt.gz differ diff --git a/src/test/resources/text_nbt_samples/empty_file.snbt b/src/test/resources/text_nbt_samples/empty_file.snbt new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/text_nbt_samples/empty_file.snbt.gz b/src/test/resources/text_nbt_samples/empty_file.snbt.gz new file mode 100644 index 00000000..718379a7 Binary files /dev/null and b/src/test/resources/text_nbt_samples/empty_file.snbt.gz differ diff --git a/src/test/resources/text_nbt_samples/named_tag_sample-with_bom.snbt b/src/test/resources/text_nbt_samples/named_tag_sample-with_bom.snbt new file mode 100644 index 00000000..b5cd3197 --- /dev/null +++ b/src/test/resources/text_nbt_samples/named_tag_sample-with_bom.snbt @@ -0,0 +1,5 @@ +HitchhikerGuide: { + question: "¿why?", + answer: 42, + when: [I; 1978, 1981, 1984, 2005] +} diff --git a/src/test/resources/text_nbt_samples/named_tag_sample-with_bom.snbt.gz b/src/test/resources/text_nbt_samples/named_tag_sample-with_bom.snbt.gz new file mode 100644 index 00000000..d1ba3976 Binary files /dev/null and b/src/test/resources/text_nbt_samples/named_tag_sample-with_bom.snbt.gz differ diff --git a/src/test/resources/text_nbt_samples/unnamed_tag_sample.snbt b/src/test/resources/text_nbt_samples/unnamed_tag_sample.snbt new file mode 100644 index 00000000..05fffa88 --- /dev/null +++ b/src/test/resources/text_nbt_samples/unnamed_tag_sample.snbt @@ -0,0 +1,5 @@ +{ + question: "¿why?", + answer: 42, + when: [I; 1978, 1981, 1984, 2005] +} diff --git a/src/test/resources/text_nbt_samples/unnamed_tag_sample.snbt.gz b/src/test/resources/text_nbt_samples/unnamed_tag_sample.snbt.gz new file mode 100644 index 00000000..2e42fae8 Binary files /dev/null and b/src/test/resources/text_nbt_samples/unnamed_tag_sample.snbt.gz differ