Add RUDP fragmentation across runtimes
This commit is contained in:
parent
743f580f9b
commit
d6f5ee28b0
9 changed files with 934 additions and 81 deletions
|
|
@ -180,16 +180,18 @@ Current progress:
|
|||
dropped data packet in both directions between TypeScript and C#/Kotlin/Python/Rust.
|
||||
The drops happen below the transport connection through a UDP bridge, so the
|
||||
recovery is RUDP resend/ack behavior, not application-level retry.
|
||||
- TypeScript and Python now have the first RUDP fragmentation implementation:
|
||||
the session can split oversized reliable ordered payloads into fragment
|
||||
packets, reassemble them before delivery, and the socket transport can carry
|
||||
fragmented `schema` frames when `maxFragmentBytes` is configured. C#, Rust,
|
||||
and Kotlin still need the same implementation before fragmentation can claim
|
||||
cross-runtime parity.
|
||||
- TypeScript, C#, Rust, Python, and Kotlin now share the first RUDP
|
||||
fragmentation implementation: the session can split oversized reliable
|
||||
ordered payloads into fragment packets, reassemble them before delivery, and
|
||||
the socket transport can carry fragmented `schema` frames when
|
||||
`maxFragmentBytes` is configured.
|
||||
- The TypeScript interop harness now proves fragmented reliable ordered
|
||||
`schema` frames in both request and response direction between TypeScript and
|
||||
C#/Kotlin/Python/Rust one-shot UDP peers.
|
||||
- The remaining parity work is to deepen Python's server-side use of its port
|
||||
and move each runtime's existing TCP/LiteNetLib/WebSocket bodies behind those
|
||||
ports, then broaden RUDP interop into the full loss, reordering,
|
||||
fragmentation, reconnect, and schema-message matrix.
|
||||
reconnect, backpressure, and schema-message matrix.
|
||||
|
||||
## RUDP Packet Contract V0
|
||||
|
||||
|
|
|
|||
|
|
@ -171,18 +171,28 @@ data class CultNetRudpSendOptions(
|
|||
)
|
||||
|
||||
private data class PendingReliablePacket(var packet: CultNetRudpPacket, var lastSentAtMs: Long)
|
||||
private data class PendingOrderedFrame(val frame: CultNetRudpDeliveredFrame, val nextSequence: Long)
|
||||
private data class FragmentBuffer(
|
||||
val channelId: String,
|
||||
val ordered: Boolean,
|
||||
val fragmentCount: Int,
|
||||
val payloads: MutableMap<Int, ByteArray> = linkedMapOf(),
|
||||
val sequences: MutableMap<Int, Long> = linkedMapOf(),
|
||||
)
|
||||
|
||||
class CultNetRudpSession(options: CultNetRudpSessionOptions) {
|
||||
val connectionId: Long = uint32(options.connectionId, "connectionId")
|
||||
val resendDelayMs: Long = options.resendDelayMs
|
||||
private var nextSequence = uint32(options.initialSequence, "initialSequence")
|
||||
private var nextFragmentId = 1
|
||||
var connected: Boolean = false
|
||||
private set
|
||||
private var highestReceivedSequence: Long? = null
|
||||
private val receivedSequences = linkedSetOf<Long>()
|
||||
private val pendingReliable = linkedMapOf<Long, PendingReliablePacket>()
|
||||
private val orderedNextSequenceByChannel = linkedMapOf<String, Long>()
|
||||
private val orderedBuffers = linkedMapOf<String, TreeMap<Long, CultNetRudpDeliveredFrame>>()
|
||||
private val orderedBuffers = linkedMapOf<String, TreeMap<Long, PendingOrderedFrame>>()
|
||||
private val fragmentBuffers = linkedMapOf<Pair<String, Int>, FragmentBuffer>()
|
||||
|
||||
val pendingReliableSequences: List<Long>
|
||||
get() = pendingReliable.keys.sorted()
|
||||
|
|
@ -204,10 +214,41 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) {
|
|||
}
|
||||
|
||||
fun send(channelId: String, payload: ByteArray, options: CultNetRudpSendOptions = CultNetRudpSendOptions()): CultNetRudpPacket {
|
||||
return sendMany(channelId, payload, options).first()
|
||||
}
|
||||
|
||||
fun sendMany(
|
||||
channelId: String,
|
||||
payload: ByteArray,
|
||||
options: CultNetRudpSendOptions = CultNetRudpSendOptions(),
|
||||
maxFragmentBytes: Int? = null,
|
||||
): List<CultNetRudpPacket> {
|
||||
if (!connected) throw IOException("Cannot send RUDP data before the session is connected")
|
||||
if (maxFragmentBytes != null && maxFragmentBytes <= 0) throw IOException("RUDP maxFragmentBytes must be greater than zero")
|
||||
if (maxFragmentBytes != null && payload.size > maxFragmentBytes) {
|
||||
val fragmentCount = (payload.size + maxFragmentBytes - 1) / maxFragmentBytes
|
||||
if (fragmentCount > 0xffff) throw IOException("RUDP payload requires more than 65535 fragments")
|
||||
val fragmentId = allocateFragmentId()
|
||||
return (0 until fragmentCount).map { index ->
|
||||
val start = index * maxFragmentBytes
|
||||
val packet = createPacket(
|
||||
channelId = channelId,
|
||||
packetType = CultNetRudpPacketType.Data,
|
||||
payload = payload.copyOfRange(start, minOf(start + maxFragmentBytes, payload.size)),
|
||||
reliable = options.reliable,
|
||||
ordered = options.ordered,
|
||||
sequenced = options.sequenced,
|
||||
fragmentId = fragmentId,
|
||||
fragmentIndex = index,
|
||||
fragmentCount = fragmentCount,
|
||||
)
|
||||
if (packet.reliable) trackReliable(packet, options.nowMs)
|
||||
packet
|
||||
}
|
||||
}
|
||||
val packet = createPacket(channelId = channelId, packetType = CultNetRudpPacketType.Data, payload = payload, reliable = options.reliable, ordered = options.ordered, sequenced = options.sequenced)
|
||||
if (packet.reliable) trackReliable(packet, options.nowMs)
|
||||
return packet
|
||||
return listOf(packet)
|
||||
}
|
||||
|
||||
fun receive(packet: CultNetRudpPacket, nowMs: Long = 0): CultNetRudpReceiveResult {
|
||||
|
|
@ -236,8 +277,8 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) {
|
|||
val duplicate = receivedSequences.contains(packet.sequence)
|
||||
rememberReceived(packet.sequence)
|
||||
if (duplicate) return CultNetRudpReceiveResult()
|
||||
val frame = CultNetRudpDeliveredFrame(packet.channelId, packet.payload.copyOf(), packet.sequence)
|
||||
return CultNetRudpReceiveResult(delivered = if (packet.ordered) deliverOrdered(frame) else listOf(frame))
|
||||
val reassembled = reassemble(packet) ?: return CultNetRudpReceiveResult()
|
||||
return CultNetRudpReceiveResult(delivered = if (reassembled.ordered) deliverOrdered(reassembled.frame, reassembled.nextSequence) else listOf(reassembled.frame))
|
||||
}
|
||||
|
||||
fun createAck(): CultNetRudpPacket = createPacket(CultNetRudpPacketType.Ack, "control", ByteArray(0))
|
||||
|
|
@ -256,11 +297,14 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) {
|
|||
reliable: Boolean = false,
|
||||
ordered: Boolean = false,
|
||||
sequenced: Boolean = false,
|
||||
fragmentId: Int = 0,
|
||||
fragmentIndex: Int = 0,
|
||||
fragmentCount: Int = 0,
|
||||
): CultNetRudpPacket {
|
||||
val sequence = nextSequence
|
||||
nextSequence = uint32(nextSequence + 1, "sequence")
|
||||
val (ack, ackMask) = ackState()
|
||||
return CultNetRudpPacket(packetType, connectionId, sequence, ack, ackMask, channelId, reliable, ordered, sequenced, payload = payload.copyOf())
|
||||
return CultNetRudpPacket(packetType, connectionId, sequence, ack, ackMask, channelId, reliable, ordered, sequenced, fragmentId, fragmentIndex, fragmentCount, payload.copyOf())
|
||||
}
|
||||
|
||||
private fun trackReliable(packet: CultNetRudpPacket, nowMs: Long) {
|
||||
|
|
@ -290,18 +334,48 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) {
|
|||
return ack to ackMask
|
||||
}
|
||||
|
||||
private fun deliverOrdered(frame: CultNetRudpDeliveredFrame): List<CultNetRudpDeliveredFrame> {
|
||||
private data class ReassembledFrame(val frame: CultNetRudpDeliveredFrame, val ordered: Boolean, val nextSequence: Long)
|
||||
|
||||
private fun reassemble(packet: CultNetRudpPacket): ReassembledFrame? {
|
||||
if (packet.fragmentCount == 0) {
|
||||
return ReassembledFrame(CultNetRudpDeliveredFrame(packet.channelId, packet.payload.copyOf(), packet.sequence), packet.ordered, packet.sequence + 1)
|
||||
}
|
||||
if (packet.fragmentId == 0) throw IOException("RUDP fragmented packet must have a non-zero fragment id")
|
||||
if (packet.fragmentIndex >= packet.fragmentCount) throw IOException("RUDP fragment index must be lower than fragment count")
|
||||
val key = packet.channelId to packet.fragmentId
|
||||
val buffer = fragmentBuffers.getOrPut(key) {
|
||||
FragmentBuffer(packet.channelId, packet.ordered, packet.fragmentCount)
|
||||
}
|
||||
if (buffer.fragmentCount != packet.fragmentCount || buffer.ordered != packet.ordered) {
|
||||
throw IOException("RUDP fragment metadata changed within a fragment set")
|
||||
}
|
||||
buffer.payloads[packet.fragmentIndex] = packet.payload.copyOf()
|
||||
buffer.sequences[packet.fragmentIndex] = packet.sequence
|
||||
if (buffer.payloads.size < packet.fragmentCount) return null
|
||||
val payload = ByteArray(buffer.payloads.values.sumOf { it.size })
|
||||
var offset = 0
|
||||
for (index in 0 until packet.fragmentCount) {
|
||||
val chunk = buffer.payloads[index] ?: return null
|
||||
chunk.copyInto(payload, offset)
|
||||
offset += chunk.size
|
||||
}
|
||||
val sequences = buffer.sequences.values
|
||||
fragmentBuffers.remove(key)
|
||||
return ReassembledFrame(CultNetRudpDeliveredFrame(buffer.channelId, payload, sequences.minOrNull() ?: packet.sequence), buffer.ordered, (sequences.maxOrNull() ?: packet.sequence) + 1)
|
||||
}
|
||||
|
||||
private fun deliverOrdered(frame: CultNetRudpDeliveredFrame, nextSequenceAfterFrame: Long): List<CultNetRudpDeliveredFrame> {
|
||||
val next = orderedNextSequenceByChannel[frame.channelId]
|
||||
if (next == null) {
|
||||
orderedNextSequenceByChannel[frame.channelId] = frame.sequence + 1
|
||||
orderedNextSequenceByChannel[frame.channelId] = nextSequenceAfterFrame
|
||||
return listOf(frame) + drainOrdered(frame.channelId)
|
||||
}
|
||||
if (frame.sequence < next) return emptyList()
|
||||
if (frame.sequence > next) {
|
||||
orderedBuffers.getOrPut(frame.channelId) { TreeMap() }[frame.sequence] = frame
|
||||
orderedBuffers.getOrPut(frame.channelId) { TreeMap() }[frame.sequence] = PendingOrderedFrame(frame, nextSequenceAfterFrame)
|
||||
return emptyList()
|
||||
}
|
||||
orderedNextSequenceByChannel[frame.channelId] = next + 1
|
||||
orderedNextSequenceByChannel[frame.channelId] = nextSequenceAfterFrame
|
||||
return listOf(frame) + drainOrdered(frame.channelId)
|
||||
}
|
||||
|
||||
|
|
@ -310,13 +384,20 @@ class CultNetRudpSession(options: CultNetRudpSessionOptions) {
|
|||
val buffer = orderedBuffers[channelId] ?: return delivered
|
||||
while (true) {
|
||||
val next = orderedNextSequenceByChannel[channelId] ?: break
|
||||
val frame = buffer.remove(next) ?: break
|
||||
delivered.add(frame)
|
||||
orderedNextSequenceByChannel[channelId] = next + 1
|
||||
val pending = buffer.remove(next) ?: break
|
||||
delivered.add(pending.frame)
|
||||
orderedNextSequenceByChannel[channelId] = pending.nextSequence
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
|
||||
private fun allocateFragmentId(): Int {
|
||||
val fragmentId = nextFragmentId
|
||||
nextFragmentId += 1
|
||||
if (nextFragmentId > 0xffff) nextFragmentId = 1
|
||||
return fragmentId
|
||||
}
|
||||
|
||||
private fun requireConnection(packet: CultNetRudpPacket) {
|
||||
if (packet.connectionId != connectionId) throw IOException("RUDP packet connection id ${packet.connectionId} does not match $connectionId")
|
||||
}
|
||||
|
|
@ -332,6 +413,7 @@ class CultNetRudpSocketTransportConnection(
|
|||
remoteAddress: InetSocketAddress? = null,
|
||||
initialSequence: Long = 1,
|
||||
resendDelayMs: Long = 250,
|
||||
private val maxFragmentBytes: Int? = null,
|
||||
) : AutoCloseable {
|
||||
private val session = CultNetRudpSession(CultNetRudpSessionOptions(connectionId, initialSequence, resendDelayMs))
|
||||
private var remote = remoteAddress
|
||||
|
|
@ -345,6 +427,7 @@ class CultNetRudpSocketTransportConnection(
|
|||
runtimeId = runtimeId,
|
||||
host = socket.localAddress.hostAddress,
|
||||
port = socket.localPort,
|
||||
maxFragmentBytes = maxFragmentBytes,
|
||||
)
|
||||
val connected: Boolean get() = session.connected
|
||||
val stats: CultNetTransportStats get() = CultNetTransportStats(bytesReceived, bytesSent, framesReceived, framesSent)
|
||||
|
|
@ -355,7 +438,7 @@ class CultNetRudpSocketTransportConnection(
|
|||
}
|
||||
|
||||
fun send(channelId: String, payload: ByteArray) {
|
||||
sendPacket(session.send(channelId, payload, channelSendOptions(channelId, nowMs())))
|
||||
session.sendMany(channelId, payload, channelSendOptions(channelId, nowMs()), maxFragmentBytes).forEach { sendPacket(it) }
|
||||
framesSent += 1
|
||||
}
|
||||
|
||||
|
|
@ -494,7 +577,9 @@ private fun nowMs(): Long = Instant.now().toEpochMilli()
|
|||
fun main(args: Array<String>) {
|
||||
if (args.isEmpty()) {
|
||||
rudpPacketCodecUsesDeterministicReliableOrderedFixture()
|
||||
rudpSessionFragmentsAndReassemblesReliableOrderedPayloads()
|
||||
rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames()
|
||||
rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -509,6 +594,9 @@ fun main(args: Array<String>) {
|
|||
private fun rudpServeOnce(options: Map<String, String>) {
|
||||
val bindHost = options["bind-host"] ?: "127.0.0.1"
|
||||
val bindPort = options["bind-port"]?.toInt() ?: 0
|
||||
val expectedClientPayload = (options["client-payload"] ?: "ts-kotlin-client-state").toByteArray(StandardCharsets.UTF_8)
|
||||
val serverPayload = (options["server-payload"] ?: "kotlin-server-state").toByteArray(StandardCharsets.UTF_8)
|
||||
val maxFragmentBytes = options["max-fragment-bytes"]?.toInt()
|
||||
val socket = DatagramSocket(bindPort, InetAddress.getByName(bindHost)).also { it.soTimeout = 20 }
|
||||
CultNetRudpSocketTransportConnection(
|
||||
socket = socket,
|
||||
|
|
@ -517,14 +605,15 @@ private fun rudpServeOnce(options: Map<String, String>) {
|
|||
connectionId = 0x446688aaL,
|
||||
initialSequence = 100,
|
||||
resendDelayMs = 25,
|
||||
maxFragmentBytes = maxFragmentBytes,
|
||||
).use { transport ->
|
||||
println("""{"status":"ready","port":${socket.localPort}}""")
|
||||
val deadline = System.nanoTime() + 5_000_000_000L
|
||||
while (System.nanoTime() < deadline) {
|
||||
val frame = transport.receiveOnce()
|
||||
if (frame != null) {
|
||||
requireRudpFrame(frame, "schema", "ts-kotlin-client-state")
|
||||
transport.send("schema", "kotlin-server-state".toByteArray(StandardCharsets.UTF_8))
|
||||
requireRudpFrame(frame, "schema", expectedClientPayload)
|
||||
transport.send("schema", serverPayload)
|
||||
pollRudpAfterSend(transport, 250)
|
||||
println("""{"status":"ok"}""")
|
||||
return
|
||||
|
|
@ -581,8 +670,11 @@ private fun pollRudpAfterSend(transport: CultNetRudpSocketTransportConnection, d
|
|||
}
|
||||
|
||||
private fun requireRudpFrame(frame: CultNetTransportFrame, expectedChannelId: String, expectedPayload: String) {
|
||||
val expectedBytes = expectedPayload.toByteArray(StandardCharsets.UTF_8)
|
||||
if (frame.channelId != expectedChannelId || !frame.payload.contentEquals(expectedBytes)) {
|
||||
requireRudpFrame(frame, expectedChannelId, expectedPayload.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
private fun requireRudpFrame(frame: CultNetTransportFrame, expectedChannelId: String, expectedPayload: ByteArray) {
|
||||
if (frame.channelId != expectedChannelId || !frame.payload.contentEquals(expectedPayload)) {
|
||||
error("Unexpected RUDP frame: channel=${frame.channelId}, payload=${String(frame.payload, StandardCharsets.UTF_8)}")
|
||||
}
|
||||
}
|
||||
|
|
@ -637,6 +729,31 @@ private fun rudpPacketCodecUsesDeterministicReliableOrderedFixture() {
|
|||
check(String(decoded.payload, StandardCharsets.UTF_8) == "hello")
|
||||
}
|
||||
|
||||
private fun rudpSessionFragmentsAndReassemblesReliableOrderedPayloads() {
|
||||
val sender = CultNetRudpSession(CultNetRudpSessionOptions(connectionId = 456, initialSequence = 1))
|
||||
val receiver = CultNetRudpSession(CultNetRudpSessionOptions(connectionId = 456, initialSequence = 100))
|
||||
sender.receive(CultNetRudpPacket(CultNetRudpPacketType.Accept, 456, 90, 0, 0, "control"))
|
||||
receiver.receive(CultNetRudpPacket(CultNetRudpPacketType.Accept, 456, 91, 0, 0, "control"))
|
||||
|
||||
val packets = sender.sendMany(
|
||||
"schema",
|
||||
"fragment-me-please".toByteArray(StandardCharsets.UTF_8),
|
||||
CultNetRudpSendOptions(reliable = true, ordered = true, nowMs = 10),
|
||||
maxFragmentBytes = 5,
|
||||
)
|
||||
check(packets.size == 4)
|
||||
check(packets.map { it.fragmentCount } == listOf(4, 4, 4, 4))
|
||||
check(packets.map { it.fragmentIndex } == listOf(0, 1, 2, 3))
|
||||
check(packets.all { it.fragmentId == packets.first().fragmentId })
|
||||
check(receiver.receive(packets[0]).delivered.isEmpty())
|
||||
check(receiver.receive(packets[1]).delivered.isEmpty())
|
||||
check(receiver.receive(packets[2]).delivered.isEmpty())
|
||||
val delivered = receiver.receive(packets[3]).delivered
|
||||
check(delivered.size == 1)
|
||||
check(String(delivered.first().payload, StandardCharsets.UTF_8) == "fragment-me-please")
|
||||
check(delivered.first().sequence == packets.first().sequence)
|
||||
}
|
||||
|
||||
private fun rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames() {
|
||||
val loopback = InetAddress.getByName("127.0.0.1")
|
||||
val serverSocket = DatagramSocket(0, loopback).also { it.soTimeout = 20 }
|
||||
|
|
@ -680,6 +797,43 @@ private fun rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames()
|
|||
}
|
||||
}
|
||||
|
||||
private fun rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames() {
|
||||
val loopback = InetAddress.getByName("127.0.0.1")
|
||||
val serverSocket = DatagramSocket(0, loopback).also { it.soTimeout = 20 }
|
||||
val clientSocket = DatagramSocket(0, loopback).also { it.soTimeout = 20 }
|
||||
val connectionId = 0x10203041L
|
||||
CultNetRudpSocketTransportConnection(
|
||||
socket = serverSocket,
|
||||
mode = CultNetRudpSocketMode.Server,
|
||||
runtimeId = "kotlin-rudp-fragment-server",
|
||||
connectionId = connectionId,
|
||||
initialSequence = 100,
|
||||
resendDelayMs = 25,
|
||||
maxFragmentBytes = 8,
|
||||
).use { server ->
|
||||
CultNetRudpSocketTransportConnection(
|
||||
socket = clientSocket,
|
||||
mode = CultNetRudpSocketMode.Client,
|
||||
runtimeId = "kotlin-rudp-fragment-client",
|
||||
connectionId = connectionId,
|
||||
remoteAddress = InetSocketAddress(loopback, serverSocket.localPort),
|
||||
initialSequence = 1,
|
||||
resendDelayMs = 25,
|
||||
maxFragmentBytes = 8,
|
||||
).use { client ->
|
||||
val payload = "this-schema-frame-is-larger-than-one-rudp-fragment".toByteArray(StandardCharsets.UTF_8)
|
||||
client.connect("join".toByteArray(StandardCharsets.UTF_8))
|
||||
pumpRudpHandshake(client, server)
|
||||
client.send("schema", payload)
|
||||
val serverFrame = receiveRudpFrame(server)
|
||||
check(serverFrame.channelId == "schema")
|
||||
check(serverFrame.payload.contentEquals(payload))
|
||||
check(client.stats.framesSent == 1L)
|
||||
check(server.stats.framesReceived == 1L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pumpRudpHandshake(
|
||||
client: CultNetRudpSocketTransportConnection,
|
||||
server: CultNetRudpSocketTransportConnection,
|
||||
|
|
|
|||
|
|
@ -458,6 +458,19 @@ fn rudp_serve_once(options: &BTreeMap<String, String>) -> Result<()> {
|
|||
.transpose()
|
||||
.with_context(|| "argument --bind-port must be a u16")?
|
||||
.unwrap_or(0);
|
||||
let expected_client_payload = options
|
||||
.get("client-payload")
|
||||
.map(|value| value.as_bytes().to_vec())
|
||||
.unwrap_or_else(|| b"ts-rust-client-state".to_vec());
|
||||
let server_payload = options
|
||||
.get("server-payload")
|
||||
.map(|value| value.as_bytes().to_vec())
|
||||
.unwrap_or_else(|| b"rust-server-state".to_vec());
|
||||
let max_fragment_bytes = options
|
||||
.get("max-fragment-bytes")
|
||||
.map(|value| value.parse::<u32>())
|
||||
.transpose()
|
||||
.with_context(|| "argument --max-fragment-bytes must be a u32")?;
|
||||
let socket = UdpSocket::bind((bind_host.as_str(), bind_port))
|
||||
.with_context(|| format!("failed to bind RUDP server on {bind_host}:{bind_port}"))?;
|
||||
socket.set_read_timeout(Some(Duration::from_millis(20)))?;
|
||||
|
|
@ -466,6 +479,7 @@ fn rudp_serve_once(options: &BTreeMap<String, String>) -> Result<()> {
|
|||
CultNetRudpSocketTransportOptions::server("rust-rudp-interop", socket, 0x22446688);
|
||||
transport_options.initial_sequence = 100;
|
||||
transport_options.resend_delay_ms = 25;
|
||||
transport_options.max_fragment_bytes = max_fragment_bytes;
|
||||
let mut transport = CultNetRudpSocketTransportConnection::new(transport_options)?;
|
||||
|
||||
print_json(&serde_json::json!({
|
||||
|
|
@ -476,14 +490,14 @@ fn rudp_serve_once(options: &BTreeMap<String, String>) -> Result<()> {
|
|||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if let Some(frame) = transport.receive_once()? {
|
||||
if frame.channel_id != "schema" || frame.payload != b"ts-rust-client-state" {
|
||||
if frame.channel_id != "schema" || frame.payload != expected_client_payload {
|
||||
return Err(anyhow!(
|
||||
"unexpected RUDP frame: channel={} payload={:?}",
|
||||
frame.channel_id,
|
||||
frame.payload
|
||||
));
|
||||
}
|
||||
transport.send("schema", b"rust-server-state".to_vec())?;
|
||||
transport.send("schema", server_payload.clone())?;
|
||||
poll_rudp_after_send(&mut transport, Duration::from_millis(250))?;
|
||||
print_json(&serde_json::json!({ "status": "ok" }))?;
|
||||
return Ok(());
|
||||
|
|
|
|||
|
|
@ -94,16 +94,33 @@ struct PendingReliablePacket {
|
|||
last_sent_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct PendingOrderedFrame {
|
||||
frame: CultNetRudpDeliveredFrame,
|
||||
next_sequence: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct FragmentBuffer {
|
||||
channel_id: String,
|
||||
ordered: bool,
|
||||
fragment_count: u16,
|
||||
payloads: BTreeMap<u16, Vec<u8>>,
|
||||
sequences: BTreeMap<u16, u32>,
|
||||
}
|
||||
|
||||
pub struct CultNetRudpSession {
|
||||
connection_id: u32,
|
||||
resend_delay_ms: u64,
|
||||
next_sequence: u32,
|
||||
next_fragment_id: u16,
|
||||
connected: bool,
|
||||
highest_received_sequence: Option<u32>,
|
||||
received_sequences: BTreeSet<u32>,
|
||||
pending_reliable: BTreeMap<u32, PendingReliablePacket>,
|
||||
ordered_next_sequence_by_channel: BTreeMap<String, u32>,
|
||||
ordered_buffers: BTreeMap<String, BTreeMap<u32, CultNetRudpDeliveredFrame>>,
|
||||
ordered_buffers: BTreeMap<String, BTreeMap<u32, PendingOrderedFrame>>,
|
||||
fragment_buffers: BTreeMap<(String, u16), FragmentBuffer>,
|
||||
}
|
||||
|
||||
impl CultNetRudpSession {
|
||||
|
|
@ -112,12 +129,14 @@ impl CultNetRudpSession {
|
|||
connection_id: options.connection_id,
|
||||
resend_delay_ms: options.resend_delay_ms,
|
||||
next_sequence: options.initial_sequence,
|
||||
next_fragment_id: 1,
|
||||
connected: false,
|
||||
highest_received_sequence: None,
|
||||
received_sequences: BTreeSet::new(),
|
||||
pending_reliable: BTreeMap::new(),
|
||||
ordered_next_sequence_by_channel: BTreeMap::new(),
|
||||
ordered_buffers: BTreeMap::new(),
|
||||
fragment_buffers: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,12 +203,59 @@ impl CultNetRudpSession {
|
|||
payload: Vec<u8>,
|
||||
options: CultNetRudpSendOptions,
|
||||
) -> Result<CultNetRudpPacket> {
|
||||
self.send_many(channel_id, payload, options, None)?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("RUDP send produced no packets"))
|
||||
}
|
||||
|
||||
pub fn send_many(
|
||||
&mut self,
|
||||
channel_id: &str,
|
||||
payload: Vec<u8>,
|
||||
options: CultNetRudpSendOptions,
|
||||
max_fragment_bytes: Option<usize>,
|
||||
) -> Result<Vec<CultNetRudpPacket>> {
|
||||
if !self.connected {
|
||||
return Err(anyhow!(
|
||||
"Cannot send RUDP data before the session is connected"
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(max_fragment_bytes) = max_fragment_bytes {
|
||||
if max_fragment_bytes == 0 {
|
||||
return Err(anyhow!("RUDP max_fragment_bytes must be greater than zero"));
|
||||
}
|
||||
if payload.len() > max_fragment_bytes {
|
||||
let fragment_count = payload.len().div_ceil(max_fragment_bytes);
|
||||
if fragment_count > u16::MAX as usize {
|
||||
return Err(anyhow!("RUDP payload requires more than 65535 fragments"));
|
||||
}
|
||||
let fragment_id = self.allocate_fragment_id();
|
||||
let mut packets = Vec::new();
|
||||
for index in 0..fragment_count {
|
||||
let start = index * max_fragment_bytes;
|
||||
let end = (start + max_fragment_bytes).min(payload.len());
|
||||
let packet = self.create_packet_with_fragments(
|
||||
CultNetRudpPacketType::Data,
|
||||
channel_id,
|
||||
payload[start..end].to_vec(),
|
||||
options.reliable,
|
||||
options.ordered,
|
||||
options.sequenced,
|
||||
fragment_id,
|
||||
index as u16,
|
||||
fragment_count as u16,
|
||||
);
|
||||
if packet.reliable {
|
||||
self.track_reliable(packet.clone(), options.now_ms);
|
||||
}
|
||||
packets.push(packet);
|
||||
}
|
||||
return Ok(packets);
|
||||
}
|
||||
}
|
||||
|
||||
let packet = self.create_packet(
|
||||
CultNetRudpPacketType::Data,
|
||||
channel_id,
|
||||
|
|
@ -201,7 +267,7 @@ impl CultNetRudpSession {
|
|||
if packet.reliable {
|
||||
self.track_reliable(packet.clone(), options.now_ms);
|
||||
}
|
||||
Ok(packet)
|
||||
Ok(vec![packet])
|
||||
}
|
||||
|
||||
pub fn receive(
|
||||
|
|
@ -262,13 +328,14 @@ impl CultNetRudpSession {
|
|||
});
|
||||
}
|
||||
|
||||
let frame = CultNetRudpDeliveredFrame {
|
||||
channel_id: packet.channel_id.clone(),
|
||||
payload: packet.payload.clone(),
|
||||
sequence: packet.sequence,
|
||||
let Some((frame, ordered, next_sequence)) = self.reassemble(packet)? else {
|
||||
return Ok(CultNetRudpReceiveResult {
|
||||
delivered: Vec::new(),
|
||||
reply: None,
|
||||
});
|
||||
};
|
||||
let delivered = if packet.ordered {
|
||||
self.deliver_ordered(frame)
|
||||
let delivered = if ordered {
|
||||
self.deliver_ordered(frame, next_sequence)
|
||||
} else {
|
||||
vec![frame]
|
||||
};
|
||||
|
|
@ -309,6 +376,32 @@ impl CultNetRudpSession {
|
|||
reliable: bool,
|
||||
ordered: bool,
|
||||
sequenced: bool,
|
||||
) -> CultNetRudpPacket {
|
||||
self.create_packet_with_fragments(
|
||||
packet_type,
|
||||
channel_id,
|
||||
payload,
|
||||
reliable,
|
||||
ordered,
|
||||
sequenced,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn create_packet_with_fragments(
|
||||
&mut self,
|
||||
packet_type: CultNetRudpPacketType,
|
||||
channel_id: &str,
|
||||
payload: Vec<u8>,
|
||||
reliable: bool,
|
||||
ordered: bool,
|
||||
sequenced: bool,
|
||||
fragment_id: u16,
|
||||
fragment_index: u16,
|
||||
fragment_count: u16,
|
||||
) -> CultNetRudpPacket {
|
||||
let sequence = self.next_sequence;
|
||||
self.next_sequence = self
|
||||
|
|
@ -326,9 +419,9 @@ impl CultNetRudpSession {
|
|||
reliable,
|
||||
ordered,
|
||||
sequenced,
|
||||
fragment_id: 0,
|
||||
fragment_index: 0,
|
||||
fragment_count: 0,
|
||||
fragment_id,
|
||||
fragment_index,
|
||||
fragment_count,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
|
@ -373,9 +466,88 @@ impl CultNetRudpSession {
|
|||
(ack, ack_mask)
|
||||
}
|
||||
|
||||
fn reassemble(
|
||||
&mut self,
|
||||
packet: &CultNetRudpPacket,
|
||||
) -> Result<Option<(CultNetRudpDeliveredFrame, bool, u32)>> {
|
||||
if packet.fragment_count == 0 {
|
||||
return Ok(Some((
|
||||
CultNetRudpDeliveredFrame {
|
||||
channel_id: packet.channel_id.clone(),
|
||||
payload: packet.payload.clone(),
|
||||
sequence: packet.sequence,
|
||||
},
|
||||
packet.ordered,
|
||||
packet.sequence + 1,
|
||||
)));
|
||||
}
|
||||
if packet.fragment_id == 0 {
|
||||
return Err(anyhow!(
|
||||
"RUDP fragmented packet must have a non-zero fragment id"
|
||||
));
|
||||
}
|
||||
if packet.fragment_index >= packet.fragment_count {
|
||||
return Err(anyhow!(
|
||||
"RUDP fragment index must be lower than fragment count"
|
||||
));
|
||||
}
|
||||
|
||||
let key = (packet.channel_id.clone(), packet.fragment_id);
|
||||
let buffer = self
|
||||
.fragment_buffers
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| FragmentBuffer {
|
||||
channel_id: packet.channel_id.clone(),
|
||||
ordered: packet.ordered,
|
||||
fragment_count: packet.fragment_count,
|
||||
payloads: BTreeMap::new(),
|
||||
sequences: BTreeMap::new(),
|
||||
});
|
||||
if buffer.fragment_count != packet.fragment_count || buffer.ordered != packet.ordered {
|
||||
return Err(anyhow!(
|
||||
"RUDP fragment metadata changed within a fragment set"
|
||||
));
|
||||
}
|
||||
buffer
|
||||
.payloads
|
||||
.insert(packet.fragment_index, packet.payload.clone());
|
||||
buffer
|
||||
.sequences
|
||||
.insert(packet.fragment_index, packet.sequence);
|
||||
if buffer.payloads.len() < packet.fragment_count as usize {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut payload = Vec::new();
|
||||
let mut sequences = Vec::new();
|
||||
for index in 0..packet.fragment_count {
|
||||
let Some(chunk) = buffer.payloads.get(&index) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(sequence) = buffer.sequences.get(&index) else {
|
||||
return Ok(None);
|
||||
};
|
||||
payload.extend_from_slice(chunk);
|
||||
sequences.push(*sequence);
|
||||
}
|
||||
let channel_id = buffer.channel_id.clone();
|
||||
let ordered = buffer.ordered;
|
||||
self.fragment_buffers.remove(&key);
|
||||
Ok(Some((
|
||||
CultNetRudpDeliveredFrame {
|
||||
channel_id,
|
||||
payload,
|
||||
sequence: *sequences.iter().min().unwrap(),
|
||||
},
|
||||
ordered,
|
||||
sequences.iter().max().unwrap() + 1,
|
||||
)))
|
||||
}
|
||||
|
||||
fn deliver_ordered(
|
||||
&mut self,
|
||||
frame: CultNetRudpDeliveredFrame,
|
||||
next_sequence_after_frame: u32,
|
||||
) -> Vec<CultNetRudpDeliveredFrame> {
|
||||
let channel_id = frame.channel_id.clone();
|
||||
let Some(next) = self
|
||||
|
|
@ -384,7 +556,7 @@ impl CultNetRudpSession {
|
|||
.copied()
|
||||
else {
|
||||
self.ordered_next_sequence_by_channel
|
||||
.insert(channel_id.clone(), frame.sequence + 1);
|
||||
.insert(channel_id.clone(), next_sequence_after_frame);
|
||||
let mut delivered = vec![frame];
|
||||
delivered.extend(self.drain_ordered(&channel_id));
|
||||
return delivered;
|
||||
|
|
@ -395,15 +567,18 @@ impl CultNetRudpSession {
|
|||
}
|
||||
|
||||
if frame.sequence > next {
|
||||
self.ordered_buffers
|
||||
.entry(channel_id)
|
||||
.or_default()
|
||||
.insert(frame.sequence, frame);
|
||||
self.ordered_buffers.entry(channel_id).or_default().insert(
|
||||
frame.sequence,
|
||||
PendingOrderedFrame {
|
||||
frame,
|
||||
next_sequence: next_sequence_after_frame,
|
||||
},
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.ordered_next_sequence_by_channel
|
||||
.insert(channel_id.clone(), next + 1);
|
||||
.insert(channel_id.clone(), next_sequence_after_frame);
|
||||
let mut delivered = vec![frame];
|
||||
delivered.extend(self.drain_ordered(&channel_id));
|
||||
delivered
|
||||
|
|
@ -422,16 +597,25 @@ impl CultNetRudpSession {
|
|||
let Some(buffer) = self.ordered_buffers.get_mut(channel_id) else {
|
||||
break;
|
||||
};
|
||||
let Some(frame) = buffer.remove(&next) else {
|
||||
let Some(pending) = buffer.remove(&next) else {
|
||||
break;
|
||||
};
|
||||
delivered.push(frame);
|
||||
delivered.push(pending.frame);
|
||||
self.ordered_next_sequence_by_channel
|
||||
.insert(channel_id.to_string(), next + 1);
|
||||
.insert(channel_id.to_string(), pending.next_sequence);
|
||||
}
|
||||
delivered
|
||||
}
|
||||
|
||||
fn allocate_fragment_id(&mut self) -> u16 {
|
||||
let fragment_id = self.next_fragment_id;
|
||||
self.next_fragment_id = self.next_fragment_id.saturating_add(1);
|
||||
if self.next_fragment_id == 0 {
|
||||
self.next_fragment_id = 1;
|
||||
}
|
||||
fragment_id
|
||||
}
|
||||
|
||||
fn require_connection(&self, packet: &CultNetRudpPacket) -> Result<()> {
|
||||
if packet.connection_id != self.connection_id {
|
||||
return Err(anyhow!(
|
||||
|
|
@ -508,6 +692,7 @@ pub struct CultNetRudpSocketTransportConnection {
|
|||
pub profile: CultNetTransportProfile,
|
||||
stats: CultNetTransportStats,
|
||||
delivered_frames: VecDeque<CultNetTransportFrame>,
|
||||
max_fragment_bytes: Option<usize>,
|
||||
}
|
||||
|
||||
impl CultNetRudpSocketTransportConnection {
|
||||
|
|
@ -535,6 +720,7 @@ impl CultNetRudpSocketTransportConnection {
|
|||
profile,
|
||||
stats: CultNetTransportStats::default(),
|
||||
delivered_frames: VecDeque::new(),
|
||||
max_fragment_bytes: options.max_fragment_bytes.map(|value| value as usize),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -558,8 +744,12 @@ impl CultNetRudpSocketTransportConnection {
|
|||
|
||||
pub fn send(&mut self, channel_id: &str, payload: Vec<u8>) -> Result<()> {
|
||||
let options = channel_send_options(channel_id, now_ms());
|
||||
let packet = self.session.send(channel_id, payload, options)?;
|
||||
self.send_packet(&packet)?;
|
||||
let packets =
|
||||
self.session
|
||||
.send_many(channel_id, payload, options, self.max_fragment_bytes)?;
|
||||
for packet in packets {
|
||||
self.send_packet(&packet)?;
|
||||
}
|
||||
self.stats.frames_sent += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -571,6 +571,97 @@ fn rudp_session_suppresses_duplicates_and_delivers_reliable_ordered_payloads_in_
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rudp_session_fragments_and_reassembles_reliable_ordered_payloads() -> Result<()> {
|
||||
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: 456,
|
||||
initial_sequence: 1,
|
||||
..CultNetRudpSessionOptions::default()
|
||||
});
|
||||
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
||||
connection_id: 456,
|
||||
initial_sequence: 100,
|
||||
..CultNetRudpSessionOptions::default()
|
||||
});
|
||||
sender.receive(
|
||||
&CultNetRudpPacket {
|
||||
packet_type: CultNetRudpPacketType::Accept,
|
||||
connection_id: 456,
|
||||
sequence: 90,
|
||||
ack: 0,
|
||||
ack_mask: 0,
|
||||
channel_id: "control".to_string(),
|
||||
reliable: false,
|
||||
ordered: false,
|
||||
sequenced: false,
|
||||
fragment_id: 0,
|
||||
fragment_index: 0,
|
||||
fragment_count: 0,
|
||||
payload: Vec::new(),
|
||||
},
|
||||
0,
|
||||
)?;
|
||||
receiver.receive(
|
||||
&CultNetRudpPacket {
|
||||
packet_type: CultNetRudpPacketType::Accept,
|
||||
connection_id: 456,
|
||||
sequence: 91,
|
||||
ack: 0,
|
||||
ack_mask: 0,
|
||||
channel_id: "control".to_string(),
|
||||
reliable: false,
|
||||
ordered: false,
|
||||
sequenced: false,
|
||||
fragment_id: 0,
|
||||
fragment_index: 0,
|
||||
fragment_count: 0,
|
||||
payload: Vec::new(),
|
||||
},
|
||||
0,
|
||||
)?;
|
||||
|
||||
let packets = sender.send_many(
|
||||
"schema",
|
||||
b"fragment-me-please".to_vec(),
|
||||
CultNetRudpSendOptions {
|
||||
reliable: true,
|
||||
ordered: true,
|
||||
now_ms: 10,
|
||||
..CultNetRudpSendOptions::default()
|
||||
},
|
||||
Some(5),
|
||||
)?;
|
||||
assert_eq!(packets.len(), 4);
|
||||
assert_eq!(
|
||||
packets
|
||||
.iter()
|
||||
.map(|packet| packet.fragment_count)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![4, 4, 4, 4]
|
||||
);
|
||||
assert_eq!(
|
||||
packets
|
||||
.iter()
|
||||
.map(|packet| packet.fragment_index)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![0, 1, 2, 3]
|
||||
);
|
||||
assert!(
|
||||
packets
|
||||
.iter()
|
||||
.all(|packet| packet.fragment_id == packets[0].fragment_id)
|
||||
);
|
||||
|
||||
assert!(receiver.receive(&packets[0], 0)?.delivered.is_empty());
|
||||
assert!(receiver.receive(&packets[1], 0)?.delivered.is_empty());
|
||||
assert!(receiver.receive(&packets[2], 0)?.delivered.is_empty());
|
||||
let delivered = receiver.receive(&packets[3], 0)?.delivered;
|
||||
assert_eq!(delivered.len(), 1);
|
||||
assert_eq!(delivered[0].payload, b"fragment-me-please");
|
||||
assert_eq!(delivered[0].sequence, packets[0].sequence);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames() -> Result<()> {
|
||||
let server_socket = bind_udp_socket()?;
|
||||
|
|
@ -628,6 +719,52 @@ fn rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames()
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames() -> Result<()> {
|
||||
let server_socket = bind_udp_socket()?;
|
||||
let client_socket = bind_udp_socket()?;
|
||||
let server_addr = server_socket.local_addr()?;
|
||||
let connection_id = 0x1020_3041;
|
||||
let mut server =
|
||||
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
|
||||
runtime_id: "rust-rudp-fragment-server".to_string(),
|
||||
socket: server_socket,
|
||||
mode: CultNetRudpSocketMode::Server,
|
||||
remote_addr: None,
|
||||
connection_id,
|
||||
initial_sequence: 100,
|
||||
resend_delay_ms: 25,
|
||||
transport_id: None,
|
||||
max_payload_bytes: None,
|
||||
max_fragment_bytes: Some(8),
|
||||
})?;
|
||||
let mut client =
|
||||
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
|
||||
runtime_id: "rust-rudp-fragment-client".to_string(),
|
||||
socket: client_socket,
|
||||
mode: CultNetRudpSocketMode::Client,
|
||||
remote_addr: Some(server_addr),
|
||||
connection_id,
|
||||
initial_sequence: 1,
|
||||
resend_delay_ms: 25,
|
||||
transport_id: None,
|
||||
max_payload_bytes: None,
|
||||
max_fragment_bytes: Some(8),
|
||||
})?;
|
||||
|
||||
client.connect(b"join".to_vec())?;
|
||||
pump_rudp_handshake(&mut client, &mut server)?;
|
||||
|
||||
let payload = b"this-schema-frame-is-larger-than-one-rudp-fragment".to_vec();
|
||||
client.send("schema", payload.clone())?;
|
||||
let server_frame = receive_rudp_frame(&mut server)?;
|
||||
assert_eq!(server_frame.channel_id, "schema");
|
||||
assert_eq!(server_frame.payload, payload);
|
||||
assert_eq!(client.stats().frames_sent, 1);
|
||||
assert_eq!(server.stats().frames_received, 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_gamecult_networking_contract_round_trips_login_union() -> Result<()> {
|
||||
let message = CultNetMessage::Login {
|
||||
|
|
|
|||
|
|
@ -72,12 +72,18 @@ interface RudpServerInteropPeerSpec {
|
|||
name: string;
|
||||
connectionId: number;
|
||||
build: () => Promise<void>;
|
||||
spawn: () => RunningPythonRudpPeer;
|
||||
spawn: (options?: RudpServerSpawnOptions) => RunningPythonRudpPeer;
|
||||
joinPayload: string;
|
||||
clientPayload: string;
|
||||
expectedPayload: string;
|
||||
}
|
||||
|
||||
interface RudpServerSpawnOptions {
|
||||
clientPayload?: string;
|
||||
serverPayload?: string;
|
||||
maxFragmentBytes?: number;
|
||||
}
|
||||
|
||||
function rudpServerInteropPeers(): RudpServerInteropPeerSpec[] {
|
||||
return [
|
||||
{
|
||||
|
|
@ -120,6 +126,7 @@ function rudpServerInteropPeers(): RudpServerInteropPeerSpec[] {
|
|||
}
|
||||
|
||||
const pythonRudpPeerScript = String.raw`
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
|
||||
|
|
@ -129,6 +136,11 @@ from cultnet_py import (
|
|||
CultNetRudpSocketTransportOptions,
|
||||
)
|
||||
|
||||
expected_client_payload = os.environ.get("RUDP_CLIENT_PAYLOAD", "client-state").encode()
|
||||
server_payload = os.environ.get("RUDP_SERVER_PAYLOAD", "python-state").encode()
|
||||
max_fragment_bytes_raw = os.environ.get("RUDP_MAX_FRAGMENT_BYTES")
|
||||
max_fragment_bytes = int(max_fragment_bytes_raw) if max_fragment_bytes_raw else None
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.settimeout(0.02)
|
||||
|
|
@ -139,6 +151,7 @@ transport = CultNetRudpSocketTransportConnection(CultNetRudpSocketTransportOptio
|
|||
connection_id=0x55667788,
|
||||
initial_sequence=100,
|
||||
resend_delay_ms=25,
|
||||
max_fragment_bytes=max_fragment_bytes,
|
||||
))
|
||||
print(f"PORT {sock.getsockname()[1]}", flush=True)
|
||||
|
||||
|
|
@ -149,10 +162,10 @@ while time.time() < deadline:
|
|||
if frame is None:
|
||||
time.sleep(0.005)
|
||||
continue
|
||||
if frame.channel_id != "schema" or frame.payload != b"client-state":
|
||||
if frame.channel_id != "schema" or frame.payload != expected_client_payload:
|
||||
transport.close()
|
||||
raise SystemExit(f"unexpected RUDP frame: {frame.channel_id!r} {frame.payload!r}")
|
||||
transport.send("schema", b"python-state")
|
||||
transport.send("schema", server_payload)
|
||||
print("OK", flush=True)
|
||||
ack_deadline = time.time() + 0.25
|
||||
while time.time() < ack_deadline:
|
||||
|
|
@ -1153,6 +1166,58 @@ test("CultNet RUDP reliable schema response survives one dropped server data pac
|
|||
}
|
||||
});
|
||||
|
||||
test("CultNet RUDP fragmented schema frames cross runtime boundaries", async () => {
|
||||
for (const peer of rudpServerInteropPeers()) {
|
||||
await peer.build();
|
||||
const clientPayload = `${peer.clientPayload}:fragmented:${"x".repeat(96)}`;
|
||||
const serverPayload = `${peer.expectedPayload}:fragmented:${"y".repeat(96)}`;
|
||||
const serverPeer = peer.spawn({
|
||||
clientPayload,
|
||||
serverPayload,
|
||||
maxFragmentBytes: 16,
|
||||
});
|
||||
const clientSocket = await bindUdpSocket();
|
||||
let client: CultNetRudpSocketTransportConnection | undefined;
|
||||
|
||||
try {
|
||||
client = new CultNetRudpSocketTransportConnection({
|
||||
runtimeId: `ts-fragmented-${peer.name.replace(/\W/g, "").toLowerCase()}-rudp-interop`,
|
||||
socket: clientSocket,
|
||||
mode: "client",
|
||||
remoteHost: "127.0.0.1",
|
||||
remotePort: await serverPeer.ready,
|
||||
connectionId: peer.connectionId,
|
||||
initialSequence: 1,
|
||||
resendDelayMs: 25,
|
||||
resendPollMs: 5,
|
||||
maxFragmentBytes: 16,
|
||||
});
|
||||
|
||||
const receivedFrame = once(client, "frame");
|
||||
client.connect(Buffer.from(peer.joinPayload));
|
||||
await waitFor(() => client?.connected === true, `TypeScript RUDP client to complete ${peer.name} fragmented handshake`);
|
||||
client.send("schema", Buffer.from(clientPayload));
|
||||
|
||||
const [frame] = await withTimeout(receivedFrame, 2_000, `${peer.name} fragmented RUDP response frame`);
|
||||
assert.equal(frame.channelId, "schema");
|
||||
assert.deepEqual(Buffer.from(frame.payload), Buffer.from(serverPayload));
|
||||
|
||||
const [exitCode] = await withTimeout(once(serverPeer.child, "exit"), 2_000, `${peer.name} RUDP peer exit after fragmented exchange`);
|
||||
assert.equal(exitCode, 0, serverPeer.stderr.join(""));
|
||||
} finally {
|
||||
if (client) {
|
||||
client.close();
|
||||
} else {
|
||||
clientSocket.close();
|
||||
}
|
||||
if (serverPeer.child.exitCode === null && !serverPeer.child.killed) {
|
||||
serverPeer.child.kill("SIGTERM");
|
||||
await once(serverPeer.child, "exit").catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function buildInteropPeers(): Promise<void> {
|
||||
await buildRustInteropPeer();
|
||||
await buildCSharpInteropPeer();
|
||||
|
|
@ -1305,10 +1370,16 @@ interface RunningPythonRudpPeer {
|
|||
stderr: string[];
|
||||
}
|
||||
|
||||
function spawnPythonRudpPeer(): RunningPythonRudpPeer {
|
||||
function spawnPythonRudpPeer(options: RudpServerSpawnOptions = {}): RunningPythonRudpPeer {
|
||||
const child = spawn(pythonCommand, ["-c", pythonRudpPeerScript], {
|
||||
cwd: cultcachePyRoot,
|
||||
env: { ...process.env, PYTHONPATH: cultcachePySrc },
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONPATH: cultcachePySrc,
|
||||
...(options.clientPayload ? { RUDP_CLIENT_PAYLOAD: options.clientPayload } : {}),
|
||||
...(options.serverPayload ? { RUDP_SERVER_PAYLOAD: options.serverPayload } : {}),
|
||||
...(options.maxFragmentBytes ? { RUDP_MAX_FRAGMENT_BYTES: String(options.maxFragmentBytes) } : {}),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const stderr: string[] = [];
|
||||
|
|
@ -1353,8 +1424,14 @@ function spawnPythonRudpPeer(): RunningPythonRudpPeer {
|
|||
return { child, ready, stderr };
|
||||
}
|
||||
|
||||
function spawnRustRudpServer(): RunningPythonRudpPeer {
|
||||
const child = spawn(rustBinaryPath, ["rudp-serve-once", "--bind-host", "127.0.0.1"], {
|
||||
function spawnRustRudpServer(options: RudpServerSpawnOptions = {}): RunningPythonRudpPeer {
|
||||
const child = spawn(rustBinaryPath, [
|
||||
"rudp-serve-once",
|
||||
"--bind-host", "127.0.0.1",
|
||||
...(options.clientPayload ? ["--client-payload", options.clientPayload] : []),
|
||||
...(options.serverPayload ? ["--server-payload", options.serverPayload] : []),
|
||||
...(options.maxFragmentBytes ? ["--max-fragment-bytes", String(options.maxFragmentBytes)] : []),
|
||||
], {
|
||||
cwd: cultnetRsRoot,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
|
@ -1403,8 +1480,15 @@ function spawnRustRudpServer(): RunningPythonRudpPeer {
|
|||
return { child, ready, stderr };
|
||||
}
|
||||
|
||||
function spawnCSharpRudpServer(): RunningPythonRudpPeer {
|
||||
const child = spawn(dotnetCommand, [csharpDllPath, "rudp-serve-once", "--bind-host", "127.0.0.1"], {
|
||||
function spawnCSharpRudpServer(options: RudpServerSpawnOptions = {}): RunningPythonRudpPeer {
|
||||
const child = spawn(dotnetCommand, [
|
||||
csharpDllPath,
|
||||
"rudp-serve-once",
|
||||
"--bind-host", "127.0.0.1",
|
||||
...(options.clientPayload ? ["--client-payload", options.clientPayload] : []),
|
||||
...(options.serverPayload ? ["--server-payload", options.serverPayload] : []),
|
||||
...(options.maxFragmentBytes ? ["--max-fragment-bytes", String(options.maxFragmentBytes)] : []),
|
||||
], {
|
||||
cwd: cultLibRoot,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
|
@ -1453,12 +1537,15 @@ function spawnCSharpRudpServer(): RunningPythonRudpPeer {
|
|||
return { child, ready, stderr };
|
||||
}
|
||||
|
||||
function spawnKotlinRudpServer(): RunningPythonRudpPeer {
|
||||
function spawnKotlinRudpServer(options: RudpServerSpawnOptions = {}): RunningPythonRudpPeer {
|
||||
const child = spawn(kotlinJavaCommand, [
|
||||
"-cp", kotlinClasspath,
|
||||
"org.gamecult.cultmesh.CultMeshKt",
|
||||
"rudp-serve-once",
|
||||
"--bind-host", "127.0.0.1",
|
||||
...(options.clientPayload ? ["--client-payload", options.clientPayload] : []),
|
||||
...(options.serverPayload ? ["--server-payload", options.serverPayload] : []),
|
||||
...(options.maxFragmentBytes ? ["--max-fragment-bytes", String(options.maxFragmentBytes)] : []),
|
||||
], {
|
||||
cwd: cultLibRoot,
|
||||
env: process.env,
|
||||
|
|
|
|||
|
|
@ -561,14 +561,31 @@ namespace GameCult.Networking
|
|||
public long LastSentAtMs { get; set; }
|
||||
}
|
||||
|
||||
private sealed class PendingOrderedFrame
|
||||
{
|
||||
public CultNetRudpDeliveredFrame Frame { get; set; } = new CultNetRudpDeliveredFrame();
|
||||
public uint NextSequence { get; set; }
|
||||
}
|
||||
|
||||
private sealed class FragmentBuffer
|
||||
{
|
||||
public string ChannelId { get; set; } = string.Empty;
|
||||
public bool Ordered { get; set; }
|
||||
public ushort FragmentCount { get; set; }
|
||||
public Dictionary<ushort, byte[]> Payloads { get; } = new Dictionary<ushort, byte[]>();
|
||||
public Dictionary<ushort, uint> Sequences { get; } = new Dictionary<ushort, uint>();
|
||||
}
|
||||
|
||||
private uint _nextSequence;
|
||||
private ushort _nextFragmentId = 1;
|
||||
private bool _connected;
|
||||
private uint? _highestReceivedSequence;
|
||||
private readonly HashSet<uint> _receivedSequences = new HashSet<uint>();
|
||||
private readonly Dictionary<uint, PendingReliablePacket> _pendingReliable = new Dictionary<uint, PendingReliablePacket>();
|
||||
private readonly Dictionary<string, uint> _orderedNextSequenceByChannel = new Dictionary<string, uint>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, SortedDictionary<uint, CultNetRudpDeliveredFrame>> _orderedBuffers =
|
||||
new Dictionary<string, SortedDictionary<uint, CultNetRudpDeliveredFrame>>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, SortedDictionary<uint, PendingOrderedFrame>> _orderedBuffers =
|
||||
new Dictionary<string, SortedDictionary<uint, PendingOrderedFrame>>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, FragmentBuffer> _fragmentBuffers = new Dictionary<string, FragmentBuffer>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a RUDP reliability session.
|
||||
|
|
@ -630,6 +647,14 @@ namespace GameCult.Networking
|
|||
/// Creates a data packet.
|
||||
/// </summary>
|
||||
public CultNetRudpPacket Send(string channelId, byte[] payload, CultNetRudpSendOptions? options = null)
|
||||
{
|
||||
return SendMany(channelId, payload, options).First();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates one or more data packets, fragmenting when requested.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CultNetRudpPacket> SendMany(string channelId, byte[] payload, CultNetRudpSendOptions? options = null, int? maxFragmentBytes = null)
|
||||
{
|
||||
if (!_connected)
|
||||
{
|
||||
|
|
@ -637,6 +662,47 @@ namespace GameCult.Networking
|
|||
}
|
||||
|
||||
options ??= new CultNetRudpSendOptions();
|
||||
payload ??= Array.Empty<byte>();
|
||||
if (maxFragmentBytes.HasValue && maxFragmentBytes.Value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxFragmentBytes), "RUDP maxFragmentBytes must be greater than zero.");
|
||||
}
|
||||
|
||||
if (maxFragmentBytes.HasValue && payload.Length > maxFragmentBytes.Value)
|
||||
{
|
||||
var fragmentCount = (payload.Length + maxFragmentBytes.Value - 1) / maxFragmentBytes.Value;
|
||||
if (fragmentCount > ushort.MaxValue)
|
||||
{
|
||||
throw new InvalidOperationException("RUDP payload requires more than 65535 fragments.");
|
||||
}
|
||||
|
||||
var fragmentId = AllocateFragmentId();
|
||||
var packets = new List<CultNetRudpPacket>();
|
||||
for (var index = 0; index < fragmentCount; index++)
|
||||
{
|
||||
var start = index * maxFragmentBytes.Value;
|
||||
var length = Math.Min(maxFragmentBytes.Value, payload.Length - start);
|
||||
var chunk = new byte[length];
|
||||
Array.Copy(payload, start, chunk, 0, length);
|
||||
var fragmentPacket = CreatePacket(
|
||||
CultNetRudpPacketType.Data,
|
||||
channelId,
|
||||
chunk,
|
||||
options.Reliable,
|
||||
options.Ordered,
|
||||
options.Sequenced,
|
||||
fragmentId,
|
||||
(ushort)index,
|
||||
(ushort)fragmentCount);
|
||||
if (fragmentPacket.Reliable)
|
||||
{
|
||||
TrackReliable(fragmentPacket, options.NowMs);
|
||||
}
|
||||
packets.Add(fragmentPacket);
|
||||
}
|
||||
return packets;
|
||||
}
|
||||
|
||||
var packet = CreatePacket(
|
||||
CultNetRudpPacketType.Data,
|
||||
channelId,
|
||||
|
|
@ -648,7 +714,7 @@ namespace GameCult.Networking
|
|||
{
|
||||
TrackReliable(packet, options.NowMs);
|
||||
}
|
||||
return packet;
|
||||
return new[] { packet };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -699,16 +765,17 @@ namespace GameCult.Networking
|
|||
return new CultNetRudpReceiveResult();
|
||||
}
|
||||
|
||||
var frame = new CultNetRudpDeliveredFrame
|
||||
var reassembled = Reassemble(packet);
|
||||
if (reassembled == null)
|
||||
{
|
||||
ChannelId = packet.ChannelId,
|
||||
Payload = packet.Payload ?? Array.Empty<byte>(),
|
||||
Sequence = packet.Sequence
|
||||
};
|
||||
return new CultNetRudpReceiveResult();
|
||||
}
|
||||
|
||||
return new CultNetRudpReceiveResult
|
||||
{
|
||||
Delivered = packet.Ordered ? DeliverOrdered(frame) : new[] { frame }
|
||||
Delivered = reassembled.Ordered
|
||||
? DeliverOrdered(reassembled.Frame, reassembled.NextSequence)
|
||||
: new[] { reassembled.Frame }
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -745,6 +812,20 @@ namespace GameCult.Networking
|
|||
bool reliable,
|
||||
bool ordered,
|
||||
bool sequenced)
|
||||
{
|
||||
return CreatePacket(packetType, channelId, payload, reliable, ordered, sequenced, 0, 0, 0);
|
||||
}
|
||||
|
||||
private CultNetRudpPacket CreatePacket(
|
||||
CultNetRudpPacketType packetType,
|
||||
string channelId,
|
||||
byte[] payload,
|
||||
bool reliable,
|
||||
bool ordered,
|
||||
bool sequenced,
|
||||
ushort fragmentId,
|
||||
ushort fragmentIndex,
|
||||
ushort fragmentCount)
|
||||
{
|
||||
var sequence = _nextSequence;
|
||||
_nextSequence = checked(_nextSequence + 1);
|
||||
|
|
@ -760,6 +841,9 @@ namespace GameCult.Networking
|
|||
Reliable = reliable,
|
||||
Ordered = ordered,
|
||||
Sequenced = sequenced,
|
||||
FragmentId = fragmentId,
|
||||
FragmentIndex = fragmentIndex,
|
||||
FragmentCount = fragmentCount,
|
||||
Payload = payload ?? Array.Empty<byte>()
|
||||
};
|
||||
}
|
||||
|
|
@ -809,11 +893,90 @@ namespace GameCult.Networking
|
|||
return (ack, ackMask);
|
||||
}
|
||||
|
||||
private IReadOnlyList<CultNetRudpDeliveredFrame> DeliverOrdered(CultNetRudpDeliveredFrame frame)
|
||||
private sealed class ReassembledFrame
|
||||
{
|
||||
public CultNetRudpDeliveredFrame Frame { get; set; } = new CultNetRudpDeliveredFrame();
|
||||
public bool Ordered { get; set; }
|
||||
public uint NextSequence { get; set; }
|
||||
}
|
||||
|
||||
private ReassembledFrame? Reassemble(CultNetRudpPacket packet)
|
||||
{
|
||||
if (packet.FragmentCount == 0)
|
||||
{
|
||||
return new ReassembledFrame
|
||||
{
|
||||
Frame = new CultNetRudpDeliveredFrame
|
||||
{
|
||||
ChannelId = packet.ChannelId,
|
||||
Payload = packet.Payload ?? Array.Empty<byte>(),
|
||||
Sequence = packet.Sequence
|
||||
},
|
||||
Ordered = packet.Ordered,
|
||||
NextSequence = packet.Sequence + 1
|
||||
};
|
||||
}
|
||||
if (packet.FragmentId == 0)
|
||||
{
|
||||
throw new InvalidOperationException("RUDP fragmented packet must have a non-zero fragment id.");
|
||||
}
|
||||
if (packet.FragmentIndex >= packet.FragmentCount)
|
||||
{
|
||||
throw new InvalidOperationException("RUDP fragment index must be lower than fragment count.");
|
||||
}
|
||||
|
||||
var key = $"{packet.ChannelId}\0{packet.FragmentId}";
|
||||
if (!_fragmentBuffers.TryGetValue(key, out var buffer))
|
||||
{
|
||||
buffer = new FragmentBuffer
|
||||
{
|
||||
ChannelId = packet.ChannelId,
|
||||
Ordered = packet.Ordered,
|
||||
FragmentCount = packet.FragmentCount
|
||||
};
|
||||
_fragmentBuffers[key] = buffer;
|
||||
}
|
||||
if (buffer.FragmentCount != packet.FragmentCount || buffer.Ordered != packet.Ordered)
|
||||
{
|
||||
throw new InvalidOperationException("RUDP fragment metadata changed within a fragment set.");
|
||||
}
|
||||
|
||||
buffer.Payloads[packet.FragmentIndex] = packet.Payload?.ToArray() ?? Array.Empty<byte>();
|
||||
buffer.Sequences[packet.FragmentIndex] = packet.Sequence;
|
||||
if (buffer.Payloads.Count < packet.FragmentCount)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var payloadLength = buffer.Payloads.Values.Sum(chunk => chunk.Length);
|
||||
var payload = new byte[payloadLength];
|
||||
var offset = 0;
|
||||
for (ushort index = 0; index < packet.FragmentCount; index++)
|
||||
{
|
||||
var chunk = buffer.Payloads[index];
|
||||
Array.Copy(chunk, 0, payload, offset, chunk.Length);
|
||||
offset += chunk.Length;
|
||||
}
|
||||
var sequences = buffer.Sequences.Values.ToArray();
|
||||
_fragmentBuffers.Remove(key);
|
||||
return new ReassembledFrame
|
||||
{
|
||||
Frame = new CultNetRudpDeliveredFrame
|
||||
{
|
||||
ChannelId = buffer.ChannelId,
|
||||
Payload = payload,
|
||||
Sequence = sequences.Min()
|
||||
},
|
||||
Ordered = buffer.Ordered,
|
||||
NextSequence = sequences.Max() + 1
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<CultNetRudpDeliveredFrame> DeliverOrdered(CultNetRudpDeliveredFrame frame, uint nextSequenceAfterFrame)
|
||||
{
|
||||
if (!_orderedNextSequenceByChannel.TryGetValue(frame.ChannelId, out var next))
|
||||
{
|
||||
_orderedNextSequenceByChannel[frame.ChannelId] = frame.Sequence + 1;
|
||||
_orderedNextSequenceByChannel[frame.ChannelId] = nextSequenceAfterFrame;
|
||||
return new[] { frame }.Concat(DrainOrdered(frame.ChannelId)).ToArray();
|
||||
}
|
||||
|
||||
|
|
@ -826,15 +989,15 @@ namespace GameCult.Networking
|
|||
{
|
||||
if (!_orderedBuffers.TryGetValue(frame.ChannelId, out var buffer))
|
||||
{
|
||||
buffer = new SortedDictionary<uint, CultNetRudpDeliveredFrame>();
|
||||
buffer = new SortedDictionary<uint, PendingOrderedFrame>();
|
||||
_orderedBuffers[frame.ChannelId] = buffer;
|
||||
}
|
||||
|
||||
buffer[frame.Sequence] = frame;
|
||||
buffer[frame.Sequence] = new PendingOrderedFrame { Frame = frame, NextSequence = nextSequenceAfterFrame };
|
||||
return Array.Empty<CultNetRudpDeliveredFrame>();
|
||||
}
|
||||
|
||||
_orderedNextSequenceByChannel[frame.ChannelId] = next + 1;
|
||||
_orderedNextSequenceByChannel[frame.ChannelId] = nextSequenceAfterFrame;
|
||||
return new[] { frame }.Concat(DrainOrdered(frame.ChannelId)).ToArray();
|
||||
}
|
||||
|
||||
|
|
@ -846,16 +1009,27 @@ namespace GameCult.Networking
|
|||
return delivered;
|
||||
}
|
||||
|
||||
while (_orderedNextSequenceByChannel.TryGetValue(channelId, out var next) && buffer.TryGetValue(next, out var frame))
|
||||
while (_orderedNextSequenceByChannel.TryGetValue(channelId, out var next) && buffer.TryGetValue(next, out var pending))
|
||||
{
|
||||
buffer.Remove(next);
|
||||
delivered.Add(frame);
|
||||
_orderedNextSequenceByChannel[channelId] = next + 1;
|
||||
delivered.Add(pending.Frame);
|
||||
_orderedNextSequenceByChannel[channelId] = pending.NextSequence;
|
||||
}
|
||||
|
||||
return delivered;
|
||||
}
|
||||
|
||||
private ushort AllocateFragmentId()
|
||||
{
|
||||
var fragmentId = _nextFragmentId;
|
||||
_nextFragmentId++;
|
||||
if (_nextFragmentId == 0)
|
||||
{
|
||||
_nextFragmentId = 1;
|
||||
}
|
||||
return fragmentId;
|
||||
}
|
||||
|
||||
private void RequireConnection(CultNetRudpPacket packet)
|
||||
{
|
||||
if (packet.ConnectionId != ConnectionId)
|
||||
|
|
@ -893,6 +1067,7 @@ namespace GameCult.Networking
|
|||
private readonly Socket _socket;
|
||||
private readonly CultNetRudpSession _session;
|
||||
private readonly CultNetRudpSocketMode _mode;
|
||||
private readonly int? _maxFragmentBytes;
|
||||
private readonly CultNetTransportStats _stats = new CultNetTransportStats();
|
||||
private readonly Queue<CultNetTransportFrame> _deliveredFrames = new Queue<CultNetTransportFrame>();
|
||||
private EndPoint? _remoteEndPoint;
|
||||
|
|
@ -908,6 +1083,7 @@ namespace GameCult.Networking
|
|||
_socket = options.Socket ?? throw new ArgumentNullException(nameof(options.Socket));
|
||||
_mode = options.Mode;
|
||||
_remoteEndPoint = options.RemoteEndPoint;
|
||||
_maxFragmentBytes = options.MaxFragmentBytes;
|
||||
_session = new CultNetRudpSession(new CultNetRudpSessionOptions
|
||||
{
|
||||
ConnectionId = options.ConnectionId,
|
||||
|
|
@ -961,8 +1137,10 @@ namespace GameCult.Networking
|
|||
/// </summary>
|
||||
public void Send(string channelId, byte[] payload)
|
||||
{
|
||||
var packet = _session.Send(channelId, payload, ChannelSendOptions(channelId));
|
||||
SendPacket(packet);
|
||||
foreach (var packet in _session.SendMany(channelId, payload, ChannelSendOptions(channelId), _maxFragmentBytes))
|
||||
{
|
||||
SendPacket(packet);
|
||||
}
|
||||
_stats.FramesSent++;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -583,6 +583,15 @@ static void RudpServeOnce(Dictionary<string, string> options)
|
|||
var bindPort = options.TryGetValue("bind-port", out var configuredBindPort)
|
||||
? int.Parse(configuredBindPort, CultureInfo.InvariantCulture)
|
||||
: 0;
|
||||
var expectedClientPayload = Ascii(options.TryGetValue("client-payload", out var configuredClientPayload)
|
||||
? configuredClientPayload
|
||||
: "ts-csharp-client-state");
|
||||
var serverPayload = Ascii(options.TryGetValue("server-payload", out var configuredServerPayload)
|
||||
? configuredServerPayload
|
||||
: "csharp-server-state");
|
||||
var maxFragmentBytes = options.TryGetValue("max-fragment-bytes", out var configuredMaxFragmentBytes)
|
||||
? int.Parse(configuredMaxFragmentBytes, CultureInfo.InvariantCulture)
|
||||
: (int?)null;
|
||||
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
socket.Bind(new IPEndPoint(IPAddress.Parse(bindHost), bindPort));
|
||||
|
|
@ -595,7 +604,8 @@ static void RudpServeOnce(Dictionary<string, string> options)
|
|||
Mode = CultNetRudpSocketMode.Server,
|
||||
ConnectionId = 0x33557799,
|
||||
InitialSequence = 100,
|
||||
ResendDelayMs = 25
|
||||
ResendDelayMs = 25,
|
||||
MaxFragmentBytes = maxFragmentBytes
|
||||
});
|
||||
|
||||
WriteJsonLine(new
|
||||
|
|
@ -610,8 +620,8 @@ static void RudpServeOnce(Dictionary<string, string> options)
|
|||
var frame = transport.ReceiveOnce();
|
||||
if (frame != null)
|
||||
{
|
||||
RequireRudpFrame(frame, "schema", "ts-csharp-client-state");
|
||||
transport.Send("schema", Ascii("csharp-server-state"));
|
||||
RequireRudpFrameBytes(frame, "schema", expectedClientPayload);
|
||||
transport.Send("schema", serverPayload);
|
||||
PollRudpAfterSend(transport, TimeSpan.FromMilliseconds(250));
|
||||
WriteJsonLine(new { status = "ok" });
|
||||
return;
|
||||
|
|
@ -680,9 +690,14 @@ static void PollRudpAfterSend(CultNetRudpSocketTransportConnection transport, Ti
|
|||
}
|
||||
|
||||
static void RequireRudpFrame(CultNetTransportFrame frame, string expectedChannelId, string expectedPayload)
|
||||
{
|
||||
RequireRudpFrameBytes(frame, expectedChannelId, Ascii(expectedPayload));
|
||||
}
|
||||
|
||||
static void RequireRudpFrameBytes(CultNetTransportFrame frame, string expectedChannelId, byte[] expectedPayload)
|
||||
{
|
||||
if (!string.Equals(frame.ChannelId, expectedChannelId, StringComparison.Ordinal)
|
||||
|| !frame.Payload.SequenceEqual(Ascii(expectedPayload)))
|
||||
|| !frame.Payload.SequenceEqual(expectedPayload))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Unexpected RUDP frame: channel={frame.ChannelId}, payload={Convert.ToHexString(frame.Payload)}.");
|
||||
|
|
|
|||
|
|
@ -494,6 +494,42 @@ namespace GameCult.Networking.Tests
|
|||
Is.EqualTo(new[] { "second", "third" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RudpSession_FragmentsAndReassemblesReliableOrderedPayloads()
|
||||
{
|
||||
var sender = new CultNetRudpSession(new CultNetRudpSessionOptions
|
||||
{
|
||||
ConnectionId = 456,
|
||||
InitialSequence = 1
|
||||
});
|
||||
var receiver = new CultNetRudpSession(new CultNetRudpSessionOptions
|
||||
{
|
||||
ConnectionId = 456,
|
||||
InitialSequence = 100
|
||||
});
|
||||
sender.Receive(new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Accept, ConnectionId = 456, Sequence = 90, ChannelId = "control" });
|
||||
receiver.Receive(new CultNetRudpPacket { PacketType = CultNetRudpPacketType.Accept, ConnectionId = 456, Sequence = 91, ChannelId = "control" });
|
||||
|
||||
var packets = sender.SendMany(
|
||||
"schema",
|
||||
Encoding.UTF8.GetBytes("fragment-me-please"),
|
||||
new CultNetRudpSendOptions { Reliable = true, Ordered = true, NowMs = 10 },
|
||||
maxFragmentBytes: 5).ToArray();
|
||||
|
||||
Assert.That(packets, Has.Length.EqualTo(4));
|
||||
Assert.That(packets.Select(packet => packet.FragmentCount).ToArray(), Is.EqualTo(new ushort[] { 4, 4, 4, 4 }));
|
||||
Assert.That(packets.Select(packet => packet.FragmentIndex).ToArray(), Is.EqualTo(new ushort[] { 0, 1, 2, 3 }));
|
||||
Assert.That(packets.All(packet => packet.FragmentId == packets[0].FragmentId), Is.True);
|
||||
|
||||
Assert.That(receiver.Receive(packets[0]).Delivered, Is.Empty);
|
||||
Assert.That(receiver.Receive(packets[1]).Delivered, Is.Empty);
|
||||
Assert.That(receiver.Receive(packets[2]).Delivered, Is.Empty);
|
||||
var delivered = receiver.Receive(packets[3]).Delivered.ToArray();
|
||||
Assert.That(delivered, Has.Length.EqualTo(1));
|
||||
Assert.That(Encoding.UTF8.GetString(delivered[0].Payload), Is.EqualTo("fragment-me-please"));
|
||||
Assert.That(delivered[0].Sequence, Is.EqualTo(packets[0].Sequence));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RudpSocketTransport_HandshakesAndCarriesReliableOrderedSchemaFrames()
|
||||
{
|
||||
|
|
@ -540,6 +576,46 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(server.Stats.FramesReceived, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RudpSocketTransport_CarriesFragmentedReliableOrderedSchemaFrames()
|
||||
{
|
||||
using var serverSocket = BindUdpSocket();
|
||||
using var clientSocket = BindUdpSocket();
|
||||
var serverEndPoint = serverSocket.LocalEndPoint!;
|
||||
const uint connectionId = 0x10203041;
|
||||
using var server = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
|
||||
{
|
||||
RuntimeId = "csharp-rudp-fragment-server",
|
||||
Socket = serverSocket,
|
||||
Mode = CultNetRudpSocketMode.Server,
|
||||
ConnectionId = connectionId,
|
||||
InitialSequence = 100,
|
||||
ResendDelayMs = 25,
|
||||
MaxFragmentBytes = 8
|
||||
});
|
||||
using var client = new CultNetRudpSocketTransportConnection(new CultNetRudpSocketTransportOptions
|
||||
{
|
||||
RuntimeId = "csharp-rudp-fragment-client",
|
||||
Socket = clientSocket,
|
||||
Mode = CultNetRudpSocketMode.Client,
|
||||
RemoteEndPoint = serverEndPoint,
|
||||
ConnectionId = connectionId,
|
||||
InitialSequence = 1,
|
||||
ResendDelayMs = 25,
|
||||
MaxFragmentBytes = 8
|
||||
});
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes("this-schema-frame-is-larger-than-one-rudp-fragment");
|
||||
client.Connect(Encoding.UTF8.GetBytes("join"));
|
||||
PumpRudpHandshake(client, server);
|
||||
client.Send("schema", payload);
|
||||
var serverFrame = ReceiveRudpFrame(server);
|
||||
Assert.That(serverFrame.ChannelId, Is.EqualTo("schema"));
|
||||
Assert.That(serverFrame.Payload, Is.EqualTo(payload));
|
||||
Assert.That(client.Stats.FramesSent, Is.EqualTo(1));
|
||||
Assert.That(server.Stats.FramesReceived, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CultNetSchemaMessageSerialization_RoundTrips_RawSnapshotResponse()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue