diff --git a/docs/README.md b/docs/README.md index 8d5a5b9..59d7f81 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,7 @@ # libmc +[中文](zh/README-zh.md) + A lightweight, modern Minecraft client protocol library designed for Kotlin Native & JVM. The core protocol library module relies on the following dependencies: @@ -19,11 +21,17 @@ module relies on the following dependencies: # Get started +# Protocol + > `libmc-protocol` is current under development. It only supports the latest Minecraft version > (Current supported Minecraft version: `26.2`, Protocol Version: `776`) -[Start using libmc-protocol](Get-started.md) +[Start using libmc-protocol](en/Get-started.md) # Assemble all context APIs -[Assemble context APIs](Assemble-context.md) \ No newline at end of file +[Assemble context APIs](en/Assemble-context.md) + +## NBT & SNBT + +[NBT & SNBT](en/NBT-SNBT.md) \ No newline at end of file diff --git a/docs/Assemble-context.md b/docs/en/Assemble-context.md similarity index 100% rename from docs/Assemble-context.md rename to docs/en/Assemble-context.md diff --git a/docs/Get-started.md b/docs/en/Get-started.md similarity index 100% rename from docs/Get-started.md rename to docs/en/Get-started.md diff --git a/docs/Impl-Crypto.md b/docs/en/Impl-Crypto.md similarity index 100% rename from docs/Impl-Crypto.md rename to docs/en/Impl-Crypto.md diff --git a/docs/Impl-HTTP-Client.md b/docs/en/Impl-HTTP-Client.md similarity index 100% rename from docs/Impl-HTTP-Client.md rename to docs/en/Impl-HTTP-Client.md diff --git a/docs/Impl-TCP-Socket.md b/docs/en/Impl-TCP-Socket.md similarity index 100% rename from docs/Impl-TCP-Socket.md rename to docs/en/Impl-TCP-Socket.md diff --git a/docs/en/NBT-SNBT.md b/docs/en/NBT-SNBT.md new file mode 100644 index 0000000..eca7b77 --- /dev/null +++ b/docs/en/NBT-SNBT.md @@ -0,0 +1,75 @@ +# NBT + +```kotlin +fun main() { + // build + val nbt = buildNBT { + "t_b" byte 0x01 + "n" compound { + "n_s" string "STR" + "n_d" double 0.0 + "n_f" float 0.1f + "n_ia" intArray intArrayOf(1, 1, 1, 1) + } + } + val buf = BytesBuffer().toNBTOutput( + NBTTag.CompoundTag( + mapOf("" to nbt) + ) + ).writeNBTRootCompound() + println(buf.toHexString()) + + // read + + val levelDat: BytesBuffer = File("src/commonTest/resources/level.dat").readBytes().wrap() + val readRoot = levelDat.toNBTInput().readNBTRootCompound() + println(readRoot) +} +``` + +# SNBT + +```kotlin +fun main() { + // parse snbt from snbt string + val raw = """{key1: 123,'key2': 'somevalue1',"key3": {subkey1: 0x1C8,"subkey2": "somevalue2"}}""" + println(snbt(raw)) + + // serialize snbt from NBTCompound + println(snbt(raw).toSNBT()) + + // build snbt + val snbt = buildSNBT { + "key1" string "TEST" + "intValue" int 1 + "Count" byte 1 + "Damage" int 0 + } + + val prettySnbt = buildSNBT(prettyPrint = true) { + "Name" string "Steve" + "Health" float 20.0f + "IsCreative" boolean true + "Pos" intArray intArrayOf(100, 64, -200) + "Custom Name" string "Alex\nWith Newline" + "Attributes" compound { + "AttackDamage" double 5.5 + "MovementSpeed" float 0.1f + } + "Inventory" list { + compound { + "id" string "minecraft:diamond_sword" + "Count" byte 1 + } + compound { + "id" string "minecraft:apple" + "Count" byte 16 + } + } + } + println(snbt) // SNBT String + println(prettySnbt) // SNBT String + println(snbt(snbt)) // parse snbt string to NBTCompound + println(snbt(prettySnbt)) // parse snbt string to NBTCompound +} +``` \ No newline at end of file diff --git a/docs/zh/README-zh.md b/docs/zh/README-zh.md index d90d851..c733b87 100644 --- a/docs/zh/README-zh.md +++ b/docs/zh/README-zh.md @@ -8,7 +8,7 @@ ## 需要实现的API -| 名称 | 是否必须实现 | 描述 | +| 名称 | 是否必须实现 | 额外说明 | |:-------------|:-------------|:-----------------------------------------------------------------------------------------------------------------------------------------------| | TCP Socket | 是 | `protocol` 模块没有内置TCP Socket实现. [实现 TCP Socket](Impl-TCP-Socket-zh.md) | | AES-128-CFB8 | 视情况而定 | 仅在登录开启了正版验证(`online-mode`)的服务器时需要, 用于加密/解密流量. [实现AES-128-CFB8](Impl-Crypto-zh.md#AES-128-CFB8) | @@ -25,4 +25,8 @@ # 将实现的API组合起来 -[将所有上下文API组合](Assemble-context-zh.md) \ No newline at end of file +[将所有上下文API组合](Assemble-context-zh.md) + +# NBT & SNBT + +[NBT & SNBT](../en/NBT-SNBT.md) \ No newline at end of file diff --git a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTCompoundBuilder.kt b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTCompoundBuilder.kt index b32a8ed..016188a 100644 --- a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTCompoundBuilder.kt +++ b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTCompoundBuilder.kt @@ -56,7 +56,7 @@ public class CompoundBuilder { } public infix fun String.compound(block: CompoundBuilder.() -> Unit) { - tags[this] = nbtCompound(block) + tags[this] = buildNBT(block) } @Suppress("FunctionName") @@ -76,7 +76,7 @@ public class CompoundBuilder { public fun build(): NBTTag.CompoundTag = NBTTag.CompoundTag(tags) } -public fun nbtCompound(block: CompoundBuilder.() -> Unit): NBTTag.CompoundTag { +public fun buildNBT(block: CompoundBuilder.() -> Unit): NBTTag.CompoundTag { val builder = CompoundBuilder() builder.block() return builder.build() diff --git a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTListBuilder.kt b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTListBuilder.kt index d333112..fded2b4 100644 --- a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTListBuilder.kt +++ b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTListBuilder.kt @@ -53,7 +53,7 @@ public class NBTListBuilder( public fun compound(block: CompoundBuilder.() -> Unit) { checkType(NBTType.Compound) - elements += nbtCompound(block) + elements += buildNBT(block) } public fun list(elementType: NBTType, block: NBTListBuilder.() -> Unit) { diff --git a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTReader.kt b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTReader.kt index 2208bfe..1923dfc 100644 --- a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTReader.kt +++ b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTReader.kt @@ -67,7 +67,7 @@ public fun NBTInput.readCompound(): NBTTag { return NBTTag.ListTag(NBTType.Compound, map.values.toMutableList()) } -public fun NBTInput.readRootCompound(): NBTCompound { +public fun NBTInput.readNBTRootCompound(): NBTCompound { val rootType = NBTType.fromID(readByte().toInt()) require(rootType == NBTType.Compound) { "Root tag must be TAG_Compound" } val nameLen = readShort().toInt() and 0xFFFF diff --git a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTTag.kt b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTTag.kt index 5e7d877..0ff97a8 100644 --- a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTTag.kt +++ b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTTag.kt @@ -15,7 +15,7 @@ public sealed class NBTTag(public val type: NBTType) { public data class FloatTag(val value: Float) : NBTTag(NBTType.Float) public data class DoubleTag(val value: Double) : NBTTag(NBTType.Double) public data class StringTag(val value: String) : NBTTag(NBTType.String) - public data class CompoundTag(val value: MutableMap) : NBTTag(NBTType.Compound) + public data class CompoundTag(val value: Map) : NBTTag(NBTType.Compound) public data class ListTag(val elementType: NBTType, val value: MutableList) : NBTTag(NBTType.List) { public val length: Int get() = value.size } diff --git a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTWriter.kt b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTWriter.kt index 2cc0dfb..a78e9cd 100644 --- a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTWriter.kt +++ b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTWriter.kt @@ -56,7 +56,7 @@ public fun NBTOutput.writeTagPayload(tag: NBTTag) { } } -public fun NBTOutput.writeRootNBTCompound(name: String = ""): ByteArray { +public fun NBTOutput.writeNBTRootCompound(name: String = ""): ByteArray { writeByte(NBTType.Compound.id) writeStringTag(name) writeTagPayload(root) diff --git a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/snbt/SNBTBuilder.kt b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/snbt/SNBTBuilder.kt new file mode 100644 index 0000000..71cf88c --- /dev/null +++ b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/snbt/SNBTBuilder.kt @@ -0,0 +1,171 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/8 + */ + +package cn.rtast.libmc.snbt + +@DslMarker +public annotation class SnbtDsl + +@SnbtDsl +public abstract class BaseSnbtBuilder( + protected val sb: StringBuilder, + protected val prettyPrint: Boolean, + protected val indentLevel: Int, +) { + public var isFirst: Boolean = true + + protected fun appendSeparator() { + if (!isFirst) { + sb.append(if (prettyPrint) ",\n" else ",") + } else { + if (prettyPrint) sb.append('\n') + isFirst = false + } + if (prettyPrint) { + sb.append(" ".repeat(indentLevel + 1)) + } + } + + protected inline fun buildBlock(openChar: Char, closeChar: Char, action: () -> Unit) { + sb.append(openChar) + action() + if (prettyPrint && !isFirst) { + sb.append('\n').append(" ".repeat(indentLevel)) + } + sb.append(closeChar) + } +} + +@SnbtDsl +public class SnbtCompoundBuilder( + sb: StringBuilder = StringBuilder(), + prettyPrint: Boolean = false, + indentLevel: Int = 0, +) : BaseSnbtBuilder(sb, prettyPrint, indentLevel) { + + private fun appendKey(key: String) { + appendSeparator() + sb.append(escapeSNBTKey(key)).append(if (prettyPrint) ": " else ":") + } + + public infix fun String.byte(value: Byte) { + appendKey(this); sb.append(value).append('b') + } + + public infix fun String.boolean(value: Boolean) { + appendKey(this); sb.append(if (value) "1b" else "0b") + } + + public infix fun String.short(value: Short) { + appendKey(this); sb.append(value).append('s') + } + + public infix fun String.int(value: Int) { + appendKey(this); sb.append(value) + } + + public infix fun String.long(value: Long) { + appendKey(this); sb.append(value).append('L') + } + + public infix fun String.float(value: Float) { + appendKey(this); sb.append(value).append('f') + } + + public infix fun String.double(value: Double) { + appendKey(this); sb.append(value).append('d') + } + + public infix fun String.string(value: String) { + appendKey(this); sb.append(escapeSNBTString(value)) + } + + public infix fun String.byteArray(value: ByteArray) { + appendKey(this) + sb.append(value.joinToString(prefix = "[B; ", postfix = "]", separator = ", ") { "${it}b" }) + } + + public infix fun String.intArray(value: IntArray) { + appendKey(this) + sb.append(value.joinToString(prefix = "[I; ", postfix = "]", separator = ", ") { "$it" }) + } + + public infix fun String.longArray(value: LongArray) { + appendKey(this) + sb.append(value.joinToString(prefix = "[L; ", postfix = "]", separator = ", ") { "${it}L" }) + } + + public infix fun String.compound(block: SnbtCompoundBuilder.() -> Unit) { + appendKey(this) + SnbtCompoundBuilder(sb, prettyPrint, indentLevel + 1).buildInternal(block) + } + + public infix fun String.list(block: SnbtListBuilder.() -> Unit) { + appendKey(this) + SnbtListBuilder(sb, prettyPrint, indentLevel + 1).buildInternal(block) + } + + internal fun buildInternal(block: SnbtCompoundBuilder.() -> Unit): String { + buildBlock('{', '}') { this.block() } + return sb.toString() + } +} + +@SnbtDsl +public class SnbtListBuilder( + sb: StringBuilder, + prettyPrint: Boolean, + indentLevel: Int, +) : BaseSnbtBuilder(sb, prettyPrint, indentLevel) { + public fun byte(value: Byte) { + appendSeparator() + sb.append(value).append('b') + } + + public fun short(value: Short) { + appendSeparator() + sb.append(value).append('s') + } + + public fun int(value: Int) { + appendSeparator() + sb.append(value) + } + + public fun long(value: Long) { + appendSeparator() + sb.append(value).append('L') + } + + public fun float(value: Float) { + appendSeparator() + sb.append(value).append('f') + } + + public fun double(value: Double) { + appendSeparator() + sb.append(value).append('d') + } + + public fun string(value: String) { + appendSeparator() + sb.append(escapeSNBTString(value)) + } + + public fun compound(block: SnbtCompoundBuilder.() -> Unit) { + appendSeparator() + SnbtCompoundBuilder(sb, prettyPrint, indentLevel + 1).buildInternal(block) + } + + internal fun buildInternal(block: SnbtListBuilder.() -> Unit) = buildBlock('[', ']') { this.block() } +} + +public fun buildSNBT( + prettyPrint: Boolean = false, + block: SnbtCompoundBuilder.() -> Unit, +): String { + return SnbtCompoundBuilder(prettyPrint = prettyPrint).buildInternal(block) +} diff --git a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/snbt/SNBTDecoder.kt b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/snbt/SNBTDecoder.kt new file mode 100644 index 0000000..9980002 --- /dev/null +++ b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/snbt/SNBTDecoder.kt @@ -0,0 +1,324 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/8 + */ + + +package cn.rtast.libmc.snbt + +import cn.rtast.libmc.nbt.NBTTag +import cn.rtast.libmc.nbt.NBTType + +public class SNBTDecoder internal constructor(private val src: String) { + private var ptr = 0 + + public fun parse(): NBTTag { + skipWhitespace() + val result = parseTag() + skipWhitespace() + require(ptr >= src.length) { "Unexpected trailing characters at index $ptr: '${src.substring(ptr)}'" } + return result + } + + private fun parseTag(): NBTTag { + skipWhitespace() + check(ptr < src.length) { "Unexpected end of SNBT string" } + if (lookingAtOperation()) return parseOperation() + return when (src[ptr]) { + '{' -> parseCompoundTag() + '[' -> parseListOrArrayTag() + '"', '\'' -> NBTTag.StringTag(parseQuotedString()) + else -> parseNumberOrStringTag() + } + } + + private fun parseCompoundTag(): NBTTag.CompoundTag { + expect('{') + val map = LinkedHashMap() + skipWhitespace() + if (peek() == '}') { + ptr++ + return NBTTag.CompoundTag(map) + } + while (true) { + skipWhitespace() + val key = parseKey() + skipWhitespace() + expect(':') + val value = parseTag() + map[key] = value + skipWhitespace() + val ch = peek() + if (ch == '}') { + ptr++ + break + } + if (ch == ',') { + ptr++ + skipWhitespace() + if (peek() == '}') { + ptr++ + break + } + } else error("Expected ',' or '}' at index $ptr, found '$ch'") + } + return NBTTag.CompoundTag(map) + } + + private fun parseListOrArrayTag(): NBTTag { + expect('[') + skipWhitespace() + if (ptr + 1 < src.length && src[ptr + 1] == ';') { + val typeChar = src[ptr].uppercaseChar() + ptr += 2 + return parseNativeArray(typeChar) + } + val list = ArrayList() + if (peek() == ']') { + ptr++ + return NBTTag.ListTag(NBTType.End, list) + } + + var elementType = NBTType.End + while (true) { + val tag = parseTag() + if (elementType == NBTType.End) elementType = tag.type + list.add(tag) + skipWhitespace() + val ch = peek() + if (ch == ']') { + ptr++ + break + } + if (ch == ',') { + ptr++ + skipWhitespace() + if (peek() == ']') { + ptr++ + break + } + } else error("Expected ',' or ']' at index $ptr, found '$ch'") + } + return NBTTag.ListTag(elementType, list) + } + + private fun parseNativeArray(typeChar: Char): NBTTag { + skipWhitespace() + val rawTags = mutableListOf() + if (peek() == ']') { + ptr++ + return castNativeArray(typeChar, rawTags) + } + while (true) { + rawTags.add(parseTag()) + skipWhitespace() + val ch = peek() + if (ch == ']') { + ptr++ + break + } + if (ch == ',') { + ptr++ + skipWhitespace() + if (peek() == ']') { + ptr++ + break + } + } else error("Expected ',' or ']' at index $ptr, found '$ch'") + } + return castNativeArray(typeChar, rawTags) + } + + private fun castNativeArray(typeChar: Char, elements: List): NBTTag = when (typeChar) { + 'B' -> NBTTag.ByteArrayTag(ByteArray(elements.size) { extractNumber(elements[it]).toByte() }) + 'I' -> NBTTag.IntArrayTag(IntArray(elements.size) { extractNumber(elements[it]).toInt() }) + 'L' -> NBTTag.LongArrayTag(LongArray(elements.size) { extractNumber(elements[it]).toLong() }) + else -> error("Unknown array prefix: $typeChar") + } + + private fun extractNumber(tag: NBTTag): Number = when (tag) { + is NBTTag.ByteTag -> tag.value + is NBTTag.ShortTag -> tag.value + is NBTTag.IntTag -> tag.value + is NBTTag.LongTag -> tag.value + is NBTTag.FloatTag -> tag.value.toInt() + is NBTTag.DoubleTag -> tag.value.toLong() + else -> error("Cannot convert $tag to array element") + } + + private fun parseKey(): String { + return when (peek()) { + '"', '\'' -> parseQuotedString() + else -> parseUnquotedKey() + } + } + + private fun parseUnquotedKey(): String { + val start = ptr + while (ptr < src.length && isAllowedUnquotedChar(src[ptr])) ptr++ + val key = src.substring(start, ptr) + require(key.isNotEmpty()) { "Expected key at index $start" } + return key + } + + private fun parseNumberOrStringTag(): NBTTag { + val start = ptr + while (ptr < src.length && isAllowedUnquotedChar(src[ptr])) ptr++ + val raw = src.substring(start, ptr) + if (raw.equals("true", ignoreCase = true)) return NBTTag.ByteTag(1) + if (raw.equals("false", ignoreCase = true)) return NBTTag.ByteTag(0) + val parsedNum = tryParseNumber(raw) + if (parsedNum != null) return parsedNum + val first = raw.firstOrNull() + if (first in '0'..'9' || first == '.' || first == '+' || first == '-') error("Unquoted string cannot start with '$first': $raw") + return NBTTag.StringTag(raw) + } + + private fun tryParseNumber(raw: String): NBTTag? { + val clean = raw.replace("_", "") + val lower = clean.lowercase() + if (lower.endsWith("f")) return NBTTag.FloatTag(lower.dropLast(1).toFloatOrNull() ?: return null) + if (lower.endsWith("d")) return NBTTag.DoubleTag(lower.dropLast(1).toDoubleOrNull() ?: return null) + if (clean.contains('.') || lower.contains('e')) return NBTTag.DoubleTag(clean.toDoubleOrNull() ?: return null) + var rad = 10 + var digits = lower + if (digits.startsWith("0x")) { + rad = 16 + digits = digits.substring(2) + } else if (digits.startsWith("0b")) { + rad = 2 + digits = digits.substring(2) + } + val (numStr, suffix) = splitSuffix(digits) + return try { + val unsigned = suffix.startsWith("u") || if (suffix.startsWith("s")) false else rad != 10 + val typeChar = suffix.lastOrNull() ?: if (rad == 10) 'i' else 'i' + when (typeChar) { + 'b' -> NBTTag.ByteTag(if (unsigned) numStr.toUByte(rad).toByte() else numStr.toByte(rad)) + 's' -> NBTTag.ShortTag(if (unsigned) numStr.toUShort(rad).toShort() else numStr.toShort(rad)) + 'l' -> NBTTag.LongTag(if (unsigned) numStr.toULong(rad).toLong() else numStr.toLong(rad)) + else -> NBTTag.IntTag(if (unsigned) numStr.toUInt(rad).toInt() else numStr.toInt(rad)) + } + } catch (_: Exception) { + null + } + } + + private fun splitSuffix(str: String): Pair { + val suffixes = listOf("sb", "ub", "ss", "us", "si", "ui", "sl", "ul", "b", "s", "i", "l") + for (s in suffixes) if (str.endsWith(s)) return Pair(str.dropLast(s.length), s) + return Pair(str, "") + } + + private fun parseQuotedString(): String { + val quote = src[ptr++] + val sb = StringBuilder() + while (ptr < src.length) { + val ch = src[ptr++] + if (ch == quote) return sb.toString() + if (ch == '\\') { + if (ptr >= src.length) break + when (val esc = src[ptr++]) { + 'b' -> sb.append('\b') + 'f' -> sb.append('\u000C') + 'n' -> sb.append('\n') + 'r' -> sb.append('\r') + 's' -> sb.append(' ') + 't' -> sb.append('\t') + '\\' -> sb.append('\\') + '\'', '"' -> sb.append(esc) + 'x' -> { + val hex = src.substring(ptr, ptr + 2) + ptr += 2 + sb.append(hex.toInt(16).toChar()) + } + + 'u' -> { + val hex = src.substring(ptr, ptr + 4) + ptr += 4 + sb.append(hex.toInt(16).toChar()) + } + + 'U' -> { + val hex = src.substring(ptr, ptr + 8) + ptr += 8 + val codePoint = hex.toInt(16) + sb.appendCodePoint(codePoint) + } + + else -> sb.append(esc) + } + } else sb.append(ch) + } + error("Unterminated quoted string") + } + + private fun lookingAtOperation(): Boolean { + val rest = src.substring(ptr) + return rest.startsWith("bool(") || rest.startsWith("uuid(") + } + + private fun parseOperation(): NBTTag { + val isBool = src.substring(ptr).startsWith("bool(") + val opName = if (isBool) "bool" else "uuid" + ptr += opName.length + expect('(') + skipWhitespace() + val argTag = parseTag() + skipWhitespace() + expect(')') + return if (isBool) { + val isTrue = when (argTag) { + is NBTTag.ByteTag -> argTag.value != 0.toByte() + is NBTTag.ShortTag -> argTag.value != 0.toShort() + is NBTTag.IntTag -> argTag.value != 0 + is NBTTag.LongTag -> argTag.value != 0L + is NBTTag.FloatTag -> argTag.value != 0.0f + is NBTTag.DoubleTag -> argTag.value != 0.0 + else -> error("bool() requires a number or boolean argument") + } + NBTTag.ByteTag(if (isTrue) 1 else 0) + } else { + require(argTag is NBTTag.StringTag) { "uuid() argument must be a string" } + val uuidStr = argTag.value.replace("-", "") + require(uuidStr.length == 32) { "Invalid UUID string: ${argTag.value}" } + val i1 = uuidStr.substring(0, 8).toLong(16).toInt() + val i2 = uuidStr.substring(8, 16).toLong(16).toInt() + val i3 = uuidStr.substring(16, 24).toLong(16).toInt() + val i4 = uuidStr.substring(24, 32).toLong(16).toInt() + NBTTag.IntArrayTag(intArrayOf(i1, i2, i3, i4)) + } + } + + private fun isAllowedUnquotedChar(ch: Char): Boolean = + ch in 'a'..'z' || ch in 'A'..'Z' || ch in '0'..'9' || ch == '_' || ch == '-' || ch == '+' || ch == '.' + + private fun skipWhitespace() { + while (ptr < src.length && src[ptr].isWhitespace()) ptr++ + } + + private fun peek(): Char = src.getOrNull(ptr) ?: '\u0000' + + private fun expect(expected: Char) { + require(peek() == expected) { "Expected '$expected' at index $ptr, found '${peek()}'" } + ptr++ + } + + public fun StringBuilder.appendCodePoint(codePoint: Int) { + when (codePoint) { + in 0x0000..0xFFFF -> append(codePoint.toChar()) + in 0x10000..0x10FFFF -> { + val cpPrime = codePoint - 0x10000 + val high = ((cpPrime shr 10) + 0xD800).toChar() + val low = ((cpPrime and 0x3FF) + 0xDC00).toChar() + append(high) + append(low) + } + + else -> error("Invalid Unicode code point: 0x${codePoint.toString(16)}") + } + } +} + +public fun snbt(src: String): NBTTag = SNBTDecoder(src).parse() \ No newline at end of file diff --git a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/snbt/SNBTEncoder.kt b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/snbt/SNBTEncoder.kt new file mode 100644 index 0000000..cbf5c38 --- /dev/null +++ b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/snbt/SNBTEncoder.kt @@ -0,0 +1,75 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/8 + */ + + +package cn.rtast.libmc.snbt + +import cn.rtast.libmc.nbt.NBTTag + +public fun NBTTag.toSNBT(prettyPrint: Boolean = false, indentLevel: Int = 0): String = when (this) { + is NBTTag.ByteTag -> "${value}b" + is NBTTag.ShortTag -> "${value}s" + is NBTTag.IntTag -> "$value" + is NBTTag.LongTag -> "${value}L" + is NBTTag.FloatTag -> "${value}f" + is NBTTag.DoubleTag -> "${value}d" + is NBTTag.StringTag -> escapeSNBTString(value) + is NBTTag.ByteArrayTag -> value.joinToString(prefix = "[B; ", postfix = "]", separator = ", ") { "${it}b" } + is NBTTag.IntArrayTag -> value.joinToString(prefix = "[I; ", postfix = "]", separator = ", ") { "$it" } + is NBTTag.LongArrayTag -> value.joinToString(prefix = "[L; ", postfix = "]", separator = ", ") { "${it}L" } + is NBTTag.ListTag -> { + if (value.isEmpty()) "[]" else if (!prettyPrint) { + value.joinToString(prefix = "[", postfix = "]", separator = ",") { it.toSNBT(false) } + } else { + val indent = " ".repeat(indentLevel + 1) + val closingIndent = " ".repeat(indentLevel) + val body = value.joinToString(separator = ",\n") { "$indent${it.toSNBT(true, indentLevel + 1)}" } + "[\n$body\n$closingIndent]" + } + } + + is NBTTag.CompoundTag -> { + if (value.isEmpty()) "{}" else if (!prettyPrint) { + value.entries.joinToString(prefix = "{", postfix = "}", separator = ",") { (k, v) -> + "${escapeSNBTKey(k)}:${v.toSNBT(false)}" + } + } else { + val indent = " ".repeat(indentLevel + 1) + val closingIndent = " ".repeat(indentLevel) + val body = value.entries.joinToString(separator = ",\n") { (k, v) -> + "$indent${escapeSNBTKey(k)}: ${v.toSNBT(true, indentLevel + 1)}" + } + "{\n$body\n$closingIndent}" + } + } +} + +internal fun escapeSNBTKey(key: String): String { + if (key.isEmpty()) return "\"\"" + val first = key.first() + val isValidFirst = first in 'a'..'z' || first in 'A'..'Z' || first == '_' + val isValidBody = + key.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '_' || it == '-' || it == '+' || it == '.' } + return if (isValidFirst && isValidBody) key else escapeSNBTString(key) +} + +internal fun escapeSNBTString(str: String): String { + val sb = StringBuilder("\"") + for (ch in str) { + when (ch) { + '\\' -> sb.append("\\\\") + '"' -> sb.append("\\\"") + '\b' -> sb.append("\\b") + '\u000C' -> sb.append("\\f") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> if (ch.code < 0x20) sb.append("\\x${ch.code.toString(16).padStart(2, '0')}") else sb.append(ch) + } + } + sb.append("\"") + return sb.toString() +} \ No newline at end of file diff --git a/libmc-nbt/src/commonTest/kotlin/test/TestNBT.kt b/libmc-nbt/src/commonTest/kotlin/test/TestNBT.kt new file mode 100644 index 0000000..c249ca4 --- /dev/null +++ b/libmc-nbt/src/commonTest/kotlin/test/TestNBT.kt @@ -0,0 +1,36 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/8 + */ + + +package test + +import cn.rtast.libmc.nbt.NBTTag +import cn.rtast.libmc.nbt.buildNBT +import cn.rtast.libmc.nbt.toNBTOutput +import cn.rtast.libmc.nbt.writeNBTRootCompound +import cn.rtast.libmc.network.BytesBuffer +import kotlin.test.Test + +class TestNBT { + @Test + fun `test nbt`() { + val nbt = buildNBT { + "t_b" byte 0x01 + "n" compound { + "n_s" string "STR" + "n_d" double 0.0 + "n_f" float 0.1f + "n_ia" intArray intArrayOf(1, 1, 1, 1) + } + } + val buf = BytesBuffer().toNBTOutput( + NBTTag.CompoundTag( + mapOf("" to nbt) + ) + ).writeNBTRootCompound() + println(buf.toHexString()) + } +} \ No newline at end of file diff --git a/libmc-nbt/src/commonTest/kotlin/test/TestSNBT.kt b/libmc-nbt/src/commonTest/kotlin/test/TestSNBT.kt new file mode 100644 index 0000000..e655fdd --- /dev/null +++ b/libmc-nbt/src/commonTest/kotlin/test/TestSNBT.kt @@ -0,0 +1,64 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/8 + */ + + +package test + +import cn.rtast.libmc.snbt.SNBTDecoder +import cn.rtast.libmc.snbt.buildSNBT +import cn.rtast.libmc.snbt.snbt +import cn.rtast.libmc.snbt.toSNBT +import kotlin.test.Test + +class TestSNBT { + val raw = """{key1: 123,'key2': 'somevalue1',"key3": {subkey1: 0x1C8,"subkey2": "somevalue2"}}""" + + @Test + fun `test snbt decode`() { + println(snbt(raw)) + } + + @Test + fun `test snbt encode`() { + println(snbt(raw).toSNBT()) + } + + @Test + fun `test snbt builder`() { + val snbt = buildSNBT { + "key1" string "TEST" + "intValue" int 1 + "Count" byte 1 + "Damage" int 0 + } + + val prettySnbt = buildSNBT(prettyPrint = true) { + "Name" string "Steve" + "Health" float 20.0f + "IsCreative" boolean true + "Pos" intArray intArrayOf(100, 64, -200) + "Custom Name" string "Alex\nWith Newline" + "Attributes" compound { + "AttackDamage" double 5.5 + "MovementSpeed" float 0.1f + } + "Inventory" list { + compound { + "id" string "minecraft:diamond_sword" + "Count" byte 1 + } + compound { + "id" string "minecraft:apple" + "Count" byte 16 + } + } + } + println(snbt) + println(prettySnbt) + println(snbt(snbt)) + println(snbt(prettySnbt)) + } +} \ No newline at end of file diff --git a/libmc-nbt/src/jvmTest/kotlin/test/TestNBTReader.kt b/libmc-nbt/src/jvmTest/kotlin/test/TestNBTReader.kt index eb4b0ba..8657015 100644 --- a/libmc-nbt/src/jvmTest/kotlin/test/TestNBTReader.kt +++ b/libmc-nbt/src/jvmTest/kotlin/test/TestNBTReader.kt @@ -7,8 +7,9 @@ package test +import cn.rtast.libmc.nbt.readNBTRootCompound +import cn.rtast.libmc.nbt.toNBTInput import cn.rtast.libmc.network.wrap -import cn.rtast.libmc.nbt.NbtReader import org.junit.Test import java.io.File @@ -18,7 +19,7 @@ class TestNBTReader { @Test fun `test read java nbt`() { - val readRoot = NbtReader(javaNBTBuffer).readRoot() + val readRoot = javaNBTBuffer.toNBTInput().readNBTRootCompound() println(readRoot) } } \ No newline at end of file diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/util/nbt.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/util/nbt.kt index 6626625..38b29a7 100644 --- a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/util/nbt.kt +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/util/nbt.kt @@ -11,7 +11,7 @@ import cn.rtast.libmc.network.BytesBuffer import cn.rtast.libmc.nbt.* internal fun BytesBuffer.readNBTCompound(): NBTCompound = - this.toNBTInput().readRootCompound() + this.toNBTInput().readNBTRootCompound() internal fun BytesBuffer.readNetworkNBTCompound(): NBTCompound = this.toNBTInput().readNetworkCompound() diff --git a/libmc-snbt/build.gradle.kts b/libmc-snbt/build.gradle.kts deleted file mode 100644 index 5418968..0000000 --- a/libmc-snbt/build.gradle.kts +++ /dev/null @@ -1,27 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -kotlin { - explicitApi() - withSourcesJar() - - linuxX64() - linuxArm64() - macosArm64() - mingwX64() - jvm { compilerOptions.jvmTarget = JvmTarget.JVM_1_8 } - - sourceSets { - commonMain.dependencies { - api(project(":common")) - } - - jvmMain.dependencies { - - } - - commonTest.dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) - } - } -} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 65df10a..df0f981 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,7 +8,7 @@ includeSubModule("common") includeSubModule("protocol") includeSubModule("protocol-context") includeSubModule("nbt") -includeSubModule("snbt") +//includeSubModule("snbt") //includeSubModule("protocol-engine-netty", path = "libmc-network-engines/netty") //includeSubModule("protocol-engine-ktor-network", path = "libmc-network-engines/ktor-network")