Support SNBT

This commit is contained in:
2026-09-08 12:23:55 +08:00
parent 08392f92b8
commit 74f9e6f529
22 files changed
+772 -41

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# Assemble context API
```kotlin
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
rsaEncryptor = RSA1024Encryptor { key, data -> ... }
sha1Hasher = Sha1Hasher { serverId, secretKey, publicKey -> ... }
cipherFactory = { key -> ... }
authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> ... }
socketEngine = MyCustomTCPSocketImpl()
}
```
> If using `DefaultProtocolContext` from `libmc-protocol-context` as the default context implementation,
> please refer to [Implementing TCPSocket](Impl-TCP-Socket.md)
# Modify exists context API implementation
```kotlin
fun main() {
val myNewProtocolContext = DefaultProtocolContext.withCustom {
rsaEncryptor = RSA1024Encryptor { key, data -> ... }
sha1Hasher = Sha1Hasher { serverId, secretKey, publicKey -> ... }
cipherFactory = { key -> ... }
authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> ... }
socketEngine = MyCustomTCPSocketImpl()
}
}
```
> Call `.withCustom` on a `ProtocolContextBuilder` to modify exists context implementation
+82
View File
@@ -0,0 +1,82 @@
# Creating a Client
```kotlin
public fun main() = runBlocking {
val client = createMinecraftClient(
"127.0.0.1", 25566, "MyBot",
generateOfflineUuid("MyBot"),
accessToken = null,
context = DefaultProtocolContext.withCustom {
socketEngine = KtorNetworkEngine()
}
)
client.launch { client.connect() }
while (true) {
delay(5.seconds)
}
}
```
> In the example code above, a `MinecraftClient` is created. This client will connect to an offline server at
> `127.0.0.1:25565` using `MyBot` as the player name, and replaces the underlying TCP Socket
> implementation with a `ktor-network` based TCP Socket. (For details on how to create a SocketEngine, please refer
> to [Implementing TCP Socket](Impl-TCP-Socket.md). For details on how to
> create a Context, please refer to [Required APIs](README.md#required-apis))
> MinecraftClient implements CoroutineScope, and calling `client.connect()` will execute the connection on a background
> thread. Blocking thread to prevent the application from exiting
## Connecting to an Online-Mode Server
```kotlin
private val accessToken = "eyJraWQiOiIw..."
public fun main() = runBlocking {
val client = createMinecraftClient(
// Other parameters
username = "RTAkland",
uuid = Uuid.parse("bb033844-e68e-4909-a636-1a5d1821ddc4"),
accessToken = accessToken,
// Other parameters
)
}
```
> In the example code above, the client will connect to the server using `RTAkland` as the player name.
# Get an AccessToken
Open [minecraft.net](https://minecraft.net), log in, press `F12`, and run the following code in the Console:
```javascript
console.log(`; ${document.cookie}`.split('; bearer_token=').pop().split(';').shift())
```
> Note: AccessTokens are valid for 24 hours
# Listening for Packets
```kotlin
// Listen for specific received packets (must start with Clientbound)
client.onPacket<ClientboundLoginSuccessPacket> {
println(it)
}
// Listen for all received packets
client.on { packet, direction ->
println("$direction ->$packet")
}
// Listen for outgoing packets (must start with Serverbound)
cli.onSent<ServerboundPongConfigurationPacket> {
println(it)
}
```
# Sending Packets
```kotlin
// Only packets starting with Serverbound can be sent, otherwise an UnsupportedOperationException will be thrown
client.networkChannel.sendPacket(
ServerboundChatCommandPacket(command = "say Hello from libmc")
)
```
+67
View File
@@ -0,0 +1,67 @@
# Before start
`AES-128-CFB8`, `RSA1024` and `SHA1` has been implemented in `libmc-protocol-context` module, it uses
`cryptography-kotlin`(and its platform based provider)
> All impl example below are based on Java's built-in security & cryptography API
# AES-128-CFB8
```kotlin
class JavaAesCipher(sharedKey: ByteArray) : NetworkCipher {
private val encryptCipher = Cipher.getInstance("AES/CFB8/NoPadding").apply {
init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedKey, "AES"), IvParameterSpec(sharedKey))
}
private val decryptCipher = Cipher.getInstance("AES/CFB8/NoPadding").apply {
init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedKey, "AES"), IvParameterSpec(sharedKey))
}
override fun encrypt(buffer: ByteArray, offset: Int, length: Int) {
encryptCipher.update(buffer, offset, length, buffer, offset)
}
override fun decrypt(buffer: ByteArray, offset: Int, length: Int) {
decryptCipher.update(buffer, offset, length, buffer, offset)
}
}
```
# RSA1024
```kotlin
private fun encrypt(publicKeyBytes: ByteArray, data: ByteArray): ByteArray {
val keySpec = X509EncodedKeySpec(publicKeyBytes)
val keyFactory = KeyFactory.getInstance("RSA")
val publicKey = keyFactory.generatePublic(keySpec)
val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
return cipher.doFinal(data)
}
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
rsaEncryptor = RSA1024Encryptor { key: ByteArray, data: ByteArray -> encrypt(key, data) }
}
```
# SHA1
```kotlin
private fun minecraftServerIdHash(serverId: String, secretKey: ByteArray, publicKey: ByteArray): String {
val serverIdBytes: ByteArray = serverId.encodeToByteArray()
for (b in serverIdBytes) require((b.toInt() and 0xFF) <= 0x7F) { "serverId contains non-US-ASCII character" }
val data = ByteArray(serverIdBytes.size + secretKey.size + publicKey.size)
System.arraycopy(serverIdBytes, 0, data, 0, serverIdBytes.size)
System.arraycopy(secretKey, 0, data, serverIdBytes.size, secretKey.size)
System.arraycopy(publicKey, 0, data, serverIdBytes.size + secretKey.size, publicKey.size)
val digest = MessageDigest.getInstance("SHA-1")
val hash = digest.digest(data)
return BigInteger(hash).toString(16)
}
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
sha1Hasher = Sha1Hasher { serverId: String, secretKey: ByteArray, publicKey: ByteArray ->
minecraftServerIdHash(serverId, secretKey, publicKey)
}
}
```
+41
View File
@@ -0,0 +1,41 @@
# Ktor based http client
```kotlin
private val httpClient = HttpClient()
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash ->
val status = httpClient.post(url) {
headers { header("Content-Type", "application/json") }
setBody("{\"accessToken\":\"$accessToken\", \"selectedProfile\":\"$uuid\", \"serverId\":\"$serverIdHash\"}")
}.status
require(status == HttpStatusCode.NoContent)
}
}
```
# HttpURLConnection based http client
```kotlin
private fun sendJoinRequest(url: String, accessToken: String, uuid: String, serverIdHash: String): Boolean {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
doOutput = true
connectTimeout = 5000
readTimeout = 5000
}
val jsonPayload = """{"accessToken":"$accessToken","selectedProfile":"$uuid","serverId":"$serverIdHash"}"""
connection.outputStream.use { os -> os.write(jsonPayload.toByteArray(Charsets.UTF_8)) }
val responseCode = connection.responseCode
connection.disconnect()
return responseCode == HttpURLConnection.HTTP_NO_CONTENT
}
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash ->
val success = sendJoinRequest(url, accessToken, uuid, serverIdHash)
require(success) { "Failed to authenticate with Mojang session server" }
}
}
```
+329
View File
@@ -0,0 +1,329 @@
# Before started
If the TCP Socket library is non-suspending, **DO NOT** wrap I/O read and write operations with `withContext`.
It will cause frequent thread context switching during network I/O, leading to a degradation in application
performance
# Netty based TCP Socket implementation
```kotlin
dependencies {
implementation("io.netty:netty-transport:4.2.17.Final")
implementation("io.netty:netty-buffer:4.2.17.Final")
}
```
<details>
<summary>Click to expand code</summary>
```kotlin
import cn.rtast.libmc.network.RawSocket
import cn.rtast.libmc.network.ReadChannel
import cn.rtast.libmc.network.SocketEngine
import cn.rtast.libmc.network.WriteChannel
import io.netty.bootstrap.Bootstrap
import io.netty.buffer.ByteBuf
import io.netty.buffer.Unpooled
import io.netty.channel.*
import io.netty.channel.nio.NioIoHandler
import io.netty.channel.socket.SocketChannel
import io.netty.channel.socket.nio.NioSocketChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import io.netty.channel.Channel as NettyChannel
import kotlinx.coroutines.channels.Channel as KotlinChannel
public class NettyNetworkSocketEngine : SocketEngine {
override fun create(host: String, port: Int): RawSocket = NettyNetworkSocket(host, port)
private class NettyNetworkSocket(private val host: String, private val port: Int) : RawSocket {
private val workerGroup: EventLoopGroup = MultiThreadIoEventLoopGroup(0, NioIoHandler.newFactory())
private lateinit var channel: NettyChannel
private val inboundQueue = KotlinChannel<ByteArray>(KotlinChannel.UNLIMITED)
private lateinit var readChannel: NettyReadChannel
private lateinit var writeChannel: NettyWriteChannel
override suspend fun connect() {
val bootstrap = Bootstrap()
.group(workerGroup)
.channel(NioSocketChannel::class.java)
.option(ChannelOption.TCP_NODELAY, true)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
.handler(object : ChannelInitializer<SocketChannel>() {
override fun initChannel(ch: SocketChannel) {
ch.pipeline().addLast(object : ChannelInboundHandlerAdapter() {
override fun channelRead(ctx: ChannelHandlerContext, msg: Any) {
if (msg is ByteBuf) {
try {
val bytes = ByteArray(msg.readableBytes())
msg.readBytes(bytes)
inboundQueue.trySend(bytes)
} finally {
msg.release()
}
}
}
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
inboundQueue.close(cause)
ctx.close()
}
override fun channelInactive(ctx: ChannelHandlerContext) {
inboundQueue.close()
super.channelInactive(ctx)
}
})
}
})
channel = withContext(Dispatchers.IO) {
bootstrap.connect(host, port).suspendAwait()
}
readChannel = NettyReadChannel(inboundQueue)
writeChannel = NettyWriteChannel(channel)
}
override fun openReadChannel(): ReadChannel = readChannel
override fun openWriteChannel(): WriteChannel = writeChannel
override fun close() {
if (::channel.isInitialized && channel.isOpen) {
channel.close()
}
inboundQueue.close()
workerGroup.shutdownGracefully()
}
}
private class NettyReadChannel(private val inboundQueue: KotlinChannel<ByteArray>) : ReadChannel {
private var currentChunk: ByteArray? = null
private var chunkOffset = 0
private suspend fun fetchNextChunk() {
val next = inboundQueue.receiveCatching().getOrNull()
?: throw IllegalStateException("Socket/Channel closed while reading")
currentChunk = next
chunkOffset = 0
}
override suspend fun readByte(): Byte {
while (currentChunk == null || chunkOffset >= currentChunk!!.size) {
fetchNextChunk()
}
val chunk = currentChunk!!
return chunk[chunkOffset++]
}
override suspend fun readBytes(length: Int): ByteArray {
val result = ByteArray(length)
readFully(result, 0, length)
return result
}
override suspend fun readFully(out: ByteArray, start: Int, end: Int) {
var written = start
while (written < end) {
while (currentChunk == null || chunkOffset >= currentChunk!!.size) {
fetchNextChunk()
}
val chunk = currentChunk!!
val available = chunk.size - chunkOffset
val toCopy = minOf(available, end - written)
chunk.copyInto(
out,
destinationOffset = written,
startIndex = chunkOffset,
endIndex = chunkOffset + toCopy
)
chunkOffset += toCopy
written += toCopy
}
}
}
private class NettyWriteChannel(private val channel: NettyChannel) : WriteChannel {
private val writeMutex = Mutex()
override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) {
val length = endIndex - startIndex
if (length <= 0) return
val nettyBuf = Unpooled.copiedBuffer(value, startIndex, length)
writeMutex.withLock {
withContext(Dispatchers.IO) {
channel.writeAndFlush(nettyBuf).suspendAwait()
}
}
}
override suspend fun flush() {
writeMutex.withLock {
withContext(Dispatchers.IO) {
channel.flush()
}
}
}
}
}
private suspend inline fun ChannelFuture.suspendAwait(): NettyChannel = suspendCancellableCoroutine { cont ->
if (isDone) {
if (isSuccess) cont.resume(channel())
else cont.resumeWithException(cause() ?: RuntimeException("Netty operation failed"))
return@suspendCancellableCoroutine
}
addListener { future ->
if (future.isSuccess) {
cont.resume(channel())
} else {
cont.resumeWithException(future.cause() ?: RuntimeException("Netty operation failed"))
}
}
cont.invokeOnCancellation { cancel(false) }
}
```
> Also copy the imports
</details>
# ktor-network based TCP Socket implementation
```kotlin
dependencies {
implementation("io.ktor:ktor-network:3.5.2")
}
```
<details>
<summary>Click to expand</summary>
```kotlin
public class KtorNetworkEngine : SocketEngine {
override fun create(host: String, port: Int): RawSocket = KtorNetworkSocket(host, port)
}
public class KtorNetworkSocket(private val host: String, private val port: Int) : RawSocket {
private val sm = SelectorManager(Dispatchers.IO)
private lateinit var socket: Socket
override suspend fun connect(): Unit = run { socket = aSocket(sm).tcp().connect(host, port) }
override fun openReadChannel(): ReadChannel = KtorReadChannel(socket.openReadChannel())
override fun openWriteChannel(): WriteChannel = KtorWriteChannel(socket.openWriteChannel())
override fun close() {
socket.close()
sm.close()
}
}
public class KtorReadChannel(private val readChannel: ByteReadChannel) : ReadChannel {
override suspend fun readByte(): Byte = readChannel.readByte()
override suspend fun readBytes(length: Int): ByteArray = readChannel.readByteArray(length)
override suspend fun readFully(out: ByteArray, start: Int, end: Int): Unit =
readChannel.readFully(out, start, end)
}
public class KtorWriteChannel(private val writeChannel: ByteWriteChannel) : WriteChannel {
override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) {
writeChannel.writeFully(value, startIndex, endIndex)
}
override suspend fun flush(): Unit = writeChannel.flush()
}
```
</details>
# Java built-in Socket based implementation
<details>
<summary>Click to expand code</summary>
```kotlin
class JavaSocketEngine : SocketEngine {
override fun create(host: String, port: Int): RawSocket = JavaNetworkSocket(host, port)
}
class JavaNetworkSocket(
private val host: String,
private val port: Int,
private val connectTimeoutMs: Int = 10000,
) : RawSocket {
private lateinit var socket: Socket
private lateinit var readChannel: JavaReadChannel
private lateinit var writeChannel: JavaWriteChannel
override suspend fun connect() {
val s = Socket()
s.tcpNoDelay = true
s.connect(InetSocketAddress(host, port), connectTimeoutMs)
socket = s
readChannel = JavaReadChannel(s.getInputStream())
writeChannel = JavaWriteChannel(s.getOutputStream())
}
override fun openReadChannel(): ReadChannel = readChannel
override fun openWriteChannel(): WriteChannel = writeChannel
override fun close() {
if (::socket.isInitialized && !socket.isClosed) {
runCatching { socket.close() }
}
}
}
class JavaReadChannel(private val inputStream: InputStream) : ReadChannel {
override suspend fun readByte(): Byte {
val b = inputStream.read()
if (b == -1) throw IllegalStateException("Socket stream reached EOF while reading byte")
return b.toByte()
}
override suspend fun readBytes(length: Int): ByteArray {
val buffer = ByteArray(length)
readFullyInternal(buffer, 0, length)
return buffer
}
override suspend fun readFully(out: ByteArray, start: Int, end: Int) {
readFullyInternal(out, start, end - start)
}
private fun readFullyInternal(out: ByteArray, offset: Int, length: Int) {
var bytesRead = 0
while (bytesRead < length) {
val count = inputStream.read(out, offset + bytesRead, length - bytesRead)
if (count == -1) {
throw IllegalStateException("Socket stream closed unexpectedly (read $bytesRead of $length bytes)")
}
bytesRead += count
}
}
}
class JavaWriteChannel(private val outputStream: OutputStream) : WriteChannel {
override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) {
outputStream.write(value, startIndex, endIndex - startIndex)
}
override suspend fun flush() {
outputStream.flush()
}
}
```
</details>
+75
View File
@@ -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
}
```