using Sandbox.Compression; using Sandbox.Network; using Sandbox.Utility; using Sentry; using Steamworks; using Steamworks.Data; using System.Buffers.Binary; using System.Runtime.InteropServices; using System.Text; using System.Threading; using Steam = NativeEngine.Steam; namespace Sandbox; /// /// Global manager to hold and tick the singleton instance of NetworkSystem. /// public static partial class Networking { internal const int MaxIncomingMessages = 32; internal static NetworkSystem System; internal static Dictionary ServerData { get; set; } = new(); private const byte FlagUncompressed = 0; private const byte FlagLz4 = 1; /// /// The minimum byte count required to compress using LZ4 encoding. This number /// was chosen because the overhead is often not worth it otherwise. /// private const int MinimumCompressionByteCount = 128; /// /// Try to encode the data from the specified using LZ4 encoding. /// If the data is less than the required byte count, the data will not be compressed. /// internal static byte[] EncodeStream( ByteStream stream ) { var src = stream.ToSpan(); // Compress only if it’s large enough if ( src.Length > MinimumCompressionByteCount ) { var compressed = LZ4.CompressBlock( src ); // Only keep compression if it actually helped if ( compressed.Length < src.Length ) { var output = new byte[1 + sizeof( int ) + compressed.Length]; output[0] = FlagLz4; BinaryPrimitives.WriteInt32LittleEndian( output.AsSpan( 1 ), src.Length ); compressed.CopyTo( output.AsSpan( 1 + sizeof( int ) ) ); return output; } } var result = new byte[1 + sizeof( int ) + src.Length]; result[0] = FlagUncompressed; BinaryPrimitives.WriteInt32LittleEndian( result.AsSpan( 1 ), src.Length ); src.CopyTo( result.AsSpan( 1 + sizeof( int ) ) ); return result; } private static readonly byte[] ReceiveBuffer = new byte[1024 * 1024 * 4]; /// /// Try to decode the supplied data using LZ4. If the data cannot be decompressed, then the /// original data will be returned. /// internal static Span DecodeStream( byte[] data ) { if ( data.Length < 1 + sizeof( int ) ) return data; var flag = data[0]; var originalLen = BinaryPrimitives.ReadInt32LittleEndian( data.AsSpan( 1, sizeof( int ) ) ); ReadOnlySpan payload = data.AsSpan( 1 + sizeof( int ) ); switch ( flag ) { case FlagUncompressed: return MemoryMarshal.CreateSpan( ref MemoryMarshal.GetArrayDataReference( data ), data.Length ) .Slice( 1 + sizeof( int ), originalLen ); case FlagLz4: { int written = LZ4.DecompressBlock( payload.ToArray(), ReceiveBuffer ); return ReceiveBuffer.AsSpan( 0, written ); } default: return data; } } /// /// Set data about the current server or lobby. Other players can query this /// when searching for a game. Note: for now, try to keep the key and value as short /// as possible, Steam enforce a character limit on server tags, so it could be possible /// to reach that limit when running a Dedicated Server. In the future we'll store this /// stuff on our backend, so that won't be a problem. /// public static void SetData( string key, string value ) { ServerData[key] = value; if ( !IsHost || System is null ) return; foreach ( var s in System.Sockets ) { s.SetData( key, value ); } var msg = new ServerDataMsg { Name = key, Value = value }; System.Broadcast( msg, Connection.ChannelState.Welcome ); } /// /// Get data about the current server or lobby. This data can be used for filtering /// when querying lobbies. /// public static string GetData( string key, string defaultValue = "" ) { return ServerData.GetValueOrDefault( key, defaultValue ); } private static string _serverName; private static string _mapName; /// /// The name of the server you are currently connected to. /// public static string ServerName { get => _serverName; set { if ( _serverName == value ) return; _serverName = value; if ( !IsHost || System is null ) return; foreach ( var s in System.Sockets ) { s.SetServerName( value ); } var msg = new ServerNameMsg { Name = value }; System.Broadcast( msg, Connection.ChannelState.Welcome ); } } /// /// The name of the map being used on the server you're connected to. /// public static string MapName { get => _mapName; internal set { if ( _mapName == value ) return; _mapName = value; if ( !IsHost || System is null ) return; foreach ( var s in System.Sockets ) { s.SetMapName( value ); } var msg = new MapNameMsg { Name = value }; System.Broadcast( msg, Connection.ChannelState.Welcome ); } } /// /// The maximum number of players allowed on the server you're connected to. /// public static int MaxPlayers { get; internal set; } /// /// The last connection string used to connect to a server. /// internal static string LastConnectionString { get; set; } [ConVar( "net_debug", ConVarFlags.Protected )] internal static bool Debug { get; set; } [ConVar( "net_shared_query_port", ConVarFlags.Protected )] internal static bool SharedQueryPort { get; set; } = true; [ConVar( "net_use_fake_ip", ConVarFlags.Protected )] internal static bool UseFakeIP { get; set; } = true; [ConVar( "net_game_server_token", ConVarFlags.Protected )] internal static string GameServerToken { get; set; } = string.Empty; [ConVar( "net_interp_time", ConVarFlags.Protected, Help = "Interpolation time in seconds" )] internal static float InterpolationTime { get; set; } = 0.1f; [ConVar( "net_fakepacketloss", ConVarFlags.Protected | ConVarFlags.Cheat, Help = "Simulate packet loss in %" )] internal static int FakePacketLoss { get; set; } = 0; [ConVar( "net_fakelag", ConVarFlags.Protected | ConVarFlags.Cheat, Help = "Simulate latency in ms" )] internal static int FakeLag { get; set; } = 0; [ConCmd( "hostname", ConVarFlags.Protected | ConVarFlags.Admin )] private static void SetHostname( string name ) { ServerName = name; } [ConVar( "port", ConVarFlags.Protected )] internal static int Port { get; set; } = 27015; [ConCmd( "kick", ConVarFlags.Protected )] private static void Kick( string id, string reason = "" ) { if ( !IsHost ) { Log.Warning( "You need to be the host to kick other players!" ); return; } var connection = Connection.All.FirstOrDefault( c => c.SteamId.ToString() == id || c.DisplayName.Contains( id ) ); if ( connection is null ) { Log.Warning( "Unable to find a matching connection with that Steam Id or Display Name!" ); return; } connection.Kick( reason ); } /// /// Get the latest host stats such as bandwidth used and the current frame rate. /// public static HostStats HostStats => System?.HostStats ?? default; /// /// True if we can be considered the host of this session. Either we're not connected to a server, or we are host of a server. /// public static bool IsHost => System is null || System.IsHost; /// /// True if we're currently connected to a server, and we are not the host /// public static bool IsClient => System is not null && System.IsClient; /// /// True if we're currently connecting to the server /// public static bool IsConnecting => System?.IsConnecting ?? false; /// /// True if we're currently connecting to the server /// public static bool IsActive => System is not null; /// /// True if we're currently disconnecting from the server /// internal static bool IsDisconnecting => System is not null && System.IsDisconnecting; /// /// The connection of the current network host. /// [Obsolete( "Moved to Connection.Host" )] public static Connection HostConnection => Connection.Host; /// /// Whether the host is busy right now. This can be used to determine if /// the host can be changed. /// internal static bool IsHostBusy { get { return System?.IsHostBusy ?? true; } } /// /// A list of connections that are currently on this server. If you're not on a server /// this will return only one connection (Connection.Local). Some games restrict the /// connection list - in which case you will get an empty list. /// [Obsolete( "Moved to Connection.All" )] public static IReadOnlyList Connections => Connection.All; internal static void Bootstrap() { var utils = NativeEngine.Steam.SteamNetworkingUtils(); if ( !utils.IsValid ) return; var sockets = NativeEngine.Steam.SteamNetworkingSockets(); if ( !sockets.IsValid ) return; Log.Info( "Bootstrap Networking..." ); // conna: fuck it, let's set these to insane values. var maxBufferSize = 1024 * 1024 * 64; utils.SetConfig( NetConfig.SendBufferSize, maxBufferSize ); utils.SetConfig( NetConfig.RecvBufferSize, maxBufferSize ); utils.SetConfig( NetConfig.RecvMaxMessageSize, maxBufferSize ); utils.SetConfig( NetConfig.RecvBufferMessages, 256 * 256 ); // conna: allow 120s before a client will disconnect from a timeout. utils.SetConfig( NetConfig.TimeoutConnected, 120 * 1000 ); // conna: when these two values are not the same, there seems to be a bug that causes the send buffer // to often become clogged up and not clear properly. Ultimately resulting in heavier load and backlog. // These values are ridiculous because there's no way to remove this limit. So let's just make it 1gbps. utils.SetConfig( NetConfig.SendRateMin, 1024 * 1024 * 1024 ); utils.SetConfig( NetConfig.SendRateMax, 1024 * 1024 * 1024 ); utils.SetConfig( NetConfig.P2P_Transport_ICE_Enable, Defines.k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_All ); utils.SetConfig( NetConfig.P2P_STUN_ServerList, "stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302" ); utils.InitializeRelayNetwork(); sockets.StartAuthentication(); } /// /// Internally update the server name without propagating to sockets. /// /// internal static void UpdateServerName( string name ) { _serverName = name; } /// /// Get the status of our connection to the Steam Datagram Relay service. /// /// internal static unsafe SteamNetworkingAvailability GetSteamRelayStatus( out string debugMsg ) { var utils = Steam.SteamNetworkingUtils(); if ( !utils.IsValid ) { debugMsg = "SteamNetworkingUtils is not initialized"; return SteamNetworkingAvailability.Unknown; } var buffer = new byte[256]; fixed ( byte* ptr = buffer ) { var availability = Glue.Networking.GetRelayNetworkStatus( new( ptr ) ); debugMsg = Encoding.UTF8.GetString( buffer ).TrimEnd( '\0' ); return availability; } } /// /// Reset any static members to their defaults or clear them. /// internal static void Reset() { MaxPlayers = 0; ServerData.Clear(); } private static int? OldFakePacketLoss { get; set; } private static int? OldFakeLag { get; set; } private static void UpdateFakeLag() { var utils = NativeEngine.Steam.SteamNetworkingUtils(); if ( !utils.IsValid ) return; if ( OldFakePacketLoss != FakePacketLoss ) { var clampedPacketLoss = FakePacketLoss.Clamp( 0, 100 ); utils.SetConfig( NetConfig.FakePacketLoss_Send, clampedPacketLoss ); utils.SetConfig( NetConfig.FakePacketLoss_Recv, clampedPacketLoss ); OldFakePacketLoss = FakePacketLoss; } if ( OldFakeLag == FakeLag ) return; utils.SetConfig( NetConfig.FakePacketLag_Send, FakeLag ); utils.SetConfig( NetConfig.FakePacketLag_Recv, FakeLag ); OldFakeLag = FakeLag; } internal static void PreFrameTick() { UpdateFakeLag(); try { SteamNetwork.RunCallbacks(); System?.Tick(); System?.SendTableUpdates(); System?.SendHeartbeat(); System?.SendHostStats(); } catch ( Exception e ) { Log.Error( e ); } } internal static void PostFrameTick() { try { System?.SendTableUpdates(); } catch ( Exception e ) { Log.Error( e ); } } [Obsolete( "Moved to Connection.Find" )] public static Connection FindConnection( Guid id ) => Connection.Find( id ); /// /// Try to join the best lobby. Return true on success. /// public static async Task JoinBestLobby( string ident ) { // get all lobbies var lobbies = await QueryLobbies( ident ); // // try to join most populated with the fewest historic hosts // foreach ( var lobby in lobbies.OrderByDescending( x => x.Members - x.Get( "hostcount" ).ToInt( 0 ) ) ) { Log.Info( $"Trying to connect to {lobby.LobbyId} ({lobby.Name}).." ); if ( await TryConnectSteamId( lobby.LobbyId ) ) { return true; } } return false; } /// /// When creating a lobby from the editor, we'll use this override for the lobby privacy. /// internal static LobbyPrivacy EditorLobbyPrivacy { get; set; } = LobbyPrivacy.Private; private static CancellationTokenSource createLobbyCts; /// /// Will create a new lobby with the specified to /// customize the lobby further. /// public static void CreateLobby( LobbyConfig config ) { // Let's not make a lobby if we're in the editor and we're not playing a game. // Editor tools could call this, and we don't want a lingering lobby. if ( Application.IsEditor && !Game.IsPlaying ) throw new UnauthorizedAccessException( "Unable to create a lobby outside of a game" ); if ( IsActive ) return; createLobbyCts?.Cancel(); createLobbyCts = new(); // // Did the menu want to override the lobby's max players? // if ( LaunchArguments.MaxPlayers > 1 ) { config.MaxPlayers = LaunchArguments.MaxPlayers; } // // Did the menu want to override the lobby's name? // if ( !string.IsNullOrEmpty( LaunchArguments.ServerName ) ) { config.Name = LaunchArguments.ServerName; } // // Did the menu want to override the lobby's privacy mode? // if ( LaunchArguments.Privacy != config.Privacy ) { config.Privacy = LaunchArguments.Privacy; } _ = CreateLobbyAsync( config, createLobbyCts ); } /// /// Will create a new lobby. /// [Obsolete( "Use CreateLobby( LobbyConfig )" )] public static void CreateLobby() { var config = new LobbyConfig { MaxPlayers = Application.GamePackage?.GetCachedMeta( "MaxPlayers", 32 ) ?? 32 }; CreateLobby( config ); } static async Task CreateDedicatedServer( LobbyConfig config, CancellationTokenSource cts = null ) { var success = await DedicatedServer.Start( config ); if ( !success ) return false; lock ( NetworkThreadLock ) { var net = new NetworkSystem( "server", Engine.IGameInstanceDll.Current.TypeLibrary ) { Config = config }; System = net; net.InitializeHost(); net.AddSocket( DedicatedServer.IpSocket ); net.AddSocket( DedicatedServer.IdSocket ); return !(cts?.IsCancellationRequested ?? false); } } static async Task CreateLobbyAsync( LobbyConfig config, CancellationTokenSource cts = null ) { if ( IsActive ) return false; if ( Application.IsEditor ) { config.Privacy = EditorLobbyPrivacy; } if ( Application.IsDedicatedServer ) { return await CreateDedicatedServer( config, cts ); } var net = new NetworkSystem( "lobbyhost", Engine.IGameInstanceDll.Current.TypeLibrary ) { Config = config }; lock ( NetworkThreadLock ) { System = net; net.InitializeHost(); } if ( Engine.IToolsDll.Current is not null ) { await Engine.IToolsDll.Current.OnInitializeHost(); } if ( cts?.IsCancellationRequested ?? false ) return false; var socket = await SteamLobbySocket.Create( config ); if ( socket is null ) { if ( cts?.IsCancellationRequested ?? false ) return false; Disconnect(); return false; } if ( cts?.IsCancellationRequested ?? false ) return false; net.AddSocket( socket ); // // If runnning in editor, we create a named socket that we can join locally // if ( Application.IsEditor || Application.IsStandalone ) { net.AddSocket( new TcpSocket( "127.0.0.1", 55333 ) ); } return true; } /// /// Disconnect from current multiplayer session. /// public static void Disconnect() { if ( System is null ) return; lock ( NetworkThreadLock ) { // Send any remaining messages System.ProcessMessagesInThread(); SentrySdk.AddBreadcrumb( $"Disconnected from {System}", "network.disconnect" ); System.Disconnect(); System = null; createLobbyCts?.Cancel(); createLobbyCts = null; DedicatedServer.Hide(); } } internal static IDisposable DisconnectScope() { if ( System is null ) return default; System.IsDisconnecting = true; return new DisposeAction( () => { System.IsDisconnecting = false; Disconnect(); } ); } public static void Connect( ulong steamid ) => Connect( steamid.ToString() ); /// /// Will try to determine the right method for connection, and then try to connect. /// public static void Connect( string target ) { Disconnect(); _ = TryConnect( target ); } internal static async Task TryConnect( string target, int retries = 30 ) { if ( string.IsNullOrWhiteSpace( target ) ) { Log.Warning( "Couldn't connect - target is null!" ); return false; } SentrySdk.AddBreadcrumb( $"Connect to '{target}'", "network.connect" ); Assert.IsNull( System ); // // SteamID // if ( ulong.TryParse( target, out var steamId ) ) { return await TryConnectSteamId( steamId ); } var count = 0; while ( count < retries ) { lock ( NetworkThreadLock ) { if ( target == "local" ) { Assert.NotNull( Engine.IGameInstanceDll.Current ); Assert.NotNull( Engine.IGameInstanceDll.Current.TypeLibrary ); Log.Info( $"Connecting to local client.." ); System = new( "localclient", Engine.IGameInstanceDll.Current.TypeLibrary ); System.Connect( new TcpChannel( "127.0.0.1", 55333 ) ); System.UpdateLoading( "Connecting" ); LastConnectionString = target; } else { Log.Info( $"Connecting to {target}.." ); System = new( "client", Engine.IGameInstanceDll.Current.TypeLibrary ); System.Connect( new SteamNetwork.IpConnection( target ) ); System.UpdateLoading( "Connecting" ); LastConnectionString = target; } } var success = await AwaitSuccessfulConnection(); if ( success ) return true; if ( System is null ) return false; Log.Info( $"Couldn't connect, trying again ({count} out of {retries})" ); count++; Disconnect(); } return false; } static async Task AwaitSuccessfulConnection() { for ( var i = 0; i < 30; i++ ) { await Task.Delay( 100 ); if ( System is null ) return false; if ( Connection.Local?.State > Connection.ChannelState.Unconnected ) return true; } return false; } /// /// Will try to connect to a server. Will return false if failed to connect. /// public static async Task TryConnectSteamId( SteamId steamId ) { Disconnect(); if ( steamId.AccountType == SteamId.AccountTypes.Lobby ) { return await JoinSteamLobbyServer( steamId ); } // Don't load no weird maps LaunchArguments.Reset(); lock ( NetworkThreadLock ) { System = new( "steamclient", Engine.IGameInstanceDll.Current.TypeLibrary ); System.Connect( new SteamNetwork.IdConnection( steamId, 77 ) ); System.UpdateLoading( "Connecting" ); } LastConnectionString = $"{steamId}"; var success = await AwaitSuccessfulConnection(); if ( success ) return true; Disconnect(); return false; } static async Task JoinSteamLobbyServer( ulong steamid ) { LoadingScreen.IsVisible = true; LoadingScreen.Title = "Connecting"; var lobbySocket = await SteamLobbySocket.Join( steamid ); if ( lobbySocket is null ) { LoadingScreen.IsVisible = false; // Try another one? return false; } Log.Trace( $"Joined Lobby {steamid}" ); LoadingScreen.Title = "Connected"; if ( System is not null ) { LoadingScreen.IsVisible = false; Log.Warning( "Network is already active - leaving lobby" ); lobbySocket?.Dispose(); return false; } // Don't load no weird maps LaunchArguments.Reset(); // This lobby should tell us what to do lock ( NetworkThreadLock ) { System = new( "lobbyclient", Engine.IGameInstanceDll.Current.TypeLibrary ); System.AddSocket( lobbySocket ); LastConnectionString = $"{steamid}"; } var success = await AwaitSuccessfulConnection(); if ( success ) return true; Disconnect(); return false; } }