diff --git a/README.md b/README.md index aeca516..ca8977d 100644 --- a/README.md +++ b/README.md @@ -23,19 +23,49 @@ repositories { } dependencies { - implementation("cn.rtast.mcping:mcping:0.0.1") + implementation("cn.rtast.mcping:mcping:0.0.2") } ``` +## Kotlin example + ```kotlin fun main() { - // return a json respose - val response: String = mcping("org.mc-complex.com", 25565) - println(response) + val sm = SelectorManager(Dispatchers.IO) + // context is not required, if not passed, default context will be used + // ping java server + val response: PingResponse = + mcping(host = "org.mc-complex.com", port = 25565, type = ServerType.Java, context = PingContext(sm)) + println(response.content) + println(response.latency) + + // ping bedrock + val response = mcping("play.wildnetwork.net", 19132, ServerType.Bedrock) + println(response.toBedrockResponse()) } ``` -> An example json response can be found at [ping-response-example](example/java-ping-response.json) (Formatted) +## Java example + +```java +void main() { + // ping java server + String testJavaHost = "org.mc-complex.com"; + PingResponse javaResponse = McPing.mcping(testJavaHost, 25565); + + // ping bedrock server + String testBedrockHost = "play.wildnetwork.net"; + PingResponse bedrockResponse = McPing.mcping(testBedrockHost, 19132, ServerType.Bedrock); + System.out.println(bedrockResponse.toBedrockResponse()); +} +``` + +> java consumers should not manually pass the context parameter + +## Other resources + +> An example of java ping json response can be found at [ping-response-example](example/java-ping-response.json) (Formatted), +> a raw response of bedrock ping response can be found at [bedrock-pinng-raw-response](example/bedrock-pinng-raw-response.txt) # Open Source diff --git a/build.gradle.kts b/build.gradle.kts index 98f1409..0c299ca 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "cn.rtast.mcping" -version = "0.0.1" +version = providers.gradleProperty("libVersion").get() repositories { mavenCentral() diff --git a/example/bedrock-pinng-raw-response.txt b/example/bedrock-pinng-raw-response.txt new file mode 100644 index 0000000..eb0e1e4 --- /dev/null +++ b/example/bedrock-pinng-raw-response.txt @@ -0,0 +1 @@ +MCPE;" &e&lWILD&f&lNETWORK &6&lS24 &8-&e discord.gg/WildPrison\n&a Release: &f531d 18h 19m 18s ago";2169;26.45;393;1500;11173189777763089377;Another Geyser server.;Survival;1;19132;0; \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 804fa97..028f884 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,6 @@ kotlin.code.style=official kotlin.native.ignoreDisabledTargets=true kotlin.native.enableKlibsCrossCompilation=true kotlin.daemon.jvmargs=-Xmx2048M -org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 \ No newline at end of file +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 + +libVersion=0.0.2 \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/bedrock/packet.kt b/src/commonMain/kotlin/cn/rtast/mcping/bedrock/packet.kt new file mode 100644 index 0000000..e875ed8 --- /dev/null +++ b/src/commonMain/kotlin/cn/rtast/mcping/bedrock/packet.kt @@ -0,0 +1,99 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/3 + */ + + +package cn.rtast.mcping.bedrock + +import cn.rtast.mcping.platform.PlatformBuffer +import kotlin.random.Random +import kotlin.time.Clock + +private val rakNetMagic = byteArrayOf( + 0x00, 0xFF.toByte(), + 0xFF.toByte(), 0x00, + 0xFE.toByte(), 0xFE.toByte(), + 0xFE.toByte(), 0xFE.toByte(), + 0xFD.toByte(), 0xFD.toByte(), + 0xFD.toByte(), 0xFD.toByte(), + 0x12, 0x34, 0x56, 0x78 +) + +internal interface MinecraftBedrockPacket { + val packetId: Byte + val time: Long + val magic: ByteArray + + fun writePayload(buffer: PlatformBuffer) +} + +internal data class BedrockRequestPacket( + override val time: Long, + override val magic: ByteArray = rakNetMagic, + val guid: Long = Random.nextLong(), +) : MinecraftBedrockPacket { + override val packetId: Byte = 0x01 + + override fun writePayload(buffer: PlatformBuffer) { + buffer.writeByte(packetId) + buffer.writeLong(time) + buffer.writeBytes(magic) + buffer.writeLong(guid) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + other as BedrockRequestPacket + if (time != other.time) return false + if (guid != other.guid) return false + if (packetId != other.packetId) return false + if (!magic.contentEquals(other.magic)) return false + return true + } + + override fun hashCode(): Int { + var result = time.hashCode() + result = 31 * result + guid.hashCode() + result = 31 * result + packetId + result = 31 * result + magic.contentHashCode() + return result + } +} + +internal data class BedrockResponsePacket( + override val packetId: Byte, + override val time: Long, + val serverGuid: Long, + override val magic: ByteArray, + val stringLength: Short, + val payload: String, +) : MinecraftBedrockPacket { + override fun writePayload(buffer: PlatformBuffer) {} + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + other as BedrockResponsePacket + if (packetId != other.packetId) return false + if (time != other.time) return false + if (serverGuid != other.serverGuid) return false + if (stringLength != other.stringLength) return false + if (!magic.contentEquals(other.magic)) return false + if (payload != other.payload) return false + + return true + } + + override fun hashCode(): Int { + var result = packetId.toInt() + result = 31 * result + time.hashCode() + result = 31 * result + serverGuid.hashCode() + result = 31 * result + stringLength + result = 31 * result + magic.contentHashCode() + result = 31 * result + payload.hashCode() + return result + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/bedrock/packet_writer.kt b/src/commonMain/kotlin/cn/rtast/mcping/bedrock/packet_writer.kt new file mode 100644 index 0000000..1a0a18b --- /dev/null +++ b/src/commonMain/kotlin/cn/rtast/mcping/bedrock/packet_writer.kt @@ -0,0 +1,17 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/3 + */ + + +package cn.rtast.mcping.bedrock + +import cn.rtast.mcping.platform.PlatformBuffer +import cn.rtast.mcping.platform.UdpSocket + +internal fun UdpSocket.sendPacket(packet: MinecraftBedrockPacket): ByteArray { + val buf = PlatformBuffer() + packet.writePayload(buf) + return sendAndReceive(buf.toByteArray()) +} \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/bedrock/ping_bedrock.kt b/src/commonMain/kotlin/cn/rtast/mcping/bedrock/ping_bedrock.kt new file mode 100644 index 0000000..0ef5723 --- /dev/null +++ b/src/commonMain/kotlin/cn/rtast/mcping/bedrock/ping_bedrock.kt @@ -0,0 +1,34 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/3 + */ + + +package cn.rtast.mcping.bedrock + +import cn.rtast.mcping.PingResponse +import cn.rtast.mcping.platform.PingContext +import cn.rtast.mcping.platform.UdpSocket +import cn.rtast.mcping.platform.wrap +import kotlin.time.Clock + +internal fun pingBedrockServer(host: String, port: Int, context: PingContext): PingResponse { + val socket = UdpSocket(host, port, context) + return try { + val sendTime = Clock.System.now().toEpochMilliseconds() + val packet = BedrockRequestPacket(sendTime) + val responseBytes = socket.sendPacket(packet) + val receiveTime = Clock.System.now().toEpochMilliseconds() + val buf = responseBytes.wrap() + buf.readByte() // packet id + buf.readLong() // time + buf.readLong() // server guid + buf.readBytes(16) // magic refer to `rakNetMagic` + val payloadLength = buf.readShort() + val payload = buf.readBytes(payloadLength.toInt()) + PingResponse(payload.decodeToString(), (receiveTime - sendTime).toInt()) + } finally { + socket.close() + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/bedrock/ping_response_parser.kt b/src/commonMain/kotlin/cn/rtast/mcping/bedrock/ping_response_parser.kt new file mode 100644 index 0000000..b9c7ebc --- /dev/null +++ b/src/commonMain/kotlin/cn/rtast/mcping/bedrock/ping_response_parser.kt @@ -0,0 +1,116 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/3 + */ + + +package cn.rtast.mcping.bedrock + +import cn.rtast.mcping.PingResponse + +public enum class BedrockGameMode(public val gameMode: String) { + Survival("Survival"), + Creative("Creative"), + Adventure("Adventure"), + Spectator("Spectator"), // reserved? + Hardcore("Hardcore"), + Unknown(""); // reserved + + public companion object { + public fun parse(value: String?): BedrockGameMode { + return entries.firstOrNull { + it.name.equals(value, ignoreCase = true) + } ?: Unknown + } + } +} + +public data class BedrockPingResponse( + /** + * always be `MCPE` + * index 0 + */ + val protocolHeader: String, + /** + * MOTD line 1 + * index 1 + */ + val motdLine1: String, + /** + * protocol version + * index 2 + */ + val protocolVersion: Int, + /** + * game version + * index 3 + */ + val gameVersion: String, + /** + * online players + * index 4 + */ + val onlinePlayers: Int, + /** + * maximum players + * index 5 + */ + val maximumPlayers: Int, + /** + * server GUID + * parsed as String -> origin bytes count is 8 + * index 6 + */ + val serverGUID: String, + /** + * MOTD line 2 + * index 7 + */ + val motdLine2: String, + /** + * game mode + * index 8 + */ + val gameMode: BedrockGameMode, + /** + * Nintendo Limited + * Nintendo Switch online restriction flag (1 indicates restriction enabled/processed) + * index 9 + */ + val nintendoLimited: Boolean, + /** + * ipv4 port + * if disabled or unconfigured, it will be 0 + * index 10 + */ + val ipv4Port: Int, + /** + * ipv6 port + * if disabled or unconfigured, it will be 0 + * index 11 + */ + val ipv6Port: Int, + /** + * latency ms + * reserved not exists in response packet + */ + val latency: Int +) + +internal fun PingResponse.parseBedrockPingResponse(): BedrockPingResponse { + try { + val fields = content.split(";") + return BedrockPingResponse( + fields[0], fields[1], fields[2].toInt(), + fields[3], fields[4].toInt(), + fields[5].toInt(), fields[6], + fields[7], BedrockGameMode.parse(fields[8]), + fields.getOrNull(9) == "1", fields[10].toInt(), + fields[11].toInt(), this.latency // pass through PingResponse.latency + ) + } catch (e: Exception) { + e.printStackTrace() + throw IllegalStateException("The server respond incorrect response: Missing fields or type mismatch") + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/mc_primitives.kt b/src/commonMain/kotlin/cn/rtast/mcping/java/mc_primitives.kt similarity index 84% rename from src/commonMain/kotlin/cn/rtast/mcping/mc_primitives.kt rename to src/commonMain/kotlin/cn/rtast/mcping/java/mc_primitives.kt index 0a60740..8856b3c 100644 --- a/src/commonMain/kotlin/cn/rtast/mcping/mc_primitives.kt +++ b/src/commonMain/kotlin/cn/rtast/mcping/java/mc_primitives.kt @@ -4,10 +4,10 @@ * Date: 2026/9/3 */ -package cn.rtast.mcping +package cn.rtast.mcping.java import cn.rtast.mcping.platform.PlatformBuffer -import cn.rtast.mcping.platform.PlatformReadChannel +import cn.rtast.mcping.platform.ReadChannel internal fun PlatformBuffer.writeVarInt(value: Int) { @@ -22,7 +22,7 @@ internal fun PlatformBuffer.writeVarInt(value: Int) { } } -internal fun PlatformReadChannel.readVarInt(): Int { +internal fun ReadChannel.readVarInt(): Int { var value = 0 var position = 0 while (true) { @@ -41,7 +41,7 @@ internal fun PlatformBuffer.writeMcString(value: String) { this.writeBytes(bytes) } -internal fun PlatformReadChannel.readMcString(): String { +internal fun ReadChannel.readMcString(): String { val length = this.readVarInt() val bytes = this.readBytes(length) return bytes.decodeToString() diff --git a/src/commonMain/kotlin/cn/rtast/mcping/packet.kt b/src/commonMain/kotlin/cn/rtast/mcping/java/packet.kt similarity index 82% rename from src/commonMain/kotlin/cn/rtast/mcping/packet.kt rename to src/commonMain/kotlin/cn/rtast/mcping/java/packet.kt index 63a2bfd..332bb40 100644 --- a/src/commonMain/kotlin/cn/rtast/mcping/packet.kt +++ b/src/commonMain/kotlin/cn/rtast/mcping/java/packet.kt @@ -5,7 +5,7 @@ */ -package cn.rtast.mcping +package cn.rtast.mcping.java import cn.rtast.mcping.platform.PlatformBuffer @@ -40,4 +40,9 @@ internal data class HandshakePacket( internal data object StatusRequestPacket : MinecraftPacket { override val packetId: Int = 0x00 override fun writePayload(buffer: PlatformBuffer) {} +} + +internal data class PingPacket(val currentTime: Long) : MinecraftPacket { + override val packetId: Int = 0x01 + override fun writePayload(buffer: PlatformBuffer) = buffer.writeLong(currentTime) } \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/packet_writer.kt b/src/commonMain/kotlin/cn/rtast/mcping/java/packet_writer.kt similarity index 76% rename from src/commonMain/kotlin/cn/rtast/mcping/packet_writer.kt rename to src/commonMain/kotlin/cn/rtast/mcping/java/packet_writer.kt index fd1014c..0d747a3 100644 --- a/src/commonMain/kotlin/cn/rtast/mcping/packet_writer.kt +++ b/src/commonMain/kotlin/cn/rtast/mcping/java/packet_writer.kt @@ -5,12 +5,12 @@ */ -package cn.rtast.mcping +package cn.rtast.mcping.java import cn.rtast.mcping.platform.PlatformBuffer -import cn.rtast.mcping.platform.PlatformWriteChannel +import cn.rtast.mcping.platform.WriteChannel -internal fun PlatformWriteChannel.sendPacket(packet: MinecraftPacket) { +internal fun WriteChannel.sendPacket(packet: MinecraftPacket) { val bodyBuffer = PlatformBuffer() bodyBuffer.writeVarInt(packet.packetId) packet.writePayload(bodyBuffer) diff --git a/src/commonMain/kotlin/cn/rtast/mcping/java/ping_java.kt b/src/commonMain/kotlin/cn/rtast/mcping/java/ping_java.kt new file mode 100644 index 0000000..9273ba8 --- /dev/null +++ b/src/commonMain/kotlin/cn/rtast/mcping/java/ping_java.kt @@ -0,0 +1,45 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/3 + */ + + +package cn.rtast.mcping.java + +import cn.rtast.mcping.PingResponse +import cn.rtast.mcping.platform.PingContext +import cn.rtast.mcping.platform.Socket +import kotlin.time.Clock + +internal fun pingJavaServer(host: String, port: Int, context: PingContext): PingResponse { + val socket = Socket(host, port, context) + val receiveChannel = socket.openReadChannel() + val sendChannel = socket.openWriteChannel() + + return try { + val handshakePacket = HandshakePacket( + protocolVersion = -1, + serverAddress = host, + serverPort = port.toUShort(), + nextState = 1 + ) + sendChannel.sendPacket(handshakePacket) + sendChannel.sendPacket(StatusRequestPacket) + + receiveChannel.readVarInt() // consume a varint + val packetId = receiveChannel.readVarInt() + val jsonResponse = if (packetId == StatusRequestPacket.packetId) receiveChannel.readMcString() + else throw IllegalStateException("Server does not respond correct packet id, expected ${StatusRequestPacket.packetId} but got $packetId") + + val sendTime = Clock.System.now().toEpochMilliseconds() + val pingPacket = PingPacket(sendTime) + sendChannel.sendPacket(pingPacket) + receiveChannel.readVarInt() // consume a varint + receiveChannel.readVarInt() // packet id + receiveChannel.readLong() // pong packet payload + PingResponse(jsonResponse, (Clock.System.now().toEpochMilliseconds() - sendTime).toInt()) + } finally { + socket.close() + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/mcping.kt b/src/commonMain/kotlin/cn/rtast/mcping/mcping.kt index e1e0025..563628e 100644 --- a/src/commonMain/kotlin/cn/rtast/mcping/mcping.kt +++ b/src/commonMain/kotlin/cn/rtast/mcping/mcping.kt @@ -4,32 +4,24 @@ * Date: 2026/9/3 */ +@file:JvmName("McPing") + package cn.rtast.mcping -import cn.rtast.mcping.platform.PlatformSocket +import cn.rtast.mcping.bedrock.pingBedrockServer +import cn.rtast.mcping.java.pingJavaServer +import cn.rtast.mcping.platform.PingContext +import kotlin.jvm.JvmName +import kotlin.jvm.JvmOverloads - -public fun mcping(host: String, port: Int): String { - val socket = PlatformSocket(host, port) - val receiveChannel = socket.openReadChannel() - val sendChannel = socket.openWriteChannel() - - return try { - val handshakePacket = HandshakePacket( - protocolVersion = -1, - serverAddress = host, - serverPort = port.toUShort(), - nextState = 1 - ) - sendChannel.sendPacket(handshakePacket) - sendChannel.sendPacket(StatusRequestPacket) - - receiveChannel.readVarInt() // consume a varint - val packetId = receiveChannel.readVarInt() - if (packetId == StatusRequestPacket.packetId) receiveChannel.readMcString() - else throw IllegalStateException("Server does not respond correct packet id, expected ${StatusRequestPacket.packetId} but got $packetId") - } finally { - socket.close() - } +@JvmOverloads +public fun mcping( + host: String, + port: Int, + type: ServerType = ServerType.Java, + context: PingContext = PingContext(), +): PingResponse = when (type) { + ServerType.Java -> pingJavaServer(host, port, context) + ServerType.Bedrock -> pingBedrockServer(host, port, context) } \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/ping_response.kt b/src/commonMain/kotlin/cn/rtast/mcping/ping_response.kt new file mode 100644 index 0000000..c8ce0cc --- /dev/null +++ b/src/commonMain/kotlin/cn/rtast/mcping/ping_response.kt @@ -0,0 +1,24 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/3 + */ + + +package cn.rtast.mcping + +import cn.rtast.mcping.bedrock.BedrockPingResponse +import cn.rtast.mcping.bedrock.parseBedrockPingResponse + +public data class PingResponse( + /** + * raw response content + */ + public val content: String, + /** + * latency ms + */ + public val latency: Int, +) { + public fun toBedrockResponse(): BedrockPingResponse = parseBedrockPingResponse() +} \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/platform/buffer.kt b/src/commonMain/kotlin/cn/rtast/mcping/platform/buffer.kt index 77245ea..9eb042e 100644 --- a/src/commonMain/kotlin/cn/rtast/mcping/platform/buffer.kt +++ b/src/commonMain/kotlin/cn/rtast/mcping/platform/buffer.kt @@ -7,11 +7,21 @@ package cn.rtast.mcping.platform -internal expect class PlatformBuffer() { +internal expect class PlatformBuffer { + constructor() + constructor(bytes: ByteArray) + fun writeByte(value: Byte) + fun writeShort(value: Short) + fun writeLong(value: Long) fun writeBytes(bytes: ByteArray) + fun readByte(): Byte + fun readShort(): Short + fun readLong(): Long fun readBytes(length: Int): ByteArray fun toByteArray(): ByteArray val size: Int -} \ No newline at end of file +} + +internal fun ByteArray.wrap(): PlatformBuffer = PlatformBuffer(this) \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/platform/channels.kt b/src/commonMain/kotlin/cn/rtast/mcping/platform/channels.kt index 62ef0c2..3dff65c 100644 --- a/src/commonMain/kotlin/cn/rtast/mcping/platform/channels.kt +++ b/src/commonMain/kotlin/cn/rtast/mcping/platform/channels.kt @@ -7,13 +7,14 @@ package cn.rtast.mcping.platform -internal expect class PlatformReadChannel { +internal expect class ReadChannel { fun readByte(): Byte fun readBytes(length: Int): ByteArray fun readFully(out: ByteArray, start: Int = 0, end: Int = out.size) + fun readLong(): Long } -internal expect class PlatformWriteChannel { +internal expect class WriteChannel { fun writeFully(value: ByteArray, startIndex: Int = 0, endIndex: Int = value.size) fun flush() } \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/platform/context.kt b/src/commonMain/kotlin/cn/rtast/mcping/platform/context.kt new file mode 100644 index 0000000..4473aa3 --- /dev/null +++ b/src/commonMain/kotlin/cn/rtast/mcping/platform/context.kt @@ -0,0 +1,14 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/3 + */ + + +package cn.rtast.mcping.platform + +/** + * An object class used to pass some parameters + * that exists only on native targets + */ +public expect class PingContext() \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/platform/socket.kt b/src/commonMain/kotlin/cn/rtast/mcping/platform/socket.kt index df9c3c7..7f575c7 100644 --- a/src/commonMain/kotlin/cn/rtast/mcping/platform/socket.kt +++ b/src/commonMain/kotlin/cn/rtast/mcping/platform/socket.kt @@ -7,8 +7,13 @@ package cn.rtast.mcping.platform -internal expect class PlatformSocket internal constructor(host: String, port: Int){ - fun openReadChannel(): PlatformReadChannel - fun openWriteChannel(): PlatformWriteChannel +internal expect class Socket internal constructor(host: String, port: Int, context: PingContext) { + fun openReadChannel(): ReadChannel + fun openWriteChannel(): WriteChannel + fun close() +} + +internal expect class UdpSocket internal constructor(host: String, port: Int, context: PingContext) { + fun sendAndReceive(data: ByteArray): ByteArray fun close() } \ No newline at end of file diff --git a/src/commonMain/kotlin/cn/rtast/mcping/server_type.kt b/src/commonMain/kotlin/cn/rtast/mcping/server_type.kt new file mode 100644 index 0000000..a654bd5 --- /dev/null +++ b/src/commonMain/kotlin/cn/rtast/mcping/server_type.kt @@ -0,0 +1,12 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/3 + */ + + +package cn.rtast.mcping + +public enum class ServerType { + Java, Bedrock +} \ No newline at end of file diff --git a/src/commonTest/kotlin/test/TestPing.kt b/src/commonTest/kotlin/test/TestPing.kt index 09b5096..84398c4 100644 --- a/src/commonTest/kotlin/test/TestPing.kt +++ b/src/commonTest/kotlin/test/TestPing.kt @@ -7,6 +7,7 @@ package test +import cn.rtast.mcping.ServerType import cn.rtast.mcping.mcping import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -18,4 +19,11 @@ class TestPing { val response = mcping("org.mc-complex.com", 25565) println(response) } + + @Test + fun `test ping bedrock server`() = runTest { + val response = mcping("play.wildnetwork.net", 19132, ServerType.Bedrock) + println(response) + println(response.toBedrockResponse()) + } } \ No newline at end of file diff --git a/src/jvmMain/kotlin/cn/rtast/mcping/platform/buffer.jvm.kt b/src/jvmMain/kotlin/cn/rtast/mcping/platform/buffer.jvm.kt index 721d965..9e3bf51 100644 --- a/src/jvmMain/kotlin/cn/rtast/mcping/platform/buffer.jvm.kt +++ b/src/jvmMain/kotlin/cn/rtast/mcping/platform/buffer.jvm.kt @@ -8,28 +8,85 @@ package cn.rtast.mcping.platform import java.io.ByteArrayOutputStream -internal actual class PlatformBuffer actual constructor() { - private val stream = ByteArrayOutputStream() +internal actual class PlatformBuffer { + private val outStream = ByteArrayOutputStream() + private var readBuffer: ByteArray? = null private var readOffset = 0 - actual fun writeByte(value: Byte) = stream.write(value.toInt()) - actual fun writeBytes(bytes: ByteArray) = stream.write(bytes) + actual constructor() + + actual constructor(bytes: ByteArray) { + this.readBuffer = bytes + outStream.write(bytes) + } + + actual fun writeByte(value: Byte) { + outStream.write(value.toInt()) + } + + actual fun writeShort(value: Short) { + val v = value.toInt() + outStream.write(v shr 8) + outStream.write(v) + } + + actual fun writeLong(value: Long) { + outStream.write((value shr 56).toInt()) + outStream.write((value shr 48).toInt()) + outStream.write((value shr 40).toInt()) + outStream.write((value shr 32).toInt()) + outStream.write((value shr 24).toInt()) + outStream.write((value shr 16).toInt()) + outStream.write((value shr 8).toInt()) + outStream.write(value.toInt()) + } + + actual fun writeBytes(bytes: ByteArray) { + outStream.write(bytes) + } + + private fun ensureReadArray(): ByteArray { + var buf = readBuffer + if (buf == null) { + buf = outStream.toByteArray() + readBuffer = buf + } + return buf + } + actual fun readByte(): Byte { - val array = stream.toByteArray() + val array = ensureReadArray() if (readOffset >= array.size) throw IndexOutOfBoundsException("Buffer underflow") return array[readOffset++] } + actual fun readShort(): Short { + val b1 = readByte().toInt() and 0xFF + val b2 = readByte().toInt() and 0xFF + return ((b1 shl 8) or b2).toShort() + } + + actual fun readLong(): Long { + return (readByte().toLong() and 0xFF shl 56) or + (readByte().toLong() and 0xFF shl 48) or + (readByte().toLong() and 0xFF shl 40) or + (readByte().toLong() and 0xFF shl 32) or + (readByte().toLong() and 0xFF shl 24) or + (readByte().toLong() and 0xFF shl 16) or + (readByte().toLong() and 0xFF shl 8) or + (readByte().toLong() and 0xFF) + } + actual fun readBytes(length: Int): ByteArray { - val array = stream.toByteArray() + val array = ensureReadArray() if (readOffset + length > array.size) throw IndexOutOfBoundsException("Buffer underflow") val result = array.copyOfRange(readOffset, readOffset + length) readOffset += length return result } - actual fun toByteArray(): ByteArray = stream.toByteArray() + actual fun toByteArray(): ByteArray = outStream.toByteArray() actual val size: Int - get() = stream.size() + get() = outStream.size() } \ No newline at end of file diff --git a/src/jvmMain/kotlin/cn/rtast/mcping/platform/channels.jvm.kt b/src/jvmMain/kotlin/cn/rtast/mcping/platform/channels.jvm.kt index 0d9a67a..3388c00 100644 --- a/src/jvmMain/kotlin/cn/rtast/mcping/platform/channels.jvm.kt +++ b/src/jvmMain/kotlin/cn/rtast/mcping/platform/channels.jvm.kt @@ -7,10 +7,11 @@ package cn.rtast.mcping.platform +import java.io.EOFException import java.io.InputStream import java.io.OutputStream -internal actual class PlatformReadChannel { +internal actual class ReadChannel { private val _inputStream: InputStream constructor(inputStream: InputStream) { @@ -28,9 +29,27 @@ internal actual class PlatformReadChannel { bytesRead += read } } + + actual fun readLong(): Long { + val bytes = ByteArray(8) + var read = 0 + while (read < 8) { + val count = _inputStream.read(bytes, read, 8 - read) + if (count == -1) throw EOFException() + read += count + } + return ((bytes[0].toLong() and 0xFF shl 56) or + (bytes[1].toLong() and 0xFF shl 48) or + (bytes[2].toLong() and 0xFF shl 40) or + (bytes[3].toLong() and 0xFF shl 32) or + (bytes[4].toLong() and 0xFF shl 24) or + (bytes[5].toLong() and 0xFF shl 16) or + (bytes[6].toLong() and 0xFF shl 8) or + (bytes[7].toLong() and 0xFF)) + } } -internal actual class PlatformWriteChannel { +internal actual class WriteChannel { private val _outputStream: OutputStream constructor(outputStream: OutputStream) { @@ -41,4 +60,15 @@ internal actual class PlatformWriteChannel { _outputStream.write(value, startIndex, endIndex - startIndex) actual fun flush() = _outputStream.flush() +} + +private fun InputStream.readNBytes(length: Int): ByteArray { + val buffer = ByteArray(length) + var totalRead = 0 + while (totalRead < length) { + val read = this.read(buffer, totalRead, length - totalRead) + if (read == -1) throw EOFException() + totalRead += read + } + return buffer } \ No newline at end of file diff --git a/src/jvmMain/kotlin/cn/rtast/mcping/platform/context.jvm.kt b/src/jvmMain/kotlin/cn/rtast/mcping/platform/context.jvm.kt new file mode 100644 index 0000000..bb135cc --- /dev/null +++ b/src/jvmMain/kotlin/cn/rtast/mcping/platform/context.jvm.kt @@ -0,0 +1,12 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/3 + */ + +package cn.rtast.mcping.platform + +/** + * No Context for jvm targets + */ +public actual class PingContext \ No newline at end of file diff --git a/src/jvmMain/kotlin/cn/rtast/mcping/platform/socket.jvm.kt b/src/jvmMain/kotlin/cn/rtast/mcping/platform/socket.jvm.kt index 43f16ad..8680375 100644 --- a/src/jvmMain/kotlin/cn/rtast/mcping/platform/socket.jvm.kt +++ b/src/jvmMain/kotlin/cn/rtast/mcping/platform/socket.jvm.kt @@ -6,12 +6,33 @@ package cn.rtast.mcping.platform -import java.net.Socket +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetSocketAddress +import java.net.Socket as JvmSocket -internal actual class PlatformSocket internal actual constructor(host: String, port: Int) { - private val socket = Socket(host, port) +internal actual class Socket internal actual constructor(host: String, port: Int, context: PingContext) { + private val socket = JvmSocket(host, port) + + actual fun openReadChannel(): ReadChannel = ReadChannel(socket.getInputStream()) + actual fun openWriteChannel(): WriteChannel = WriteChannel(socket.getOutputStream()) + actual fun close() = socket.close() +} + +internal actual class UdpSocket internal actual constructor(host: String, port: Int, context: PingContext) { + private val socket = DatagramSocket().apply { + soTimeout = 3000 + connect(InetSocketAddress(host, port)) + } + + actual fun sendAndReceive(data: ByteArray): ByteArray { + socket.send(DatagramPacket(data, data.size)) + + val buf = ByteArray(2048) + val receivePacket = DatagramPacket(buf, buf.size) + socket.receive(receivePacket) + return buf.copyOf(receivePacket.length) + } - actual fun openReadChannel(): PlatformReadChannel = PlatformReadChannel(socket.getInputStream()) - actual fun openWriteChannel(): PlatformWriteChannel = PlatformWriteChannel(socket.getOutputStream()) actual fun close() = socket.close() } \ No newline at end of file diff --git a/src/jvmTest/java/test/TestPingInJava.java b/src/jvmTest/java/test/TestPingInJava.java new file mode 100644 index 0000000..81d0ef6 --- /dev/null +++ b/src/jvmTest/java/test/TestPingInJava.java @@ -0,0 +1,33 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/3 + */ + + +package test; + +import cn.rtast.mcping.McPing; +import cn.rtast.mcping.PingResponse; +import cn.rtast.mcping.ServerType; +import org.junit.Test; + + +public class TestPingInJava { + + @Test + public void testPingJava() { + String testJavaHost = "org.mc-complex.com"; + PingResponse resp = McPing.mcping(testJavaHost, 25565); + System.out.println(resp); + } + + @Test + public void testPingBedrock() { + String testBedrockHost = "play.wildnetwork.net"; + PingResponse resp = McPing.mcping(testBedrockHost, 19132, ServerType.Bedrock); + System.out.println(resp.getContent()); + System.out.println(resp.getLatency()); + System.out.println(resp.toBedrockResponse()); + } +} diff --git a/src/mingwTest/kotlin/test/TestMingwPing.kt b/src/mingwTest/kotlin/test/TestMingwPing.kt new file mode 100644 index 0000000..7a08ca1 --- /dev/null +++ b/src/mingwTest/kotlin/test/TestMingwPing.kt @@ -0,0 +1,37 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/3 + */ + + +package test + +import cn.rtast.mcping.ServerType +import cn.rtast.mcping.mcping +import cn.rtast.mcping.platform.PingContext +import io.ktor.network.selector.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class TestMingwPing { + + @Test + fun `test ping java on mingw with selectorManager`() = runTest { + val sm = SelectorManager(Dispatchers.IO) + val response = + mcping(host = "org.mc-complex.com", port = 25565, type = ServerType.Java, context = PingContext(sm)) + println(response) + } + + @Test + fun `test ping bedrock on mingw with selectorManager`() = runTest { + val sm = SelectorManager(Dispatchers.IO) + val response = + mcping(host = "play.wildnetwork.net", port = 19132, type = ServerType.Bedrock, context = PingContext(sm)) + println(response) + println(response.toBedrockResponse()) + } +} \ No newline at end of file diff --git a/src/nativeMain/kotlin/cn/rtast/mcping/platform/buffer.native.kt b/src/nativeMain/kotlin/cn/rtast/mcping/platform/buffer.native.kt index 61ae21d..3e17b89 100644 --- a/src/nativeMain/kotlin/cn/rtast/mcping/platform/buffer.native.kt +++ b/src/nativeMain/kotlin/cn/rtast/mcping/platform/buffer.native.kt @@ -4,19 +4,32 @@ * Date: 2026/9/3 */ - package cn.rtast.mcping.platform import kotlinx.io.Buffer import kotlinx.io.readByteArray -internal actual class PlatformBuffer actual constructor() { - private val _delegateBuf = Buffer() +internal actual class PlatformBuffer { + private val _delegateBuf: Buffer + + actual constructor() { + _delegateBuf = Buffer() + } + + actual constructor(bytes: ByteArray) { + _delegateBuf = Buffer().apply { write(bytes) } + } actual fun writeByte(value: Byte) = _delegateBuf.writeByte(value) + actual fun writeShort(value: Short) = _delegateBuf.writeShort(value) + actual fun writeLong(value: Long) = _delegateBuf.writeLong(value) actual fun writeBytes(bytes: ByteArray) = _delegateBuf.write(bytes) + actual fun readByte(): Byte = _delegateBuf.readByte() + actual fun readShort(): Short = _delegateBuf.readShort() + actual fun readLong(): Long = _delegateBuf.readLong() actual fun readBytes(length: Int): ByteArray = _delegateBuf.readByteArray(length) + actual fun toByteArray(): ByteArray = _delegateBuf.peek().readByteArray() actual val size: Int diff --git a/src/nativeMain/kotlin/cn/rtast/mcping/platform/channels.native.kt b/src/nativeMain/kotlin/cn/rtast/mcping/platform/channels.native.kt index d1a1f6e..17f65a0 100644 --- a/src/nativeMain/kotlin/cn/rtast/mcping/platform/channels.native.kt +++ b/src/nativeMain/kotlin/cn/rtast/mcping/platform/channels.native.kt @@ -9,7 +9,7 @@ package cn.rtast.mcping.platform import io.ktor.utils.io.* import kotlinx.coroutines.runBlocking -internal actual class PlatformReadChannel { +internal actual class ReadChannel { private val _readChannel: ByteReadChannel constructor(readChannel: ByteReadChannel) { @@ -19,9 +19,10 @@ internal actual class PlatformReadChannel { actual fun readByte(): Byte = runBlocking { _readChannel.readByte() } actual fun readBytes(length: Int): ByteArray = runBlocking { _readChannel.readByteArray(length) } actual fun readFully(out: ByteArray, start: Int, end: Int) = runBlocking { _readChannel.readFully(out, start, end) } + actual fun readLong(): Long = runBlocking { _readChannel.readLong() } } -internal actual class PlatformWriteChannel { +internal actual class WriteChannel { private val _writeChannel: ByteWriteChannel constructor(writeChannel: ByteWriteChannel) { diff --git a/src/nativeMain/kotlin/cn/rtast/mcping/platform/context.native.kt b/src/nativeMain/kotlin/cn/rtast/mcping/platform/context.native.kt new file mode 100644 index 0000000..dce97ef --- /dev/null +++ b/src/nativeMain/kotlin/cn/rtast/mcping/platform/context.native.kt @@ -0,0 +1,23 @@ +/* + * Copyright © 2026 RTAkland + * Author: RTAkland + * Date: 2026/9/3 + */ + +@file:Suppress("PropertyName") + +package cn.rtast.mcping.platform + +import io.ktor.network.selector.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO + +public actual class PingContext actual constructor() { + internal var _selectorManager: SelectorManager = SelectorManager(Dispatchers.IO) + internal var _autoCloseSelectorManager: Boolean = false + + public constructor(selectorManager: SelectorManager, autoClose: Boolean = false) : this() { + _selectorManager = selectorManager + _autoCloseSelectorManager = autoClose + } +} \ No newline at end of file diff --git a/src/nativeMain/kotlin/cn/rtast/mcping/platform/socket.native.kt b/src/nativeMain/kotlin/cn/rtast/mcping/platform/socket.native.kt index 086aff0..4075f9f 100644 --- a/src/nativeMain/kotlin/cn/rtast/mcping/platform/socket.native.kt +++ b/src/nativeMain/kotlin/cn/rtast/mcping/platform/socket.native.kt @@ -6,23 +6,40 @@ package cn.rtast.mcping.platform -import io.ktor.network.selector.* import io.ktor.network.sockets.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO +import io.ktor.utils.io.core.* import kotlinx.coroutines.runBlocking +import kotlinx.io.readByteArray -internal actual class PlatformSocket internal actual constructor(host: String, port: Int) { +internal actual class Socket internal actual constructor(host: String, port: Int, context: PingContext) { + private val ctx = context + private val socket = runBlocking { aSocket(ctx._selectorManager).tcp().connect(host, port) } - private val sm = SelectorManager(Dispatchers.IO) - private val socket = runBlocking { aSocket(sm).tcp().connect(host, port) } - - actual fun openReadChannel(): PlatformReadChannel = PlatformReadChannel(socket.openReadChannel()) - actual fun openWriteChannel(): PlatformWriteChannel = - PlatformWriteChannel(socket.openWriteChannel(autoFlush = true)) + actual fun openReadChannel(): ReadChannel = ReadChannel(socket.openReadChannel()) + actual fun openWriteChannel(): WriteChannel = + WriteChannel(socket.openWriteChannel(autoFlush = true)) actual fun close() { socket.close() - sm.close() + if (ctx._autoCloseSelectorManager) ctx._selectorManager.close() + } +} + +internal actual class UdpSocket internal actual constructor(host: String, port: Int, context: PingContext) { + private val ctx = context + + // use bind to create an unconnected socket + private val socket = runBlocking { aSocket(ctx._selectorManager).udp().bind() } + private val remoteAddress = InetSocketAddress(host, port) + + actual fun sendAndReceive(data: ByteArray): ByteArray = runBlocking { + val packet = buildPacket { writeFully(data) } + socket.send(Datagram(packet, remoteAddress)) + socket.receive().packet.readByteArray() + } + + actual fun close() { + socket.close() + if (ctx._autoCloseSelectorManager) ctx._selectorManager.close() } } \ No newline at end of file