diff --git a/README.md b/README.md index 4b0fffd..b3db259 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A lightweight minecraft client-side protocol library for Kotlin Native and JVM - [x] **Online Mode Authentication & Encryption/Decryption**: See [Embedded cryptography](docs/Embedded-cryptography.md) - [x] **Structured `TextComponent` Parser**: TextComponent AST decoder - [x] **Command Tree Parser**: Full binary graph decoder for brigadier nodes, argument types, and suggestions -- [ ] **Recipe Book & Recipe Data**: Recipe layout declarations and client-side recipe settings +- [x] **Recipe Book & Recipe Data** - [ ] **Chunk & World Data**: Level Chunk Data with Light decoder (Paletted Containers, Direct/Indirect Palettes) - [ ] **Light Engine Update**: Sky & Block light nibble array parser - [ ] **Explosion Event Decoder**: Knockback vectors and destroyed block offsets array diff --git a/docs/Get-started.md b/docs/Get-started.md index c1afb69..9b26912 100644 --- a/docs/Get-started.md +++ b/docs/Get-started.md @@ -6,7 +6,7 @@ public fun main() = runBlocking { "127.0.0.1", 25566, "MyBot", generateOfflineUuid("MyBot"), accessToken = null, - context = DefaultProtocolContext.withCustom { + context = { socketEngine = KtorNetworkEngine() } ) @@ -81,11 +81,34 @@ client.networkChannel.sendPacket( ) ``` +# Get server MOTD + +```kotlin +fun main() { + val cli = createMinecraftClient( + "127.0.0.1", 25566, "11", + generateOfflineUuid("11"), null, + context = { + socketEngine = KtorNetworkEngine() + } + ) + cli.session.onEvent { + println(status()) + disconnect() + } + cli.session.onEvent { + println(it.reason.toJsonString()) + } + cli.connect() + awaitCancellation() +} +``` + # Respond velocity and update client motion > This part uses math calculations -When joined to the level(aka `world`), the server will send a packet +When joined to the level (aka `world`), the server will send a packet `ClientboundSetEntityVelocityPacket` to the client, packet contains a vec3 and entity id, The client sync this data to the player and sends it to the server during the next tick loop to inform the server: "Hi, I know my current position; here is the result of my calculations. I'm sending it to you". diff --git a/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTTagJson.kt b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTTagJson.kt new file mode 100644 index 0000000..45e1bdd --- /dev/null +++ b/libmc-nbt/src/commonMain/kotlin/cn/rtast/libmc/nbt/NBTTagJson.kt @@ -0,0 +1,55 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/10 + */ + + +package cn.rtast.libmc.nbt + +public fun NBTTag.toJsonString(): String = when (this) { + is NBTTag.StringTag -> buildString { + append('"') + for (ch in value) { + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (ch < ' ') { + val hex = ch.code.toString(16).padStart(4, '0') + append("\\u").append(hex) + } else { + append(ch) + } + } + } + } + append('"') + } + + is NBTTag.ByteTag -> if (value == 1.toByte() || value == 0.toByte()) { + if (value == 1.toByte()) "true" else "false" + } else value.toString() + + is NBTTag.ShortTag -> value.toString() + is NBTTag.IntTag -> value.toString() + is NBTTag.LongTag -> value.toString() + is NBTTag.FloatTag -> value.toString() + is NBTTag.DoubleTag -> value.toString() + + is NBTTag.ByteArrayTag -> value.joinToString(prefix = "[", postfix = "]") { it.toString() } + is NBTTag.IntArrayTag -> value.joinToString(prefix = "[", postfix = "]") { it.toString() } + is NBTTag.LongArrayTag -> value.joinToString(prefix = "[", postfix = "]") { it.toString() } + + is NBTTag.ListTag -> value.joinToString(prefix = "[", postfix = "]") { it.toJsonString() } + + is NBTTag.CompoundTag -> value.entries.joinToString(prefix = "{", postfix = "}") { (k, v) -> + val escapedKey = NBTTag.StringTag(k).toJsonString() + "$escapedKey:${v.toJsonString()}" + } +} \ No newline at end of file diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/ClientTickingLoop.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/ClientTickingLoop.kt deleted file mode 100644 index df41ee2..0000000 --- a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/ClientTickingLoop.kt +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright © 2026 RTAkland - * Author: RTAkland - * Date: 2026/9/9 - */ - -package cn.rtast.libmc.protocol.client - -import cn.rtast.libmc.protocol.crypto.minecraftServerIdHash -import cn.rtast.libmc.protocol.crypto.rsaEncrypt -import cn.rtast.libmc.protocol.packet.configuration.clientbound.* -import cn.rtast.libmc.protocol.packet.configuration.serverbound.* -import cn.rtast.libmc.protocol.packet.login.clientbound.ClientboundDisconnectLoginPacket -import cn.rtast.libmc.protocol.packet.login.clientbound.ClientboundHelloPacket -import cn.rtast.libmc.protocol.packet.login.clientbound.ClientboundLoginSuccessPacket -import cn.rtast.libmc.protocol.packet.login.clientbound.ClientboundSetCompressionPacket -import cn.rtast.libmc.protocol.packet.login.serverbound.ServerboundKeyPacket -import cn.rtast.libmc.protocol.packet.login.serverbound.ServerboundLoginAcknowledgedPacket -import cn.rtast.libmc.protocol.packet.play.clientbound.* -import cn.rtast.libmc.protocol.packet.play.serverbound.* -import cn.rtast.libmc.protocol.protocol.state.ProtocolState -import cn.rtast.libmc.protocol.util.generateRandom16Bytes -import kotlinx.coroutines.* -import kotlin.concurrent.Volatile -import kotlin.math.abs -import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds - -public class ClientTickingLoop internal constructor(private val client: MinecraftClient) { - private var tickJob: Job? = null - private val tickIntervalMs = 50L - public var currentTick: Long = 0L - private set - - @Volatile - public var isRunning: Boolean = false - private set - - private val listeners = mutableListOf Unit>() - internal fun registerListener(action: suspend (Long) -> Unit) = listeners.add(action) - - init { - client.onPacket { handleLoginSuccess() } - client.onPacket { client.networkChannel.setCompression(it.threshold) } - client.onPacket { handleEncryptRequest(it) } - client.onPacket { client.close() } - client.onPacket { client.close() } - client.onPacket { client.close() } - client.onPacket { syncServerTick(it.worldAge) } - client.onPacket { client.networkChannel.sendPacket(ServerboundPongPlayPacket(it.id)) } - client.onPacket { - client.networkChannel.sendPacket(ServerboundSelectKnownPacksPacket(emptyList())) - } - client.onPacket { - client.networkChannel.sendPacket(ServerboundAcceptCodeOfConductPacket) - } - client.onPacket { - client.networkChannel.sendPacket(ServerboundKeepAlivePlayPacket(it.id)) - } - client.onPacket { - client.networkChannel.sendPacket(ServerboundPongConfigurationPacket(it.id)) - } - client.onPacket { - client.networkChannel.sendPacket(ServerboundConfigurationAcknowledgedPacket) - client.stateMachine.transitionTo(ProtocolState.CONFIGURATION) - } - client.onPacket { - client.networkChannel.sendPacket(ServerboundKeepAliveConfigurationPacket(it.id)) - } - client.onPacket { - client.networkChannel.sendPacket(ServerboundAckFinishConfigurationPacket) - client.stateMachine.transitionTo(ProtocolState.PLAY) - } - } - - internal suspend fun handleLoginSuccess() { - client.networkChannel.sendPacket(ServerboundLoginAcknowledgedPacket) - client.stateMachine.transitionTo(ProtocolState.CONFIGURATION) - } - - internal suspend fun handleEncryptRequest(packet: ClientboundHelloPacket) { - val sharedSecret = generateRandom16Bytes() - if (client.isOnlineMode) { - val serverHash = minecraftServerIdHash(packet.serverId, sharedSecret, packet.publicKey) - client.protocolContext.authProvider!!.joinServer( - "https://sessionserver.mojang.com/session/minecraft/join", - client.accessToken!!, client.uuid.toString().replace("-", ""), serverHash - ) - } - val encryptedSecret = rsaEncrypt(packet.publicKey, sharedSecret) - val encryptedVerifyToken = rsaEncrypt(packet.publicKey, packet.verifyToken) - client.networkChannel.sendPacket(ServerboundKeyPacket(encryptedSecret, encryptedVerifyToken)) - client.networkChannel.session.enableEncryption(sharedSecret) - } - - internal fun syncServerTick(serverWorldAge: Long) { - if (abs(this.currentTick - serverWorldAge) > 2) this.currentTick = serverWorldAge - } - - internal fun start() { - if (isRunning) return - isRunning = true - tickJob = client.launch(CoroutineName("LibMC-ClientTickingLoop")) { - var nextTickTime = Clock.System.now().toEpochMilliseconds() - while (isActive && isRunning) { - val now = Clock.System.now().toEpochMilliseconds() - if (now >= nextTickTime) { - try { - listeners.forEach { it.invoke(currentTick) } - } catch (e: Exception) { - if (e is CancellationException) throw e - e.printStackTrace() - } - currentTick++ - nextTickTime += tickIntervalMs - if (now - nextTickTime > tickIntervalMs * 5) nextTickTime = now + tickIntervalMs - } else delay((nextTickTime - now).milliseconds) - } - } - } - - internal fun stop() { - isRunning = false - tickJob?.cancel() - tickJob = null - } -} \ No newline at end of file diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/MinecraftClient.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/MinecraftClient.kt index 45b39a5..7581955 100644 --- a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/MinecraftClient.kt +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/MinecraftClient.kt @@ -9,10 +9,10 @@ package cn.rtast.libmc.protocol.client import cn.rtast.libmc.crypto.ProtocolContext import cn.rtast.libmc.crypto.ProtocolContextBuilder import cn.rtast.libmc.protocol.network.NetworkChannel -import cn.rtast.libmc.protocol.packet.handshake.ServerboundHandshakePacket -import cn.rtast.libmc.protocol.packet.login.serverbound.ServerboundLoginStartPacket -import cn.rtast.libmc.protocol.protocol.state.HandshakeIntent -import cn.rtast.libmc.protocol.protocol.state.ProtocolState +import cn.rtast.libmc.protocol.protocol.event.PacketEventDispatcher +import cn.rtast.libmc.protocol.protocol.session.Session +import cn.rtast.libmc.protocol.protocol.session.SessionEvent +import cn.rtast.libmc.protocol.protocol.session.SessionImpl import cn.rtast.libmc.protocol.util.TransactionIdManager import cn.rtast.libmc.protocol.util.generateOfflineUuid import kotlinx.coroutines.* @@ -20,68 +20,51 @@ import kotlin.coroutines.CoroutineContext import kotlin.uuid.Uuid public class MinecraftClient internal constructor( - private val host: String, - private val port: Int, - private val username: String, + internal val host: String, + internal val port: Int, + internal val username: String, internal val uuid: Uuid, internal val accessToken: String?, parentJob: Job?, private val ioDispatcher: CoroutineDispatcher, internal val protocolContext: ProtocolContext, -) : PacketEventDispatcher(), CoroutineScope { + public val session: SessionImpl = SessionImpl(), +) : PacketEventDispatcher(), CoroutineScope, Session by session { internal val stateMachine = ClientStateMachine() - public val networkChannel: NetworkChannel = NetworkChannel(host, port, stateMachine, this, protocolContext) + public val networkChannel: NetworkChannel = NetworkChannel(this) private val clientJob = SupervisorJob(parentJob) private var listenJob: Job? = null - - public val isOnlineMode: Boolean = accessToken != null - public val transactionManager: TransactionIdManager = TransactionIdManager() - public val clientTickingLoop: ClientTickingLoop = ClientTickingLoop(this) - /** - * Register a client ticking event callback. - * NOTE: Blocking operations will **block** the bot thread. - * Using #launch to avoid blocking. - */ - public fun onTick(action: suspend (Long) -> Unit): Unit = run { clientTickingLoop.registerListener(action) } - - public suspend fun connect(protocolVersion: Int = CURRENT_MINECRAFT_PROTOCOL_VERSION) { - networkChannel.connect() - startListening() - clientTickingLoop.start() - networkChannel.sendPacket( - ServerboundHandshakePacket( - protocolVersion, - host, port.toUShort(), - HandshakeIntent.LOGIN - ) - ) - stateMachine.transitionTo(ProtocolState.LOGIN) - networkChannel.sendPacket(ServerboundLoginStartPacket(username, uuid)) + init { + session.attachClient(this) } - public fun setCompression(threshold: Int): Unit = networkChannel.setCompression(threshold) + public suspend fun connect() { + networkChannel.connect() + startListening() + session.init() + session.emitEvent(SessionEvent.ConnectedEvent) + } private fun startListening() { listenJob = launch { try { while (isActive) networkChannel.readNextPacket() - } catch (e: Exception) { - if (e is CancellationException) throw e - if (isActive) { - e.printStackTrace() - println("Network read loop exception: ${e.message}") - close() - } + } catch (e: Throwable) { + if (e is CancellationException) return@launch + println("Network read loop exception: ${e.message}") + } finally { + networkChannel.close() } } } public fun close() { networkChannel.close() - clientTickingLoop.stop() + listenJob?.cancel() clientJob.cancel() + cancel() } public override val coroutineContext: CoroutineContext diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/PacketEventDispatcher.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/PacketEventDispatcher.kt deleted file mode 100644 index f0d70c7..0000000 --- a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/client/PacketEventDispatcher.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright © 2026 RTAkland - * Author: RTAkland - * Date: 2026/9/5 - */ - -package cn.rtast.libmc.protocol.client - -import cn.rtast.libmc.packet.MinecraftPacket -import cn.rtast.libmc.protocol.protocol.PacketDirection -import kotlin.concurrent.Volatile -import kotlin.reflect.KClass - -private typealias Handler = suspend (MinecraftPacket) -> Unit -private typealias DirectionalHandler = suspend (MinecraftPacket, PacketDirection) -> Unit - -public abstract class PacketEventDispatcher { - @Volatile - @PublishedApi - internal var receiveHandlers: Map, List> = emptyMap() - - @Volatile - @PublishedApi - internal var sentHandlers: Map, List> = emptyMap() - - @Volatile - @PublishedApi - internal var globalHandlers: List = emptyList() - - @PublishedApi - internal fun addTypedHandler( - isReceive: Boolean, - key: KClass, - handler: Handler, - ) { - if (isReceive) { - val current = receiveHandlers[key] ?: emptyList() - receiveHandlers = receiveHandlers + (key to (current + handler)) - } else { - val current = sentHandlers[key] ?: emptyList() - sentHandlers = sentHandlers + (key to (current + handler)) - } - } - - private suspend fun dispatch( - handlersMap: Map, List>, - packet: MinecraftPacket, - direction: PacketDirection, - ) { - handlersMap[packet::class]?.forEach { handler -> handler(packet) } - globalHandlers.forEach { handler -> handler(packet, direction) } - } - - internal suspend fun dispatchReceive(packet: MinecraftPacket) = - dispatch(receiveHandlers, packet, PacketDirection.CLIENTBOUND) - - internal suspend fun dispatchSent(packet: MinecraftPacket) = - dispatch(sentHandlers, packet, PacketDirection.SERVERBOUND) - - /** - * Lambda will be invoked when received a packet - */ - public inline fun onPacket(crossinline block: suspend (T) -> Unit) { - addTypedHandler(true, T::class) { block(it as T) } - } - - /** - * Lambda will be invoked after a packet sent - */ - public inline fun onSent(crossinline block: suspend (T) -> Unit) { - addTypedHandler(false, T::class) { block(it as T) } - } - - /** - * All packets will be appeared here, including `Outbound(Serverbound)` and `Inbound(Clientbound)` packet - */ - public fun on(block: suspend (packet: MinecraftPacket, direction: PacketDirection) -> Unit) { - globalHandlers = globalHandlers + block - } -} \ No newline at end of file diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkChannel.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkChannel.kt index 45ca5c0..c19b6f3 100644 --- a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkChannel.kt +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkChannel.kt @@ -6,34 +6,27 @@ package cn.rtast.libmc.protocol.network -import cn.rtast.libmc.crypto.ProtocolContext import cn.rtast.libmc.network.BytesBuffer import cn.rtast.libmc.network.wrap import cn.rtast.libmc.packet.MinecraftPacket import cn.rtast.libmc.packet.writeBuffer import cn.rtast.libmc.primitives.readVarInt import cn.rtast.libmc.primitives.writeVarInt -import cn.rtast.libmc.protocol.client.ClientStateMachine -import cn.rtast.libmc.protocol.client.PacketEventDispatcher +import cn.rtast.libmc.protocol.client.MinecraftClient +import cn.rtast.libmc.protocol.protocol.event.PacketEventDispatcher import cn.rtast.libmc.protocol.protocol.GamePacketsProtocolCodec.clientboundGameProtocols import cn.rtast.libmc.protocol.protocol.GamePacketsProtocolCodec.serverboundGameProtocols import cn.rtast.libmc.zlibCompress import cn.rtast.libmc.zlibDecompress import kotlin.concurrent.Volatile -public class NetworkChannel internal constructor( - host: String, - port: Int, - private val stateMachine: ClientStateMachine, - private val dispatcher: PacketEventDispatcher, - protocolContext: ProtocolContext, -) { - internal val session: NetworkSession = NetworkSession(host, port, protocolContext) +public class NetworkChannel internal constructor(private val client: MinecraftClient) { + internal val networkSession: NetworkSession = NetworkSession(client) @Volatile private var threshold = -1 - public suspend fun connect(): Unit = session.connect() + public suspend fun connect(): Unit = networkSession.connect() public fun setCompression(threshold: Int): Unit = run { this.threshold = threshold } /** @@ -42,8 +35,8 @@ public class NetworkChannel internal constructor( * Use [PacketEventDispatcher.onPacket] to get packet event */ public suspend fun readNextPacket(): MinecraftPacket { - val packetLength = session.readVarInt() - val frameBuf = session.readBytes(packetLength).wrap() + val packetLength = networkSession.readVarInt() + val frameBuf = networkSession.readBytes(packetLength).wrap() val payloadBuf = if (threshold < 0) frameBuf else { val dataLength = frameBuf.readVarInt() if (dataLength == 0) frameBuf else { @@ -51,10 +44,10 @@ public class NetworkChannel internal constructor( compressedBytes.zlibDecompress(dataLength).wrap() } } - val currentState = stateMachine.currentState + val currentState = client.stateMachine.currentState val packetId = payloadBuf.readVarInt() val packet = clientboundGameProtocols.getRegistry(currentState).decodePacket(packetId, payloadBuf) - dispatcher.dispatchReceive(packet) + client.dispatchReceive(packet, client.session) return packet } @@ -65,7 +58,7 @@ public class NetworkChannel internal constructor( */ public suspend fun sendPacket(packet: MinecraftPacket) { val uncompressedBodyBuf = BytesBuffer() - serverboundGameProtocols.getRegistry(stateMachine.currentState).encodePacket(uncompressedBodyBuf, packet) + serverboundGameProtocols.getRegistry(client.stateMachine.currentState).encodePacket(uncompressedBodyBuf, packet) val uncompressedData = uncompressedBodyBuf.toByteArray() val frameBuffer = BytesBuffer() if (threshold < 0) { @@ -84,9 +77,9 @@ public class NetworkChannel internal constructor( frameBuffer.writeVarInt(contentBuf.size) frameBuffer.writeBuffer(contentBuf) } - session.writeFully(frameBuffer.toByteArray()) - dispatcher.dispatchSent(packet) + networkSession.writeFully(frameBuffer.toByteArray()) + client.dispatchSent(packet, client.session) } - public fun close(): Unit = session.close() + public fun close(): Unit = networkSession.close() } \ No newline at end of file diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkSession.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkSession.kt index f77531d..46f45d3 100644 --- a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkSession.kt +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/network/NetworkSession.kt @@ -6,18 +6,14 @@ package cn.rtast.libmc.protocol.network -import cn.rtast.libmc.crypto.ProtocolContext import cn.rtast.libmc.network.RawSocket import cn.rtast.libmc.network.ReadChannel import cn.rtast.libmc.network.WriteChannel import cn.rtast.libmc.primitives.readVarInt +import cn.rtast.libmc.protocol.client.MinecraftClient import cn.rtast.libmc.protocol.crypto.Aes128Cfb8ChannelCipher -public class NetworkSession internal constructor( - private val host: String, - private val port: Int, - private val context: ProtocolContext, -) { +public class NetworkSession internal constructor(private val client: MinecraftClient) { private var socket: RawSocket? = null public var readChannel: ReadChannel? = null private set @@ -26,7 +22,7 @@ public class NetworkSession internal constructor( private set public suspend fun connect() { - val sk = context.createSocket(host, port) + val sk = client.protocolContext.createSocket(client.host, client.port) sk.connect() this.socket = sk this.readChannel = sk.openReadChannel() diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/event/ListenerRegistration.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/event/ListenerRegistration.kt new file mode 100644 index 0000000..e52d756 --- /dev/null +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/event/ListenerRegistration.kt @@ -0,0 +1,12 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/10 + */ + + +package cn.rtast.libmc.protocol.protocol.event + +public fun interface ListenerRegistration { + public suspend fun unregister() +} diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/event/PacketEventDispatcher.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/event/PacketEventDispatcher.kt new file mode 100644 index 0000000..7574002 --- /dev/null +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/event/PacketEventDispatcher.kt @@ -0,0 +1,102 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/5 + */ + +package cn.rtast.libmc.protocol.protocol.event + +import cn.rtast.libmc.packet.MinecraftPacket +import cn.rtast.libmc.protocol.protocol.PacketDirection +import cn.rtast.libmc.protocol.protocol.session.Session +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.reflect.KClass + +private typealias Handler = suspend Session.(MinecraftPacket) -> Unit +private typealias DirectionalHandler = suspend Session.(MinecraftPacket, PacketDirection) -> Unit + +public abstract class PacketEventDispatcher { + private val mutex = Mutex() + + @PublishedApi + internal var receiveHandlers: Map, List> = emptyMap() + + @PublishedApi + internal var sentHandlers: Map, List> = emptyMap() + + @PublishedApi + internal var globalHandlers: List = emptyList() + + @PublishedApi + internal suspend fun addTypedHandler( + isReceive: Boolean, + key: KClass, + handler: Handler, + ): ListenerRegistration { + mutex.withLock { + if (isReceive) { + val current = receiveHandlers[key] ?: emptyList() + receiveHandlers = receiveHandlers + (key to (current + handler)) + } else { + val current = sentHandlers[key] ?: emptyList() + sentHandlers = sentHandlers + (key to (current + handler)) + } + } + return ListenerRegistration { removeTypedHandler(isReceive, key, handler) } + } + + @PublishedApi + internal suspend fun removeTypedHandler( + isReceive: Boolean, + key: KClass, + handler: Handler, + ) { + mutex.withLock { + if (isReceive) { + val current = receiveHandlers[key] ?: return@withLock + val updated = current - handler + receiveHandlers = if (updated.isEmpty()) receiveHandlers - key else receiveHandlers + (key to updated) + } else { + val current = sentHandlers[key] ?: return@withLock + val updated = current - handler + sentHandlers = if (updated.isEmpty()) sentHandlers - key else sentHandlers + (key to updated) + } + } + } + + private suspend fun dispatch( + session: Session, + isReceive: Boolean, + packet: MinecraftPacket, + direction: PacketDirection, + ) { + val (typeHandlers, globals) = mutex.withLock { + val map = if (isReceive) receiveHandlers else sentHandlers + Pair(map[packet::class], globalHandlers) + } + typeHandlers?.forEach { handler -> handler(session, packet) } + globals.forEach { handler -> handler(session, packet, direction) } + } + + internal suspend fun dispatchReceive(packet: MinecraftPacket, session: Session) = + dispatch(session, true, packet, PacketDirection.CLIENTBOUND) + + internal suspend fun dispatchSent(packet: MinecraftPacket, session: Session) = + dispatch(session, false, packet, PacketDirection.SERVERBOUND) + + public suspend inline fun onPacket(noinline block: suspend Session.(T) -> Unit): ListenerRegistration { + val handler: Handler = { block(it as T) } + return addTypedHandler(true, T::class, handler) + } + + public suspend inline fun onSent(noinline block: suspend Session.(T) -> Unit): ListenerRegistration { + val handler: Handler = { block(it as T) } + return addTypedHandler(false, T::class, handler) + } + + public suspend fun on(block: suspend Session.(packet: MinecraftPacket, direction: PacketDirection) -> Unit): ListenerRegistration { + mutex.withLock { globalHandlers = globalHandlers + block } + return ListenerRegistration { mutex.withLock { globalHandlers = globalHandlers - block } } + } +} \ No newline at end of file diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/game/chat/TextComponent.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/game/chat/TextComponent.kt index ae2acb3..9a0b12b 100644 --- a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/game/chat/TextComponent.kt +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/game/chat/TextComponent.kt @@ -9,10 +9,11 @@ package cn.rtast.libmc.protocol.protocol.game.chat import cn.rtast.libmc.nbt.NBTCompound import cn.rtast.libmc.nbt.NBTTag import cn.rtast.libmc.nbt.NBTType +import cn.rtast.libmc.nbt.toJsonString +import cn.rtast.libmc.network.BytesBuffer import cn.rtast.libmc.protocol.protocol.game.Identifier import cn.rtast.libmc.protocol.protocol.util.readNetworkNBTCompound import cn.rtast.libmc.protocol.protocol.util.writeNetworkNBTCompound -import cn.rtast.libmc.network.BytesBuffer import kotlin.uuid.Uuid public data class TextComponent( @@ -223,6 +224,8 @@ public data class TextComponent( } } + public fun toJsonString(): String = this.toNBTCompound().toJsonString() + public companion object { public fun of(text: String): TextComponent = TextComponent(content = Content.PlainText(text)) } @@ -261,6 +264,117 @@ public fun NBTTag.toTextComponent(): TextComponent { } } +public fun TextComponent.toNBTCompound(): NBTTag.CompoundTag { + val map = mutableMapOf() + when (val c = this.content) { + is TextComponent.Content.PlainText -> { + map["type"] = NBTTag.StringTag("text") + map["text"] = NBTTag.StringTag(c.text) + } + + is TextComponent.Content.Translatable -> { + map["type"] = NBTTag.StringTag("translatable") + map["translate"] = NBTTag.StringTag(c.key) + c.fallback?.let { map["fallback"] = NBTTag.StringTag(it) } + if (c.args.isNotEmpty()) { + map["with"] = NBTTag.ListTag(NBTType.List, c.args.map { it.toNBTCompound() as NBTTag }.toMutableList()) + } + } + + is TextComponent.Content.Score -> { + map["type"] = NBTTag.StringTag("score") + map["score"] = NBTTag.CompoundTag( + mutableMapOf( + "name" to NBTTag.StringTag(c.name), + "objective" to NBTTag.StringTag(c.objective) + ) + ) + } + + is TextComponent.Content.Selector -> { + map["type"] = NBTTag.StringTag("selector") + map["selector"] = NBTTag.StringTag(c.selector) + c.separator?.let { map["separator"] = it.toNBTCompound() } + } + + is TextComponent.Content.Keybind -> { + map["type"] = NBTTag.StringTag("keybind") + map["keybind"] = NBTTag.StringTag(c.keybind) + } + + is TextComponent.Content.Nbt -> { + map["type"] = NBTTag.StringTag("nbt") + map["nbt"] = NBTTag.StringTag(c.path) + map["interpret"] = NBTTag.ByteTag(if (c.interpret) 1 else 0) + map["plain"] = NBTTag.ByteTag(if (c.plain) 1 else 0) + c.separator?.let { map["separator"] = it.toNBTCompound() } + when (val s = c.source) { + is TextComponent.Content.Nbt.NbtSource.Entity -> map["entity"] = NBTTag.StringTag(s.selector) + is TextComponent.Content.Nbt.NbtSource.Block -> map["block"] = NBTTag.StringTag(s.coordinates) + is TextComponent.Content.Nbt.NbtSource.Storage -> map["storage"] = NBTTag.StringTag(s.id.toString()) + } + } + + is TextComponent.Content.ObjectContent.Atlas -> { + map["type"] = NBTTag.StringTag("object") + map["object"] = NBTTag.StringTag("atlas") + map["sprite"] = NBTTag.StringTag(c.sprite.toString()) + map["atlas"] = NBTTag.StringTag(c.atlas.toString()) + } + + is TextComponent.Content.ObjectContent.Player -> { + map["type"] = NBTTag.StringTag("object") + map["object"] = NBTTag.StringTag("player") + map["hat"] = NBTTag.ByteTag(if (c.hat) 1 else 0) + when (val p = c.profile) { + is TextComponent.PlayerProfile.Name -> map["player"] = NBTTag.StringTag(p.name) + is TextComponent.PlayerProfile.FullProfile -> { + val pMap = mutableMapOf() + p.name?.let { pMap["name"] = NBTTag.StringTag(it) } + p.id?.let { pMap["id"] = NBTTag.StringTag(it.toString()) } + map["player"] = NBTTag.CompoundTag(pMap) + } + } + } + } + + this.style.color?.let { + map["color"] = NBTTag.StringTag( + when (it) { + is TextComponent.TextColor.Named -> it.name + is TextComponent.TextColor.Hex -> it.hex + } + ) + } + this.style.font?.let { map["font"] = NBTTag.StringTag(it.toString()) } + this.style.bold?.let { map["bold"] = NBTTag.ByteTag(if (it) 1 else 0) } + this.style.italic?.let { map["italic"] = NBTTag.ByteTag(if (it) 1 else 0) } + this.style.underlined?.let { map["underlined"] = NBTTag.ByteTag(if (it) 1 else 0) } + this.style.strikethrough?.let { map["strikethrough"] = NBTTag.ByteTag(if (it) 1 else 0) } + this.style.obfuscated?.let { map["obfuscated"] = NBTTag.ByteTag(if (it) 1 else 0) } + this.style.shadowColor?.let { + when (it) { + is TextComponent.ShadowColor.ArgbInt -> map["shadow_color"] = NBTTag.IntTag(it.argb.toInt()) + is TextComponent.ShadowColor.RgbaFloat -> map["shadow_color"] = NBTTag.ListTag( + NBTType.List, mutableListOf( + NBTTag.FloatTag(it.red), + NBTTag.FloatTag(it.green), + NBTTag.FloatTag(it.blue), + NBTTag.FloatTag(it.alpha) + ) + ) + } + } + + if (this.extra.isNotEmpty()) map["extra"] = + NBTTag.ListTag(NBTType.List, this.extra.map { it.toNBTCompound() as NBTTag }.toMutableList()) + this.insertion?.let { map["insertion"] = NBTTag.StringTag(it) } + this.clickEvent?.let { map["click_event"] = encodeClickEventToNbt(it) } + this.hoverEvent?.let { map["hover_event"] = encodeHoverEventToNbt(it) } + + return NBTTag.CompoundTag(map) +} + private fun parseContentFromNbt(tag: NBTTag.CompoundTag): TextComponent.Content { val type = tag.getStringOrNull("type") return when { @@ -437,117 +551,6 @@ private fun parseHoverEventFromNbt(tag: NBTTag.CompoundTag): TextComponent.Hover } } -public fun TextComponent.toNbt(): NBTTag.CompoundTag { - val map = mutableMapOf() - when (val c = this.content) { - is TextComponent.Content.PlainText -> { - map["type"] = NBTTag.StringTag("text") - map["text"] = NBTTag.StringTag(c.text) - } - - is TextComponent.Content.Translatable -> { - map["type"] = NBTTag.StringTag("translatable") - map["translate"] = NBTTag.StringTag(c.key) - c.fallback?.let { map["fallback"] = NBTTag.StringTag(it) } - if (c.args.isNotEmpty()) { - map["with"] = NBTTag.ListTag(NBTType.List, c.args.map { it.toNbt() as NBTTag }.toMutableList()) - } - } - - is TextComponent.Content.Score -> { - map["type"] = NBTTag.StringTag("score") - map["score"] = NBTTag.CompoundTag( - mutableMapOf( - "name" to NBTTag.StringTag(c.name), - "objective" to NBTTag.StringTag(c.objective) - ) - ) - } - - is TextComponent.Content.Selector -> { - map["type"] = NBTTag.StringTag("selector") - map["selector"] = NBTTag.StringTag(c.selector) - c.separator?.let { map["separator"] = it.toNbt() } - } - - is TextComponent.Content.Keybind -> { - map["type"] = NBTTag.StringTag("keybind") - map["keybind"] = NBTTag.StringTag(c.keybind) - } - - is TextComponent.Content.Nbt -> { - map["type"] = NBTTag.StringTag("nbt") - map["nbt"] = NBTTag.StringTag(c.path) - map["interpret"] = NBTTag.ByteTag(if (c.interpret) 1 else 0) - map["plain"] = NBTTag.ByteTag(if (c.plain) 1 else 0) - c.separator?.let { map["separator"] = it.toNbt() } - when (val s = c.source) { - is TextComponent.Content.Nbt.NbtSource.Entity -> map["entity"] = NBTTag.StringTag(s.selector) - is TextComponent.Content.Nbt.NbtSource.Block -> map["block"] = NBTTag.StringTag(s.coordinates) - is TextComponent.Content.Nbt.NbtSource.Storage -> map["storage"] = NBTTag.StringTag(s.id.toString()) - } - } - - is TextComponent.Content.ObjectContent.Atlas -> { - map["type"] = NBTTag.StringTag("object") - map["object"] = NBTTag.StringTag("atlas") - map["sprite"] = NBTTag.StringTag(c.sprite.toString()) - map["atlas"] = NBTTag.StringTag(c.atlas.toString()) - } - - is TextComponent.Content.ObjectContent.Player -> { - map["type"] = NBTTag.StringTag("object") - map["object"] = NBTTag.StringTag("player") - map["hat"] = NBTTag.ByteTag(if (c.hat) 1 else 0) - when (val p = c.profile) { - is TextComponent.PlayerProfile.Name -> map["player"] = NBTTag.StringTag(p.name) - is TextComponent.PlayerProfile.FullProfile -> { - val pMap = mutableMapOf() - p.name?.let { pMap["name"] = NBTTag.StringTag(it) } - p.id?.let { pMap["id"] = NBTTag.StringTag(it.toString()) } - map["player"] = NBTTag.CompoundTag(pMap) - } - } - } - } - - this.style.color?.let { - map["color"] = NBTTag.StringTag( - when (it) { - is TextComponent.TextColor.Named -> it.name - is TextComponent.TextColor.Hex -> it.hex - } - ) - } - this.style.font?.let { map["font"] = NBTTag.StringTag(it.toString()) } - this.style.bold?.let { map["bold"] = NBTTag.ByteTag(if (it) 1 else 0) } - this.style.italic?.let { map["italic"] = NBTTag.ByteTag(if (it) 1 else 0) } - this.style.underlined?.let { map["underlined"] = NBTTag.ByteTag(if (it) 1 else 0) } - this.style.strikethrough?.let { map["strikethrough"] = NBTTag.ByteTag(if (it) 1 else 0) } - this.style.obfuscated?.let { map["obfuscated"] = NBTTag.ByteTag(if (it) 1 else 0) } - this.style.shadowColor?.let { - when (it) { - is TextComponent.ShadowColor.ArgbInt -> map["shadow_color"] = NBTTag.IntTag(it.argb.toInt()) - is TextComponent.ShadowColor.RgbaFloat -> map["shadow_color"] = NBTTag.ListTag( - NBTType.List, mutableListOf( - NBTTag.FloatTag(it.red), - NBTTag.FloatTag(it.green), - NBTTag.FloatTag(it.blue), - NBTTag.FloatTag(it.alpha) - ) - ) - } - } - - if (this.extra.isNotEmpty()) map["extra"] = - NBTTag.ListTag(NBTType.List, this.extra.map { it.toNbt() as NBTTag }.toMutableList()) - this.insertion?.let { map["insertion"] = NBTTag.StringTag(it) } - this.clickEvent?.let { map["click_event"] = encodeClickEventToNbt(it) } - this.hoverEvent?.let { map["hover_event"] = encodeHoverEventToNbt(it) } - - return NBTTag.CompoundTag(map) -} - private fun encodeClickEventToNbt(event: TextComponent.ClickEvent): NBTTag.CompoundTag { val map = mutableMapOf("action" to NBTTag.StringTag(event.action.text)) when (event) { @@ -575,7 +578,7 @@ private fun encodeClickEventToNbt(event: TextComponent.ClickEvent): NBTTag.Compo private fun encodeHoverEventToNbt(event: TextComponent.HoverEvent): NBTTag.CompoundTag { val map = mutableMapOf("action" to NBTTag.StringTag(event.action.text)) when (event) { - is TextComponent.HoverEvent.ShowText -> map["value"] = event.component.toNbt() + is TextComponent.HoverEvent.ShowText -> map["value"] = event.component.toNBTCompound() is TextComponent.HoverEvent.ShowItem -> { map["id"] = NBTTag.StringTag(event.id.toString()) map["count"] = NBTTag.IntTag(event.count) @@ -590,7 +593,7 @@ private fun encodeHoverEventToNbt(event: TextComponent.HoverEvent): NBTTag.Compo intArrayOf(u.i1, u.i2, u.i3, u.i4) ) } - event.name?.let { map["name"] = it.toNbt() } + event.name?.let { map["name"] = it.toNBTCompound() } } } return NBTTag.CompoundTag(map) @@ -617,6 +620,6 @@ internal fun BytesBuffer.readTextComponent(): TextComponent = this.readNetworkNBTCompound().element.toTextComponent() internal fun BytesBuffer.writeTextComponent(component: TextComponent) { - val nbt = component.toNbt() + val nbt = component.toNBTCompound() this.writeNetworkNBTCompound(NBTCompound("", nbt)) } \ No newline at end of file diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/session/Session.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/session/Session.kt new file mode 100644 index 0000000..3d107ab --- /dev/null +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/session/Session.kt @@ -0,0 +1,31 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/10 + */ + + +package cn.rtast.libmc.protocol.protocol.session + +import cn.rtast.libmc.protocol.client.CURRENT_MINECRAFT_PROTOCOL_VERSION +import cn.rtast.libmc.protocol.protocol.state.HandshakeIntent +import kotlin.reflect.KClass + +public interface Session { + @Suppress("FunctionName") + public fun _onEvent(clazz: KClass, block: suspend Session.(T) -> Unit) + public suspend fun emitEvent(event: SessionEvent): Unit? + public suspend fun login(protocolVersion: Int = CURRENT_MINECRAFT_PROTOCOL_VERSION) + public suspend fun handshake( + protocolVersion: Int = CURRENT_MINECRAFT_PROTOCOL_VERSION, + intent: HandshakeIntent = HandshakeIntent.LOGIN, + ) + + public suspend fun status(protocolVersion: Int = CURRENT_MINECRAFT_PROTOCOL_VERSION): String + public suspend fun disconnect() + public suspend fun init() +} + +public inline fun Session.onEvent(noinline block: suspend Session.(T) -> Unit) { + _onEvent(T::class, block) +} \ No newline at end of file diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/session/SessionEvent.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/session/SessionEvent.kt new file mode 100644 index 0000000..dd7da1b --- /dev/null +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/session/SessionEvent.kt @@ -0,0 +1,16 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/10 + */ + + +package cn.rtast.libmc.protocol.protocol.session + +import cn.rtast.libmc.protocol.protocol.game.chat.TextComponent +import cn.rtast.libmc.protocol.protocol.state.ProtocolState + +public sealed interface SessionEvent { + public data class DisconnectedEvent(val reason: TextComponent, val state: ProtocolState) : SessionEvent + public object ConnectedEvent : SessionEvent +} \ No newline at end of file diff --git a/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/session/SessionImpl.kt b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/session/SessionImpl.kt new file mode 100644 index 0000000..e8b6a3a --- /dev/null +++ b/libmc-protocol/src/commonMain/kotlin/cn/rtast/libmc/protocol/protocol/session/SessionImpl.kt @@ -0,0 +1,172 @@ +package cn.rtast.libmc.protocol.protocol.session + +import cn.rtast.libmc.protocol.client.MinecraftClient +import cn.rtast.libmc.protocol.crypto.minecraftServerIdHash +import cn.rtast.libmc.protocol.crypto.rsaEncrypt +import cn.rtast.libmc.protocol.packet.configuration.clientbound.* +import cn.rtast.libmc.protocol.packet.configuration.serverbound.ServerboundAckFinishConfigurationPacket +import cn.rtast.libmc.protocol.packet.configuration.serverbound.ServerboundKeepAliveConfigurationPacket +import cn.rtast.libmc.protocol.packet.configuration.serverbound.ServerboundPongConfigurationPacket +import cn.rtast.libmc.protocol.packet.configuration.serverbound.ServerboundSelectKnownPacksPacket +import cn.rtast.libmc.protocol.packet.handshake.ServerboundHandshakePacket +import cn.rtast.libmc.protocol.packet.login.clientbound.ClientboundDisconnectLoginPacket +import cn.rtast.libmc.protocol.packet.login.clientbound.ClientboundHelloPacket +import cn.rtast.libmc.protocol.packet.login.clientbound.ClientboundLoginSuccessPacket +import cn.rtast.libmc.protocol.packet.login.clientbound.ClientboundSetCompressionPacket +import cn.rtast.libmc.protocol.packet.login.serverbound.ServerboundKeyPacket +import cn.rtast.libmc.protocol.packet.login.serverbound.ServerboundLoginAcknowledgedPacket +import cn.rtast.libmc.protocol.packet.login.serverbound.ServerboundLoginStartPacket +import cn.rtast.libmc.protocol.packet.play.clientbound.ClientboundDisconnectPlayPacket +import cn.rtast.libmc.protocol.packet.play.clientbound.ClientboundKeepAlivePlayPacket +import cn.rtast.libmc.protocol.packet.play.clientbound.ClientboundPingPacket +import cn.rtast.libmc.protocol.packet.play.serverbound.ServerboundKeepAlivePlayPacket +import cn.rtast.libmc.protocol.packet.play.serverbound.ServerboundPongPlayPacket +import cn.rtast.libmc.protocol.packet.status.clientbound.ClientboundStatusResponsePacket +import cn.rtast.libmc.protocol.packet.status.serverbound.ServerboundStatusRequestPacket +import cn.rtast.libmc.protocol.protocol.event.ListenerRegistration +import cn.rtast.libmc.protocol.protocol.game.chat.TextComponent +import cn.rtast.libmc.protocol.protocol.state.HandshakeIntent +import cn.rtast.libmc.protocol.protocol.state.ProtocolState +import cn.rtast.libmc.protocol.util.generateRandom16Bytes +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.reflect.KClass + +private typealias EventHandler = suspend Session.(SessionEvent) -> Unit + +public class SessionImpl internal constructor() : Session { + private lateinit var client: MinecraftClient + internal fun attachClient(client: MinecraftClient) { + this.client = client + } + + private val stateMachine get() = client.stateMachine + private val networkChannel get() = client.networkChannel + + @PublishedApi + internal val eventListener: HashMap, MutableList> = hashMapOf() + + override suspend fun init() { + client.onPacket { + emitEvent(SessionEvent.DisconnectedEvent(it.reason, stateMachine.currentState)) + } + client.onPacket { + emitEvent(SessionEvent.DisconnectedEvent(it.reason, stateMachine.currentState)) + } + client.onPacket { + emitEvent(SessionEvent.DisconnectedEvent(it.reason, stateMachine.currentState)) + } + + client.onPacket { acceptEncryption(it) } + client.onPacket { setCompression(it.threshold) } + client.onPacket { acknowledgeLogin() } + client.onPacket { finishConfiguration() } + client.onPacket { networkChannel.sendPacket(ServerboundPongPlayPacket(it.id)) } + client.onPacket { networkChannel.sendPacket(ServerboundKeepAlivePlayPacket(it.id)) } + client.onPacket { + networkChannel.sendPacket(ServerboundKeepAliveConfigurationPacket(it.id)) + } + client.onPacket { + networkChannel.sendPacket(ServerboundPongConfigurationPacket(it.id)) + } + client.onPacket { + networkChannel.sendPacket(ServerboundSelectKnownPacksPacket(emptyList())) + } + } + + override fun _onEvent(clazz: KClass, block: suspend Session.(T) -> Unit) { + @Suppress("UNCHECKED_CAST") + this.eventListener.getOrPut(clazz) { mutableListOf() }.add { block(it as T) } + } + + override suspend fun emitEvent(event: SessionEvent): Unit? = + eventListener[event::class]?.forEach { handler -> handler(event) } + + override suspend fun login(protocolVersion: Int) { + handshake(protocolVersion, HandshakeIntent.LOGIN) + loginStart() + } + + override suspend fun handshake(protocolVersion: Int, intent: HandshakeIntent) { + ensureState(ProtocolState.HANDSHAKE) + client.networkChannel.sendPacket( + ServerboundHandshakePacket(protocolVersion, client.host, client.port.toUShort(), intent) + ) + val targetState = if (intent == HandshakeIntent.LOGIN) ProtocolState.LOGIN else { + if (intent == HandshakeIntent.TRANSFER) ProtocolState.HANDSHAKE else ProtocolState.STATUS + } + stateMachine.transitionTo(targetState) + } + + override suspend fun status(protocolVersion: Int): String = + suspendCancellableCoroutine { continuation -> + client.launch { + try { + var reg: ListenerRegistration? = null + reg = client.onPacket { packet -> + reg?.unregister() + if (continuation.isActive) continuation.resume(packet.jsonResponse) + } + handshake(protocolVersion, HandshakeIntent.STATUS) + networkChannel.sendPacket(ServerboundStatusRequestPacket) + } catch (e: Exception) { + if (continuation.isActive) continuation.resumeWithException(e) + } + } + } + + internal suspend fun loginStart() { + ensureState(ProtocolState.LOGIN) + networkChannel.sendPacket(ServerboundLoginStartPacket(client.username, client.uuid)) + } + + internal suspend fun acceptEncryption(context: ClientboundHelloPacket) { + ensureState(ProtocolState.LOGIN) + val sharedSecret = generateRandom16Bytes() + val serverHash = minecraftServerIdHash(context.serverId, sharedSecret, context.publicKey) + client.protocolContext.authProvider!!.joinServer( + "https://sessionserver.mojang.com/session/minecraft/join", + client.accessToken!!, client.uuid.toString().replace("-", ""), serverHash + ) + val encryptedSecret = rsaEncrypt(context.publicKey, sharedSecret) + val encryptedVerifyToken = rsaEncrypt(context.publicKey, context.verifyToken) + networkChannel.sendPacket(ServerboundKeyPacket(encryptedSecret, encryptedVerifyToken)) + networkChannel.networkSession.enableEncryption(sharedSecret) + } + + internal suspend fun acknowledgeLogin() { + ensureState(ProtocolState.LOGIN) + networkChannel.sendPacket(ServerboundLoginAcknowledgedPacket) + stateMachine.transitionTo(ProtocolState.CONFIGURATION) + } + + internal suspend fun finishConfiguration() { + ensureState(ProtocolState.CONFIGURATION) + networkChannel.sendPacket(ServerboundAckFinishConfigurationPacket) + stateMachine.transitionTo(ProtocolState.PLAY) + } + + internal fun setCompression(threshold: Int) { + ensureState(ProtocolState.LOGIN, ProtocolState.CONFIGURATION) + networkChannel.setCompression(threshold) + } + + override suspend fun disconnect(): Unit = client.close().apply { + emitEvent( + SessionEvent.DisconnectedEvent( + TextComponent(TextComponent.Content.PlainText("LibMC-Disconnected by calling disconnect()")), + stateMachine.currentState + ) + ) + } + + private fun ensureState(vararg allowedStates: ProtocolState) { + if (stateMachine.currentState !in allowedStates) throw IllegalStateException( + "Cannot perform this action in state ${stateMachine.currentState}. Expected state: ${ + allowedStates.joinToString(",").removeSuffix(",") + }." + ) + } +} \ No newline at end of file diff --git a/libmc-protocol/src/commonTest/kotlin/client/TestClient.kt b/libmc-protocol/src/commonTest/kotlin/client/TestClient.kt index cc10294..a6d9c11 100644 --- a/libmc-protocol/src/commonTest/kotlin/client/TestClient.kt +++ b/libmc-protocol/src/commonTest/kotlin/client/TestClient.kt @@ -8,28 +8,23 @@ package client import cn.rtast.libmc.crypto.AuthenticationProvider -import cn.rtast.libmc.packet.ClientboundUnknownPacket import cn.rtast.libmc.protocol.client.createMinecraftClient -import cn.rtast.libmc.protocol.packet.play.clientbound.ClientboundLevelParticlePacket import cn.rtast.libmc.protocol.packet.play.clientbound.ClientboundPlayerChatMessagePacket -import cn.rtast.libmc.protocol.packet.play.clientbound.ClientboundRecipeBookRemovePacket -import cn.rtast.libmc.protocol.packet.play.clientbound.ClientboundRecipeBookSettingsPacket import cn.rtast.libmc.protocol.packet.play.clientbound.ClientboundStepTickPacket -import cn.rtast.libmc.protocol.packet.play.serverbound.ServerboundChatMessagePacket +import cn.rtast.libmc.protocol.protocol.session.SessionEvent +import cn.rtast.libmc.protocol.protocol.session.onEvent import cn.rtast.libmc.protocol.util.generateOfflineUuid import io.ktor.client.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.utils.io.* -import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest import kotlinx.io.buffered import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem import test.KtorNetworkEngine -import kotlin.random.Random import kotlin.test.Test -import kotlin.time.Clock import kotlin.uuid.Uuid @@ -40,7 +35,7 @@ class TestClient { private val httpClient = HttpClient() @Test - fun `test client`() { + fun `test client`() = runTest { val cli = createMinecraftClient( "127.0.0.1", 25565, "RTAkland", Uuid.parse("bb033844-e68e-4909-a636-1a5d1821ddc4"), @@ -56,14 +51,25 @@ class TestClient { } } ) + cli.onEvent { +// println(status()) + login() +// disconnect() + } + cli.onEvent { + println(it.reason.toJsonString()) + } + cli.onPacket { chatTracker.onReceivePlayerChat(it.messageSignature) } + cli.onPacket { println(it) } cli.on { packet, direction -> println("$direction -> $packet") } - cli.launch { cli.connect() } + cli.connect() +// awaitCancellation() while (true) { } } @Test - fun `test client offline mode`() { + fun `test client offline mode`() = runTest { val cli = createMinecraftClient( "127.0.0.1", 25566, "11", generateOfflineUuid("11"), null, @@ -83,9 +89,20 @@ class TestClient { // ) // ) // } + + cli.onEvent { +// println(status()) + login() +// disconnect() + } + cli.onEvent { + println(it.reason.toJsonString()) + } cli.onPacket { chatTracker.onReceivePlayerChat(it.messageSignature) } cli.onPacket { println(it) } - cli.launch { cli.connect() } + cli.on { packet, direction -> println("$direction -> $packet") } + cli.connect() +// awaitCancellation() while (true) { } } diff --git a/libmc-protocol/src/commonTest/kotlin/test/KtorNetworkEngine.kt b/libmc-protocol/src/commonTest/kotlin/test/KtorNetworkEngine.kt index 90716db..b2d7cd2 100644 --- a/libmc-protocol/src/commonTest/kotlin/test/KtorNetworkEngine.kt +++ b/libmc-protocol/src/commonTest/kotlin/test/KtorNetworkEngine.kt @@ -16,6 +16,7 @@ import io.ktor.network.sockets.* import io.ktor.utils.io.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlin.coroutines.cancellation.CancellationException class KtorNetworkEngine : SocketEngine { override fun create(host: String, port: Int): RawSocket = KtorNetworkSocket(host, port) @@ -24,21 +25,61 @@ class KtorNetworkEngine : SocketEngine { class KtorNetworkSocket(private val host: String, private val port: Int) : RawSocket { private val sm = SelectorManager(Dispatchers.IO) private lateinit var socket: Socket + private var ktorReadChannel: ByteReadChannel? = null + private var ktorWriteChannel: ByteWriteChannel? = null 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 openReadChannel(): ReadChannel { + val channel = socket.openReadChannel() + this.ktorReadChannel = channel + return KtorReadChannel(channel) + } + + override fun openWriteChannel(): WriteChannel { + val channel = socket.openWriteChannel(autoFlush = false) + this.ktorWriteChannel = channel + return KtorWriteChannel(channel) + } + override fun close() { - socket.close() - sm.close() + try { + ktorReadChannel?.cancel(CancellationException("Socket closed")) + ktorWriteChannel?.close(null) + if (::socket.isInitialized) socket.dispose() + } catch (_: Exception) { + } finally { + sm.close() + } } } 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 = + private inline fun wrapChannelException(block: () -> T): T { + return try { + block() + } catch (e: Throwable) { + if (e is ClosedByteChannelException || e is CancellationException || readChannel.isClosedForRead) { + throw CancellationException("ReadChannel was closed", e) + } + throw e + } + } + + override suspend fun readByte(): Byte = wrapChannelException { + if (readChannel.isClosedForRead) throw CancellationException("ReadChannel is closed for read") + readChannel.readByte() + } + + override suspend fun readBytes(length: Int): ByteArray = wrapChannelException { + if (readChannel.isClosedForRead) throw CancellationException("ReadChannel is closed for read") + readChannel.readByteArray(length) + } + + override suspend fun readFully(out: ByteArray, start: Int, end: Int): Unit = wrapChannelException { + if (readChannel.isClosedForRead) throw CancellationException("ReadChannel is closed for read") readChannel.readFully(out, start, end) + } } class KtorWriteChannel(private val writeChannel: ByteWriteChannel) : WriteChannel {