Author Topic: Nov 23, 2017 -- Integrating TNet with Steam Networking  (Read 254 times)

ArenMook

  • Administrator
  • Hero Member
  • *****
  • Thank You
  • -Given: 337
  • -Receive: 1171
  • Posts: 22,137
  • Toronto, Canada
    • View Profile
Nov 23, 2017 -- Integrating TNet with Steam Networking
« on: November 23, 2017, 03:07:24 AM »
Back in Windward days, I would sometimes get comments from players saying they can't join their friends' games, no matter what they tried. Let's face it, while us devs find it a trivial task to open up a port on the router, and TNet does indeed us UPnP to do this automatically, the players are less savvy and can sometimes be behind such firewalls that even UPnP can't breach. Fortunately, Steam has ways around it, and they've indeed taken care of all of this with their networking API. It uses UDP to simulate TCP-like functionality, but since it's UDP, NAT punchthrough is easy to do. Better still, if NAT punchthrough fails, Steam allows using its servers as relays to still make it possible for two players to play together.

Of course there are limitations: first, both players must be using Steam. But hey, let's face it -- Steam is the best platform out there for gamers. Is there a reason NOT to use it? Second, the packet size is limited to just over 1 MB -- but quite frankly if your game is sending out packets greater than 1 MB in size, you're probably doing something wrong. And last but not least, the API itself is a little... weird. To explain just what I mean by that, let's look at the steps required with the latest version of TNet (from the Pro repository as of this writing).

First, you will want to grab Steamworks.NET here: https://github.com/rlabrecque/Steamworks.NET

Next, let's start by making a new controller / wrapper class. I called mine "Steam" for simplicity.
  1. using UnityEngine;
  2. using Steamworks;
  3. using TNet;
  4.  
  5. public partial class Steam
  6. {
  7.     CSteamID userID;
  8.  
  9.     void Awake ()
  10.     {
  11.         SteamAPI.Init();
  12.         SteamUserStats.RequestCurrentStats();
  13.         userID = SteamUser.GetSteamID();
  14.         DontDestroyOnLoad(gameObject);
  15.     }
  16.  
  17.     void OnDestroy () { SteamAPI.Shutdown(); }
  18.  
  19.     void Update () { SteamAPI.RunCallbacks(); }
  20. }
With the script attached to a game object in your first scene, Steamworks API will be initialized, and it will be shut down when your application does. The Update() function simply lets Steam do its thing.

Next, we need to create a special connection wrapper class for Steam to use with TNet. By default TNet will use its sockets for communication, but since we'll be using Steam here, we should bypass that. Fortunately the latest Pro version of TNet has a way to specify an IConnection object for every single TcpProtocol which essentially inserts its operations in between of TNet's sockets, making all of this possible. I chose to make this class inside the Steam class, but it's up to you where you place it.
  1. public partial class Steam
  2. {
  3.         [System.NonSerialized] static System.Collections.Generic.Dictionary<CSteamID, TcpProtocol> mOpen = new System.Collections.Generic.Dictionary<CSteamID, TcpProtocol>();
  4.         [System.NonSerialized] static System.Collections.Generic.HashSet<CSteamID> mClosed = new System.Collections.Generic.HashSet<CSteamID>();
  5.  
  6.         class P2PConnection : IConnection
  7.         {
  8.                 public CSteamID id;
  9.                 public bool connecting = false;
  10.                 public bool disconnected = false;
  11.  
  12.                 public bool isConnected { get { return !disconnected; } }
  13.  
  14.                 public bool SendPacket (Buffer buffer) { return SteamNetworking.SendP2PPacket(id, buffer.buffer, (uint)buffer.size, EP2PSend.k_EP2PSendReliable); }
  15.  
  16.                 public void ReceivePacket (out Buffer buffer) { buffer = null; }
  17.  
  18.                 public void OnDisconnect ()
  19.                 {
  20.                         if (!disconnected)
  21.                         {
  22.                                 disconnected = true;
  23.  
  24.                                 var buffer = Buffer.Create();
  25.                                 buffer.BeginPacket(Packet.Disconnect);
  26.                                 buffer.EndPacket();
  27.                                 SteamNetworking.SendP2PPacket(id, buffer.buffer, (uint)buffer.size, EP2PSend.k_EP2PSendReliable);
  28.                                 buffer.Recycle();
  29.  
  30.                                 lock (mOpen)
  31.                                 {
  32.                                         mOpen.Remove(id);
  33.                                         if (!mClosed.Contains(id)) mClosed.Add(id);
  34.                                 }
  35.  
  36.                                 if (TNManager.custom == this) TNManager.custom = null;
  37.                         }
  38.                 }
  39.         }
  40. }
So what does the P2PConnection class do? Not much.  It keeps the Steam ID identifier since that's the "address" for each "connection". I use both terms in quotations because instead of addresses, Steam's packets are sent directly to players, and players are identified by their Steam ID. Likewise, there are no "connections" established with Steam's API. Remember how I said that the API itself is a bit weird? Well, this right here is what I meant. Instead of the expected workflow where a connection must first be established and acknowledged before packets start flowing, Steam's approach is different. You simply start sending packets to your friend like you're the best buddies in the world. Your friend's client gets the packets along with a special notification of whether to accept the incoming packets or not. If the client chooses to accept the packets, they can be received immediately. There is no "decline" option. In fact, no notification is sent back to the first player at all, and trying to do so will actually auto-accept the packets! So the options are: accept packets, or ignore them, leaving the other player wondering.

Anyway, so back to P2PConnection. ReceivePacket() can't be handled here, because packets don't arrive via sockets. Instead they arrive in one place, and must then be queued in the right place -- which we'll get to in a bit. For now, the only two useful functions in that class are SendPacket -- which simply calls the appropriate SteamNetworking API function, and the OnDisconnect notification. This one needs some explanation.

Since there is no concept of "connections" with Steam's API, we have to account for this ourselves. So to keep it short, we're simply sending a Disconnect packet to the other player when we're done. We're also keeping a list of known open and closed "connections" (and I'm going to tire of using quotation marks by the end of this post...). So to sum it up, when TNet says that the connection is closed, we still send out a Disconnect packet to the other player, ensuring that they know to stop sending us packets.

Moving on -- we have the custom connection class for TNet. We should now use it. Let's start by writing the Connect function:
  1.         void ConnectP2P (CSteamID id)
  2.         {
  3.                 if (TNManager.custom == null && !TNManager.isConnected && !TNManager.isTryingToConnect && mInst != null)
  4.                 {
  5.                         CancelInvoke("CancelConnect");
  6.  
  7.                         var p2p = new P2PConnection();
  8.                         p2p.id = id;
  9.                         p2p.connecting = true;
  10.                         TNManager.custom = p2p;
  11.                         TNManager.client.stage = TcpProtocol.Stage.Verifying;
  12.  
  13.                         var buffer = Buffer.Create();
  14.                         var writer = buffer.BeginPacket(Packet.RequestID);
  15.                         writer.Write(Player.version);
  16.                         writer.Write(TNManager.playerName);
  17.                         writer.Write(TNManager.playerData);
  18.                         var size = buffer.EndPacket();
  19.                         SteamNetworking.SendP2PPacket(id, buffer.buffer, (uint)size, EP2PSend.k_EP2PSendReliable);
  20.                         buffer.Recycle();
  21.  
  22.                         Invoke("CancelConnect", 8f);
  23.                 }
  24. #if UNITY_EDITOR
  25.                 else Debug.Log("Already connecting, ignoring");
  26. #endif
  27.         }
Inside the ConnectP2P function we create our custom P2PConnection object and assign it as TNManager.custom -- meaning it will be used by TNManager's TcpProtocol for all communication instead of sockets. We also immediately send out a packet requesting the ID. TNet does this whenever a TCP connection is established, so we should follow the same path. This packet will be received by the other player (the one hosting the game server), and a response will be sent back, actually activating the connection.

One other thing the function does is it calls the "CancelConnect" function via a delayed invoke, which will simply act as a time-out:
  1.         void CancelConnect ()
  2.         {
  3.                 var p2p = TNManager.custom as P2PConnection;
  4.  
  5.                 if (p2p != null && p2p.connecting)
  6.                 {
  7.                         TNManager.client.stage = TcpProtocol.Stage.NotConnected;
  8.                         TNManager.onConnect(false, "Unable to connect");
  9.                         TNManager.custom = null;
  10.                 }
  11.         }
It's also useful to have a String-accepting version of the Connect function, for convenience:
  1.         static public bool Connect (string str)
  2.         {
  3.                 ulong steamID;
  4.  
  5.                 if (mInst != null && isActive && !str.Contains(".") && ulong.TryParse(str, out steamID))
  6.                 {
  7.                         mInst.ConnectP2P(new Steamworks.CSteamID(steamID));
  8.                         return true;
  9.                 }
  10.                 return false;
  11.         }
So -- we now have a way to start the connection with a remote player. We now need to handle this operation on the other side. To do that, we need to subscribe to a few events. First is the P2PSessionRequest_t callback -- this is the notification that effectively asks you if you want to receive packets from the other player. Ignoring it is one option, but simply calling AcceptP2PSessionWithUser is more useful. Just in case though, we only do it if there is a game server running. We also need to handle the error notification:
  1.         // Callbacks are added to a list so they don't get discarded by GC
  2.         List<object> mCallbacks = new List<object>();
  3.  
  4.         void Start ()
  5.         {
  6.                 // P2P connection request
  7.                 mCallbacks.Add(Callback<P2PSessionRequest_t>.Create(delegate (P2PSessionRequest_t val)
  8.                 {
  9.                         if (TNServerInstance.isListening) SteamNetworking.AcceptP2PSessionWithUser(val.m_steamIDRemote);
  10.                 }));
  11.  
  12.                 // P2P connection error
  13.                 mCallbacks.Add(Callback<P2PSessionConnectFail_t>.Create(delegate (P2PSessionConnectFail_t val)
  14.                 {
  15.                         Debug.LogError("P2P Error: " + val.m_steamIDRemote + " (" + val.m_eP2PSessionError + ")");
  16.                         CancelInvoke("CancelConnect");
  17.                         CancelConnect();
  18.                 }));
  19.         }
With this done, the server-hosting client is now able to start accepting the packets. We now need to actually receive them. To do that, let's expand the Update() function:
  1.         // Buffer used to receive data
  2.         static byte[] mTemp;
  3.  
  4.         void Update ()
  5.         {
  6.                 SteamAPI.RunCallbacks();
  7.  
  8.                 uint size;
  9.                 if (!SteamNetworking.IsP2PPacketAvailable(out size)) return;
  10.  
  11.                 CSteamID id;
  12.  
  13.                 lock (mOpen)
  14.                 {
  15.                         for (;;)
  16.                         {
  17.                                 if (mTemp == null || mTemp.Length < size) mTemp = new byte[size < 4096 ? 4096 : size];
  18.  
  19.                                 if (SteamNetworking.ReadP2PPacket(mTemp, size, out size, out id))
  20.                                 {
  21.                                         int offset = 0;
  22.  
  23.                                         while (offset + 4 < size)
  24.                                         {
  25.                                                 int expected = mTemp[offset++];
  26.                                                 expected |= (mTemp[offset++] << 8);
  27.                                                 expected |= (mTemp[offset++] << 16);
  28.                                                 expected |= (mTemp[offset++] << 24);
  29.  
  30.                                                 var buffer = Buffer.Create();
  31.                                                 var writer = buffer.BeginWriting();
  32.                                                 writer.Write(expected);
  33.                                                 writer.Write(mTemp, offset, expected);
  34.                                                 buffer.BeginReading(4);
  35.                                                 AddPacketP2P(id, buffer);
  36.  
  37.                                                 offset += expected;
  38.                                         }
  39.                                 }
  40.  
  41.                                 if (!SteamNetworking.IsP2PPacketAvailable(out size))
  42.                                 {
  43.                                         UnityEngine.Profiling.Profiler.EndSample();
  44.                                         return;
  45.                                 }
  46.                         }
  47.                 }
  48.         }
The code above simply checks -- is there a packet to process? If so, it enters the receiving loop where data is read into a temporary buffer, and then placed into individual buffers that TNet expects. Basically the stuff that TNet does under the hood when receiving packets. Since we're doing the receiving, we also need to do the splitting. Each packet is added to the appropriate queue by calling the AddPacketP2P function which we will write now:
  1.         static void AddPacketP2P (CSteamID id, Buffer buffer)
  2.         {
  3.                 TcpProtocol tcp;
  4.  
  5.                 if (mOpen.TryGetValue(id, out tcp))
  6.                 {
  7.                         // Existing connection
  8.                         var p2p = tcp.custom as P2PConnection;
  9.                         if (p2p != null && p2p.connecting) p2p.connecting = false;
  10.                 }
  11.                 else if (TNServerInstance.isListening)
  12.                 {
  13.                         // New connection
  14.                         var p2p = new P2PConnection();
  15.                         p2p.id = id;
  16.  
  17.                         lock (mOpen)
  18.                         {
  19.                                 tcp = TNServerInstance.AddPlayer(p2p);
  20.                                 mOpen[id] = tcp;
  21.                                 mClosed.Remove(id);
  22.                         }
  23.                 }
  24.                 else if (TNManager.custom != null)
  25.                 {
  26.                         // New connection
  27.                         var p2p = TNManager.custom as P2PConnection;
  28.                         if (p2p == null) return;
  29.  
  30.                         p2p.id = id;
  31.                         tcp = TNManager.client.protocol;
  32.  
  33.                         lock (mOpen)
  34.                         {
  35.                                 mOpen[id] = tcp;
  36.                                 mClosed.Remove(id);
  37.                         }
  38.                 }
  39.                 else return;
  40.  
  41.                 tcp.AddPacket(buffer);
  42.         }
The AddPacketP2P function checks if it's an existing connection first. If it is, the connection is marked as no longer trying to connect, and the packet is added to the TcpProtocol's receiving queue. If the connection is not yet open, we check to see if a game server is running. If it is, a new P2PConnection is created and a new player gets created on the server. This player won't have an IP address or an open TCP socket. Instead, it has the reference to the P2PConnection which it will use for communication.

Last but not least, if the game server is not running, the function checks to see if the TNManager has its own P2P reference set. We assigned it in ConnectP2P(), so this means that this check effectively makes sure that we are trying to connect. If this check passes, the packet is added to the TNManager client's incoming queue.

If all else fails, the packet is simply ignored.

So that's that! This is all you need to be able to effectively overwrite TNet's networking functionality with Steam Networking. Before you go though, you may want to make it possible for people to be able to right-click their friends in the Steam friends list and use the "Join game" option. To do this, you need to set the rich presence's "+connect" key:
  1.         public void AllowFriendsToJoin (bool allow)
  2.         {
  3.                 if (allow) SteamFriends.SetRichPresence("connect", "+connect " + userID);
  4.                 else SteamFriends.SetRichPresence("connect", "");
  5.         }
Simply call Steam.AllowFriendsToJoin(true) when you want to make it possible for them to join. Personally, I placed it inside a function called from TNManager.onConnect, but it's up to you where you need it to be.

You will also need to subscribe to the Join Request like so:
  1.                 // Join a friend
  2.                 mCallbacks.Add(Callback<GameRichPresenceJoinRequested_t>.Create(delegate (GameRichPresenceJoinRequested_t val)
  3.                 {
  4.                         var addr = val.m_rgchConnect;
  5.                         addr = addr.Replace("+connect ", "");
  6.                         if (!Connect(addr)) TNManager.Connect(addr);
  7.                 }));
When a player chooses the "Join Friend" option from within a game, GameRichPresenceJoinRequested_t will be triggered. When a player uses the same option while the game isn't launched, GameRichPresenceJoinRequested_t won't be sent. Instead a "+connect <string>" command-line option will be sent to the game's executable -- so you will want to handle that yourself.

Anyway, that's it! This is all you need to make your TNet-powered game be able to use Steam Networking. I hope this helps someone!
« Last Edit: November 23, 2017, 03:00:11 PM by ArenMook »