七、多人游戏客户端连接服务端过程解析

453人浏览 / 0人评论

    正常游玩游戏时是在服务器列表里找到想加入的服务器然后点击连接,和服务端同理其实是向命令行发送命令连接的,这个命令就是connect命令:

CON_COMMAND_F( connect, "Connect to specified server.", FCVAR_DONTRECORD )
{
	// Default command processing considers ':' a command separator,
	// and we donly want spaces to count.  So we'll need to re-split the arg string
	CUtlVector<char*> vecArgs;
	V_SplitString( args.ArgS(), " ", vecArgs );

	// How many arguments?
	if ( vecArgs.Count() == 1  )
	{
		CL_Connect( vecArgs[0], "" );
	}
	else if ( vecArgs.Count() == 2 )
	{
		CL_Connect( vecArgs[0], vecArgs[1] );
	}
	else
	{
		ConMsg( "Usage:  connect <server>\n" );
	}
	vecArgs.PurgeAndDeleteElementsArray();
}

在控制台输入 connect ip:端口 也能连接到服务器,这个命令处理函数里首先校验参数然后调用了CL_Connect方法

void CL_Connect( const char *address, const char *pszSourceTag )
{
	// If it's not a single player connection to "localhost", initialize networking & stop listenserver
	if ( Q_strncmp( address, "localhost", 9 ) )
	{
		Host_Disconnect(false);	

		// allow remote
		NET_SetMutiplayer( true );		

		// start progress bar immediately for remote connection
		EngineVGui()->EnabledProgressBarForNextLoad();

		SCR_BeginLoadingPlaque();

		EngineVGui()->UpdateProgressBar(PROGRESS_BEGINCONNECT);
	}
	else
	{
		// we are connecting/reconnecting to local game
		// so don't stop listenserver 
		cl.Disconnect( "Connecting to local host", false );
	}

	// This happens as part of the load process anyway, but on slower systems it causes the server to timeout the
	// connection.  Use the opportunity to flush anything before starting a new connection.
	UpdateMaterialSystemConfig();

	cl.Connect( address, pszSourceTag );

	// Reset error conditions
	gfExtendedError = false;
}

这个方法里先断掉已经存在的连接,如果是连接到远程服务器,则先停止本地服务器,Q_strncmp是字符串比较函数,如果比较的两个字符串完全相同返回0,不相同返回1,然后调用cl.Connect连接到新的服务器

void CBaseClientState::Connect(const char* adr, const char *pszSourceTag)
{
#if !defined( NO_STEAM )
	// Get our name from steam. Needs to be done before connecting
	// because we won't have triggered a check by changing our name.
	IConVar *pVar = g_pCVar->FindVar( "name" );
	if ( pVar )
	{
		SetNameToSteamIDName( pVar );
	}
#endif


	Q_strncpy( m_szRetryAddress, adr, sizeof(m_szRetryAddress) );
	m_retryChallenge = (RandomInt(0,0x0FFF) << 16) | RandomInt(0,0xFFFF);
	m_ulGameServerSteamID = 0;
	m_sRetrySourceTag = pszSourceTag;
	cl_connectmethod.SetValue( m_sRetrySourceTag.String() );

	// For the check for resend timer to fire a connection / getchallenge request.
	SetSignonState( SIGNONSTATE_CHALLENGE, -1 );
	
	// Force connection request to fire.
	m_flConnectTime = -FLT_MAX;  

	m_nRetryNumber = 0;
}

根据类继承关系可以找到调用的是CBaseClientState的Connect方法,这个方法里先设置连接地址,然后生成随机的挑战数,设置当前客户端的连接状态为SIGNONSTATE_CHALLENGE,意为挑战中,重置重试连接次数。这里并没有看到连接服务器的操作,在其他地方发出了连接服务器请求

void CBaseClientState::RunFrame (void)
{
	VPROF("CBaseClientState::RunFrame");
	tmZone( TELEMETRY_LEVEL0, TMZF_NONE, "%s", __FUNCTION__ );

	if ( (m_nSignonState > SIGNONSTATE_NEW) && m_NetChannel && g_GameEventManager.HasClientListenersChanged() )
	{
		// assemble a list of all events we listening to and tell the server
		CLC_ListenEvents msg;
		g_GameEventManager.WriteListenEventList( &msg );
		m_NetChannel->SendNetMsg( msg );
	}

	if ( m_nSignonState == SIGNONSTATE_CHALLENGE )
	{
		CheckForResend();
	}
}

在CBaseClientState::RunFrame方法里如果是挑战中的状态就会向服务器发送挑战请求,因为使用的UDP数据包,数据可能丢失,所以这儿设计成了可以多次重试的模式

	if ( m_nRetryNumber == 0 )
	{
		IGameEvent *event = g_GameEventManager.CreateEvent( "client_beginconnect" );
		if ( event )
		{
			event->SetString( "address", m_szRetryAddress);
			event->SetInt(    "ip", adr.GetIPNetworkByteOrder() ); // <<< Network byte order?
			event->SetInt(    "port", adr.GetPort() );
			//event->SetInt(    "retry_number", m_nRetryNumber );
			event->SetString( "source", m_sRetrySourceTag );
			g_GameEventManager.FireEventClientSide( event );
		}
	}

	m_nRetryNumber++;

	// Request another challenge value.
	{
		ALIGN4 char		msg_buffer[MAX_ROUTABLE_PAYLOAD] ALIGN4_POST;
		bf_write	msg( msg_buffer, sizeof(msg_buffer) );

		msg.WriteLong( CONNECTIONLESS_HEADER );
		msg.WriteByte( A2S_GETCHALLENGE );
		msg.WriteLong( m_retryChallenge );
		msg.WriteString( "0000000000" ); // pad out
		NET_SendPacket( NULL, m_Socket, adr, msg.GetData(), msg.GetNumBytesWritten() );
	}

这个方法里先判断是否符合条件,不是挑战中状态或者发送太频繁或者重试失败次数太多会返回,然后将字符串的地址转换成数据结构,发出客户端开始连接事件,在代码末尾发出连接服务器数据包。注意这个数据包开头必须是CONNECTIONLESS_HEADER。然后消息类型是A2S_GETCHALLENGE,携带了客户端的挑战数。在代码里搜索A2S_GETCHALLENGE,即可找到服务端处理这个数据包的代码

bool CBaseServer::ProcessConnectionlessPacket(netpacket_t * packet)
{
	master->ProcessConnectionlessPacket( packet );

	bf_read msg = packet->message;	// handy shortcut 

	char c = msg.ReadChar();

	if ( c== 0  )
	{
		return false;
	}

	switch ( c )
	{
		case A2S_GETCHALLENGE :
			{
				int clientChallenge = msg.ReadLong();
				ReplyChallenge( packet->from, clientChallenge );
			}

			break;

服务端拿到客户端的挑战数,然后调用ReplyChallenge

void CBaseServer::ReplyChallenge(netadr_t &adr, int clientChallenge )
{
	ALIGN4 char	buffer[STEAM_KEYSIZE+32] ALIGN4_POST;
	bf_write msg(buffer,sizeof(buffer));

	// get a free challenge number
	int challengeNr = GetChallengeNr( adr );
	int	authprotocol = GetChallengeType( adr );

	msg.WriteLong( CONNECTIONLESS_HEADER );
	
	msg.WriteByte( S2C_CHALLENGE );
	msg.WriteLong( S2C_MAGICVERSION ); // This makes it so we can detect that this server is correct
	msg.WriteLong( challengeNr ); // Server to client challenge
	msg.WriteLong( clientChallenge ); // Client to server challenge to ensure our reply is what they asked
	msg.WriteLong( authprotocol );

#if !defined( NO_STEAM ) //#ifndef _XBOX
	if ( authprotocol == PROTOCOL_STEAM )
	{
		msg.WriteShort( 0 ); //  steam2 encryption key not there anymore
		CSteamID steamID = Steam3Server().GetGSSteamID();
		uint64 unSteamID = steamID.ConvertToUint64();
		msg.WriteBytes( &unSteamID, sizeof(unSteamID) );
		msg.WriteByte( Steam3Server().BSecure() );
	}
#else
	msg.WriteShort( 1 );
	msg.WriteByte( 0 );
	uint64 unSteamID = 0;
	msg.WriteBytes( &unSteamID, sizeof(unSteamID) );
	msg.WriteByte( 0 );
#endif
	msg.WriteString( "000000" );	// padding bytes

	NET_SendPacket( NULL, m_Socket, adr, msg.GetData(), msg.GetNumBytesWritten() );
}

在这个方法里会用客户端信息和随机数生成返回给客户端的挑战数和挑战类型,然后向客户端发送以CONNECTIONLESS_HEADER开头,类型是S2C_CHALLENGE的数据包,携带了一个魔数,服务器生成的挑战数,客户端发送的挑战数,挑战类型等。代码里搜索S2C_CHALLENGE,即可找到客户端处理这个数据包的代码

	case S2C_CHALLENGE:		// Response from getchallenge we sent to the server we are connecting to
							// Blow it off if we are not connected.
							if ( m_nSignonState == SIGNONSTATE_CHALLENGE )
							{
								int magicVersion = msg.ReadLong();
								if ( magicVersion != S2C_MAGICVERSION )
								{
									COM_ExplainDisconnection( true, "#GameUI_ServerConnectOutOfDate"  );
									Disconnect( "#GameUI_ServerConnectOutOfDate", true );
									return false;
								}

								int challenge = msg.ReadLong();
								int myChallenge = msg.ReadLong();
								if ( myChallenge != m_retryChallenge )
								{
									Msg( "Server challenge did not have the correct challenge, ignoring.\n" );
									return false;
								}

								int authprotocol = msg.ReadLong();
								uint64 unGSSteamID = 0;
								bool bGSSecure = false;
#if 0
								if ( authprotocol == PROTOCOL_STEAM )
								{
									if ( msg.ReadShort() != 0 )
									{
										Msg( "Invalid Steam key size.\n" );
										Disconnect( "Invalid Steam key size", true );
										return false;
									}
									if ( msg.GetNumBytesLeft() > sizeof(unGSSteamID) ) 
									{
										if ( !msg.ReadBytes( &unGSSteamID, sizeof(unGSSteamID) ) )
										{
											Msg( "Invalid GS Steam ID.\n" );
											Disconnect( "Invalid GS Steam ID", true );
											return false;
										}

										bGSSecure = ( msg.ReadByte() == 1 );
									}
									// The host can disable access to secure servers if you load unsigned code (mods, plugins, hacks)
									if ( bGSSecure && !Host_IsSecureServerAllowed() )
									{
										COM_ExplainDisconnection( true, "#GameUI_ServerInsecure" );
										Disconnect( "#GameUI_ServerInsecure", true );
										return false;
									}
								}
#endif
								SendConnectPacket( challenge, authprotocol, unGSSteamID, bGSSecure );
							}
							break;

客户端拿到服务端反的魔数,和自己的魔数对比,不符合就直接断开,拿到服务端反的挑战数,和自己的挑战数比对,如果不符就断开,拿到挑战协议类型,然后调用SendConnectPacket正式发出连接服务器请求。

	ALIGN4 char		msg_buffer[MAX_ROUTABLE_PAYLOAD] ALIGN4_POST;
	bf_write	msg( msg_buffer, sizeof(msg_buffer) );

	msg.WriteLong( CONNECTIONLESS_HEADER );
	msg.WriteByte( C2S_CONNECT );
	msg.WriteLong( PROTOCOL_VERSION );
	msg.WriteLong( authProtocol );
	msg.WriteLong( challengeNr );
	msg.WriteLong( m_retryChallenge );
	msg.WriteString( GetClientName() );	// Name
	msg.WriteString( password.GetString() );		// password
	msg.WriteString( GetSteamInfIDVersionInfo().szVersionString );	// product version
//	msg.WriteByte( ( g_pServerPluginHandler->GetNumLoadedPlugins() > 0 ) ? 1 : 0 ); // have any client-side server plug-ins been loaded?

	switch ( authProtocol )
	{
		// Fall through, bogus protocol type, use CD key hash.
		case PROTOCOL_HASHEDCDKEY:	CDKey = GetCDKeyHash();
									msg.WriteString( CDKey );		// cdkey
									break;

		case PROTOCOL_STEAM:		if ( !PrepareSteamConnectResponse( unGSSteamID, bGSSecure, adr, msg ) )
									{
										return;
									}
									break;

		default: 					Host_Error( "Unexepected authentication protocol %i!\n", authProtocol );
									return;
	}

正式连接的数据包如图,还是以CONNECTIONLESS_HEADER开头,消息类型是C2S_CONNECT,携带了通信协议版本,挑战类型,前面服务器返回的挑战数,自己的挑战数,客户端的玩家名称,密码等,记录下发送时间,必要的参数后发出连接数据包,在代码里搜索C2S_CONNECT可以找到服务端处理这个数据包的代码

		case C2S_CONNECT :
			{
				char cdkey[STEAM_KEYSIZE];
				char name[256];
				char password[256];
				char productVersion[32];
				
				int protocol = msg.ReadLong();
				int authProtocol = msg.ReadLong();
				int challengeNr = msg.ReadLong();
				int clientChallenge = msg.ReadLong();

				// pull the challenge number check early before we do any expensive processing on the connect
				if ( !CheckChallengeNr( packet->from, challengeNr ) )
				{
					RejectConnection( packet->from, clientChallenge, "#GameUI_ServerRejectBadChallenge" );
					break;
				}

				// rate limit the connections
				if ( !s_connectRateChecker.CheckIP( packet->from ) )
					return false;

				msg.ReadString( name, sizeof(name) );
				msg.ReadString( password, sizeof(password) );
				msg.ReadString( productVersion, sizeof(productVersion) );

服务端拿到客户端支持的协议版本号,挑战类型,服务端挑战数,客户端挑战数,检查挑战数是否正确,检查是否发送太频繁,拿到客户端名字,密码,产品版本号,检查产品版本号,没有问题之后就调用ConnectClient接受这个客户端并为之分配数据结构。

IClient *CBaseServer::ConnectClient ( netadr_t &adr, int protocol, int challenge, int clientChallenge, int authProtocol, 
							    const char *name, const char *password, const char *hashedCDkey, int cdKeyLen )
{
	COM_TimestampedLog( "CBaseServer::ConnectClient" );

	if ( !IsActive() )
	{
		return NULL;
	}

	if ( !name || !password || !hashedCDkey )
	{
		return NULL;
	}

	// Make sure protocols match up
	if ( !CheckProtocol( adr, protocol, clientChallenge ) )
	{
		return NULL;
	}


	if ( !CheckChallengeNr( adr, challenge ) )
	{
		RejectConnection( adr, clientChallenge, "#GameUI_ServerRejectBadChallenge" );
		return NULL;
	}
	COM_TimestampedLog( "CBaseServer::ConnectClient:  GetFreeClient" );

	CBaseClient	*client = GetFreeClient( adr );

	if ( !client )
	{
		RejectConnection( adr, clientChallenge, "#GameUI_ServerRejectServerFull" );
		return NULL;	// no free slot found
	}

	int nNextUserID = GetNextUserID();
	if ( !CheckChallengeType( client, nNextUserID, adr, authProtocol, hashedCDkey, cdKeyLen, clientChallenge ) ) // we use the client pointer to track steam requests
	{
		return NULL;
	}

	ISteamGameServer *pSteamGameServer = Steam3Server().SteamGameServer();
	if ( !pSteamGameServer && authProtocol == PROTOCOL_STEAM )
	{
		Warning("NULL ISteamGameServer in ConnectClient. Steam authentication may fail.\n");
	}

	if ( Filter_IsUserBanned( client->GetNetworkID() ) )
	{
		// Need to make sure the master server is updated with the rejected connection because
		// we called Steam3Server().NotifyClientConnect() in CheckChallengeType() above.
		if ( pSteamGameServer && authProtocol == PROTOCOL_STEAM )
			pSteamGameServer->SendUserDisconnect( client->m_SteamID ); 

		RejectConnection( adr, clientChallenge, "#GameUI_ServerRejectBanned" );
		return NULL;
	}

还是首先各种检查,参数是否合法,挑战数是否正确,协议版本是否匹配,如果协议版本不同,相当于两个只会不同方言的人交流,肯定不可以,检查ip是否被限制,检查连接服务器密码是否正确(如果服务器设置了密码的话),已连接客户端是否达到上限等等

	// create network channel
	INetChannel * netchan = NET_CreateNetChannel( m_Socket, &adr, adr.ToString(), client );

	if ( !netchan )
	{
		// Need to make sure the master server is updated with the rejected connection because
		// we called Steam3Server().NotifyClientConnect() in CheckChallengeType() above.
		if ( pSteamGameServer && authProtocol == PROTOCOL_STEAM )
			pSteamGameServer->SendUserDisconnect( client->m_SteamID ); 

		RejectConnection( adr, clientChallenge, "#GameUI_ServerRejectFailedChannel" );
		return NULL;
	}

	// setup netchannl settings
	netchan->SetChallengeNr( challenge );
	
	COM_TimestampedLog( "CBaseServer::ConnectClient:  client->Connect" );

	// make sure client is reset and clear
	client->Connect( name, nNextUserID, netchan, false, clientChallenge );

	m_nUserid = nNextUserID;
	m_nNumConnections++;

	// Will get reset from userinfo, but this value comes from sv_updaterate ( the default )
	client->m_fSnapshotInterval = 1.0f/20.0f;
	client->m_fNextMessageTime = net_time + client->m_fSnapshotInterval;
	// Force a full delta update on first packet.
	client->m_nDeltaTick = -1;
	client->m_nSignonTick = 0;
	client->m_nStringTableAckTick = 0;
	client->m_pLastSnapshot = NULL;

符合条件后给客户端创建专门的通信频道,之后通信就通过这个专门的频道,不再处处检查,提高效率。初始化client数据结构的各个参数,m_fSnapshotInterval是和客户端通信频率,m_fNextMessageTime标识发送消息时间,m_nDeltaTick、m_nSignonTick、m_nStringTableAckTick、m_pLastSnapshot是客户端和服务端数据同步的重要参数,以后解析。这个时候服务端已经认可了这个客户端并为客户端准备好了必须的数据结构,只需要通知客户端连接成功,然后等待客户端发送数据。

	// Tell client connection worked, now use netchannels
	{
		ALIGN4 char		msg_buffer[MAX_ROUTABLE_PAYLOAD] ALIGN4_POST;
		bf_write	msg( msg_buffer, sizeof(msg_buffer) );

		msg.WriteLong( CONNECTIONLESS_HEADER );
		msg.WriteByte( S2C_CONNECTION );
		msg.WriteLong( clientChallenge );
		msg.WriteString( "0000000000" ); // pad out

		NET_SendPacket ( NULL, m_Socket, adr, msg.GetData(), msg.GetNumBytesWritten() );
	}

	// Set up client structure.
	if ( authProtocol == PROTOCOL_HASHEDCDKEY )
	{
		// use hased CD key as player GUID
		Q_strncpy ( client->m_GUID, hashedCDkey, SIGNED_GUID_LEN );
		client->m_GUID[SIGNED_GUID_LEN] = '\0';
	}
	else if ( authProtocol == PROTOCOL_STEAM )
	{
		// StartSteamValidation() above initialized the clients networkid
	}

最后发送通知客户端的数据包,这个数据包还是CONNECTIONLESS_HEADER开头,消息类型是S2C_CONNECTION,在代码里搜素S2C_CONNECTION可以找到客户端处理这个数据包的代码

	case S2C_CONNECTION:	if ( m_nSignonState == SIGNONSTATE_CHALLENGE )
							{
								int myChallenge = msg.ReadLong();
								if ( myChallenge != m_retryChallenge )
								{
									Msg( "Server connection did not have the correct challenge, ignoring.\n" );
									return false;
								}

								// server accepted our connection request
								FullConnect( packet->from );
							}
							break;

客户端调用FullConnect完成连接

void CBaseClientState::FullConnect( netadr_t &adr )
{
	// Initiate the network channel
	
	COM_TimestampedLog( "CBaseClientState::FullConnect" );

	m_NetChannel = NET_CreateNetChannel( m_Socket, &adr, "CLIENT", this );

	Assert( m_NetChannel );
	
	m_NetChannel->StartStreaming( m_nChallengeNr );	// open TCP stream

	// Bump connection time to now so we don't resend a connection
	// Request	
	m_flConnectTime = net_time; 

	// We'll request a full delta from the baseline
	m_nDeltaTick = -1;

	// We can send a cmd right away
	m_flNextCmdTime = net_time;

	// Mark client as connected
	SetSignonState( SIGNONSTATE_CONNECTED, -1 );
#if !defined(SWDS)
	RCONClient().SetAddress( m_NetChannel->GetRemoteAddress() );
#endif

	// Fire an event when we get our connection
	IGameEvent *event = g_GameEventManager.CreateEvent( "client_connected" );
	if ( event )
	{
		event->SetString( "address", m_NetChannel->GetRemoteAddress().ToString( true )	);
		event->SetInt(    "ip", m_NetChannel->GetRemoteAddress().GetIPNetworkByteOrder() ); // <<< Network byte order?
		event->SetInt(    "port", m_NetChannel->GetRemoteAddress().GetPort() );
		g_GameEventManager.FireEventClientSide( event );
	}
	
}

这里初始化和服务端的专用通信频道,初始化参数,将客户端连接状态改成了SIGNONSTATE_CONNECTED标识客户端连接服务端成功。

全部评论