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;
}
}