Coder Social home page Coder Social logo

benwoodworth / knbt Goto Github PK

View Code? Open in Web Editor NEW
65.0 5.0 2.0 1.14 MB

Kotlin NBT library for kotlinx.serialization

License: GNU Lesser General Public License v3.0

Kotlin 100.00%
kotlin kotlinx-serialization nbt minecraft serialization kotlin-multiplatform okio library named-binary-tag

knbt's People

Contributors

benwoodworth avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

Forkers

nothendev pandier

knbt's Issues

Floats are decoded as Doubles on Kolin/JS

Seems like an issue with Kotlin/JS itself, since all numbers are stored as Doubles in Javascript: KT-35422

BinaryNbtReaderTest.Should_decode_uncompressed_bigtest_nbt_to_class_correctly failed:

Structures are not equal.
level.floatTest: Expected <0.49823147>, actual <0.4982314705848694>.

Support `Char` serialization

NBT doesn't have a tag type for single characters, but it makes sense to follow kotlinx-serialization's lead with how it handles Chars in JSON. It parses as a string (or other primitive) and fails if the length isn't 1

Char could also be encoded as a UShort since the bitlength is the same, but that doesn't seem right semantically. If someone opens the serialized NBT in an NBT viewer, the encoded char would be a number, whereas a string would show correctly

Support Enum serialization

Minecraft appears to encode all enum-like values as an ordinal, so consider doing the same with enum values instead of throwing an "unsupported" error.

Serialize unsigned arrays as NbtArrays

In v0.11, UByteArray, UIntArray, and ULongArray all serialize to NbtLists, unlike normal signed Arrays, which serialize to their Nbt*Array counterparts

Support polymorphic serialization

If you have any use cases please drop them here!

I'm pretty busy and not even sure polymorphic serialization is needed for NBT so unless there's a need for it I probably won't get around to it for a while

Investigate `NaN` and infinity values for SNBT

Currently the encoder is happy to print out NaNd, -Infinityd, etc. but there's nothing in the decoder to pick those up. It gets read in as NbtStrings.

And interestingly, Java Minecraft seems to have the same behavior

[FEATURE] A JsonPath-like DSL for reading specific properties from an NBT source

It'd be cool to have a DSL that allows you to access specifc properties of an arbitrary NBT source without needing a model class. Similar to how KotlinNBT does it or how I plan to do it in my version.

The DSL definitions for KotlinNBT can be found here. An example of this DSL would be:

val fibonacci: IntArray = nbt["testCompound"].asTagCompound["fibonacciWithoutZero"].asIntArray
val message: String = nbt["testList"].asTagList[0].asTagCompound["firstString"].asString
val timestamp: Long = nbt["timestamp"].asLong

The DSL definitions for KXSmine can be found here. An example of this DSL would be:

val fibonacci = root.compound("testCompound").intarray("fibonacciWithoutZero").data
val message = root.list("testList").compoundAt(0).string("firstString").data
val timestamp = root.long("timestamp").data

The main benefit from such a DSL would be the ability to type-safely access just specific properties without having to write a whole model class for the NBT source you're parsing.

Add extension methods/properties to Kotlin types for creating Nbt versions of a type.

Some utility methods/properties could be added on kotlin classes to simplify creation of Nbt types.
I actually have some methods listed in the examples on some projects and I use them often.

Exemples :

  • "test".nbt to transform a String to a NbtString.
  • true.asNbtByte() to transform a Boolean to a NbtByte.
  • listOf(1, 2, 3).asNbtList() to transform a List of Number to the NbtList version, maybe to the Nbt[Number]Array version when possible.

Rename `Nbt` to `BinaryNbt`, and `NbtFormat` to `Nbt`, and add `Nbt` companion object for `NbtTag` serialization

It would be nice to have default functionality common to binary/stringified NBT accessible with Nbt.[...], but the current NbtFormat/Nbt/StringifiedNbt naming doesn't work nicely with that.

Brief rationale:

  • Having Nbt.[...] for the API feels nicer from a naming perspective for basic un-configured serialization
  • This API could be added under the current (binary) Nbt class
    • but the companion object would implement NbtFormat, and it feels awkward/unexpected to be a different configuration type
  • This API could be added to the NbtFormat interface as a companion object
    • but it feels awkward/unexpected to have to write NbtFormat.encodeToNbtTag(serializer, value)
  • It feels better to me to have the Nbt class represent the general structure for the format, dealing with the in-memory representation and the NbtTag classes.
    • It would be an actual concrete & usable API, instead of just a parent interface for shared functionality.
    • Then, have more types of Nbt named according to what functionality they bring (BinaryNbt and StringifiedNbt).
  • This new naming means all general NBT API is named Nbt-, while binary/stringified API are BinaryNbt- and StringifiedNbt-, making the API easier to find through intellisense, and grouping code files more nicely when alphatized.

ClassCastException when serializing sealed classes

I have found a problem regarding the poymorphic serialization in the default serializer. When encoding a serializable marked sealed class, it throws an exception with the following stack trace:

Exception in thread "main" java.lang.ClassCastException: class kotlinx.serialization.descriptors.PolymorphicKind$SEALED cannot be cast to class kotlinx.serialization.descriptors.StructureKind (kotlinx.serialization.descriptors.PolymorphicKind$SEALED and kotlinx.serialization.descriptors.StructureKind are in unnamed module of loader 'app')
	at net.benwoodworth.knbt.internal.DefaultNbtEncoder.encodeElement(DefaultNbtEncoder.kt:27)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeStringElement(AbstractEncoder.kt:65)
	at kotlinx.serialization.internal.AbstractPolymorphicSerializer.serialize(AbstractPolymorphicSerializer.kt:34)
	at kotlinx.serialization.encoding.Encoder$DefaultImpls.encodeSerializableValue(Encoding.kt:282)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeSerializableValue(AbstractEncoder.kt:18)
	at net.benwoodworth.knbt.AbstractNbtEncoder.encodeSerializableValue(NbtEncoder.kt:100)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeSerializableElement(AbstractEncoder.kt:80)
	at Person$Child.write$Self(NbtTest.kt:16)
	at Person$Child$$serializer.serialize(NbtTest.kt:16)
	at Person$Child$$serializer.serialize(NbtTest.kt:16)
	at kotlinx.serialization.encoding.Encoder$DefaultImpls.encodeSerializableValue(Encoding.kt:282)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeSerializableValue(AbstractEncoder.kt:18)
	at net.benwoodworth.knbt.AbstractNbtEncoder.encodeSerializableValue(NbtEncoder.kt:100)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeSerializableElement(AbstractEncoder.kt:80)
	at net.benwoodworth.knbt.internal.RootClassSerializer.serialize(RootClassSerializer.kt:23)
	at kotlinx.serialization.encoding.Encoder$DefaultImpls.encodeSerializableValue(Encoding.kt:282)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeSerializableValue(AbstractEncoder.kt:18)
	at net.benwoodworth.knbt.AbstractNbtEncoder.encodeSerializableValue(NbtEncoder.kt:100)
	at net.benwoodworth.knbt.NbtFormatKt.encodeToNbtWriter(NbtFormat.kt:49)
	at net.benwoodworth.knbt.Nbt.encodeToSink(Nbt.kt:25)
	at net.benwoodworth.knbt.JvmStreamsKt.encodeToStream(JvmStreams.kt:18)
	at NbtTestKt.main(NbtTest.kt:46)
	at NbtTestKt.main(NbtTest.kt)

When looking at the line that threw the exception, it shows this:

when (descriptor.kind as StructureKind) {
    //...
}

The reason why it throws the error is because descriptor.kind is of type SerialKind, which is in fact a supertype of StructureKind, however, the type of descriptor.kind at this point is PolymorphicKind, which is not a subtype of StructureKind. They are on different parts of the hierarchy.

SerialKind
| - PolymorphicKind
| - ...
|- StructureKind
|- ...

My Nbt object setup looks like this:

val nbt = Nbt {
        variant = NbtVariant.Java
        compression = NbtCompression.None
        compressionLevel = null
        encodeDefaults = false
        ignoreUnknownKeys = false
        serializersModule = SerializersModule {
            polymorphic(DataElement::class){
                subclass(DataElement.Section::class)
                subclass(DataElement.Group::class)
                subclass(DataElement.Unit::class)
            }
        }
    }

However, this also crashes if I use the EmptySerializersModule.

I was able to reproduce it with the following code:

import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.modules.EmptySerializersModule
import net.benwoodworth.knbt.*
import okio.use
import java.io.File

@kotlinx.serialization.Serializable
sealed class Person{
    abstract val name: String
    abstract val age: Int

    @kotlinx.serialization.Serializable
    class Adult(override val name: String, override val age: Int, val occupancy: String): Person()
    @kotlinx.serialization.Serializable
    class Teen(override val name: String, override val age: Int, val school: String): Person()
    @kotlinx.serialization.Serializable
    class Child(override val name: String, override val age: Int, val father: Person, val mother: Person): Person()
}

fun main(){
    val nbt = Nbt {
        variant = NbtVariant.Java // Java, Bedrock, BedrockNetwork
        compression = NbtCompression.None // None, Gzip, Zlib
        compressionLevel = null // in 0..9
        encodeDefaults = false
        ignoreUnknownKeys = false
        serializersModule = EmptySerializersModule
    }
    val father = Person.Adult("jon", 32, "accountant")
    val mother = Person.Adult("kathy", 31, "baker")
    val child = Person.Child("nate", 6, father, mother)
    val fs = File("nbt.test")
    val childBA = nbt.encodeToByteArray(child)
//    fs.outputStream().use {
//        nbt.encodeToStream(child, it)
//    }
//    val childFromNbt: Person.Child = fs.inputStream().use {
//        nbt.decodeFromStream(it)
//    }
    val childFromNbt: Person.Child = nbt.decodeFromByteArray(childBA)
    println(childFromNbt)

The okio code which is commented was the original code I used to reproduce it but I decided tried it for just a regular bytearray encode/decode call. Of course it would be same because this crash is in the default nbt encoder.

Re-design root class name NbtCompound-nesting functionality

Problem

Currently (v0.11) classes are serialized within a single-entry NbtCompound, with the serial name as the key. But, only if serialized at the root.

This behavior is convenient, but has a couple issues:

  • @SerialName doesn't support empty strings, which is common in NBT files.
  • But more importantly, and the focus of this issue, the meaning of an NBT compound tag is different depending on the context.
    • A deserializer looking at an NbtCompound needs to know where in the serialization process it is to know how to deserialize the tag.
    • Specifically for a NbtContentPolymorphicSerializer, because extra nesting is added for a class depending on if it's at the root, looking at the shape of the tag alone isn't enough, and properly accounting for it is undesirable complexity.
  • This behavior has also been the source of confusion, with it being unintuitive that e.g. Nbt.encodeToNbtTag(someClass) and encoder.encodeSerializableElement(serializer, someClass) don't produce the same result.
    • And, upon understanding the issue, it becomes an inconvenience that needs to be worked around. E.g. the v0.11 NbtTransformingSerializer implementation that needs to use internal API, and this code in slack that resorts to inspecting a NbtCompound before encoding

Old redesign

This issue's initial redesign has been implemented, but is being replaced by the new design proposed in this comment

Original solution
  • Remove the special conditional logic for adding serial names at the root, so data serializes the same regardless of where in the serialization process/data it is
    • This makes deserializing based on the structure more straightforward
  • Add an annotation that, when applied to a serial descriptor, instructs the serializer unconditionally to nest the data in a single-entry NbtCompound
    • This covers the use case of top-level NBT binary/files needing to be a named tag (single-element NbtCompound)
    • From the docs:

      An NBT file consists of a single GZIPped Named Tag of type TAG_Compound.

      • So @NbtNamed might be a good annotation name. @NbtRoot and @NbtFile have been used in earlier versions of knbt, but they seem too specific

Tasks

  • StructureKind.CLASS/OBJECT
    • Covers the previous default class nesting using the serial name
  • StructureKind.MAP
  • StructureKind.LIST

New Design

This design aims to add a concept of NBT names that all serializable types have, with every type either:

  • having a static name that can be easily annotated and optionally validated.
  • being dynamically named, giving full control to the serializer for encoding/decoding the name, and the full behavior around it.

The initial implementation will mainly focus on implementing static naming, since most use cases don't need logic around the serialized NBT names, and it's also not clear yet how an API for dynamic names should behave. See the dynamic names section below for details.

For the scope of this initial design, the NBT name only applies to the root tag name. Though in the future, especially with dynamic names, it's possible the NBT name could apply more broadly. (e.g. a value within an NBT compound knowing its own element name while deserializing)

Use Cases

These are use cases that are being designed for in this new approach

Default use, without using any named NBT API

  • All data implicitly has @NbtName("")
  • Decoding from non-empty named NBT will fail
    • Not lenient with mismatched names by default, similar to how Json is strict by default, e.g. with isLenient and ignoreUnknownKeys
    • Can be made lenient with a ignoreNbtName configuration option or similar

Statically setting the NBT root name for a type

  • Annotating a class/interface with @NbtName (or including it in the serial descriptor annotations)

Inspecting NBT (including root name) by decoding to an in-memory representation

val decodedNbt: NbtNamed<NbtTag> = nbt.decodeFromBufferedSource(source)

Inspecting how data is encoded to NBT (including root name) through an in-memory representation

val encodedNbt: NbtNamed<NbtTag> = nbt.encodeToNamedNbtTag(data)

Named-root NBT variants

Only some variants of NBT have root names encoded. With this new design, serializing values should be the same between named and unnamed root variants (instead of named variants being modeled as nested in an NBT compound).

Unnamed root variants:

  • Java Network (starting from 1.20.2)
  • SNBT (Based on MC Java's net.minecraft.data.Main NBT -> SNBT conversion tool, since its output SNBT doesn't include the root name anywhere)
  • The in-memory representation with NbtTags

Static NBT names for all serializable types

  • Every serializable type has an NBT name
    • This means all serializers can be used without needing additional logic, notably serializers from other modules that can't be changed.
    • The name will be an empty string unless otherwise set.
      • Minecraft's implementation always uses empty strings, so it's unimportant for most uses
  • Denoted with a @NbtName annotation, on classes or in serial descriptors
  • Name is taken from the "outermost" value
    • When one serializer delegates to another, the first serializer's NBT name is used. That way names can be overwritten.
    • Conceptually, the NBT name of a TAG_Compound element is used over its value's NBT name
  • Used only when the serializable type is the root data of a named-root NBT variant
    • Ignored when the serializable type is an element of another
    • Ignored when used with the root value of an unnamed-root NBT variant
  • When decoding, serves to validate that the NBT has the expected name
    • Strict by default, failing if decoding a different name than expected
    • Can be disabled with a configuration option

Dynamically serializing NBT names, and future encoding/decoding API

Add a basic NbtNamed class for an in-memory representation of the (root) NBT value and its name.

  •   class NbtNamed<T>(val name: String, val value: T)
    • Non-nullable name, since using this class implies that the serialized NBT is a named tag
    • Nullable T, since the newer Java Network NBT supports null (TAG_End) as a root value
  • Initially very restrictive, supporting basic root name-accessing functionality until it can better designed later on
    • NbtNamed's serializer will be specially handled by the NBT encoder/decoder for now
  • Serializers should be able to delegate to NbtNamed's serializer
    1. Expected behavior is the outer serializer effectively having a dynamic NBT name.
    2. Because of this, the outer serializer should not validate against its static name.
    3. That means the writing/validating of the NBT name should be held off until a value is actually decoded/encoded
      • Since, until then, it's impossible to know if another serializer will be delegated to
  • Wrapping any value with NbtNamed should override that value's NBT name
    • This means wrapping another NbtNamed (directly, or indirectly when delegating serializers) should give precedence to the outermost dynamic name

Care was taken when deciding how this would work, making sure there's room for full-blown dynamically serialized names to be added later in a forward-compatible way.

  • Only support NbtNamed at the root for now
    • The NBT spec describes TAG_Compound entries as being a list of named tags, so there is an interpretation where it makes sense for NBT names` to be used with nested tag entries
    • Potential problem: serializers that start a new serialization, like NbtTransformingSerializer that can serialize a nested value as root NBT before re-serializing
  • Fail if used with unnamed root NBT variants
    • It's not clear how encoding an NBT name where there isn't one should work. Discard it? Fail?
    • It's also not clear how decoding a non-existent name should work. Empty string? Fail?

Back `Nbt*Array`s with `List`s, and encode arrays as `NbtList` by default, and require `@NbtArray`

  • Encourages using List instead of Array types (generally a good practice in Kotlin)
  • Allows Arrays to be serialized as NbtLists more easily
    • currently required a custom serializer
    • would otherwise require more machinery to support
  • Lets Nbt*Array types be constructed from lists
    • currently, wrapping lists in NbtArrays was not possible and required copying to new arrays
    • still supports arrays, just with e.g. NbtIntArray(intArray.asList())

Test fails on mingwX64

BinaryNbtReaderTest.should_decode_to_class_correctly:

> Task :mingwX64ProcessResources NO-SOURCE
> Task :mingwX64MainKlibrary
> Task :compileTestKotlinMingwX64
> Task :linkDebugTestMingwX64

Expiring Daemon because JVM heap space is exhausted
Daemon will be stopped at the end of the build after running out of JVM memory
Expiring Daemon because JVM heap space is exhausted
Expiring Daemon because JVM heap space is exhausted

> Task :mingwX64Test FAILED

35 tests completed, 1 failed

net.benwoodworth.knbt.internal.BinaryNbtReaderTest.should_decode_to_class_correctly FAILED
    Unknown

4 actionable tasks: 4 executed
FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':mingwX64Test'.
> Test running process exited unexpectedly.
  Current test: should_decode_to_class_correctly


* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.

* Get more help at https://help.gradle.org/

BUILD FAILED in 4m
Error: Process completed with exit code 1.

Extra bytes are read when decoding Nbt from stream

When decoding Nbt from a stream, the decoder will read past the point where the actual Nbt data is.
This is generally not an issue if you're solely reading the Nbt data, or if it's the last in a sequence of reads, but becomes a problem when you need to perform subsequent reads after decoding the Nbt.

Here's an example to reproduce the problem:

val nbt = Nbt {
    variant = NbtVariant.JavaNetwork(764)
    compression = NbtCompression.None
}

//A stream of data simulating the structure of a packet in the protocol
//NbtCompound ({text:"test"}), followed by an int (3) and a byte (5)
val array = byteArrayOf(10, 8, 0, 4, 116, 101, 120, 116, 0, 4, 116, 101, 115, 116, 0, 0, 0, 0, 3, 5)
val input = ByteArrayInputStream(array)

val tag: NbtTag = nbt.decodeFromStream(input)

println(tag)
println(input.available())

The expected output would be the following, since only the tag was read:

{text:"test"}
5

But I'm getting:

{text:"test"}
0

Allow the encoding/decoding of end tags at root

The title pretty much sums it up: Allow the end tag as root to represent an element that is not present, which would allow the serialization of nullable types at root. This is a behavior that already exists on some parts of the Vanilla client/server (item nbts, query responses, among others), so there is an argument in support of making this a more general behavior.

I'm currently using a workaround to make it possible. But it's quite hacky to have to do this from outside the library, since it involves having to read ahead and then rolling back depending on the type of tag.

[BUG] Config option `ignoreUnknownKeys` breaks when there are unknown keys at the end

I was trying to parse level.dat to get the level name, but it turns out that it doesn't work with the models

@OptIn(ExperimentalNbtApi::class)
@NbtRoot(name = "")
@Serializable
data class LevelRoot(
    @SerialName("Data")
    val data: LevelData
)


@SerialName("Data")
@Serializable
data class LevelData(
    @SerialName("LevelName")
    val levelName: String
)

and the config

Nbt {
    variant = NbtVariant.Java
    compression = NbtCompression.Gzip
    ignoreUnknownKeys = true
}

The following exception + stacktrace is thrown:

Exception in thread "main" java.lang.IllegalStateException: Unexpected TAG_End
	at net.benwoodworth.knbt.internal.NbtReaderKt.discardTag(NbtReader.kt:203)
	at net.benwoodworth.knbt.internal.ClassNbtDecoder.handleUnknownKey(NbtDecoder.kt:253)
	at net.benwoodworth.knbt.internal.ClassNbtDecoder.decodeElementIndex(NbtDecoder.kt:269)
	at msw.server.core.model.world.LevelData$$serializer.deserialize(leveldat.kt:17)
	at msw.server.core.model.world.LevelData$$serializer.deserialize(leveldat.kt:17)
	at kotlinx.serialization.encoding.Decoder$DefaultImpls.decodeSerializableValue(Decoding.kt:260)
	at kotlinx.serialization.encoding.AbstractDecoder.decodeSerializableValue(AbstractDecoder.kt:16)
	at net.benwoodworth.knbt.AbstractNbtDecoder.decodeSerializableValue(NbtDecoding.kt:84)
	at net.benwoodworth.knbt.internal.BaseNbtDecoder.decodeSerializableValue(NbtDecoder.kt:177)
	at kotlinx.serialization.encoding.AbstractDecoder.decodeSerializableValue(AbstractDecoder.kt:43)
	at net.benwoodworth.knbt.internal.BaseNbtDecoder.decodeSerializableValue(NbtDecoder.kt:180)
	at kotlinx.serialization.encoding.AbstractDecoder.decodeSerializableElement(AbstractDecoder.kt:70)
	at msw.server.core.model.world.LevelRoot$$serializer.deserialize(leveldat.kt:8)
	at msw.server.core.model.world.LevelRoot$$serializer.deserialize(leveldat.kt:8)
	at kotlinx.serialization.encoding.Decoder$DefaultImpls.decodeSerializableValue(Decoding.kt:260)
	at kotlinx.serialization.encoding.AbstractDecoder.decodeSerializableValue(AbstractDecoder.kt:16)
	at net.benwoodworth.knbt.AbstractNbtDecoder.decodeSerializableValue(NbtDecoding.kt:84)
	at net.benwoodworth.knbt.internal.BaseNbtDecoder.decodeSerializableValue(NbtDecoder.kt:177)
	at kotlinx.serialization.encoding.AbstractDecoder.decodeSerializableValue(AbstractDecoder.kt:43)
	at net.benwoodworth.knbt.internal.BaseNbtDecoder.decodeSerializableValue(NbtDecoder.kt:180)
	at kotlinx.serialization.encoding.AbstractDecoder.decodeSerializableElement(AbstractDecoder.kt:70)
	at kotlinx.serialization.encoding.CompositeDecoder$DefaultImpls.decodeSerializableElement$default(Decoding.kt:535)
	at net.benwoodworth.knbt.NbtRootDeserializer.deserialize(NbtRoot.kt:66)
	at kotlinx.serialization.encoding.Decoder$DefaultImpls.decodeSerializableValue(Decoding.kt:260)
	at kotlinx.serialization.encoding.AbstractDecoder.decodeSerializableValue(AbstractDecoder.kt:16)
	at net.benwoodworth.knbt.AbstractNbtDecoder.decodeSerializableValue(NbtDecoding.kt:84)
	at net.benwoodworth.knbt.internal.BaseNbtDecoder.decodeSerializableValue(NbtDecoder.kt:177)
	at net.benwoodworth.knbt.NbtFormatKt.decodeFromNbtReader(NbtFormat.kt:67)
	at net.benwoodworth.knbt.Nbt.decodeFromSource(Nbt.kt:41)
	at net.benwoodworth.knbt.JvmStreamsKt.decodeFromStream(JvmStreams.kt:35)
	at [invocation of Nbt.decodeFromStream<LevelRoot>(inputStream)]

I found out that this exception is thrown when

  1. ignoreUnknownKeys is true
  2. the model classes don't cover all keys present in the nbt file
  3. the last key of a compound is unknown

I started with testing this with level.dat, trying to access [root]/Data/LevelName. That didn't work because there are other keys after LevelName that are not present in the model. Then I tested it with a constructed nbt file that basically just contained

[root]: {
    Data: {
        LevelName: "test"
    }
}

This could be deserialized into the models. Then, I added a random key that is not present in the models:

[root]: {
    Data: {
        ScheduledEvents: []
        LevelName: "test"
    }
}

This still works, since ignoreUnknownKeys is turned on. However, when I swap the positions of ScheduledEvents and LevelName:

[root]: {
    Data: {
        LevelName: "test"
        ScheduledEvents: []
    }
}

The above error is thrown.

Kotlin 1.5.30
KXS 1.2.2
knbt 0.9.1

Consistency issues with equality in list-like `NbtTag` types

History

Currently (v0.11), the NbtList/Array classes all implement the List interface, following the example that kotlinx-serialization-json set with JsonArray (implementing List for convenience).
https://github.com/Kotlin/kotlinx.serialization/blob/8a2c1c0e05ac9c77746141837f6d53d923e24d8a/formats/json/commonMain/src/kotlinx/serialization/json/JsonElement.kt#L191-L195

Its equals implementation simply compares the content, following the contract for list equality. From the List.equals() javadoc:

...two lists are defined to be equal if they contain the same elements in the same order. This definition ensures that the equals method works properly across different implementations of the List interface.

However, there's a problem that NBT is faced with, having four list-like types. Comparing NbtList == NbtIntArray, you would always expect that to be false, as they represent two different serialized forms. But, according to the List contract, an empty NbtList should equal an empty NbtIntArray, since they have the same content.

In v0.11, this is worked around by special-casing NbtTag comparisons, and only checking for the same NbtTag type if both are NbtTags. E.g. NbtList.equals():

override fun equals(other: Any?): Boolean = when {
this === other -> true
other is NbtTag -> other is NbtByteArray && content.contentEquals(other.content)
else -> list == other
}

So, for empty list/nbtList/nbtIntArrays:

  • The List contract is satisfied for non-NBT types: list == nbtList and list == nbtIntArray
  • And content-equal NBT tags of different types are unequal: nbtList != nbtIntArray

The remaining problem

This approach satisfies all of the requirements in the Any.equals() contract, except transitive:

  • Reflexive: for any non-null value x, x.equals(x) should return true.
  • Symmetric: for any non-null values x and y, x.equals(y) should return true if and only if y.equals(x) returns true.
  • Transitive: for any non-null values x, y, and z, if x.equals(y) returns true and y.equals(z) returns true, then x.equals(z) should return true.
  • Consistent: for any non-null values x and y, multiple invocations of x.equals(y) consistently return true or consistently return false, provided no information used in equals comparisons on the objects is modified.
  • Never equal to null: for any non-null value x, x.equals(null) should return false.

Example:

  • nbtList == list, and
  • list == NbtIntArray, so ideally
  • nbtList == nbtIntArray, but it doesn't.

Potential solutions

  • Have the list-like NBT types implement Collection instead (or nothing at all, similar to Kotlin's Array classes)
    • Pros:
      • Unbeholden to the List equality contract
      • Allows for more NBT-specific collection semantics (e.g. how empty TAG_Lists can have a different element type encoded)
    • Cons: (inconvenience, which can be mostly alleviated by NbtList/Nbt*Array.asList() extensions)
      • Can't directly use as Lists
      • Lose access to some of Kotlin's list-specific stdlib extensions
      • May have to add member functions to mirror the methods in List
      • Have to consider making NbtCompound not implement Map for similar reasons
  • Add an equals(NbtTag?) overload to NbtTag
    • Pros:
      • Can retain the convenience of the types implementing List
    • Cons:
      • May not be possible to work with kotlin's Any?.equals() stdlib extension (since a similar NbtTag?.equals() would need to be explicitly imported, and that seems like a potentially major source of issue)
      • Overloading equals in general feels like a design smell, since values are either equal or they aren't, and it doesn't seem like something the caller should be concerned with
  • Leave the implementation how it is, accepting the broken transitivity requirement
    • Pros:
      • Keeps the same convenience
    • Cons:
      • Breaks assumptions made by List consumers, albeit in potentially uncommon cases

Possible conclusion

After having thought on this for admittedly way too much time, I'm leaning towards the first option and implementing nothing for the list-like types (and NbtCompound as well for consistency), leaving richer functionality to the kotlin stdlib List/Maps, and bridging the gap in convenience with asList() and asMap() extensions. Plus adding basic members like size and get() that are often used at the deserialization boundary.

Loosen constraints on boolean deserialization

Currently only the bytes 0b and 1b can be read for boolean values, with all others throwing a serialization exception.
However, Minecraft is more lenient and parses everything but 0b as true, with no values throwing an exception.

Expose `NbtTagType` in the public API

This can be useful for more efficient switching in when statements (with enum whens compiling to branch tables on JVM), it can be useful for exposing the specific TAG_List element type (which could be TAG_End or something else for empty lists), and acts as a standard way to work with the tag types instead of library users having to create their own enum.

Strict binary equality for `NbtFloat` and `NbtDouble`

Currently, two binary different NaN values will return false for equality checks, even though they serialize differently.

Since the NbtTag types represent the serial form, if the serialized value is different, then the serial form should be unequal.

So, these equal functions should compare by the binary representation (with .toRawBits())

Serializing NbtTag results in a SerializationException

If you attempt to serialize an NbtTag and have a subclass of that tag, a SerializationException occurs.

Exception in thread "main" kotlinx.serialization.SerializationException: Class 'NbtCompound' is not registered for polymorphic serialization in the scope of 'NbtTag'.
Mark the base class as 'sealed' or register the serializer explicitly.
	at kotlinx.serialization.internal.AbstractPolymorphicSerializerKt.throwSubtypeNotRegistered(AbstractPolymorphicSerializer.kt:102)
	at kotlinx.serialization.internal.AbstractPolymorphicSerializerKt.throwSubtypeNotRegistered(AbstractPolymorphicSerializer.kt:113)
	at kotlinx.serialization.PolymorphicSerializerKt.findPolymorphicSerializer(PolymorphicSerializer.kt:109)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeSerializableValue(StreamingJsonEncoder.kt:229)
	at kotlinx.serialization.encoding.Encoder$DefaultImpls.encodeNullableSerializableValue(Encoding.kt:299)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeNullableSerializableValue(AbstractEncoder.kt:18)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeNullableSerializableElement(AbstractEncoder.kt:90)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeNullableSerializableElement(StreamingJsonEncoder.kt:154)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeSerializableValue(StreamingJsonEncoder.kt:224)
	at kotlinx.serialization.internal.NullableSerializer.serialize(NullableSerializer.kt:23)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeSerializableValue(StreamingJsonEncoder.kt:224)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeSerializableElement(AbstractEncoder.kt:80)
	at kotlinx.serialization.internal.CollectionLikeSerializer.serialize(CollectionSerializers.kt:69)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeSerializableValue(StreamingJsonEncoder.kt:224)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeSerializableElement(AbstractEncoder.kt:80)
	at io.github.gaming32.mckt.PlayerData.write$Self(world.kt:494)
	at io.github.gaming32.mckt.PlayerData$$serializer.serialize(world.kt:494)
	at io.github.gaming32.mckt.PlayerData$$serializer.serialize(world.kt:494)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeSerializableValue(StreamingJsonEncoder.kt:224)
	at kotlinx.serialization.json.internal.JsonStreamsKt.encodeByWriter(JsonStreams.kt:28)
	at kotlinx.serialization.json.JvmStreamsKt.encodeToStream(JvmStreams.kt:25)
	... 19 definitely unrelated frames

Use Kotlin 1.7's Hierarchical MPP

Currently shared platform code uses symlinks to work around a limitation in Kotlin 1.5's MPP. This workaround shouldn't be needed anymore

Make NbtTags mutable

It would be extremely useful to have data structure-based tags (such as NbtCompound) be mutable.

How to serialize into a Json ?

Hi, for a specific reason, I have to serialize an object as a JSON that also contains a NbtTag property, but I can't figure out how to make it works. By having the default Serializer I get the following error :

Class 'NbtList' is not registered for polymorphic serialization in the scope of 'NbtTag'.
Mark the base class as 'sealed' or register the serializer explicitly.

Support Java protocol version 764+ with nameless tags

As of 23w31a, NBT tags sent over the network no longer encode the usual "empty tag name", and instead only the tag type id and payload. A comparison can be seen here.

A configuration option to not encode the root tag's name should be included in the library, but I'm unsure what would be the best approach. Here are some ideas and arguments in favor/against:

  • A new NbtVariant (such as NbtVariant.JavaNetwork). This would make sense since the change only affects tags sent over the network, while the ones saved to the disk remain the same. However, it could cause confusion since this changes only affects 23w31a and beyond, while prior versions remain unaffected.
  • A new general configuration option (such as encodeRootTagName = true). This would eliminate the necessity for a NbtVariant that would be version dependent. However, this option wouldn't make sense for other NbtVariants aside from Java.

Support other native platforms

Currently the published knbt-native can only be used on linux_x64 (since I'm publishing it from my linux machine)

This should work fine for other platforms, since the only platform-specific dependencies are okio and platform.posix.zlib (which both support most native targets)

I get this error when trying to use this library on Windows (mingw_x64):

Execution failed for task ':compileKotlinNative'.
> Could not resolve all files for configuration ':nativeCompileKlibraries'.
   > Could not resolve net.benwoodworth.knbt:knbt:0.3.0.
     Required by:
         project :
      > No matching variant of net.benwoodworth.knbt:knbt:0.3.0 was found. The consumer was configured to find a usage of 'kotlin-api' of a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native', attribute 'org.jetbrains.kotlin.native.target' with value 'mingw_x64' but:
          - Variant 'commonMainMetadataElements' capability net.benwoodworth.knbt:knbt:0.3.0 declares a usage of 'kotlin-api' of a component:
              - Incompatible because this component declares a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'common' and the consumer needed a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
              - Other compatible attribute:
                  - Doesn't say anything about org.jetbrains.kotlin.native.target (required 'mingw_x64')
          - Variant 'jsApiElements-published' capability net.benwoodworth.knbt:knbt:0.3.0 declares a usage of 'kotlin-api' of a component:
              - Incompatible because this component declares a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'js' and the consumer needed a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
              - Other compatible attribute:
                  - Doesn't say anything about org.jetbrains.kotlin.native.target (required 'mingw_x64')
          - Variant 'jsRuntimeElements-published' capability net.benwoodworth.knbt:knbt:0.3.0:
              - Incompatible because this component declares a usage of 'kotlin-runtime' of a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'js' and the consumer needed a usage of 'kotlin-api' of a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
              - Other compatible attribute:
                  - Doesn't say anything about org.jetbrains.kotlin.native.target (required 'mingw_x64')
          - Variant 'jvmApiElements-published' capability net.benwoodworth.knbt:knbt:0.3.0 declares an API of a component:
              - Incompatible because this component declares a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'jvm' and the consumer needed a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
              - Other compatible attribute:
                  - Doesn't say anything about org.jetbrains.kotlin.native.target (required 'mingw_x64')
          - Variant 'jvmRuntimeElements-published' capability net.benwoodworth.knbt:knbt:0.3.0 declares a runtime of a component:
              - Incompatible because this component declares a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'jvm' and the consumer needed a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
              - Other compatible attribute:
                  - Doesn't say anything about org.jetbrains.kotlin.native.target (required 'mingw_x64')
          - Variant 'metadataApiElements' capability net.benwoodworth.knbt:knbt:0.3.0:
              - Incompatible because this component declares a usage of 'kotlin-metadata' of a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'common' and the consumer needed a usage of 'kotlin-api' of a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native'
              - Other compatible attribute:
                  - Doesn't say anything about org.jetbrains.kotlin.native.target (required 'mingw_x64')
          - Variant 'nativeApiElements-published' capability net.benwoodworth.knbt:knbt:0.3.0 declares a usage of 'kotlin-api' of a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'native':
              - Incompatible because this component declares a component, as well as attribute 'org.jetbrains.kotlin.native.target' with value 'linux_x64' and the consumer needed a component, as well as attribute 'org.jetbrains.kotlin.native.target' with value 'mingw_x64'

Support Compressed NBT

NBT can either be uncompressed, or compressed with gzip/zlib

Decoding NBT should automatically work.

Encoding NBT can use a compression method configured with the NbtConfiguration.

Kotlin/JVM

  • Gzip compression
  • Gzip decompression
  • Zlib compression
  • Zlib decompression

Kotlin/JS (pako, zlib ported to JavaScript)

  • Gzip compression
  • Gzip decompression
  • Zlib compression
  • Zlib decompression

Kotlin/Native (platform.zlib)

  • Gzip compression
  • Gzip decompression
  • Zlib compression
  • Zlib decompression

Check that SNBT on JS reads/writes floats/doubles correctly

Kotlin/JS (and JavaScript in general) will stringify Floats/Doubles differently than on other targets, so make sure the TAG_Float and TAG_Double string representations are compatible with Minecraft's SNBT implementation.

For example, 10^100:

  • Java: 1.0E100
  • JavaScript: 1e+100

This applies to StringifiedNbt, and to NbtFloat/NbtDouble.toString()

Refactor `NbtEncoder`/`NbtDecoder` implementations

Becoming difficult to maintain, and the two implementations are very different.

If possible, leaning on a more stateless design and leaning on SerialDescriptors would be ideal.

TODO:

  • Re-write with minimal abstractions, so logic is easier to follow
  • Don't use values to determine how to encode. SerialDescriptor should be the source of truth (see: here)
  • Track NBT path more nicely

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.