Support SNBT
This commit is contained in:
22 files changed
+772
-41
No files matched your search
+10
-2
@@ -1,5 +1,7 @@
|
|||||||
# libmc
|
# libmc
|
||||||
|
|
||||||
|
[中文](zh/README-zh.md)
|
||||||
|
|
||||||
A lightweight, modern Minecraft client protocol library designed for Kotlin Native & JVM. The core protocol library
|
A lightweight, modern Minecraft client protocol library designed for Kotlin Native & JVM. The core protocol library
|
||||||
module relies on the following dependencies:
|
module relies on the following dependencies:
|
||||||
|
|
||||||
@@ -19,11 +21,17 @@ module relies on the following dependencies:
|
|||||||
|
|
||||||
# Get started
|
# Get started
|
||||||
|
|
||||||
|
# Protocol
|
||||||
|
|
||||||
> `libmc-protocol` is current under development. It only supports the latest Minecraft version
|
> `libmc-protocol` is current under development. It only supports the latest Minecraft version
|
||||||
> (Current supported Minecraft version: `26.2`, Protocol Version: `776`)
|
> (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 all context APIs
|
||||||
|
|
||||||
[Assemble context APIs](Assemble-context.md)
|
[Assemble context APIs](en/Assemble-context.md)
|
||||||
|
|
||||||
|
## NBT & SNBT
|
||||||
|
|
||||||
|
[NBT & SNBT](en/NBT-SNBT.md)
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## 需要实现的API
|
## 需要实现的API
|
||||||
|
|
||||||
| 名称 | 是否必须实现 | 描述 |
|
| 名称 | 是否必须实现 | 额外说明 |
|
||||||
|:-------------|:-------------|:-----------------------------------------------------------------------------------------------------------------------------------------------|
|
|:-------------|:-------------|:-----------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| TCP Socket | 是 | `protocol` 模块没有内置TCP Socket实现. [实现 TCP Socket](Impl-TCP-Socket-zh.md) |
|
| 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) |
|
| AES-128-CFB8 | 视情况而定 | 仅在登录开启了正版验证(`online-mode`)的服务器时需要, 用于加密/解密流量. [实现AES-128-CFB8](Impl-Crypto-zh.md#AES-128-CFB8) |
|
||||||
@@ -26,3 +26,7 @@
|
|||||||
# 将实现的API组合起来
|
# 将实现的API组合起来
|
||||||
|
|
||||||
[将所有上下文API组合](Assemble-context-zh.md)
|
[将所有上下文API组合](Assemble-context-zh.md)
|
||||||
|
|
||||||
|
# NBT & SNBT
|
||||||
|
|
||||||
|
[NBT & SNBT](../en/NBT-SNBT.md)
|
||||||
@@ -56,7 +56,7 @@ public class CompoundBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public infix fun String.compound(block: CompoundBuilder.() -> Unit) {
|
public infix fun String.compound(block: CompoundBuilder.() -> Unit) {
|
||||||
tags[this] = nbtCompound(block)
|
tags[this] = buildNBT(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("FunctionName")
|
@Suppress("FunctionName")
|
||||||
@@ -76,7 +76,7 @@ public class CompoundBuilder {
|
|||||||
public fun build(): NBTTag.CompoundTag = NBTTag.CompoundTag(tags)
|
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()
|
val builder = CompoundBuilder()
|
||||||
builder.block()
|
builder.block()
|
||||||
return builder.build()
|
return builder.build()
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class NBTListBuilder(
|
|||||||
|
|
||||||
public fun compound(block: CompoundBuilder.() -> Unit) {
|
public fun compound(block: CompoundBuilder.() -> Unit) {
|
||||||
checkType(NBTType.Compound)
|
checkType(NBTType.Compound)
|
||||||
elements += nbtCompound(block)
|
elements += buildNBT(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
public fun list(elementType: NBTType, block: NBTListBuilder.() -> Unit) {
|
public fun list(elementType: NBTType, block: NBTListBuilder.() -> Unit) {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ public fun NBTInput.readCompound(): NBTTag {
|
|||||||
return NBTTag.ListTag(NBTType.Compound, map.values.toMutableList())
|
return NBTTag.ListTag(NBTType.Compound, map.values.toMutableList())
|
||||||
}
|
}
|
||||||
|
|
||||||
public fun NBTInput.readRootCompound(): NBTCompound {
|
public fun NBTInput.readNBTRootCompound(): NBTCompound {
|
||||||
val rootType = NBTType.fromID(readByte().toInt())
|
val rootType = NBTType.fromID(readByte().toInt())
|
||||||
require(rootType == NBTType.Compound) { "Root tag must be TAG_Compound" }
|
require(rootType == NBTType.Compound) { "Root tag must be TAG_Compound" }
|
||||||
val nameLen = readShort().toInt() and 0xFFFF
|
val nameLen = readShort().toInt() and 0xFFFF
|
||||||
|
|||||||
@@ -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 FloatTag(val value: Float) : NBTTag(NBTType.Float)
|
||||||
public data class DoubleTag(val value: Double) : NBTTag(NBTType.Double)
|
public data class DoubleTag(val value: Double) : NBTTag(NBTType.Double)
|
||||||
public data class StringTag(val value: String) : NBTTag(NBTType.String)
|
public data class StringTag(val value: String) : NBTTag(NBTType.String)
|
||||||
public data class CompoundTag(val value: MutableMap<String, NBTTag>) : NBTTag(NBTType.Compound)
|
public data class CompoundTag(val value: Map<String, NBTTag>) : NBTTag(NBTType.Compound)
|
||||||
public data class ListTag(val elementType: NBTType, val value: MutableList<NBTTag>) : NBTTag(NBTType.List) {
|
public data class ListTag(val elementType: NBTType, val value: MutableList<NBTTag>) : NBTTag(NBTType.List) {
|
||||||
public val length: Int get() = value.size
|
public val length: Int get() = value.size
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
writeByte(NBTType.Compound.id)
|
||||||
writeStringTag(name)
|
writeStringTag(name)
|
||||||
writeTagPayload(root)
|
writeTagPayload(root)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<String, NBTTag>()
|
||||||
|
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<NBTTag>()
|
||||||
|
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<NBTTag>()
|
||||||
|
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>): 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<String, String> {
|
||||||
|
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()
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@
|
|||||||
|
|
||||||
package test
|
package test
|
||||||
|
|
||||||
|
import cn.rtast.libmc.nbt.readNBTRootCompound
|
||||||
|
import cn.rtast.libmc.nbt.toNBTInput
|
||||||
import cn.rtast.libmc.network.wrap
|
import cn.rtast.libmc.network.wrap
|
||||||
import cn.rtast.libmc.nbt.NbtReader
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ class TestNBTReader {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test read java nbt`() {
|
fun `test read java nbt`() {
|
||||||
val readRoot = NbtReader(javaNBTBuffer).readRoot()
|
val readRoot = javaNBTBuffer.toNBTInput().readNBTRootCompound()
|
||||||
println(readRoot)
|
println(readRoot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ import cn.rtast.libmc.network.BytesBuffer
|
|||||||
import cn.rtast.libmc.nbt.*
|
import cn.rtast.libmc.nbt.*
|
||||||
|
|
||||||
internal fun BytesBuffer.readNBTCompound(): NBTCompound =
|
internal fun BytesBuffer.readNBTCompound(): NBTCompound =
|
||||||
this.toNBTInput().readRootCompound()
|
this.toNBTInput().readNBTRootCompound()
|
||||||
|
|
||||||
internal fun BytesBuffer.readNetworkNBTCompound(): NBTCompound =
|
internal fun BytesBuffer.readNetworkNBTCompound(): NBTCompound =
|
||||||
this.toNBTInput().readNetworkCompound()
|
this.toNBTInput().readNetworkCompound()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -8,7 +8,7 @@ includeSubModule("common")
|
|||||||
includeSubModule("protocol")
|
includeSubModule("protocol")
|
||||||
includeSubModule("protocol-context")
|
includeSubModule("protocol-context")
|
||||||
includeSubModule("nbt")
|
includeSubModule("nbt")
|
||||||
includeSubModule("snbt")
|
//includeSubModule("snbt")
|
||||||
|
|
||||||
//includeSubModule("protocol-engine-netty", path = "libmc-network-engines/netty")
|
//includeSubModule("protocol-engine-netty", path = "libmc-network-engines/netty")
|
||||||
//includeSubModule("protocol-engine-ktor-network", path = "libmc-network-engines/ktor-network")
|
//includeSubModule("protocol-engine-ktor-network", path = "libmc-network-engines/ktor-network")
|
||||||
|
|||||||
Reference in New Issue
Block a user