本篇内容和源码均参考 UE5。

# Unreal 握手 && 登录流程

# 握手包结构

先来了解一下握手包的结构:

image-20230803102753308

主要由 5 个部分组成:

  • MagicHeader:暂未使用的空包头。
  • SessionID && ClientID:历史会话 ID(用于重连)及客户端 ID。
  • 包体:握手包的数据块,其内又可以分为三个部分:
    • 基础数据块:主要包含基本的包信息如包类型、客户端版本、客户端包编号等。
    • 密钥 ID:加密使用的服务器密钥编号。
    • 包时间戳和 Cookie 信息:Cookie 是通过密钥 ID 对应的密钥对时间戳 + 客户端 ip 端口计算得到的密文。
  • 随机数据:单纯填充用,可以让包大小不是恒定。
  • 终止符:包结束位,如果整个包不是 8 bit 整数倍,还会在其末尾填充至 8bit 整数倍。

# 四次握手流程

ue-握手流程.drawio

整个握手流程主要分为四个步骤:

  • Init
  • Challenge
  • Response
  • Ack

# Init 流程

客户端在触发 UEngine::Browse 打开地图文件的时候会初始化对应的 Driver 对象,并尝试和服务器建立无状态链接。为了容忍丢包会进行 1s / 次 的重复发包,如果超过一定时间仍然未收到回包的情况下则结束握手流程。

# Challenge 流程

服务器收到 InitPacket 后,服务器会根据当前正在生效的密钥用时间戳 + 客户端 IP 端口进行加密,得到 Cookie 信息。并连同密钥 ID 和 Cookie 信息一起发送回客户端。由于服务器是无状态的,因此会多次处理客户的的 InitPacket。

# Response 流程

由于每个密钥的有效时间是 15 s,客户端收到 ChallengePacket 后,会检查 Cookie 的时效性(当前时间 - 上次发包时间),确认有效后会把服务器的密钥 ID 和 Cookie 信息留存起来,并再次发送回服务器,同时更新客户端连接状态由 UnInitialized → InitializedOnLocal 。

如果发现密钥失效,则会返回 Init 流程并重置客户端状态为 UnInitialized,并在 Tick 过程重新发送 InitPacket 。

由于服务器无状态,这里可能会接收到多个服务器的 Challenge 包,客户端都会进行处理,并保留最新的密钥 ID 和 Cookie 信息。并且为了容忍丢包会进行 1s / 次 的重复发包(使用最新的密钥 ID 和 Cookie 信息)

# Ack 流程

服务器收到 ResponsePacket 后,会先检查包的时效性,由于服务器存储了 2 个密钥(密钥 0 && 密钥 1),每间隔 5 s 检查一次密钥有效性,密钥激活 15 s 后会被轮替,因此理论上一个密钥的激活时间最长为 20 s。当密钥失活后依旧会保留 15 s,并在 5 s 一次的检查中被清理,失活状态下的密钥依旧有效因此一个密钥最长有效时间为 40 s。

在确认密钥有效的情况下,根据密钥对 Cookie 进行校验比对。成功后服务器便认为客户端连接,又再次发回给客户端 Cookie 信息,密钥 ID 为 1 是默认值,该字段后面不会被用到。之后服务器会初始化一个客户端的 UNetConnect 对象,根据 Cookie 信息得到 2 个随机序列,一个用于客户端一个用于服务器。并且忽略后续的握手包。

客户端收到 AckPacket 后,同样会根据 Cookie 信息初始化客户端和服务器包序列,保留 Cookie 信息并设置连接状态由 InitializedOnLocal → Initialized ,至此整个握手流程完成,可以开始通讯。

# 登录流程

客户端收到 Ack Packet 后,就会进入登录流程,触发的方式是注册握手完成后的回调函数:

// ========================== 注册回调 ==========================
void UPendingNetGame::InitNetDriver()
{
	if (!GDisallowNetworkTravel)
	{
		if( NetDriver->InitConnect( this, URL, ConnectionError ) )
		{
			if (ServerConn->Handler.IsValid())
			{
				ServerConn->Handler->BeginHandshaking(
					FPacketHandlerHandshakeComplete::CreateUObject(this, &UPendingNetGame::SendInitialJoin));
			}
		}
	}
}
void PacketHandler::BeginHandshaking(FPacketHandlerHandshakeComplete InHandshakeDel/*=FPacketHandlerHandshakeComplete()*/)
{
	bBeganHandshaking = true;
	HandshakeCompleteDel = InHandshakeDel;
}
// ========================== 握手完成后触发回调 ==========================
void StatelessConnectHandlerComponent::Incoming(FBitReader& Packet)
{
    // 收到握手包
    else if (HandshakeData.HandshakePacketType == EHandshakePacketType::Ack && HandshakeData.Timestamp < 0.0)
    {
        Initialized();
    }
}
void HandlerComponent::Initialized()
{
	bInitialized = true;
	Handler->HandlerComponentInitialized(this);
}
void PacketHandler::HandlerComponentInitialized(HandlerComponent* InComponent)
{
	if (State != Handler::State::Initialized)
	{
		if (bAllInitialized)
		{
			HandlerInitialized();
		}
	}
}
void PacketHandler::HandlerInitialized()
{
	if (bBeganHandshaking)
	{
         // 执行回调
		HandshakeCompleteDel.ExecuteIfBound();
	}
}

SendInitialJoin 最终会发送 Hello 消息给服务器:

void UPendingNetGame::SendInitialJoin()
{
	if (NetDriver != nullptr)
	{
		UNetConnection* ServerConn = NetDriver->ServerConnection;
		if (ServerConn != nullptr)
		{
			if (!bEncryptionRequirementsFailure)
			{
				EEngineNetworkRuntimeFeatures LocalNetworkFeatures = NetDriver->GetNetworkRuntimeFeatures();
				FNetControlMessage<NMT_Hello>::Send(ServerConn, IsLittleEndian, LocalNetworkVersion, EncryptionToken, LocalNetworkFeatures);
				ServerConn->FlushNet();
			}
		}
	}
}

# Hello Packet

FNetControlMessage<NMT_Hello> 在 Unreal 中是通过宏定义实现的:

#define DEFINE_CONTROL_CHANNEL_MESSAGE(Name, Index, ...) \
enum { NMT_##Name = Index }; \
template<> class FNetControlMessage<Index> \
{ \
public: \
	static uint8 Initialize() \
	{ \
		FNetControlMessageInfo::SetName(Index, TEXT(#Name)); \
		return 0; \
	} \
	/** sends a message of this type on the specified connection's control channel \
		* @note: const not used only because of the FArchive interface; the parameters are not modified \
		*/ \
	template<typename... ParamTypes> \
	static void Send(UNetConnection* Conn, ParamTypes&... Params) \
	{ \
		static_assert(Index < FNetControlMessageInfo::MaxNames, "Control channel message must be a byte."); \
		checkSlow(!Conn->IsA(UChildConnection::StaticClass())); /** control channel messages can only be sent on the parent connection */ \
		if (Conn->Channels[0] != NULL && !Conn->Channels[0]->Closing) \
		{ \
			FControlChannelOutBunch Bunch(Conn->Channels[0], false); \
			uint8 MessageType = Index; \
			Bunch << MessageType; \
			FNetControlMessageInfo::SendParams(Bunch, Params...); \
			Conn->Channels[0]->SendBunch(&Bunch, true); \
		} \
	} \
	/** receives a message of this type from the passed in bunch */ \
	template<typename... ParamTypes> \
	UE_NODISCARD static bool Receive(FInBunch& Bunch, ParamTypes&... Params) \
	{ \
		FNetControlMessageInfo::ReceiveParams(Bunch, Params...); \
		return !Bunch.IsError(); \
	} \
	/** throws away a message of this type from the passed in bunch */ \
	static void Discard(FInBunch& Bunch) \
	{ \
		TTuple<__VA_ARGS__> Params; \
		VisitTupleElements([&Bunch](auto& Param) \
		{ \
			Bunch << Param; \
		}, \
		Params); \
	} \
};
DEFINE_CONTROL_CHANNEL_MESSAGE(Hello, 0, uint8, uint32, FString, uint16);

宏展开后长下面这个样子:

enum
{
    NMT_Hello = 0
};
template <>
class FNetControlMessage<0>
{
public:
    static uint8 Initialize()
    {
        FNetControlMessageInfo::SetName(0, TEXT("Hello"));
        return 0;
    }
    template <typename... ParamTypes>
    static void Send(UNetConnection* Conn, ParamTypes&... Params)
    {
        static_assert(0 < FNetControlMessageInfo::MaxNames, "Control channel message must be a byte.");
        checkSlow(!Conn->IsA(UChildConnection::StaticClass()));
        if (Conn->Channels[0] != NULL && !Conn->Channels[0]->Closing)
        {
            FControlChannelOutBunch Bunch(Conn->Channels[0], false);
            uint8 MessageType = 0;
            Bunch << MessageType;
            FNetControlMessageInfo::SendParams(Bunch, Params...);
            Conn->Channels[0]->SendBunch(&Bunch, true);
        }
    }
    template <typename... ParamTypes>
    UE_NODISCARD static bool Receive(FInBunch& Bunch, ParamTypes&... Params)
    {
        FNetControlMessageInfo::ReceiveParams(Bunch, Params...);
        return !Bunch.IsError();
    }
    static void Discard(FInBunch& Bunch)
    {
        TTuple<uint8, uint32, FString, uint16> Params;
        VisitTupleElements([&Bunch](auto& Param) { Bunch << Param; }, Params);
    }
};;

FNetControlMessageInfo::SendParams(Bunch, Params...); 负责对参数进行打包,实现还是比较有意思的,用的模板的模式匹配 + 递归:

template<typename... ParamTypes>
static void SendParams(FControlChannelOutBunch& Bunch, ParamTypes&... Params) {}
template<typename FirstParamType, typename... ParamTypes>
static void SendParams(FControlChannelOutBunch& Bunch, FirstParamType& FirstParam, ParamTypes&... Params)
{
    Bunch << FirstParam;
    SendParams(Bunch, Params...);
}

# 其他协议包定义:

// 【c2s.1】Hello
DEFINE_CONTROL_CHANNEL_MESSAGE(Hello, 0, uint8, uint32, FString, uint16);
// 【s2c.1】Challenge 
DEFINE_CONTROL_CHANNEL_MESSAGE(Challenge, 3, FString);
// 【c2s.2】Login 
DEFINE_CONTROL_CHANNEL_MESSAGE(Login, 5, FString, FString, FUniqueNetIdRepl, FString);
// 【s2c.2】Welcome 
DEFINE_CONTROL_CHANNEL_MESSAGE(Welcome, 1, FString, FString, FString);
// 【c2s.3】Netspeed 
DEFINE_CONTROL_CHANNEL_MESSAGE(Netspeed, 4, int32);
// 【c2s.4】Join 
DEFINE_CONTROL_CHANNEL_MESSAGE(Join, 9);

# 登录包定义

image-20230816154635846

主要可以分成三个部分:

  • PacketBaseInfo:里面主要包含包序列号、ACK 信息、网络抖动等信息。
  • BunchHeader:包含一些标志信息如创建 or 关闭 Channel、是否可靠、是否分包等。值得注意的是 FName 字段的打包规则比较特殊:由于 Unreal 内部会优化字符串,硬编码会通过一个全局字符表存储,然后直接通过编号索引来指代。
  • BunchData:BunchData 里就是各个业务的包了,在登录流程中前 8 bit 会被用来解析为 MessageType,后续的内容则根据业务自行处理压包和解包。

# 登录流程总览图

ue-登录流程.drawio

整个登录流程会接着握手包的 Ack 继续执行。

  • 客户端会先发起 Hello 请求,里面可以选择携带相关的密文摘要信息。
  • 服务器收到 Hello 包后,如果没有密文摘要信息,则直接回复 Challenge 包。
  • 客户端收到 Challenge 包后,会发送 Login 包,并携带角色 ID 和 URL。
  • 服务器收到 Login 包后,会发送 Welcome 包,并携带当前 World 的 PersistentLevelName 和 GameModeName。
  • 客户端收到 Welcome 包后会同步给服务器当前的 NetSpeed。并根据 PersistentLevelName 加载地图、完成 World 的切换。
  • 当客户端地图加载完毕后,会回复客户端 Join 包。
  • 服务器收到 Join 包后会在地图内创建玩家并且同步各项内容。之后业务就可以正常基于网络交互通讯了。

# 加密通信

Unreal 本身提供了三种加密:

  • FAESHandlerComponent
  • FAESGCMHandlerComponent
  • FDTLSHandlerComponent

这里以 LyraGame 中的 DTLS 为例进行简单介绍:

// 加密结构
struct FEncryptionData
{
	/** Encryption key */
	TArray<uint8> Key;
	/** Encryption fingerprint */
	TArray<uint8> Fingerprint;
	/** Encryption identifier */
	FString Identifier;
};

加密开启流程在客户端发送 Hello 包之后(Hello 及 Hello 包之前都是明文传输) :

  • Hello 包可以携带一个 EncryptionToken 用来唯一标识一个连接。
  • 服务器收到 EncryptionToken 后会调用 XXXGameInstance::ReceivedNetworkEncryptionToken 函数,为该标识生成一个独有的服务器证书 Cert(有效时长 4 小时)
  • 证书生成完成后会发送 EncryptionAck 包,通知客户端服务器开启加密
  • 客户端收到 EncryptionAck 包后会调用 XXXGameInstance::ReceivedNetworkEncryptionAck 同样设置客户端证书。
  • 其中证书内的加密信息 FEncryptionData 内容如下:
    • FEncryptionData::Key 一般是通过 AES256 生成的密钥做对称加密。
    • FEncryptionData::Fingerprint 需要从特定的安全服务器提取的指纹信息。
    • FEncryptionData::Identifier 对应客户端的 EncryptionToken 。一般是用客户端的 UUID 作为 EncryptionToken

# 加密解密

上述的三种加密组件: FAESHandlerComponentFAESGCMHandlerComponentFDTLSHandlerComponent 可以注册到 PacketHandler 上。

  • FDTLSHandlerComponent::Incoming 处理解密。
  • FDTLSHandlerComponent::Outgoing 处理加密。

image-20230822145102872

FDTLSHandlerComponent 会在原本包体的前面新增 3 个 bit 用来标识加密是否开启,是否为握手(加密的握手,非建立连接的握手),以及一个终止位。

# 开启加密流程

在 Config/DefaultEngine.ini 中新增配置:

[PacketHandlerComponents]
; Options can be set in this section of DefaultEngine.ini to enable different types of network packet encruption plugins
EncryptionComponent=DTLSHandlerComponent

并且在 URL 中加入 EncryptionToken 后缀,这里方便测试直接修改了 Hello 包的默认值:

void UPendingNetGame::SendInitialJoin()
{
	if (NetDriver != nullptr)
	{
		UNetConnection* ServerConn = NetDriver->ServerConnection;

		if (ServerConn != nullptr)
		{
			uint8 IsLittleEndian = uint8(PLATFORM_LITTLE_ENDIAN);
			check(IsLittleEndian == !!IsLittleEndian); // should only be one or zero

			const int32 AllowEncryption = CVarNetAllowEncryption.GetValueOnGameThread();
			FString EncryptionToken;

			if (AllowEncryption != 0)
			{
				//EncryptionToken = URL.GetOption(TEXT("EncryptionToken="), TEXT(""));
             	 EncryptionToken = URL.GetOption(TEXT("EncryptionToken="), TEXT("1"));
			}
        }
    }
}

然后就可以开始调试了,在 FDTLSHandlerComponent::TickHandshake 中会定时检查状态,并发送握手包。当状态为 InternalState = EDTLSHandlerState::Encrypted 时标识开始进行加密传输。

# 可靠性

由于登陆包是通过 ControlChannel 进行处理的,见上面的消息宏展开,使用的是 FControlChannelOutBunch 传输,其中 bReliable 始终是 true。因此登录包以及后续玩家相关包都是可靠。

FControlChannelOutBunch::FControlChannelOutBunch(UChannel* InChannel, bool bClose)
	: FOutBunch(InChannel, bClose)
{
	checkSlow(Cast<UControlChannel>(InChannel) != nullptr);
	// control channel bunches contain critical handshaking/synchronization and should always be reliable
	bReliable = true;
}

另外每个 Channel 的 Close 包必定是 bReliable 的。

# 重传

发送过的 Bunch 会根据 bReliable 字段存储在 OutRec 中用于重传。重传机制主要通过两种方式保证消息必达(如果消息超过消息缓冲区队列大小,会直接断开 Connect 连接):

  • ControlChannel 的重传机制(通过 Tick 进行触发):如果是 Open 包,即开启一个 ControlChannel 通道的包,这个包的重传间隔是 1s,另外就是 ControlChannel 创建成功后的中间数据包,这部分包会存储在 QueuedMessages 用于重传。
  • 通用的 Channel 重传机制(通过 ReplicateActor 进行触发):这里重传是基于 SendBunch 触发的,每次 SendBunch 的时候并不一定会发送当前需要发送的包,而是把该可靠包放入队尾,然后取出最早没有 Ack 的多个包进行发送。
void UControlChannel::QueueMessage(const FOutBunch* Bunch)
{
	LLM_SCOPE_BYTAG(NetChannel);
	if (QueuedMessages.Num() >= MAX_QUEUED_CONTROL_MESSAGES)
	{
		// we're out of room in our extra buffer as well, so kill the connection
		UE_LOG(LogNet, Log, TEXT("Overflowed control channel message queue, disconnecting client"));
		// intentionally directly setting State as the messaging in Close() is not going to work in this case
		Connection->SetConnectionState(USOCK_Closed);
	}
}
void UControlChannel::Tick()
{
	Super::Tick();
	if( !OpenAcked )
	{
		int32 Count = 0;
		for (FOutBunch* Out = OutRec; Out; Out = Out->Next)
		{
			if (!Out->ReceivedAck)
			{
				Count++;
			}
		}
		if (Count > 8)
		{
			return;
		}
		// Resend any pending packets if we didn't get the appropriate acks.
		for( FOutBunch* Out=OutRec; Out; Out=Out->Next )
		{
			if( !Out->ReceivedAck )
			{
				const double Wait = Connection->Driver->GetElapsedTime() - Out->Time;
				checkSlow(Wait >= 0.0);
				if (Wait > 1.0)
				{
					UE_LOG(LogNetTraffic, Log, TEXT("Channel %i ack timeout); resending %i..."), ChIndex, Out->ChSequence );
					check(Out->bReliable);
					Connection->SendRawBunch( *Out, 0 );
				}
			}
		}
	}
	else
	{
		// attempt to send queued messages
		while (QueuedMessages.Num() > 0 && !Closing)
		{
			FControlChannelOutBunch Bunch(this, 0);
			if (Bunch.IsError())
			{
				break;
			}
			else
			{
				Bunch.bReliable = 1;
				Bunch.SerializeBits(QueuedMessages[0].Data.GetData(), QueuedMessages[0].CountBits);
				if (!Bunch.IsError())
				{
					Super::SendBunch(&Bunch, 1);
					QueuedMessages.RemoveAt(0, 1);
				}
				else
				{
					// an error here most likely indicates an unfixable error, such as the text using more than the maximum packet size
					// so there is no point in queueing it as it will just fail again
					ensureMsgf(false, TEXT("Control channel queued bunch overflowed"));
					UE_LOG(LogNet, Error, TEXT("Control channel queued bunch overflowed"));
					Connection->Close(ENetCloseResult::ControlChannelQueueBunchOverflowed);
					break;
				}
			}
		}
	}
}
FPacketIdRange UChannel::SendBunch( FOutBunch* Bunch, bool Merge )
{
	//-----------------------------------------------------
	// Contemplate merging or Possibly split large bunch into list of smaller partial bunches
	//-----------------------------------------------------
    //-----------------------------------------------------
	// OverflowsReliable
	//-----------------------------------------------------
	const bool bOverflowsReliable = (NumOutRec + OutgoingBunches.Num() >= RELIABLE_BUFFER + Bunch->bClose);
	if ((GCVarNetPartialBunchReliableThreshold > 0) && (OutgoingBunches.Num() >= GCVarNetPartialBunchReliableThreshold) && !Connection->IsInternalAck())
	{
		if (!bOverflowsReliable)
		{
			UE_LOG(LogNetPartialBunch, Log, TEXT("	OutgoingBunches.Num (%d) exceeds reliable threashold (%d). Making bunches reliable. Property replication will be paused on this channel until these are ACK'd."), OutgoingBunches.Num(), GCVarNetPartialBunchReliableThreshold);
			Bunch->bReliable = true;
			bPausedUntilReliableACK = true;
		}
		else
		{
			// The threshold was hit, but making these reliable would overflow the reliable buffer. This is a problem: there is just too much data.
			UE_LOG(LogNetPartialBunch, Warning, TEXT("	OutgoingBunches.Num (%d) exceeds reliable threashold (%d) but this would overflow the reliable buffer! Consider sending less stuff. Channel: %s"), OutgoingBunches.Num(), GCVarNetPartialBunchReliableThreshold, *Describe());
		}
	}
	if (Bunch->bReliable && bOverflowsReliable)
	{
		UE_LOG(LogNetPartialBunch, Warning, TEXT("SendBunch: Reliable partial bunch overflows reliable buffer! %s"), *Describe() );
		UE_LOG(LogNetPartialBunch, Warning, TEXT("   Num OutgoingBunches: %d. NumOutRec: %d"), OutgoingBunches.Num(), NumOutRec );
		PrintReliableBunchBuffer();
		// Bail out, we can't recover from this (without increasing RELIABLE_BUFFER)
		FString ErrorMsg = NSLOCTEXT("NetworkErrors", "ClientReliableBufferOverflow", "Outgoing reliable buffer overflow").ToString();
		Connection->SendCloseReason(ENetCloseResult::ReliableBufferOverflow);
		FNetControlMessage<NMT_Failure>::Send(Connection, ErrorMsg);
		Connection->FlushNet(true);
		Connection->Close(ENetCloseResult::ReliableBufferOverflow);
	
		return PacketIdRange;
	}
	//-----------------------------------------------------
	// Send all the bunches we need to
	//	Note: this is done all at once. We could queue this up somewhere else before sending to Out.
	//-----------------------------------------------------
	for( int32 PartialNum = 0; PartialNum < OutgoingBunches.Num(); ++PartialNum)
	{
		FOutBunch * NextBunch = OutgoingBunches[PartialNum];
		NextBunch->bReliable = Bunch->bReliable;
		NextBunch->bOpen = Bunch->bOpen;
		NextBunch->bClose = Bunch->bClose;
		NextBunch->CloseReason = Bunch->CloseReason;
		NextBunch->bIsReplicationPaused = Bunch->bIsReplicationPaused;
		NextBunch->ChIndex = Bunch->ChIndex;
		NextBunch->ChName = Bunch->ChName;
		if ( !NextBunch->bHasPackageMapExports )
		{
			NextBunch->bHasMustBeMappedGUIDs |= Bunch->bHasMustBeMappedGUIDs;
		}
		if (OutgoingBunches.Num() > 1)
		{
			NextBunch->bPartial = 1;
			NextBunch->bPartialInitial = (PartialNum == 0 ? 1: 0);
			NextBunch->bPartialFinal = (PartialNum == OutgoingBunches.Num() - 1 ? 1: 0);
			NextBunch->bOpen &= (PartialNum == 0);											// Only the first bunch should have the bOpen bit set
			NextBunch->bClose = (Bunch->bClose && (OutgoingBunches.Num()-1 == PartialNum)); // Only last bunch should have bClose bit set
		}
         // 这里通过 PrepBunch 获取到需要重传的起始包
		FOutBunch *ThisOutBunch = PrepBunch(NextBunch, OutBunch, Merge); // This handles queuing reliable bunches into the ack list
		// Update Packet Range
		int32 PacketId = SendRawBunch(ThisOutBunch, Merge, GetTraceCollector(*NextBunch));
		if (PartialNum == 0)
		{
			PacketIdRange = FPacketIdRange(PacketId);
		}
		else
		{
			PacketIdRange.Last = PacketId;
		}
		// Update channel sequence count.
		Connection->LastOut = *ThisOutBunch;
		Connection->LastEnd	= FBitWriterMark( Connection->SendBuffer );
	}
	// Update open range if necessary
	if (Bunch->bOpen && (Connection->ResendAllDataState == EResendAllDataState::None))
	{
		OpenPacketId = PacketIdRange;		
	}
	// Destroy outgoing bunches now that they are sent, except the one that was passed into ::SendBunch
	//	This is because the one passed in ::SendBunch is the responsibility of the caller, the other bunches in OutgoingBunches
	//	were either allocated in this function for partial bunches, or taken from the package map, which expects us to destroy them.
	for (auto It = OutgoingBunches.CreateIterator(); It; ++It)
	{
		FOutBunch *DeleteBunch = *It;
		if (DeleteBunch != Bunch)
			delete DeleteBunch;
	}
	return PacketIdRange;
}

# 参考文档

  • UE4 UDP 是如何进行可靠传输的
  • 虚幻引擎中如何使用数据包处理程序组件启用加密
  • 在 Unreal 引擎中应用 TLS 加密
更新于 阅读次数

请我[恰饭]~( ̄▽ ̄)~*

鑫酱(●'◡'●) 微信支付

微信支付

鑫酱(●'◡'●) 支付宝

支付宝