Files
sbox-public/engine/Sandbox.Engine/Systems/Networking/Networking.cs
Sol Williams fefb34b778 Better disconnect feedback, flow tweaks (#4516)
https://files.facepunch.com/solw/2026/April/16_16-00-DapperAmethystgemclam.png

* Display disconnect, kick, or connection failed messages to users in a big new messagebox.
* Client reconnect (from map change etc) uses normal, interruptable connect flow.
* Connecting by SteamId attempts with retries (incl. for lobbies for now)
* Connecting by IP swaps localhost for loopback address, appends default port if not supplied.
* More explicit loading screen activation for connection progress, no longer reactivates from a text update.
* Fix main menu being briefly reloaded when loading games or on reconnect.
2026-04-16 16:03:17 +01:00

865 lines
23 KiB
C#

using Sandbox.Engine;
using Sandbox.Network;
using Sandbox.Utility;
using Sentry;
using Steamworks;
using Steamworks.Data;
using System.Text;
using System.Threading;
using Steam = NativeEngine.Steam;
namespace Sandbox;
/// <summary>
/// Global manager to hold and tick the singleton instance of NetworkSystem.
/// </summary>
public static partial class Networking
{
internal const int ReceiveBatchSize = 32;
internal static NetworkSystem System;
[ConVar( "net_max_outgoing", ConVarFlags.Protected, Help = "Maximum outgoing messages to send per tick. 0 = unlimited." )]
internal static int MaxOutgoingMessagesPerTick { get; set; } = 1024;
[ConVar( "net_max_incoming", ConVarFlags.Protected, Help = "Maximum incoming messages to receive per tick. 0 = unlimited." )]
internal static int ReceiveBatchSizePerTick { get; set; } = 1024;
internal static Dictionary<string, string> ServerData { get; set; } = new();
/// <summary>
/// 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.
/// </summary>
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 );
}
/// <summary>
/// Get data about the current server or lobby. This data can be used for filtering
/// when querying lobbies.
/// </summary>
public static string GetData( string key, string defaultValue = "" )
{
return ServerData.GetValueOrDefault( key, defaultValue );
}
private static string _serverName;
private static string _mapName;
/// <summary>
/// The name of the server you are currently connected to.
/// </summary>
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 );
}
}
/// <summary>
/// The name of the map being used on the server you're connected to.
/// </summary>
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 );
}
}
/// <summary>
/// The maximum number of players allowed on the server you're connected to.
/// </summary>
public static int MaxPlayers { get; internal set; }
/// <summary>
/// The last connection string used to connect to a server.
/// </summary>
internal static string LastConnectionString { get; set; }
[ConVar( "net_debug", ConVarFlags.Protected )]
internal static bool Debug { get; set; }
[ConVar( "net_hide_address", ConVarFlags.Protected )]
internal static bool HideAddress { 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;
[ConVar( "net_query_port", ConVarFlags.Protected )]
internal static int QueryPort { get; set; } = 27016;
[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 );
}
/// <summary>
/// Get the latest host stats such as bandwidth used and the current frame rate.
/// </summary>
public static HostStats HostStats => System?.HostStats ?? default;
// Wire-level network stats for this machine, aggregated across all non-local connections.
// Post-compression, post-framing bytes as reported by the transport layer.
internal static ConnectionStats LocalStats { get; private set; }
/// <summary>
/// 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.
/// </summary>
public static bool IsHost => System is null || System.IsHost;
/// <summary>
/// True if we're currently connected to a server, and we are not the host
/// </summary>
public static bool IsClient => System is not null && System.IsClient;
/// <summary>
/// True if we're currently connecting to the server
/// </summary>
public static bool IsConnecting => System?.IsConnecting ?? false;
/// <summary>
/// True if we're currently connecting to the server
/// </summary>
public static bool IsActive => System is not null;
/// <summary>
/// True if we're currently disconnecting from the server
/// </summary>
internal static bool IsDisconnecting => System is not null && System.IsDisconnecting;
/// <summary>
/// The connection of the current network host.
/// </summary>
[Obsolete( "Moved to Connection.Host" )]
public static Connection HostConnection => Connection.Host;
/// <summary>
/// Whether the host is busy right now. This can be used to determine if
/// the host can be changed.
/// </summary>
internal static bool IsHostBusy
{
get
{
return System?.IsHostBusy ?? true;
}
}
/// <summary>
/// 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.
/// </summary>
[Obsolete( "Moved to Connection.All" )]
public static IReadOnlyList<Connection> 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" );
sockets.StartAuthentication();
}
/// <summary>
/// Internally update the server name without propagating to sockets.
/// </summary>
/// <param name="name"></param>
internal static void UpdateServerName( string name )
{
_serverName = name;
}
/// <summary>
/// Get the status of our connection to the Steam Datagram Relay service.
/// </summary>
/// <returns></returns>
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;
}
}
/// <summary>
/// Reset any static members to their defaults or clear them.
/// </summary>
internal static void Reset()
{
MaxPlayers = 0;
ServerData.Clear();
LocalStats = default;
}
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;
}
/// <summary>
/// Aggregate wire stats across all active non-local connections, expose them via
/// <see cref="LocalStats"/>, and feed them into the performance telemetry pipeline
/// so they appear in activity updates alongside frametime and render stats.
/// </summary>
private static void UpdateLocalStats()
{
if ( System is null )
{
LocalStats = default;
return;
}
var totalIn = 0f;
var totalOut = 0f;
var totalPing = 0;
var connectionCount = 0;
// Iterate System.Connections (real wire connections in the NetworkSystem) rather than
// Connection.All, which allocates and includes mock ConnectionInfo entries with zero stats.
foreach ( var c in System.Connections )
{
var s = c.Stats;
totalIn += s.InBytesPerSecond;
totalOut += s.OutBytesPerSecond;
totalPing += s.Ping;
connectionCount++;
}
LocalStats = new ConnectionStats( "local" )
{
InBytesPerSecond = totalIn,
OutBytesPerSecond = totalOut,
// Average ping across real wire connections; on a client this is just the host ping
Ping = connectionCount > 0 ? totalPing / connectionCount : 0,
};
}
internal static void PreFrameTick()
{
UpdateFakeLag();
try
{
SteamNetwork.RunCallbacks();
System?.Tick();
System?.SendTableUpdates();
System?.SendHeartbeat();
System?.SendHostStats();
UpdateLocalStats();
}
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 );
/// <summary>
/// Try to join the best lobby. Return true on success.
/// </summary>
public static async Task<bool> 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;
}
/// <summary>
/// When creating a lobby from the editor, we'll use this override for the lobby privacy.
/// </summary>
internal static LobbyPrivacy EditorLobbyPrivacy { get; set; } = LobbyPrivacy.Private;
private static CancellationTokenSource lobbyCts;
/// <summary>
/// Will create a new lobby with the specified <see cref="LobbyConfig"/> to
/// customize the lobby further.
/// </summary>
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 this game can only be hosted on a Dedicated Server, ensure that we are one. We'll allow
// the Editor to host a lobby as well, though, for testing and development purposes.
var launchMode = Application.GamePackage?.GetCachedMeta( "LaunchMode", "default" ).ToLower();
if ( launchMode == "dedicatedserveronly" && !Application.IsDedicatedServer && !Application.IsEditor )
throw new UnauthorizedAccessException( "This game can only be hosted on a Dedicated Server" );
if ( IsActive )
return;
lobbyCts?.Cancel();
lobbyCts = 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, lobbyCts.Token );
}
/// <summary>
/// Will create a new lobby.
/// </summary>
[Obsolete( "Use CreateLobby( LobbyConfig )" )]
public static void CreateLobby()
{
var config = new LobbyConfig
{
MaxPlayers = Application.GamePackage?.GetCachedMeta( "MaxPlayers", 32 ) ?? 32
};
CreateLobby( config );
}
static async Task<bool> CreateDedicatedServer( LobbyConfig config, CancellationToken token = default )
{
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 !token.IsCancellationRequested;
}
}
static async Task<bool> CreateLobbyAsync( LobbyConfig config, CancellationToken token = default )
{
if ( IsActive )
return false;
if ( Application.IsEditor )
{
config.Privacy = EditorLobbyPrivacy;
}
if ( Application.IsDedicatedServer )
{
return await CreateDedicatedServer( config, token );
}
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 ( token.IsCancellationRequested )
return false;
var socket = await SteamLobbySocket.Create( config );
if ( socket is null )
{
if ( token.IsCancellationRequested )
return false;
Disconnect();
return false;
}
if ( token.IsCancellationRequested )
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;
}
/// <summary>
/// Disconnect from current multiplayer session.
/// </summary>
public static void Disconnect()
{
lobbyCts?.Cancel();
lobbyCts = null;
if ( System is null ) return;
lock ( NetworkThreadLock )
{
// Send any remaining messages
System.ProcessMessagesInThread();
SentrySdk.AddBreadcrumb( $"Disconnected from {System}", "network.disconnect" );
System.Disconnect();
System = 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() );
/// <summary>
/// Will try to determine the right method for connection, and then try to connect.
/// </summary>
public static void Connect( string target )
{
_ = TryConnect( target );
}
static async Task<bool> TryConnect( string target, int retries = 30 )
{
Disconnect();
if ( string.IsNullOrWhiteSpace( target ) )
{
Log.Warning( "Couldn't connect - target is null!" );
return false;
}
//
// SteamID
//
if ( ulong.TryParse( target, out var steamId ) )
{
return await TryConnectSteamId( steamId, retries );
}
SentrySdk.AddBreadcrumb( $"Connect to '{target}'", "network.connect" );
Assert.IsNull( System );
LoadingScreen.IsVisible = true;
LoadingScreen.Media = null;
LoadingScreen.Title = "Connecting";
var count = 0;
while ( count < retries )
{
lock ( NetworkThreadLock )
{
if ( target == "local" )
{
Log.Info( $"Connecting to local client.." );
System = new( "localclient", IGameInstanceDll.Current.TypeLibrary );
System.Connect( new TcpChannel( "127.0.0.1", 55333 ) );
}
else
{
// replace localhost
target = target.Replace( "localhost", "127.0.0.1", StringComparison.OrdinalIgnoreCase );
// append port if needed
if ( !target.Contains( ':' ) )
target = $"{target}:{Port}";
Log.Info( $"Connecting to {target}.." );
System = new( "client", IGameInstanceDll.Current.TypeLibrary );
System.Connect( new SteamNetwork.IpConnection( target ) );
}
LastConnectionString = target;
}
var success = await AwaitSuccessfulConnection();
if ( success ) return true;
if ( System is null )
return false;
Log.Info( $"Couldn't connect, retrying ({count}/{retries})" );
count++;
Disconnect();
}
IGameInstanceDll.Current.Disconnect( $"Connection failed after {retries} retries." );
return false;
}
static async Task<bool> 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;
}
public static async Task<bool> TryConnectSteamId( SteamId steamId, int retries = 30 )
{
Disconnect();
SentrySdk.AddBreadcrumb( $"Connect to '{steamId}'", "network.connect" );
Assert.IsNull( System );
LoadingScreen.IsVisible = true;
LoadingScreen.Media = null;
LoadingScreen.Title = "Connecting";
LastConnectionString = steamId.ToString();
if ( steamId.AccountType == SteamId.AccountTypes.Lobby )
{
lobbyCts?.Cancel();
lobbyCts = new();
return await JoinSteamLobbyServer( steamId, retries, lobbyCts.Token );
}
var count = 0;
while ( count < retries )
{
Log.Info( $"Connecting to {steamId}.." );
lock ( NetworkThreadLock )
{
System = new( "steamclient", IGameInstanceDll.Current.TypeLibrary );
System.Connect( new SteamNetwork.IdConnection( steamId, 77 ) );
}
var success = await AwaitSuccessfulConnection();
if ( success ) return true;
if ( System is null )
return false;
Log.Info( $"Couldn't connect, retrying ({count}/{retries})" );
count++;
Disconnect();
}
IGameInstanceDll.Current.Disconnect( $"Connection failed after {retries} retries." );
return false;
}
static async Task<bool> JoinSteamLobbyServer( ulong steamid, int retries, CancellationToken token = default )
{
SteamLobbySocket lobbySocket = null;
// attempt to join the lobby, allowing for the possibility that the lobby doesn't exist yet because the host is still setting up.
// in future when lobbies persist thru map changes etc, we should be able to remove this retry logic and just attempt to join once.
var count = 0;
while ( count < retries )
{
var result = await SteamLobbySocket.Join( steamid );
if ( token.IsCancellationRequested )
return false;
if ( result.Response == RoomEnter.Success )
{
// ok!
lobbySocket = result.Socket;
break;
}
if ( result.Response != RoomEnter.DoesntExist )
{
// the lobby exists, but we failed to join for some reason. no point in retrying.
IGameInstanceDll.Current.Disconnect( $"Failed to join lobby: {result.Response}" );
return false;
}
Log.Info( $"Couldn't join lobby ({result.Response}), retrying ({count}/{retries})" );
count++;
// the lobby doesn't exist, it might be because the host is still setting up.
// let's wait a bit and retry.
await Task.Delay( 2000 );
if ( token.IsCancellationRequested )
return false;
}
if ( lobbySocket is null )
{
IGameInstanceDll.Current.Disconnect( $"Joining lobby failed after {retries} retries." );
return false;
}
Log.Trace( $"Joined Lobby {steamid}" );
LoadingScreen.Title = "Joined lobby";
if ( System is not null )
{
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", IGameInstanceDll.Current.TypeLibrary );
System.AddSocket( lobbySocket );
}
var success = await AwaitSuccessfulConnection();
if ( success ) return true;
Disconnect();
IGameInstanceDll.Current.Disconnect( "Connection timed out." );
return false;
}
/// <summary>
/// The client has been told to reconnect to the server. Pause while the server restarts, then attempt to reconnect.
/// </summary>
internal static async Task<bool> ClientReconnect( ReconnectMsg data )
{
IGameInstanceDll.Current?.CloseGame();
string address = LastConnectionString;
if ( string.IsNullOrWhiteSpace( address ) )
{
IGameInstanceDll.Current.Disconnect( "Reconnect failed, missing target address." );
return false;
}
Disconnect();
Log.Info( $"Reconnecting to {address}" );
LoadingScreen.IsVisible = true;
LoadingScreen.Media = null;
LoadingScreen.Title = "Server Restarting";
await Task.Delay( 4000 ); // pause to allow server to restart
return await TryConnect( address );
}
}