Add Kotlin interop RUDP serve dial path

This commit is contained in:
Meta Vens 2026-06-17 12:45:30 +01:00
commit b2f2e1a0ff
4 changed files with 893 additions and 2 deletions

View file

@ -32,7 +32,7 @@ CultNet owns cross-runtime transport semantics:
| TypeScript `cultnet-ts` | `CultNetPeer` over any Node `Duplex`, TCP-framed transport, or single-peer RUDP socket transport; interop uses TCP | UDP multicast probe/announce in the interop peer | First UDP socket binding for the shared RUDP reliability owner |
| Rust `cultnet-rs` | Interop example serves and dials schema-v0 MessagePack over TCP-framed or shared RUDP transport; single-peer RUDP socket transport exists in the library | UDP multicast probe/announce with TCP and RUDP transport profiles | UDP socket binding for the shared RUDP reliability owner |
| Python `cultcache-py` | TCP sockets with 4-byte length-prefixed MessagePack frames for local CultMesh/CultNet server and client; single-peer RUDP socket transport exists in the library | Endpoint lists and CultMesh peer/Verse catalogs | UDP socket binding for the shared RUDP reliability owner |
| Kotlin `cultmesh-kotlin` | Channel-aware WebSocket transport connection for reliable ordered `schema` frames; single-peer RUDP socket transport exists in the library | CultMesh Verse and peer catalogs, plus endpoint lists carried in schema-v0 catalog messages | WebSocket adapter plus UDP socket binding for the shared RUDP reliability owner, with build-script self-test |
| Kotlin `cultmesh-kotlin` | Channel-aware WebSocket transport connection for reliable ordered `schema` frames; single-peer RUDP socket transport exists in the library; interop CLI serves and dials schema-v0 MessagePack over TCP-framed or shared RUDP transport | CultMesh Verse and peer catalogs, plus endpoint lists carried in schema-v0 catalog messages | WebSocket/TCP adapters plus UDP socket binding for the shared RUDP reliability owner, with build-script and TypeScript harness proof |
The live split is therefore: payload language and the native RUDP packet/session
language now converge across the targeted runtimes, while production service
@ -360,6 +360,12 @@ Current progress:
the socket transport. The TypeScript interop peer now also serves and dials
the schema-v0 interop flow over `interop-rudp` while keeping TCP-framed as the
compatibility lane; discovery and hello responses advertise both profiles.
- Kotlin now has the same full interop RUDP proof shape as the other targeted
runtimes: the JVM CLI can serve schema-v0 hello/catalog/snapshot/mutation/fire
over `interop-rudp`, and it can dial a TypeScript full peer over the same
shared RUDP connection id and reliable ordered `schema` channel. TCP-framed
remains a compatibility lane for the interop peer; WebSocket remains the
JVM/Android-friendly client transport surface.
- TypeScript now also has a `CultNetSchemaCatalog` for remote schema
descriptors plus `CultNetPeer` request/fetch/sync helpers for
`cultnet.schema_catalog_request.v0` / `cultnet.schema_catalog_response.v0`.

View file

@ -34,7 +34,7 @@ CultMesh.
| Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup, shard catalogs/logs, schema-transport factory for raw client request/subscription flows, local TCP and RUDP CultMesh server listeners, Python interop RUDP serve/dial path, session/prediction/simulation fact helpers, RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe |
| TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, advertised schema/shard catalog wire contracts plus request/sync helpers, transport profiles, TCP-framed transport port and peer helper, shared RUDP socket transport, RUDP interop serve/dial path, RUDP reconnect policy/controller, RUDP reconnect-loop helper, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup | Full C# game session/server stack |
| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade, stream catalog, or production game-session server |
| Kotlin | JVM/Android client substrate | Typed MessagePack cache, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store, full C# server daemon, or Unity hot-loop memory layout |
| Kotlin | JVM/Android client substrate | Typed MessagePack cache, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store, full C# server daemon, or Unity hot-loop memory layout |
## Evidence Gates

View file

@ -3,7 +3,9 @@ package org.gamecult.cultmesh
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.EOFException
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
@ -2953,6 +2955,8 @@ fun main(args: Array<String>) {
"rudp-dial-once" -> rudpDialOnce(options)
"rudp-serve-message-once" -> rudpServeSchemaMessageOnce(options)
"rudp-dial-message-once" -> rudpDialSchemaMessageOnce(options)
"serve" -> interopServe(options)
"dial" -> interopDial(options)
else -> error("Unknown mode ${args[0]}")
}
}
@ -3194,6 +3198,785 @@ private fun cultNetReconnectControllerSchedulesAttemptsAndReset() {
check(controller.canAttempt(99_000))
}
private const val interopDocumentType = "cultnet.interop-note"
private const val interopSchemaVersion = "cultnet.interop_note.v0"
private const val interopMutationIntentType = "cultnet.interop-note-mutation-intent"
private const val interopMutationIntentSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-intent.schema.json"
private const val interopMutationIntentSchemaVersion = "cultnet.interop_note_mutation_intent.v0"
private const val interopMutationReceiptType = "cultnet.interop-note-mutation-receipt"
private const val interopMutationReceiptSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-receipt.schema.json"
private const val interopMutationReceiptSchemaVersion = "cultnet.interop_note_mutation_receipt.v0"
private const val interopFireCommandType = "cultnet.interop-fire-weapon-command"
private const val interopFireCommandSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-command.schema.json"
private const val interopFireCommandSchemaVersion = "cultnet.interop_fire_weapon_command.v0"
private const val interopFireReceiptType = "cultnet.interop-fire-weapon-receipt"
private const val interopFireReceiptSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-receipt.schema.json"
private const val interopFireReceiptSchemaVersion = "cultnet.interop_fire_weapon_receipt.v0"
private const val rudpInteropConnectionId = 0x43554c54L
private const val rudpInteropMaxFragmentBytes = 1024
private const val rudpInteropResendDelayMs = 25L
private val interopReadTimeoutMs = 15_000L
private data class InteropServeConfig(
val runtimeId: String,
val runtimeKind: String,
val displayName: String,
val agentId: String,
val bindHost: String,
val advertiseHost: String,
val tcpPort: Int,
val rudpPort: Int,
val discoveryPort: Int,
val discoveryGroup: String,
val schemaPath: String,
)
private data class InteropDialConfig(
val runtimeId: String,
val runtimeKind: String,
val displayName: String,
val agentId: String,
val targetHost: String,
val targetPort: Int?,
val targetRudpPort: Int?,
val schemaPath: String,
)
private data class InteropSchemaRegistration(val schemaId: String, val title: String?, val schemaJson: String)
private data class InteropDocuments(
val note: CultDocumentDefinition<InteropNote>,
val mutationIntent: CultDocumentDefinition<InteropMutationIntent>,
val mutationReceipt: CultDocumentDefinition<InteropMutationReceipt>,
val fireCommand: CultDocumentDefinition<InteropFireCommand>,
val fireReceipt: CultDocumentDefinition<InteropFireReceipt>,
)
private data class InteropNote(
val schemaVersion: String = interopSchemaVersion,
val documentId: String,
val authorRuntimeId: String,
val title: String,
val body: String,
val tags: List<String>,
)
private data class InteropMutationIntent(
val schemaVersion: String = interopMutationIntentSchemaVersion,
val intentId: String,
val targetDocumentId: String,
val appendBody: String,
val appendTag: String,
)
private data class InteropMutationReceipt(
val schemaVersion: String = interopMutationReceiptSchemaVersion,
val intentId: String,
val accepted: Boolean,
val documentId: String,
val body: String,
val tags: List<String>,
)
private data class InteropFireCommand(
val schemaVersion: String = interopFireCommandSchemaVersion,
val commandId: String,
val characterId: String,
val weaponId: String,
)
private data class InteropFireReceipt(
val schemaVersion: String = interopFireReceiptSchemaVersion,
val commandId: String,
val accepted: Boolean,
val characterId: String,
val weaponId: String,
val shotsFired: Long,
val ammoRemaining: Long,
)
private class InteropCodec<T : Any>(
override val documentType: String,
override val schemaVersion: String,
private val encodeValue: (T) -> List<Any?>,
private val decodeValue: (List<Any?>) -> T,
) : CultDocumentCodec<T> {
override fun encode(value: T): ByteArray = MessagePackWriter().value(encodeValue(value)).toByteArray()
override fun decode(payload: ByteArray): T = decodeValue(anyList(MessagePackReader(payload).readAny()))
}
private fun interopServe(options: Map<String, String>) {
val config = parseInteropServeConfig(options)
val registration = loadInteropSchemaRegistration(config.schemaPath)
val documents = defineInteropDocuments(registration.schemaId)
val cache = CultCache()
registerInteropDocuments(cache, documents)
val schemaCatalog = CultNetSchemaCatalog()
schemaCatalog.upsert(
defineCultNetSchemaDescriptor(
schemaId = registration.schemaId,
kind = "document_payload",
schemaVersion = interopSchemaVersion,
documentType = interopDocumentType,
title = registration.title,
wireContracts = listOf("cultnet.schema.v0"),
schemaJson = registration.schemaJson,
),
)
cache.put(documents.note, "note:${config.runtimeId}", buildInteropNote(config.runtimeId, config.displayName))
startInteropTcpServer(config, cache, documents, schemaCatalog)
startInteropRudpServer(config, cache, documents, schemaCatalog)
println(jsonLine(linkedMapOf(
"status" to "ready",
"mode" to "serve",
"runtimeId" to config.runtimeId,
"runtimeKind" to config.runtimeKind,
"tcpPort" to config.tcpPort,
"rudpPort" to config.rudpPort,
"discoveryPort" to config.discoveryPort,
"discoveryGroup" to config.discoveryGroup,
)))
while (true) Thread.sleep(3_600_000)
}
private fun interopDial(options: Map<String, String>) {
val config = parseInteropDialConfig(options)
val registration = loadInteropSchemaRegistration(config.schemaPath)
val documents = defineInteropDocuments(registration.schemaId)
val cache = CultCache()
registerInteropDocuments(cache, documents)
val transportName = if (config.targetRudpPort != null) "rudp" else "tcp_framed"
val transport = openInteropDialTransport(config)
transport.use {
it.sendSchemaMessage(
cultNetHello(
runtimeId = config.runtimeId,
runtimeKind = config.runtimeKind,
displayName = config.displayName,
supportedDocumentTypes = listOf(interopDocumentType),
supportedMutationContracts = listOf(interopMutationContract()),
supportedMessageVersions = listOf(interopSchemaVersion),
transportProfiles = dialInteropTransportProfiles(config),
supportsSchemaCatalog = true,
),
)
val remoteHello = requireSchemaResponse(it.receiveSchemaMessage(interopReadTimeoutMs, 5), "cultnet.hello.v0", "interop hello")
val remoteRuntimeId = requireWireString(remoteHello.body, "runtimeId")
it.sendSchemaMessage(
CultNetMessage(
"cultnet.schema_catalog_request.v0",
linkedMapOf("messageId" to "${config.runtimeId}-catalog", "includeSchemaJson" to true),
),
)
val catalogResponse = requireSchemaResponse(it.receiveSchemaMessage(interopReadTimeoutMs, 5), "cultnet.schema_catalog_response.v0", "interop schema catalog")
val hasInteropSchema = mapList(catalogResponse.body["schemas"]).any {
it["schemaId"] == registration.schemaId && it["documentType"] == interopDocumentType
}
it.sendSchemaMessage(CultNetMessage("cultnet.snapshot_request.v0", linkedMapOf("messageId" to "${config.runtimeId}-snapshot")))
val snapshotResponse = requireSchemaResponse(it.receiveSchemaMessage(interopReadTimeoutMs, 5), "cultnet.snapshot_response_raw.v0", "interop snapshot")
val snapshotDocuments = mapList(snapshotResponse.body["documents"]).map { rawDocumentRecordFromWire(it) }
val decodedSnapshotNotes = snapshotDocuments
.mapNotNull { document -> runCatching { documents.note.codec.decode(document.payload) }.getOrNull() }
val note = cache.applyRawSnapshotResponse(snapshotResponse)
.filterIsInstance<InteropNote>()
.firstOrNull { candidate -> candidate.authorRuntimeId == remoteRuntimeId }
?: decodedSnapshotNotes.firstOrNull { candidate -> candidate.authorRuntimeId == remoteRuntimeId }
?: throw IOException(
"did not receive an interop note from $remoteRuntimeId; snapshotDocuments=${snapshotDocuments.size}; " +
"schemaIds=${snapshotDocuments.map { document -> document.schemaId }}; " +
"decodedAuthors=${decodedSnapshotNotes.map { decoded -> decoded.authorRuntimeId }}",
)
val mutationIntent = InteropMutationIntent(
intentId = "${config.runtimeId}-decorate",
targetDocumentId = note.documentId,
appendBody = " Decorated by ${config.runtimeId}.",
appendTag = "decorated:${config.runtimeId}",
)
it.sendSchemaMessage(rawPut("${config.runtimeId}-decorate-put", interopMutationIntentSchemaId, mutationIntent.intentId, documents.mutationIntent.codec.encode(mutationIntent)))
val mutationReceiptMessage = readDocumentPutFor(it, interopMutationReceiptSchemaId)
val mutationReceipt = cache.applyRawDocumentPut(mutationReceiptMessage) as InteropMutationReceipt
val mutatedNoteMessage = readDocumentPutFor(it, registration.schemaId)
val mutatedNote = cache.applyRawDocumentPut(mutatedNoteMessage) as InteropNote
val fireCommand = InteropFireCommand(
commandId = "${config.runtimeId}-fire",
characterId = remoteRuntimeId,
weaponId = "interop-rifle",
)
it.sendSchemaMessage(rawPut("${config.runtimeId}-fire-put", interopFireCommandSchemaId, fireCommand.commandId, documents.fireCommand.codec.encode(fireCommand)))
val fireReceipt = cache.applyRawDocumentPut(readDocumentPutFor(it, interopFireReceiptSchemaId)) as InteropFireReceipt
println(jsonLine(linkedMapOf(
"mode" to "dial",
"runtimeId" to config.runtimeId,
"targetHost" to config.targetHost,
"targetPort" to (config.targetRudpPort ?: config.targetPort),
"transport" to transportName,
"remoteHello" to linkedMapOf("schemaVersion" to "cultnet.hello.v0", "runtimeId" to remoteRuntimeId),
"hasInteropSchema" to hasInteropSchema,
"retrievedNote" to interopNoteWire(note),
"mutatedNote" to interopNoteWire(mutatedNote),
"mutationReceipt" to interopMutationReceiptWire(mutationReceipt),
"fireReceipt" to interopFireReceiptWire(fireReceipt),
)))
}
}
private fun startInteropTcpServer(
config: InteropServeConfig,
cache: CultCache,
documents: InteropDocuments,
schemaCatalog: CultNetSchemaCatalog,
) {
val server = ServerSocket(config.tcpPort, 50, InetAddress.getByName(config.bindHost))
val thread = Thread {
while (true) {
try {
val socket = server.accept()
Thread {
try {
InteropTcpFramedTransport(
socket,
createTcpFramedInteropProfile(config.runtimeId, config.advertiseHost, config.tcpPort),
).use { transport ->
while (true) {
val message = transport.receiveSchemaMessage(interopReadTimeoutMs, 5) ?: break
handleInteropServerMessage(transport, message, config, cache, documents, schemaCatalog)
}
}
} catch (error: Throwable) {
System.err.println("kotlin interop tcp error: ${error.message}")
}
}.also { it.isDaemon = true; it.start() }
} catch (error: Throwable) {
System.err.println("kotlin interop tcp accept error: ${error.message}")
}
}
}
thread.isDaemon = true
thread.start()
}
private fun startInteropRudpServer(
config: InteropServeConfig,
cache: CultCache,
documents: InteropDocuments,
schemaCatalog: CultNetSchemaCatalog,
) {
val socket = DatagramSocket(config.rudpPort, InetAddress.getByName(config.bindHost)).also { it.soTimeout = 20 }
val sessions = linkedMapOf<InetSocketAddress, CultNetRudpSession>()
val thread = Thread {
val buffer = ByteArray(65535)
while (true) {
try {
val datagram = DatagramPacket(buffer, buffer.size)
socket.receive(datagram)
val remote = InetSocketAddress(datagram.address, datagram.port)
val packet = try {
decodeRudpPacket(buffer.copyOf(datagram.length))
} catch (_: IOException) {
continue
}
if (packet.connectionId != rudpInteropConnectionId) continue
val session = sessions.getOrPut(remote) {
CultNetRudpSession(CultNetRudpSessionOptions(rudpInteropConnectionId, 100, rudpInteropResendDelayMs))
}
if (packet.packetType == CultNetRudpPacketType.Connect) {
sendInteropRudpPacket(socket, remote, session.acceptConnect(packet, nowMs(), "cultnet-interop-rudp".toByteArray(StandardCharsets.UTF_8)))
continue
}
val result = session.receive(packet, nowMs())
result.reply?.let { sendInteropRudpPacket(socket, remote, it) }
if (packet.packetType == CultNetRudpPacketType.Data) sendInteropRudpPacket(socket, remote, session.createAck())
for (frame in result.delivered) {
if (frame.channelId != "schema") continue
val sender = InteropRudpSessionSender(socket, remote, session)
handleInteropServerMessage(sender, parseCultNetMessage(frame.payload), config, cache, documents, schemaCatalog)
}
} catch (_: SocketTimeoutException) {
val now = nowMs()
for ((remote, session) in sessions) {
session.dueResends(now).forEach { sendInteropRudpPacket(socket, remote, it) }
}
} catch (error: Throwable) {
System.err.println("kotlin interop rudp error: ${error.message}")
}
}
}
thread.isDaemon = true
thread.start()
}
private fun handleInteropServerMessage(
transport: CultNetSchemaMessageTransport,
message: CultNetMessage,
config: InteropServeConfig,
cache: CultCache,
documents: InteropDocuments,
schemaCatalog: CultNetSchemaCatalog,
) {
synchronized(cache) {
when (message.schemaVersion) {
"cultnet.hello.v0" -> transport.sendSchemaMessage(
cultNetHello(
runtimeId = config.runtimeId,
runtimeKind = config.runtimeKind,
displayName = config.displayName,
supportedDocumentTypes = listOf(interopDocumentType),
supportedMutationContracts = listOf(interopMutationContract()),
supportedMessageVersions = listOf(interopSchemaVersion),
transportProfiles = listOf(
createTcpFramedInteropProfile(config.runtimeId, config.advertiseHost, config.tcpPort),
createRudpTransportProfile(config.runtimeId, transportId = "interop-rudp", host = config.advertiseHost, port = config.rudpPort, maxFragmentBytes = rudpInteropMaxFragmentBytes),
),
supportsSchemaCatalog = true,
),
)
"cultnet.schema_catalog_request.v0" -> transport.sendSchemaMessage(schemaCatalog.createResponse(message))
"cultnet.snapshot_request.v0" -> {
val response = cache.createRawSnapshotResponse(message, sourceRuntimeId = config.runtimeId)
val documentsWithSource = mapList(response.body["documents"]).map {
rawDocumentRecordFromWire(it).copy(
sourceRuntimeId = config.runtimeId,
sourceAgentId = config.agentId,
sourceRole = "peer",
tags = listOf("interop", config.runtimeId),
)
}
transport.sendSchemaMessage(cultNetSnapshotResponseRaw(response.body["messageId"] as? String ?: "", documentsWithSource))
}
"cultnet.document_put_raw.v0" -> handleInteropRawPut(transport, message, config, cache, documents)
}
}
}
private fun handleInteropRawPut(
transport: CultNetSchemaMessageTransport,
message: CultNetMessage,
config: InteropServeConfig,
cache: CultCache,
documents: InteropDocuments,
) {
val document = rawDocumentRecordFromWire(mapValue(message.body["document"]))
when (document.schemaId) {
interopMutationIntentSchemaId -> {
val intent = cache.applyRawDocumentPut(message) as InteropMutationIntent
val note = cache.getRequired(documents.note, intent.targetDocumentId)
val mutated = note.copy(
body = note.body + intent.appendBody,
tags = note.tags + intent.appendTag,
)
cache.put(documents.note, mutated.documentId, mutated)
val receipt = InteropMutationReceipt(
intentId = intent.intentId,
accepted = true,
documentId = mutated.documentId,
body = mutated.body,
tags = mutated.tags,
)
transport.sendSchemaMessage(rawPut("${config.runtimeId}-mutation-receipt", interopMutationReceiptSchemaId, receipt.intentId, documents.mutationReceipt.codec.encode(receipt), config, "mutation"))
transport.sendSchemaMessage(rawPut("${config.runtimeId}-mutated-note", documents.note.schemaVersion, mutated.documentId, documents.note.codec.encode(mutated), config, "mutation"))
}
interopFireCommandSchemaId -> {
val command = cache.applyRawDocumentPut(message) as InteropFireCommand
val receipt = InteropFireReceipt(
commandId = command.commandId,
accepted = true,
characterId = command.characterId,
weaponId = command.weaponId,
shotsFired = 1,
ammoRemaining = 29,
)
transport.sendSchemaMessage(rawPut("${config.runtimeId}-fire-receipt", interopFireReceiptSchemaId, receipt.commandId, documents.fireReceipt.codec.encode(receipt), config, "side-effect"))
}
}
}
private class InteropTcpFramedTransport(
private val socket: Socket,
override val profile: CultNetTransportProfile,
) : CultNetSchemaMessageTransport {
private val input = DataInputStream(socket.getInputStream())
private val output = DataOutputStream(socket.getOutputStream())
override var stats: CultNetTransportStats = CultNetTransportStats()
private set
override fun sendSchemaMessage(message: CultNetMessage) {
val payload = message.toBytes()
synchronized(output) {
output.writeInt(payload.size)
output.write(payload)
output.flush()
}
stats = stats.copy(bytesSent = stats.bytesSent + payload.size + 4, framesSent = stats.framesSent + 1)
}
override fun receiveSchemaMessage(timeoutMs: Long, pollIntervalMs: Long): CultNetMessage? {
val previousTimeout = socket.soTimeout
socket.soTimeout = timeoutMs.coerceAtLeast(1).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
return try {
val length = input.readInt()
val payload = ByteArray(length)
input.readFully(payload)
stats = stats.copy(bytesReceived = stats.bytesReceived + payload.size + 4, framesReceived = stats.framesReceived + 1)
parseCultNetMessage(payload)
} catch (_: SocketTimeoutException) {
null
} catch (_: EOFException) {
null
} finally {
socket.soTimeout = previousTimeout
}
}
override fun close() {
socket.close()
}
}
private class InteropRudpSessionSender(
private val socket: DatagramSocket,
private val remote: InetSocketAddress,
private val session: CultNetRudpSession,
) : CultNetSchemaMessageTransport {
override val profile: CultNetTransportProfile = createRudpTransportProfile("kotlin-interop-rudp-session", transportId = "interop-rudp")
override val stats: CultNetTransportStats = CultNetTransportStats()
override fun sendSchemaMessage(message: CultNetMessage) {
session.sendMany("schema", message.toBytes(), CultNetRudpSendOptions(reliable = true, ordered = true, nowMs = nowMs()), rudpInteropMaxFragmentBytes)
.forEach { sendInteropRudpPacket(socket, remote, it) }
}
override fun receiveSchemaMessage(timeoutMs: Long, pollIntervalMs: Long): CultNetMessage? = null
override fun close() {}
}
private fun openInteropDialTransport(config: InteropDialConfig): CultNetSchemaMessageTransport {
val rudpPort = config.targetRudpPort
if (rudpPort != null) {
val transport = cultNetRudpClient(
runtimeId = "${config.runtimeId}-interop-rudp-dial",
connectionId = rudpInteropConnectionId,
remoteHost = config.targetHost,
remotePort = rudpPort,
tuning = CultNetRudpSocketTuning(resendDelayMs = rudpInteropResendDelayMs, maxFragmentBytes = rudpInteropMaxFragmentBytes),
)
if (!transport.connectAndWait("cultnet-interop-rudp", timeoutMs = 5_000, pollIntervalMs = 5)) {
throw IOException("timed out waiting for RUDP interop connect")
}
return transport
}
val tcpPort = config.targetPort ?: throw IOException("dial requires --target-port or --target-rudp-port")
val socket = Socket(config.targetHost, tcpPort)
return InteropTcpFramedTransport(socket, createTcpFramedInteropProfile(config.runtimeId, config.targetHost, tcpPort))
}
private fun createTcpFramedInteropProfile(runtimeId: String, host: String, port: Int): CultNetTransportProfile =
CultNetTransportProfile(
runtimeId = runtimeId,
transports = listOf(
CultNetTransportDescriptor(
transportId = "interop-tcp",
protocol = "tcp_framed",
wireContracts = listOf("cultnet.schema.v0"),
host = host,
port = port,
channels = listOf(CultNetTransportChannel("schema", "reliable", "ordered")),
),
),
)
private fun dialInteropTransportProfiles(config: InteropDialConfig): List<CultNetTransportProfile> {
val profiles = mutableListOf<CultNetTransportProfile>()
if (config.targetPort != null) profiles.add(createTcpFramedInteropProfile(config.runtimeId, config.targetHost, config.targetPort))
if (config.targetRudpPort != null) profiles.add(createRudpTransportProfile(config.runtimeId, transportId = "interop-rudp", host = config.targetHost, port = config.targetRudpPort, maxFragmentBytes = rudpInteropMaxFragmentBytes))
return profiles
}
private fun sendInteropRudpPacket(socket: DatagramSocket, remote: InetSocketAddress, packet: CultNetRudpPacket) {
val wire = encodeRudpPacket(packet)
socket.send(DatagramPacket(wire, wire.size, remote.address, remote.port))
}
private fun readDocumentPutFor(transport: CultNetSchemaMessageTransport, schemaId: String): CultNetMessage {
val deadline = nowMs() + interopReadTimeoutMs
while (nowMs() < deadline) {
val message = transport.receiveSchemaMessage(250, 5) ?: continue
if (message.schemaVersion == "cultnet.document_put_raw.v0") {
val document = mapValue(message.body["document"])
if (document["schemaId"] == schemaId) return message
}
}
throw IOException("timed out waiting for document put $schemaId")
}
private fun rawPut(
messageId: String,
schemaId: String,
recordKey: String,
payload: ByteArray,
config: InteropServeConfig? = null,
tag: String? = null,
): CultNetMessage = cultNetDocumentPutRaw(
messageId = messageId,
document = CultNetRawDocumentRecord(
schemaId = schemaId,
recordKey = recordKey,
storedAt = Instant.now().toString(),
payload = payload,
sourceRuntimeId = config?.runtimeId,
sourceAgentId = config?.agentId,
sourceRole = config?.let { "peer" },
tags = listOfNotNull(tag, config?.runtimeId),
),
)
private fun defineInteropDocuments(noteSchemaId: String): InteropDocuments {
val note = CultDocumentDefinition(InteropCodec(interopDocumentType, noteSchemaId, ::interopNoteSlots, ::interopNoteFromSlots))
val mutationIntent = CultDocumentDefinition(InteropCodec(interopMutationIntentType, interopMutationIntentSchemaId, ::interopMutationIntentSlots, ::interopMutationIntentFromSlots))
val mutationReceipt = CultDocumentDefinition(InteropCodec(interopMutationReceiptType, interopMutationReceiptSchemaId, ::interopMutationReceiptSlots, ::interopMutationReceiptFromSlots))
val fireCommand = CultDocumentDefinition(InteropCodec(interopFireCommandType, interopFireCommandSchemaId, ::interopFireCommandSlots, ::interopFireCommandFromSlots))
val fireReceipt = CultDocumentDefinition(InteropCodec(interopFireReceiptType, interopFireReceiptSchemaId, ::interopFireReceiptSlots, ::interopFireReceiptFromSlots))
return InteropDocuments(note, mutationIntent, mutationReceipt, fireCommand, fireReceipt)
}
private fun registerInteropDocuments(cache: CultCache, documents: InteropDocuments) {
cache.register(documents.note)
cache.register(documents.mutationIntent)
cache.register(documents.mutationReceipt)
cache.register(documents.fireCommand)
cache.register(documents.fireReceipt)
}
private fun buildInteropNote(runtimeId: String, displayName: String): InteropNote = InteropNote(
documentId = "note:$runtimeId",
authorRuntimeId = runtimeId,
title = "$displayName keeps a little note",
body = "$runtimeId can move CultNet state without begging the gods for translation.",
tags = listOf(runtimeId, "interop", "cultnet"),
)
private fun interopMutationContract(): Map<String, Any?> = linkedMapOf(
"documentType" to interopDocumentType,
"payloadSchemaVersion" to interopSchemaVersion,
"operations" to listOf("snapshot", "documentPut", "intentSubmit", "receiptWatch"),
"authority" to "runtime",
"intentDocumentTypes" to listOf(interopMutationIntentType, interopFireCommandType),
"receiptDocumentTypes" to listOf(interopMutationReceiptType, interopFireReceiptType),
)
private fun loadInteropSchemaRegistration(schemaPath: String): InteropSchemaRegistration {
val schemaJson = File(schemaPath).readText(StandardCharsets.UTF_8)
return InteropSchemaRegistration(
schemaId = extractJsonString(schemaJson, "\$id") ?: throw IOException("schema $schemaPath is missing \$id"),
title = extractJsonString(schemaJson, "title"),
schemaJson = schemaJson,
)
}
private fun extractJsonString(json: String, field: String): String? {
val pattern = Regex(""""${Regex.escape(field)}"\s*:\s*"((?:\\.|[^"\\])*)"""")
return pattern.find(json)?.groupValues?.get(1)?.replace("\\\"", "\"")
}
private fun parseInteropServeConfig(options: Map<String, String>): InteropServeConfig {
val tcpPort = requiredIntArg(options, "tcp-port")
return InteropServeConfig(
runtimeId = requiredArg(options, "runtime-id"),
runtimeKind = requiredArg(options, "runtime-kind"),
displayName = requiredArg(options, "display-name"),
agentId = requiredArg(options, "agent-id"),
bindHost = options["bind-host"] ?: "127.0.0.1",
advertiseHost = requiredArg(options, "advertise-host"),
tcpPort = tcpPort,
rudpPort = options["rudp-port"]?.toInt() ?: tcpPort,
discoveryPort = requiredIntArg(options, "discovery-port"),
discoveryGroup = requiredArg(options, "discovery-group"),
schemaPath = requiredArg(options, "schema-path"),
)
}
private fun parseInteropDialConfig(options: Map<String, String>): InteropDialConfig = InteropDialConfig(
runtimeId = requiredArg(options, "runtime-id"),
runtimeKind = requiredArg(options, "runtime-kind"),
displayName = requiredArg(options, "display-name"),
agentId = requiredArg(options, "agent-id"),
targetHost = requiredArg(options, "target-host"),
targetPort = options["target-port"]?.toInt(),
targetRudpPort = options["target-rudp-port"]?.toInt(),
schemaPath = requiredArg(options, "schema-path"),
).also {
if (it.targetPort == null && it.targetRudpPort == null) throw IOException("dial requires --target-port or --target-rudp-port")
}
private fun requiredArg(options: Map<String, String>, name: String): String =
options[name] ?: throw IOException("missing --$name")
private fun requiredIntArg(options: Map<String, String>, name: String): Int =
requiredArg(options, name).toInt()
private fun interopNoteWire(value: InteropNote): Map<String, Any?> = linkedMapOf(
"schemaVersion" to value.schemaVersion,
"documentId" to value.documentId,
"authorRuntimeId" to value.authorRuntimeId,
"title" to value.title,
"body" to value.body,
"tags" to value.tags,
)
private fun interopNoteSlots(value: InteropNote): List<Any?> = listOf(
value.schemaVersion,
value.documentId,
value.authorRuntimeId,
value.title,
value.body,
value.tags,
)
private fun interopNoteFromSlots(slots: List<Any?>): InteropNote = InteropNote(
schemaVersion = slots.stringAt(0, interopSchemaVersion),
documentId = slots.requiredStringAt(1, "documentId"),
authorRuntimeId = slots.requiredStringAt(2, "authorRuntimeId"),
title = slots.requiredStringAt(3, "title"),
body = slots.requiredStringAt(4, "body"),
tags = stringList(slots.getOrNull(5)),
)
private fun interopMutationIntentWire(value: InteropMutationIntent): Map<String, Any?> = linkedMapOf(
"schemaVersion" to value.schemaVersion,
"intentId" to value.intentId,
"targetDocumentId" to value.targetDocumentId,
"appendBody" to value.appendBody,
"appendTag" to value.appendTag,
)
private fun interopMutationIntentSlots(value: InteropMutationIntent): List<Any?> = listOf(
value.schemaVersion,
value.intentId,
value.targetDocumentId,
value.appendBody,
value.appendTag,
)
private fun interopMutationIntentFromSlots(slots: List<Any?>): InteropMutationIntent = InteropMutationIntent(
schemaVersion = slots.stringAt(0, interopMutationIntentSchemaVersion),
intentId = slots.requiredStringAt(1, "intentId"),
targetDocumentId = slots.requiredStringAt(2, "targetDocumentId"),
appendBody = slots.requiredStringAt(3, "appendBody"),
appendTag = slots.requiredStringAt(4, "appendTag"),
)
private fun interopMutationReceiptWire(value: InteropMutationReceipt): Map<String, Any?> = linkedMapOf(
"schemaVersion" to value.schemaVersion,
"intentId" to value.intentId,
"accepted" to value.accepted,
"documentId" to value.documentId,
"body" to value.body,
"tags" to value.tags,
)
private fun interopMutationReceiptSlots(value: InteropMutationReceipt): List<Any?> = listOf(
value.schemaVersion,
value.intentId,
value.accepted,
value.documentId,
value.body,
value.tags,
)
private fun interopMutationReceiptFromSlots(slots: List<Any?>): InteropMutationReceipt = InteropMutationReceipt(
schemaVersion = slots.stringAt(0, interopMutationReceiptSchemaVersion),
intentId = slots.requiredStringAt(1, "intentId"),
accepted = slots.getOrNull(2) == true,
documentId = slots.requiredStringAt(3, "documentId"),
body = slots.requiredStringAt(4, "body"),
tags = stringList(slots.getOrNull(5)),
)
private fun interopFireCommandWire(value: InteropFireCommand): Map<String, Any?> = linkedMapOf(
"schemaVersion" to value.schemaVersion,
"commandId" to value.commandId,
"characterId" to value.characterId,
"weaponId" to value.weaponId,
)
private fun interopFireCommandSlots(value: InteropFireCommand): List<Any?> = listOf(
value.schemaVersion,
value.commandId,
value.characterId,
value.weaponId,
)
private fun interopFireCommandFromSlots(slots: List<Any?>): InteropFireCommand = InteropFireCommand(
schemaVersion = slots.stringAt(0, interopFireCommandSchemaVersion),
commandId = slots.requiredStringAt(1, "commandId"),
characterId = slots.requiredStringAt(2, "characterId"),
weaponId = slots.requiredStringAt(3, "weaponId"),
)
private fun interopFireReceiptWire(value: InteropFireReceipt): Map<String, Any?> = linkedMapOf(
"schemaVersion" to value.schemaVersion,
"commandId" to value.commandId,
"accepted" to value.accepted,
"characterId" to value.characterId,
"weaponId" to value.weaponId,
"shotsFired" to value.shotsFired,
"ammoRemaining" to value.ammoRemaining,
)
private fun interopFireReceiptSlots(value: InteropFireReceipt): List<Any?> = listOf(
value.schemaVersion,
value.commandId,
value.accepted,
value.characterId,
value.weaponId,
value.shotsFired,
value.ammoRemaining,
)
private fun interopFireReceiptFromSlots(slots: List<Any?>): InteropFireReceipt = InteropFireReceipt(
schemaVersion = slots.stringAt(0, interopFireReceiptSchemaVersion),
commandId = slots.requiredStringAt(1, "commandId"),
accepted = slots.getOrNull(2) == true,
characterId = slots.requiredStringAt(3, "characterId"),
weaponId = slots.requiredStringAt(4, "weaponId"),
shotsFired = (slots.getOrNull(5) as? Number)?.toLong() ?: 0,
ammoRemaining = (slots.getOrNull(6) as? Number)?.toLong() ?: 0,
)
private fun anyList(value: Any?): List<Any?> = when (value) {
is Iterable<*> -> value.toList()
is Array<*> -> value.toList()
else -> throw IOException("Expected MessagePack array")
}
private fun List<Any?>.requiredStringAt(index: Int, fieldName: String): String {
val value = getOrNull(index)
if (value !is String || value.isBlank()) throw IOException("$fieldName must be a non-empty string")
return value
}
private fun List<Any?>.stringAt(index: Int, fallback: String): String =
(getOrNull(index) as? String)?.takeIf { it.isNotBlank() } ?: fallback
private fun jsonLine(value: Map<String, Any?>): String = jsonValue(value)
private fun jsonValue(value: Any?): String = when (value) {
null -> "null"
is String -> "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r") + "\""
is Boolean -> if (value) "true" else "false"
is Number -> value.toString()
is Map<*, *> -> value.entries.joinToString(prefix = "{", postfix = "}") { (key, nested) -> jsonValue(key.toString()) + ":" + jsonValue(nested) }
is Iterable<*> -> value.joinToString(prefix = "[", postfix = "]") { jsonValue(it) }
is Array<*> -> value.joinToString(prefix = "[", postfix = "]") { jsonValue(it) }
else -> jsonValue(value.toString())
}
private fun cultNetRudpReconnectLoopConsumesSharedController() {
var now = 10_000L
var capturedTimer: (() -> Unit)? = null

View file

@ -1908,6 +1908,108 @@ test("CultNet TypeScript and C# full interop peers exchange schema-v0 over RUDP"
assert.equal(csharpRudpDial.fireReceipt.accepted, true);
});
test("CultNet TypeScript and Kotlin full interop peers exchange schema-v0 over RUDP", async (t) => {
logInteropPhase("ts-kotlin-full-rudp", "build kotlin peer");
await buildKotlinInteropPeer();
cleanInteropStores([
"ts-kotlin-rudp-peer",
"ts-kotlin-rudp-client-dial",
]);
const kotlinTcpPort = await getFreePort();
const kotlinRudpPort = await getFreePort();
const tsTcpPort = await getFreePort();
const tsRudpPort = await getFreePort();
const discoveryPort = await getFreePort();
const advertiseHost = findAdvertiseHost();
const servers: RunningServeProcess[] = [];
servers.push(await spawnServeProcess("kotlin-rudp", {
command: kotlinJavaCommand,
args: [
"-cp", kotlinClasspath,
"org.gamecult.cultmesh.CultMeshKt",
"serve",
"--runtime-id", "kotlin-rudp-peer",
"--runtime-kind", "kotlin",
"--display-name", "Kotlin RUDP Peer",
"--agent-id", "kotlin-rudp-agent",
"--bind-host", "127.0.0.1",
"--advertise-host", advertiseHost,
"--tcp-port", String(kotlinTcpPort),
"--rudp-port", String(kotlinRudpPort),
"--discovery-port", String(discoveryPort),
"--discovery-group", discoveryGroup,
"--schema-path", interopSchemaPath,
],
cwd: cultLibRoot,
}));
await servers[servers.length - 1].ready;
servers.push(await spawnServeProcess("ts-kotlin-rudp", {
command: process.execPath,
args: [
tsPeerScript,
"serve",
"--runtime-id", "ts-kotlin-rudp-peer",
"--runtime-kind", "node",
"--display-name", "TypeScript Kotlin RUDP Peer",
"--agent-id", "ts-kotlin-rudp-agent",
"--bind-host", "127.0.0.1",
"--advertise-host", advertiseHost,
"--tcp-port", String(tsTcpPort),
"--rudp-port", String(tsRudpPort),
"--discovery-port", String(discoveryPort),
"--discovery-group", discoveryGroup,
"--schema-path", interopSchemaPath,
],
cwd: cultNetTsRoot,
}));
await servers[servers.length - 1].ready;
t.after(async () => {
await Promise.all(servers.map(stopProcess));
});
logInteropPhase("ts-kotlin-full-rudp", "typescript dials kotlin over rudp");
const tsRudpDial = await runJsonCommand("ts-kotlin-rudp-dial", process.execPath, [
tsPeerScript,
"dial",
"--runtime-id", "ts-kotlin-rudp-client",
"--runtime-kind", "node",
"--display-name", "TS Kotlin RUDP Dialer",
"--agent-id", "ts-kotlin-rudp-client-agent",
"--target-host", "127.0.0.1",
"--target-rudp-port", String(kotlinRudpPort),
"--schema-path", interopSchemaPath,
], cultNetTsRoot);
assert.equal(tsRudpDial.transport, "rudp");
assert.equal(tsRudpDial.remoteHello.runtimeId, "kotlin-rudp-peer");
assert.equal(tsRudpDial.hasInteropSchema, true);
assert.equal(tsRudpDial.retrievedNote.authorRuntimeId, "kotlin-rudp-peer");
assert.ok(tsRudpDial.mutatedNote.tags.includes("decorated:ts-kotlin-rudp-client"));
assert.equal(tsRudpDial.fireReceipt.accepted, true);
logInteropPhase("ts-kotlin-full-rudp", "kotlin dials typescript over rudp");
const kotlinRudpDial = await runJsonCommand("kotlin-rudp-dial", kotlinJavaCommand, [
"-cp", kotlinClasspath,
"org.gamecult.cultmesh.CultMeshKt",
"dial",
"--runtime-id", "kotlin-rudp-client",
"--runtime-kind", "kotlin",
"--display-name", "Kotlin RUDP Dialer",
"--agent-id", "kotlin-rudp-client-agent",
"--target-host", "127.0.0.1",
"--target-rudp-port", String(tsRudpPort),
"--schema-path", interopSchemaPath,
], cultLibRoot);
assert.equal(kotlinRudpDial.transport, "rudp");
assert.equal(kotlinRudpDial.remoteHello.runtimeId, "ts-kotlin-rudp-peer");
assert.equal(kotlinRudpDial.hasInteropSchema, true);
assert.equal(kotlinRudpDial.retrievedNote.authorRuntimeId, "ts-kotlin-rudp-peer");
assert.ok(kotlinRudpDial.mutatedNote.tags.includes("decorated:kotlin-rudp-client"));
assert.equal(kotlinRudpDial.fireReceipt.accepted, true);
});
test("CultNet RUDP reliable schema delivery survives one dropped client data packet across runtimes", async () => {
for (const peer of rudpServerInteropPeers()) {
await peer.build();