Sounds: - Add chat sounds (global, map, DM) converted from game client ogg to caf - Audio category .ambient (mixes with other audio, respects silent switch) - Per-buffer sound toggles and background buffer sound option in Settings Messages: - Card-style message rows with .regularMaterial background, bumped fonts (content .title3, sender .headline) - Context menu and VoiceOver actions: DM and Show Profile on other players' messages - Generate local UUIDs for message identity (server ID field is not unique, caused ForEach to collapse messages) Own-message detection: - Capture player entity ID from LoadInstance packet (Key 1) on game join - Hide DM/Profile actions on own messages via senderId comparison Crash fix: - Guard all UInt64 timestamp subtractions against underflow (retransmitPending, ping, disconnect timeout)
788 lines
26 KiB
Swift
788 lines
26 KiB
Swift
import Foundation
|
|
import Network
|
|
import os
|
|
|
|
private let lnlLog = Logger(subsystem: "dev.smoll.KDChat", category: "LiteNetLib")
|
|
|
|
// MARK: - Constants
|
|
|
|
private enum LNL {
|
|
static let protocolId: Int32 = 13
|
|
static let maxSequence: UInt16 = 32768
|
|
static let halfMaxSequence: Int = 16384
|
|
static let windowSize: Int = 64
|
|
static let channeledHeaderSize: Int = 4
|
|
static let reliableOrderedChannelId: UInt8 = 2 // channel 0, ReliableOrdered (0*4+2)
|
|
static let pingIntervalMs: Int = 1000
|
|
static let disconnectTimeoutMs: Int = 5000
|
|
static let resendBaseMs: Double = 25
|
|
static let resendRttMultiplier: Double = 2.1
|
|
static let updateIntervalMs: Int = 50
|
|
|
|
// .NET epoch offset: ticks between 0001-01-01 and 1970-01-01
|
|
static let dotNetEpochOffset: Double = 62_135_596_800
|
|
|
|
static var dotNetTicks: Int64 {
|
|
Int64((Date.now.timeIntervalSince1970 + dotNetEpochOffset) * 10_000_000)
|
|
}
|
|
}
|
|
|
|
// MARK: - Packet Property
|
|
|
|
private enum PacketProperty: UInt8 {
|
|
case unreliable = 0
|
|
case channeled = 1
|
|
case ack = 2
|
|
case ping = 3
|
|
case pong = 4
|
|
case connectRequest = 5
|
|
case connectAccept = 6
|
|
case disconnect = 7
|
|
case unconnectedMessage = 8
|
|
case mtuCheck = 9
|
|
case mtuOk = 10
|
|
case broadcast = 11
|
|
case merged = 12
|
|
case shutdownOk = 13
|
|
case peerNotFound = 14
|
|
case invalidProtocol = 15
|
|
case natMessage = 16
|
|
case empty = 17
|
|
}
|
|
|
|
// MARK: - Connection State
|
|
|
|
private enum ConnectionState {
|
|
case disconnected
|
|
case connecting
|
|
case connected
|
|
case shutdownRequested
|
|
}
|
|
|
|
// MARK: - Pending Packet
|
|
|
|
private struct PendingPacket {
|
|
var data: Data
|
|
var timestamp: UInt64
|
|
var isSent: Bool
|
|
}
|
|
|
|
// MARK: - Fragment Reassembly
|
|
|
|
private struct IncomingFragment {
|
|
let totalParts: Int
|
|
let channelId: UInt8
|
|
var parts: [Data?]
|
|
var receivedCount: Int
|
|
|
|
init(totalParts: Int, channelId: UInt8) {
|
|
self.totalParts = totalParts
|
|
self.channelId = channelId
|
|
self.parts = Array(repeating: nil, count: totalParts)
|
|
self.receivedCount = 0
|
|
}
|
|
}
|
|
|
|
// MARK: - Client Event
|
|
|
|
extension LNLClient {
|
|
enum Event: Sendable {
|
|
case connected
|
|
case disconnected(String?)
|
|
case received(Data)
|
|
}
|
|
}
|
|
|
|
// MARK: - LNLClient
|
|
|
|
/// Minimal LiteNetLib client supporting ReliableOrdered delivery over UDP.
|
|
actor LNLClient {
|
|
// NWConnection requires a DispatchQueue for its callback-based API
|
|
private let nwQueue = DispatchQueue(label: "com.kdchat.litenetlib.nw")
|
|
private var connection: NWConnection?
|
|
private var updateTask: Task<Void, Never>?
|
|
|
|
// Event stream for consumers
|
|
let events: AsyncStream<Event>
|
|
private let eventContinuation: AsyncStream<Event>.Continuation
|
|
|
|
// Connection state
|
|
private var state: ConnectionState = .disconnected
|
|
private var connectionTime: Int64 = 0
|
|
private var connectionNumber: UInt8 = 0
|
|
private var remotePeerId: Int32 = 0
|
|
private var localPeerId: Int32 = Int32.random(in: 1...Int32.max)
|
|
private var connectKey: String = ""
|
|
private var connectAttempts: Int = 0
|
|
private let maxConnectAttempts: Int = 10
|
|
private var lastConnectSendTime: UInt64 = 0
|
|
|
|
// Reliable ordered channel - send side
|
|
private var localSequence: UInt16 = 0
|
|
private var localWindowStart: UInt16 = 0
|
|
private var pendingPackets: [PendingPacket?]
|
|
private var outgoingQueue: [Data] = []
|
|
|
|
// Reliable ordered channel - receive side
|
|
private var remoteSequence: UInt16 = 0
|
|
private var remoteWindowStart: UInt16 = 0
|
|
private var receivedPackets: [Data?]
|
|
private var ackBitfield: [UInt8]
|
|
private var mustSendAcks: Bool = false
|
|
|
|
// Fragment reassembly
|
|
private var incomingFragments: [UInt16: IncomingFragment] = [:]
|
|
|
|
// Ping / keepalive
|
|
private var pingSequence: UInt16 = 0
|
|
private var lastPingSentTime: UInt64 = 0
|
|
private var lastPacketReceivedTime: UInt64 = 0
|
|
private var avgRtt: Double = 50
|
|
private var rttSum: Double = 0
|
|
private var rttCount: Int = 0
|
|
private var pingSentTimestamp: UInt64 = 0
|
|
|
|
// Resend delay (computed from RTT)
|
|
private var resendDelay: Double { LNL.resendBaseMs + avgRtt * LNL.resendRttMultiplier }
|
|
|
|
// Send stats
|
|
private var totalBytesSent: Int = 0
|
|
private var totalPacketsSent: Int = 0
|
|
|
|
init() {
|
|
let ws = LNL.windowSize
|
|
self.pendingPackets = Array(repeating: nil, count: ws)
|
|
self.receivedPackets = Array(repeating: nil, count: ws)
|
|
let ackSize = (ws - 1) / 8 + 2
|
|
self.ackBitfield = Array(repeating: 0, count: ackSize)
|
|
|
|
let (stream, continuation) = AsyncStream.makeStream(of: Event.self)
|
|
self.events = stream
|
|
self.eventContinuation = continuation
|
|
}
|
|
|
|
deinit {
|
|
eventContinuation.finish()
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
func connect(host: String, port: UInt16, key: String) {
|
|
guard state == .disconnected else {
|
|
lnlLog.warning("connect() called but state is not disconnected")
|
|
return
|
|
}
|
|
|
|
lnlLog.info("Connecting to \(host):\(port) with key '\(key)'")
|
|
connectKey = key
|
|
connectionTime = LNL.dotNetTicks
|
|
connectAttempts = 0
|
|
state = .connecting
|
|
|
|
let nwHost = NWEndpoint.Host(host)
|
|
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
|
|
lnlLog.error("Invalid port: \(port)")
|
|
return
|
|
}
|
|
let conn = NWConnection(host: nwHost, port: nwPort, using: .udp)
|
|
connection = conn
|
|
|
|
conn.stateUpdateHandler = { [weak self] newState in
|
|
Task { [weak self] in await self?.handleNWState(newState) }
|
|
}
|
|
|
|
conn.pathUpdateHandler = { path in
|
|
lnlLog.info("Path status: \(String(describing: path.status)), isExpensive: \(path.isExpensive), isConstrained: \(path.isConstrained)")
|
|
}
|
|
|
|
conn.start(queue: nwQueue)
|
|
}
|
|
|
|
func send(data: Data) {
|
|
guard state == .connected else { return }
|
|
outgoingQueue.append(data)
|
|
}
|
|
|
|
func disconnect() {
|
|
performDisconnect(reason: "Client disconnected")
|
|
}
|
|
|
|
// MARK: - NWConnection State
|
|
|
|
private func handleNWState(_ newState: NWConnection.State) {
|
|
lnlLog.info("NWConnection state: \(String(describing: newState))")
|
|
switch newState {
|
|
case .ready:
|
|
if let path = connection?.currentPath {
|
|
lnlLog.info("UDP socket ready. Local: \(String(describing: path.localEndpoint)), remote: \(String(describing: path.remoteEndpoint))")
|
|
for iface in path.availableInterfaces {
|
|
lnlLog.info("Interface: \(iface.name) type: \(String(describing: iface.type))")
|
|
}
|
|
}
|
|
startReceiving()
|
|
startUpdateLoop()
|
|
sendConnectRequest()
|
|
case .failed(let error):
|
|
lnlLog.error("NWConnection failed: \(error.localizedDescription)")
|
|
performDisconnect(reason: "Connection failed: \(error.localizedDescription)")
|
|
case .cancelled:
|
|
lnlLog.info("NWConnection cancelled")
|
|
performDisconnect(reason: nil)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Receive Loop
|
|
|
|
private func startReceiving() {
|
|
lnlLog.debug("Waiting for incoming UDP data...")
|
|
connection?.receiveMessage { [weak self] data, _, _, error in
|
|
Task { [weak self] in await self?.handleReceiveCallback(data: data, error: error) }
|
|
}
|
|
}
|
|
|
|
private func handleReceiveCallback(data: Data?, error: NWError?) {
|
|
if let error {
|
|
lnlLog.error("Receive error: \(error.localizedDescription)")
|
|
}
|
|
if let data, !data.isEmpty {
|
|
lnlLog.info("Received \(data.count) bytes: \(data.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " "))\(data.count > 20 ? "..." : "")")
|
|
lastPacketReceivedTime = currentMs()
|
|
handleRawPacket(data)
|
|
} else if data == nil && error == nil {
|
|
lnlLog.warning("receiveMessage returned nil data with no error")
|
|
}
|
|
if error == nil && state != .disconnected {
|
|
startReceiving()
|
|
} else if state == .disconnected {
|
|
lnlLog.info("Not re-registering receive (disconnected)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Update Loop
|
|
|
|
private func startUpdateLoop() {
|
|
updateTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
await self?.update()
|
|
try? await Task.sleep(for: .milliseconds(LNL.updateIntervalMs))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func update() {
|
|
let now = currentMs()
|
|
|
|
switch state {
|
|
case .connecting:
|
|
if now >= lastConnectSendTime, now - lastConnectSendTime > 500 {
|
|
connectAttempts += 1
|
|
lnlLog.info("ConnectRequest attempt \(self.connectAttempts)/\(self.maxConnectAttempts)")
|
|
if connectAttempts > maxConnectAttempts {
|
|
lnlLog.error("Connection timed out after \(self.maxConnectAttempts) attempts")
|
|
performDisconnect(reason: "Connection timed out")
|
|
return
|
|
}
|
|
sendConnectRequest()
|
|
}
|
|
|
|
case .connected:
|
|
if now >= lastPacketReceivedTime, now - lastPacketReceivedTime > UInt64(LNL.disconnectTimeoutMs) {
|
|
performDisconnect(reason: "Connection timed out")
|
|
return
|
|
}
|
|
flushOutgoingQueue()
|
|
retransmitPending(now: now)
|
|
if mustSendAcks {
|
|
sendAck()
|
|
mustSendAcks = false
|
|
}
|
|
if now >= lastPingSentTime, now - lastPingSentTime > UInt64(LNL.pingIntervalMs) {
|
|
sendPing()
|
|
}
|
|
|
|
case .shutdownRequested, .disconnected:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Packet Handling
|
|
|
|
private func handleRawPacket(_ data: Data) {
|
|
guard !data.isEmpty else { return }
|
|
let byte0 = data[data.startIndex]
|
|
let property = byte0 & 0x1f
|
|
let connNum = (byte0 >> 5) & 0x03
|
|
|
|
guard let prop = PacketProperty(rawValue: property) else {
|
|
lnlLog.debug("Unknown packet property: \(property)")
|
|
return
|
|
}
|
|
lnlLog.debug("Received packet: \(String(describing: prop)), \(data.count) bytes")
|
|
|
|
if state == .connected && prop != .connectAccept && prop != .connectRequest {
|
|
if connNum != connectionNumber { return }
|
|
}
|
|
|
|
switch prop {
|
|
case .connectAccept: handleConnectAccept(data)
|
|
case .channeled: handleChanneled(data)
|
|
case .ack: handleAck(data)
|
|
case .ping: handlePing(data)
|
|
case .pong: handlePong(data)
|
|
case .disconnect: handleServerDisconnect(data)
|
|
case .merged: handleMerged(data)
|
|
case .shutdownOk: performDisconnect(reason: nil)
|
|
default: break
|
|
}
|
|
}
|
|
|
|
// MARK: - Connect Handshake
|
|
|
|
private func sendConnectRequest() {
|
|
lnlLog.debug("Sending ConnectRequest")
|
|
var w = DataWriter()
|
|
|
|
w.writeByte(PacketProperty.connectRequest.rawValue)
|
|
w.writeInt32LE(LNL.protocolId)
|
|
w.writeInt64LE(connectionTime)
|
|
w.writeInt32LE(localPeerId)
|
|
|
|
// Dummy IPv4 sockaddr (16 bytes, .NET SocketAddress format)
|
|
w.writeByte(16)
|
|
var addr = Data(count: 16)
|
|
addr[0] = 2 // AF_INET
|
|
w.writeData(addr)
|
|
|
|
// Connection key: ushort16 LE (byteCount + 1) + UTF-8
|
|
let keyBytes = Array(connectKey.utf8)
|
|
w.writeUInt16LE(UInt16(keyBytes.count + 1))
|
|
w.writeData(Data(keyBytes))
|
|
|
|
lastConnectSendTime = currentMs()
|
|
let packet = w.data
|
|
lnlLog.info("ConnectRequest packet (\(packet.count) bytes): \(packet.map { String(format: "%02x", $0) }.joined(separator: " "))")
|
|
sendRaw(packet)
|
|
}
|
|
|
|
private func handleConnectAccept(_ data: Data) {
|
|
guard state == .connecting else {
|
|
lnlLog.warning("ConnectAccept received but not in connecting state")
|
|
return
|
|
}
|
|
guard data.count >= 15 else {
|
|
lnlLog.error("ConnectAccept too short: \(data.count) bytes")
|
|
return
|
|
}
|
|
|
|
var r = DataReader(data: data, offset: 1)
|
|
guard let echoedTime = r.readInt64LE(),
|
|
echoedTime == connectionTime else {
|
|
lnlLog.error("ConnectAccept connectionTime mismatch")
|
|
return
|
|
}
|
|
|
|
connectionNumber = r.readByte() ?? 0
|
|
_ = r.readByte() // isReused
|
|
remotePeerId = r.readInt32LE() ?? 0
|
|
|
|
lnlLog.info("Connected! connNum=\(self.connectionNumber), remotePeerId=\(self.remotePeerId)")
|
|
state = .connected
|
|
lastPacketReceivedTime = currentMs()
|
|
lastPingSentTime = currentMs()
|
|
|
|
eventContinuation.yield(.connected)
|
|
}
|
|
|
|
// MARK: - Channeled (ReliableOrdered)
|
|
|
|
private func handleChanneled(_ data: Data) {
|
|
guard data.count >= LNL.channeledHeaderSize else { return }
|
|
|
|
var r = DataReader(data: data, offset: 1)
|
|
guard let seq = r.readUInt16LE() else { return }
|
|
guard let channelId = r.readByte() else { return }
|
|
guard channelId == LNL.reliableOrderedChannelId else { return }
|
|
|
|
// Pass the full packet (including fragment header if present) through
|
|
// the reliable ordered channel for sequencing and ACKs.
|
|
// Delivery/reassembly happens in deliverOrderedPacket.
|
|
let fullPayload = Data(data[data.startIndex ..< data.endIndex])
|
|
processReliableOrdered(sequence: seq, data: fullPayload)
|
|
}
|
|
|
|
/// Called when a reliable ordered packet is delivered in-sequence.
|
|
/// Handles fragment reassembly before yielding to the consumer.
|
|
private func deliverOrderedPacket(_ data: Data) {
|
|
guard data.count >= LNL.channeledHeaderSize else { return }
|
|
let isFragmented = (data[data.startIndex] & 0x80) != 0
|
|
|
|
if isFragmented {
|
|
guard data.count >= 10 else { return }
|
|
var r = DataReader(data: data, offset: 4) // skip channeled header
|
|
guard let fragmentId = r.readUInt16LE() else { return }
|
|
guard let fragmentPart = r.readUInt16LE() else { return }
|
|
guard let fragmentsTotal = r.readUInt16LE() else { return }
|
|
guard fragmentsTotal > 0, fragmentPart < fragmentsTotal else { return }
|
|
|
|
let channelId = data[data.startIndex + 3]
|
|
let fragmentData = Data(data[data.startIndex + 10 ..< data.endIndex])
|
|
|
|
if incomingFragments[fragmentId] == nil {
|
|
incomingFragments[fragmentId] = IncomingFragment(
|
|
totalParts: Int(fragmentsTotal), channelId: channelId
|
|
)
|
|
}
|
|
guard var fragment = incomingFragments[fragmentId],
|
|
fragment.channelId == channelId,
|
|
Int(fragmentPart) < fragment.totalParts,
|
|
fragment.parts[Int(fragmentPart)] == nil else { return }
|
|
|
|
fragment.parts[Int(fragmentPart)] = fragmentData
|
|
fragment.receivedCount += 1
|
|
incomingFragments[fragmentId] = fragment
|
|
|
|
if fragment.receivedCount == fragment.totalParts {
|
|
var assembled = Data()
|
|
for part in fragment.parts {
|
|
if let part { assembled.append(part) }
|
|
}
|
|
incomingFragments.removeValue(forKey: fragmentId)
|
|
lnlLog.info("Reassembled fragmented packet: \(fragment.totalParts) parts, \(assembled.count) bytes")
|
|
eventContinuation.yield(.received(assembled))
|
|
}
|
|
} else {
|
|
let userData = Data(data[data.startIndex + LNL.channeledHeaderSize ..< data.endIndex])
|
|
eventContinuation.yield(.received(userData))
|
|
}
|
|
}
|
|
|
|
private func processReliableOrdered(sequence seq: UInt16, data: Data) {
|
|
// Reject already-delivered sequences
|
|
let relateToDelivered = relativeSequence(seq, to: remoteSequence)
|
|
if relateToDelivered < 0 { return }
|
|
|
|
let relate = relativeSequence(seq, to: remoteWindowStart)
|
|
|
|
if relate >= LNL.windowSize {
|
|
let diff = relate - LNL.windowSize + 1
|
|
for _ in 0..<diff {
|
|
let idx = Int(remoteWindowStart) % LNL.windowSize
|
|
receivedPackets[idx] = nil
|
|
let bitIndex = Int(remoteWindowStart) % LNL.windowSize
|
|
ackBitfield[bitIndex / 8] &= ~(1 << (bitIndex % 8))
|
|
remoteWindowStart = (remoteWindowStart &+ 1) % LNL.maxSequence
|
|
}
|
|
}
|
|
|
|
let ackIdx = Int(seq) % LNL.windowSize
|
|
ackBitfield[ackIdx / 8] |= (1 << (ackIdx % 8))
|
|
mustSendAcks = true
|
|
|
|
if seq == remoteSequence {
|
|
deliverOrderedPacket(data)
|
|
remoteSequence = (remoteSequence &+ 1) % LNL.maxSequence
|
|
lnlLog.debug("Delivered seq \(seq), next expected: \(self.remoteSequence)")
|
|
|
|
while true {
|
|
let idx = Int(remoteSequence) % LNL.windowSize
|
|
guard let buffered = receivedPackets[idx] else { break }
|
|
receivedPackets[idx] = nil
|
|
deliverOrderedPacket(buffered)
|
|
remoteSequence = (remoteSequence &+ 1) % LNL.maxSequence
|
|
}
|
|
} else {
|
|
let idx = Int(seq) % LNL.windowSize
|
|
receivedPackets[idx] = data
|
|
lnlLog.warning("Buffered out-of-order seq \(seq), expecting \(self.remoteSequence)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Send Reliable
|
|
|
|
private func flushOutgoingQueue() {
|
|
while !outgoingQueue.isEmpty {
|
|
let windowUsed = relativeSequence(localSequence, to: localWindowStart)
|
|
if windowUsed >= LNL.windowSize { break }
|
|
|
|
let userData = outgoingQueue.removeFirst()
|
|
let packet = buildChanneledPacket(sequence: localSequence, data: userData)
|
|
|
|
let idx = Int(localSequence) % LNL.windowSize
|
|
pendingPackets[idx] = PendingPacket(data: packet, timestamp: currentMs(), isSent: true)
|
|
localSequence = (localSequence &+ 1) % LNL.maxSequence
|
|
|
|
sendRaw(packet)
|
|
}
|
|
}
|
|
|
|
private func retransmitPending(now: UInt64) {
|
|
var seq = localWindowStart
|
|
while seq != localSequence {
|
|
let idx = Int(seq) % LNL.windowSize
|
|
if let pending = pendingPackets[idx] {
|
|
if now >= pending.timestamp, Double(now - pending.timestamp) >= resendDelay {
|
|
pendingPackets[idx]?.timestamp = now
|
|
sendRaw(pending.data)
|
|
}
|
|
}
|
|
seq = (seq &+ 1) % LNL.maxSequence
|
|
}
|
|
}
|
|
|
|
private func buildChanneledPacket(sequence: UInt16, data: Data) -> Data {
|
|
var w = DataWriter()
|
|
w.writeByte((connectionNumber << 5) | PacketProperty.channeled.rawValue)
|
|
w.writeUInt16LE(sequence)
|
|
w.writeByte(LNL.reliableOrderedChannelId)
|
|
w.writeData(data)
|
|
return w.data
|
|
}
|
|
|
|
// MARK: - ACK
|
|
|
|
private func sendAck() {
|
|
var w = DataWriter()
|
|
w.writeByte((connectionNumber << 5) | PacketProperty.ack.rawValue)
|
|
w.writeUInt16LE(remoteWindowStart)
|
|
w.writeByte(LNL.reliableOrderedChannelId)
|
|
w.writeData(Data(ackBitfield))
|
|
sendRaw(w.data)
|
|
}
|
|
|
|
private func handleAck(_ data: Data) {
|
|
guard data.count >= LNL.channeledHeaderSize else { return }
|
|
|
|
var r = DataReader(data: data, offset: 1)
|
|
guard let ackWindowStart = r.readUInt16LE() else { return }
|
|
guard let channelId = r.readByte(), channelId == LNL.reliableOrderedChannelId else { return }
|
|
|
|
let bitfieldStart = data.startIndex + LNL.channeledHeaderSize
|
|
let bitfieldData = data[bitfieldStart...]
|
|
|
|
var seq = localWindowStart
|
|
while seq != localSequence {
|
|
let relToAckStart = relativeSequence(seq, to: ackWindowStart)
|
|
if relToAckStart < 0 { break }
|
|
if relToAckStart >= LNL.windowSize { break }
|
|
|
|
let byteIdx = relToAckStart / 8
|
|
let bitIdx = relToAckStart % 8
|
|
if byteIdx < bitfieldData.count {
|
|
let acked = (bitfieldData[bitfieldStart + byteIdx] & (1 << bitIdx)) != 0
|
|
if acked {
|
|
let idx = Int(seq) % LNL.windowSize
|
|
pendingPackets[idx] = nil
|
|
if seq == localWindowStart {
|
|
localWindowStart = (localWindowStart &+ 1) % LNL.maxSequence
|
|
}
|
|
}
|
|
}
|
|
seq = (seq &+ 1) % LNL.maxSequence
|
|
}
|
|
|
|
while localWindowStart != localSequence {
|
|
let idx = Int(localWindowStart) % LNL.windowSize
|
|
if pendingPackets[idx] != nil { break }
|
|
localWindowStart = (localWindowStart &+ 1) % LNL.maxSequence
|
|
}
|
|
}
|
|
|
|
// MARK: - Ping / Pong
|
|
|
|
private func sendPing() {
|
|
var w = DataWriter()
|
|
w.writeByte((connectionNumber << 5) | PacketProperty.ping.rawValue)
|
|
w.writeUInt16LE(pingSequence)
|
|
pingSequence = (pingSequence &+ 1) % LNL.maxSequence
|
|
pingSentTimestamp = currentMs()
|
|
lastPingSentTime = pingSentTimestamp
|
|
sendRaw(w.data)
|
|
}
|
|
|
|
private func handlePing(_ data: Data) {
|
|
guard data.count >= 3 else { return }
|
|
var r = DataReader(data: data, offset: 1)
|
|
guard let seq = r.readUInt16LE() else { return }
|
|
|
|
var w = DataWriter()
|
|
w.writeByte((connectionNumber << 5) | PacketProperty.pong.rawValue)
|
|
w.writeUInt16LE(seq)
|
|
w.writeInt64LE(LNL.dotNetTicks)
|
|
sendRaw(w.data)
|
|
}
|
|
|
|
private func handlePong(_ data: Data) {
|
|
guard data.count >= 3 else { return }
|
|
let now = currentMs()
|
|
guard now >= pingSentTimestamp else { return }
|
|
let elapsed = Double(now - pingSentTimestamp)
|
|
rttSum += elapsed
|
|
rttCount += 1
|
|
avgRtt = rttSum / Double(rttCount)
|
|
|
|
if rttCount > 10 {
|
|
rttSum = avgRtt
|
|
rttCount = 1
|
|
}
|
|
}
|
|
|
|
// MARK: - Disconnect
|
|
|
|
private func handleServerDisconnect(_ data: Data) {
|
|
guard data.count >= 9 else { return }
|
|
var w = DataWriter()
|
|
w.writeByte((connectionNumber << 5) | PacketProperty.shutdownOk.rawValue)
|
|
sendRaw(w.data)
|
|
performDisconnect(reason: "Server disconnected")
|
|
}
|
|
|
|
private func handleMerged(_ data: Data) {
|
|
var offset = 1
|
|
while offset + 2 <= data.count {
|
|
let sizeBytes = data[data.startIndex + offset ..< data.startIndex + offset + 2]
|
|
let size = Int(UInt16(littleEndian: sizeBytes.withUnsafeBytes { $0.loadUnaligned(as: UInt16.self) }))
|
|
offset += 2
|
|
guard offset + size <= data.count else { break }
|
|
let subPacket = Data(data[data.startIndex + offset ..< data.startIndex + offset + size])
|
|
offset += size
|
|
handleRawPacket(subPacket)
|
|
}
|
|
}
|
|
|
|
private func performDisconnect(reason: String?) {
|
|
guard state != .disconnected else { return }
|
|
lnlLog.info("Disconnecting: \(reason ?? "no reason")")
|
|
|
|
if state == .connected {
|
|
var w = DataWriter()
|
|
w.writeByte((connectionNumber << 5) | PacketProperty.disconnect.rawValue)
|
|
w.writeInt64LE(connectionTime)
|
|
sendRaw(w.data)
|
|
}
|
|
|
|
state = .disconnected
|
|
updateTask?.cancel()
|
|
updateTask = nil
|
|
connection?.cancel()
|
|
connection = nil
|
|
|
|
// Reset channel state
|
|
localSequence = 0
|
|
localWindowStart = 0
|
|
remoteSequence = 0
|
|
remoteWindowStart = 0
|
|
outgoingQueue.removeAll()
|
|
for i in 0..<LNL.windowSize {
|
|
pendingPackets[i] = nil
|
|
receivedPackets[i] = nil
|
|
}
|
|
for i in 0..<ackBitfield.count {
|
|
ackBitfield[i] = 0
|
|
}
|
|
incomingFragments.removeAll()
|
|
|
|
eventContinuation.yield(.disconnected(reason))
|
|
}
|
|
|
|
// MARK: - Raw Send
|
|
|
|
private func sendRaw(_ data: Data) {
|
|
guard let conn = connection else {
|
|
lnlLog.error("sendRaw called but connection is nil")
|
|
return
|
|
}
|
|
if conn.state != .ready {
|
|
lnlLog.warning("sendRaw called but NWConnection state is \(String(describing: conn.state))")
|
|
}
|
|
totalPacketsSent += 1
|
|
totalBytesSent += data.count
|
|
let pktNum = totalPacketsSent
|
|
conn.send(content: data, completion: .contentProcessed { error in
|
|
if let error {
|
|
lnlLog.error("Send #\(pktNum) failed: \(error.localizedDescription)")
|
|
} else {
|
|
lnlLog.debug("Send #\(pktNum) completed (\(data.count) bytes)")
|
|
}
|
|
})
|
|
}
|
|
|
|
// MARK: - Utility
|
|
|
|
private func currentMs() -> UInt64 {
|
|
UInt64(DispatchTime.now().uptimeNanoseconds / 1_000_000)
|
|
}
|
|
|
|
private func relativeSequence(_ number: UInt16, to expected: UInt16) -> Int {
|
|
let n = Int(number)
|
|
let e = Int(expected)
|
|
let max = Int(LNL.maxSequence)
|
|
return (n - e + max + LNL.halfMaxSequence) % max - LNL.halfMaxSequence
|
|
}
|
|
}
|
|
|
|
// MARK: - Binary Helpers
|
|
|
|
private struct DataWriter {
|
|
var data = Data()
|
|
|
|
mutating func writeByte(_ byte: UInt8) {
|
|
data.append(byte)
|
|
}
|
|
|
|
mutating func writeUInt16LE(_ value: UInt16) {
|
|
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
|
|
}
|
|
|
|
mutating func writeInt32LE(_ value: Int32) {
|
|
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
|
|
}
|
|
|
|
mutating func writeInt64LE(_ value: Int64) {
|
|
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
|
|
}
|
|
|
|
mutating func writeData(_ d: Data) {
|
|
data.append(d)
|
|
}
|
|
}
|
|
|
|
private struct DataReader {
|
|
let data: Data
|
|
var offset: Int
|
|
|
|
mutating func readByte() -> UInt8? {
|
|
let idx = data.startIndex + offset
|
|
guard idx < data.endIndex else { return nil }
|
|
offset += 1
|
|
return data[idx]
|
|
}
|
|
|
|
mutating func readUInt16LE() -> UInt16? {
|
|
let start = data.startIndex + offset
|
|
guard start + 2 <= data.endIndex else { return nil }
|
|
offset += 2
|
|
return data[start..<start+2].withUnsafeBytes {
|
|
UInt16(littleEndian: $0.loadUnaligned(as: UInt16.self))
|
|
}
|
|
}
|
|
|
|
mutating func readInt32LE() -> Int32? {
|
|
let start = data.startIndex + offset
|
|
guard start + 4 <= data.endIndex else { return nil }
|
|
offset += 4
|
|
return data[start..<start+4].withUnsafeBytes {
|
|
Int32(littleEndian: $0.loadUnaligned(as: Int32.self))
|
|
}
|
|
}
|
|
|
|
mutating func readInt64LE() -> Int64? {
|
|
let start = data.startIndex + offset
|
|
guard start + 8 <= data.endIndex else { return nil }
|
|
offset += 8
|
|
return data[start..<start+8].withUnsafeBytes {
|
|
Int64(littleEndian: $0.loadUnaligned(as: Int64.self))
|
|
}
|
|
}
|
|
}
|