Networking

The game uses a UDP-based protocol. Unlike TCP, UDP does not guarantee that
packets arrive, or that they arrive in the correct order. For this reason,
the game protocol implements those features using the following:

* All packets have a sequence number that is utilized for reordering
* Important packets are wrapped in a SlottedMetaPacket with a subslot number
* RelatedA packets ae used to request lost packets using the subslot number
* RelatedB packets are used to confirm received SlottedMetaPackets

All of these go both ways, server <-> client. We used to only partially
implement these features: Outgoing packet bundles used SMPs and could be
resent, but not all packets were bundled and there was no logic for requesting
lost packets from the client and there was no packet reordering, which resulted
in dire consequences in the case of packet loss (zoning failures, crashes and many
other odd bugs). This patch addresses all of these issues.

* Packet bundling: Packets are now automatically bundled and sent as
  SlottedMetaPackets using a recurring timer. All manual bundling functionality
  was removed.

* Packet reordering: Incoming packets, if received out of order, are stashed and
  reordered. The maximum wait time for reordering is 20ms.

* Packet requesting: Missing SlottedMetaPackets are requested from the client.

* PacketCoding refactor: Dropped confusing packet container types. Fixes #5.

* Crypto rewrite: PSCrypto is based on a ancient buggy version of cryptopp.
  Updating to a current version was not possible because it removed the
  MD5-MAC algorithm. For more details, see Md5Mac.scala.
  This patch replaces PSCrypto with native Scala code.

* Added two new actors:
  * SocketActor: A simple typed UDP socket actor
  * MiddlewareActor: The old session pipeline greatly simplified into a
    typed actor that does most of the things mentioned above.

* Begun work on a headless client

* Fixed anniversary gun breaking stamina regen

* Resolved a few sentry errors
This commit is contained in:
Jakob Gillich 2020-09-17 17:04:06 +02:00
parent 5827204b10
commit 407429ee21
232 changed files with 2906 additions and 4385 deletions

View file

@ -0,0 +1,173 @@
package net.psforever.tools.client
import java.net.{DatagramPacket, DatagramSocket, InetSocketAddress}
import java.security.{SecureRandom, Security}
import akka.actor.typed.ActorRef
import akka.io.Udp
import enumeratum.{Enum, EnumEntry}
import net.psforever.packet.{CryptoPacketOpcode, PacketCoding, PlanetSideControlPacket, PlanetSideCryptoPacket, PlanetSideGamePacket, PlanetSidePacket}
import net.psforever.packet.PacketCoding.CryptoCoding
import net.psforever.packet.control.{ClientStart, ServerStart}
import net.psforever.packet.crypto.{ClientChallengeXchg, ServerChallengeXchg}
import org.bouncycastle.jce.provider.BouncyCastleProvider
import scodec.{Attempt, Err}
import scodec.Attempt.{Failure, Successful}
import scodec.bits._
import scala.concurrent.duration.{DurationInt, FiniteDuration}
import scala.util.control.Breaks._
object Client {
Security.addProvider(new BouncyCastleProvider)
def main(args: Array[String]): Unit = {
val client = new Client("test", "test")
client.login(new InetSocketAddress("localhost", 51000))
}
sealed trait ClientState extends EnumEntry
object ClientState extends Enum[ClientState] {
case object Disconnected extends ClientState
case object WorldSelection extends ClientState
case object AvatarSelection extends ClientState
case object AvatarCreation extends ClientState
val values: IndexedSeq[ClientState] = findValues
}
}
class Client(username: String, password: String) {
import Client._
private var sequence = 0
private def nextSequence = {
val r = sequence
sequence += 1
r
}
private val socket = new DatagramSocket()
socket.setSoTimeout(1000)
private var host: Option[InetSocketAddress] = None
private var ref: Option[ActorRef[Udp.Message]] = None
private var crypto: Option[CryptoCoding] = None
private val buffer = new Array[Byte](65535)
val random = new SecureRandom()
private var _state: ClientState = ClientState.Disconnected
def state: ClientState = _state
/** Login using given host address */
def login(host: InetSocketAddress): Unit = {
this.host = Some(host)
login()
}
/** Login using given actor ref */
/*
def login(ref: ActorRef[Udp.Message]): Unit = {
this.ref = Some(ref)
login()
}
*/
private def login() = {
assert(state == ClientState.Disconnected)
var macBuffer: ByteVector = ByteVector.empty
send(ClientStart(0))]
val serverStart = waitFor[ServerStart]().require
assert(.clientNonce == 0)
val time = System.currentTimeMillis()
val challenge = randomBytes(12)
val p = randomBytes(16)
val g = ByteVector(1.toByte).reverse.padTo(16).reverse
send(ClientChallengeXchg(time, challenge, p, g))
val serverKey = waitFor[ServerChallengeXchg]().require.pubKey
val
println(res)
}
private def waitFor[T](
cryptoState: CryptoPacketOpcode.Type = CryptoPacketOpcode.Ignore,
timeout: FiniteDuration = 5.seconds
): Attempt[T] = {
val time = System.currentTimeMillis()
var res: Attempt[T] = Failure(Err("timeout"))
while (res.isFailure && System.currentTimeMillis() - time < timeout.toMillis) {
receive(cryptoState) match {
case Successful((packet, sequence)) =>
packet match {
case packet: T => res = Successful(packet)
case p =>
println(s"receive: ${p}")
()
}
case Failure(cause) => ???
}
}
res
}
def send(packet: PlanetSideControlPacket): Attempt[BitVector] = {
send(packet, if (crypto.isDefined) Some(nextSequence) else None, crypto)
}
def send(packet: PlanetSideCryptoPacket): Attempt[BitVector] = {
send(packet, Some(nextSequence), crypto)
}
def send(packet: PlanetSideGamePacket): Attempt[BitVector] = {
send(packet, Some(nextSequence), crypto)
}
private def send(
packet: PlanetSidePacket,
sequence: Option[Int],
crypto: Option[CryptoCoding]
): Attempt[BitVector] = {
PacketCoding.marshalPacket(packet, sequence, crypto) match {
case Successful(payload) =>
send(payload.toByteArray)
Successful(payload)
case f: Failure =>
f
}
}
private def send(payload: Array[Byte]): Unit = {
(host, ref) match {
case (Some(host), None) =>
socket.send(new DatagramPacket(payload, payload.length, host))
case (None, Some(ref)) =>
// ref ! Udp.Received(ByteString(payload), new InetSocketAddress(socket.getInetAddress, socket.getPort))
}
}
private def receive(
cryptoState: CryptoPacketOpcode.Type = CryptoPacketOpcode.Ignore
): Attempt[(PlanetSidePacket, Option[Int])] = {
try {
val p = new DatagramPacket(buffer, buffer.length)
socket.receive(p)
PacketCoding.unmarshalPacket(ByteVector.view(p.getData), crypto, cryptoState)
} catch {
case e: Throwable => Failure(Err(e.getMessage))
}
}
private def randomBytes(amount: Int): ByteVector = {
val array = Array.ofDim[Byte](amount)
random.nextBytes(array)
ByteVector.view(array)
}
}

View file

@ -96,12 +96,9 @@ object DecodePackets {
var linesToSkip = 0
for (line <- lines.drop(1)) {
breakable {
if (linesToSkip > 0) {
linesToSkip -= 1
break()
}
if (linesToSkip > 0) {
linesToSkip -= 1
} else {
val decodedLine = decodePacket(line.drop(line.lastIndexOf(' ')))
writer.write(s"${shortGcapyString(line)}")
writer.newLine()
@ -142,10 +139,9 @@ object DecodePackets {
FileUtils.forceDelete(tmpFolder)
}
/*
Traverse down any nested packets such as SlottedMetaPacket, MultiPacket and MultiPacketEx and add indent for each layer down
The number of lines to skip will be returned so duplicate lines following SlottedMetaPackets in the gcapy output can be filtered out
*/
/** Traverse down any nested packets such as SlottedMetaPacket, MultiPacket and MultiPacketEx and add indent for each layer down
* The number of lines to skip will be returned so duplicate lines following SlottedMetaPackets in the gcapy output can be filtered out
*/
def recursivelyHandleNestedPacket(decodedLine: String, writer: BufferedWriter, depth: Int = 0): Int = {
if (decodedLine.indexOf("Failed to parse") >= 0) return depth
val regex = "(0x[a-f0-9]+)".r
@ -190,9 +186,9 @@ object DecodePackets {
}
def decodePacket(hexString: String): String = {
PacketCoding.DecodePacket(ByteVector.fromValidHex(hexString)) match {
PacketCoding.decodePacket(ByteVector.fromValidHex(hexString)) match {
case Successful(value) => value.toString
case Failure(cause) => cause.toString
case Failure(cause) => s"Decoding error '${cause.toString}' for data ${hexString}"
}
}
}