Shutdown fixes (#3553)

* Stop generating solutions via -test flag add -generatesolution

* Add TestAppSystem remove Application.InitUnitTest

Avoids some hacks and also makes sure our tests are as close to a real AppSystem as possible.

* Add shutdown unit test

shuts down an re-inits the engine

* Properly dispose native resources hold by managed during shutdown

Should fix a bunch of crashes

* Fix filesystem and networking tests

* StandaloneTest does proper Game Close

* Make sure package tests clean up properly

* Make sure menu scene and resources are released on shutdown

* Report leaked scenes on shutdown

* Ensure DestroyImmediate is not used on scenes

* Fix unmounting in unit tests not clearing native refs

* Force destroy native resource on ResourceLib Clear
This commit is contained in:
Lorenz Junglas
2025-12-08 15:55:11 +01:00
committed by GitHub
parent 86f8de75cc
commit 6808d8768e
51 changed files with 531 additions and 390 deletions

View File

@@ -1,4 +1,5 @@
using System;
using Sandbox.Engine;
using System;
using System.Diagnostics;
using System.Linq;
@@ -8,6 +9,17 @@ public static class Launcher
{
public static int Main()
{
if ( HasCommandLineSwitch( "-generatesolution" ) )
{
NetCore.InitializeInterop( Environment.CurrentDirectory );
Bootstrap.InitMinimal( Environment.CurrentDirectory );
Project.InitializeBuiltIn( false ).GetAwaiter().GetResult();
Project.GenerateSolution().GetAwaiter().GetResult();
Managed.SandboxEngine.NativeInterop.Free();
EngineFileSystem.Shutdown();
return 0;
}
if ( !HasCommandLineSwitch( "-project" ) && !HasCommandLineSwitch( "-test" ) )
{
// we pass the command line, so we can pass it on to the sbox-launcher (for -game etc)

View File

@@ -1,6 +1,8 @@
using Sandbox.Diagnostics;
using Sandbox.Engine;
using Sandbox.Internal;
using Sandbox.Network;
using Sandbox.Rendering;
using System;
using System.Globalization;
using System.Runtime;
@@ -130,15 +132,69 @@ public class AppSystem
public virtual void Shutdown()
{
// Shut the games down
EngineLoop.Exiting();
// Make sure game instance is closed
IGameInstanceDll.Current?.CloseGame();
// Send shutdown event, should allow us to track successful shutdown vs crash
{
var analytic = new Api.Events.EventRecord( "Exit" );
analytic.SetValue( "uptime", RealTime.Now );
// We could record a bunch of stats during the session and
// submit them here. I'm thinking things like num games played
// menus visited, time in menus, time in game, files downloaded.
// Things to give us a whole session picture.
analytic.Submit();
}
ConVarSystem.SaveAll();
IToolsDll.Current?.Exiting();
IMenuDll.Current?.Exiting();
IGameInstanceDll.Current?.Exiting();
SoundFile.Shutdown();
SoundHandle.Shutdown();
DedicatedServer.Shutdown();
// Flush API
Api.Shutdown();
ConVarSystem.ClearNativeCommands();
// Whatever package still exists needs to fuck off
PackageManager.UnmountAll();
// Clear static resources
Texture.DisposeStatic();
Model.DisposeStatic();
Material.UI.DisposeStatic();
Gizmo.GizmoDraw.DisposeStatic();
CubemapRendering.DisposeStatic();
Graphics.DisposeStatic();
TextRendering.ClearCache();
NativeResourceCache.Clear();
// Renderpipeline may hold onto native resources, clear them out
RenderPipeline.ClearPool();
// Run GC and finalizers to clear any resources held by managed
GC.Collect();
GC.WaitForPendingFinalizers();
// Run the queue one more time, since some finalizers queue tasks
MainThread.RunQueues();
// print each scene that is leaked
foreach ( var leakedScene in Scene.All )
{
log.Warning( $"Leaked scene {leakedScene.Id} during shutdown." );
}
// Shut the engine down (close window etc)
NativeEngine.EngineGlobal.SourceEngineShutdown( _appSystem, false );
// Flush the api (close actvity, update stats etc)
Api.Shutdown();
if ( _appSystem.IsValid )
{
_appSystem.Destroy();
@@ -161,11 +217,14 @@ public class AppSystem
Managed.SourceHammer.NativeInterop.Free();
Managed.SourceModelDoc.NativeInterop.Free();
Managed.SourceAnimgraph.NativeInterop.Free();
EngineFileSystem.Shutdown();
Application.Shutdown();
}
protected void InitGame( AppSystemCreateInfo createInfo )
protected void InitGame( AppSystemCreateInfo createInfo, string commandLine = null )
{
var commandLine = System.Environment.CommandLine;
commandLine ??= System.Environment.CommandLine;
commandLine = commandLine.Replace( ".dll", ".exe" ); // uck
_appSystem = CMaterialSystem2AppSystemDict.Create( createInfo.ToMaterialSystem2AppSystemDictCreateInfo() );

View File

@@ -68,7 +68,7 @@ public class StandaloneAppSystem : AppSystem
// Quit next loop after load, if we are testing
else if ( Utility.CommandLine.HasSwitch( "-test-standalone" ) )
{
Application.Exit();
Game.Close();
}
return !wantsToQuit;

View File

@@ -0,0 +1,34 @@
using System;
using System.Runtime;
namespace Sandbox;
public class TestAppSystem : AppSystem
{
public override void Init()
{
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
var GameFolder = System.Environment.GetEnvironmentVariable( "FACEPUNCH_ENGINE", EnvironmentVariableTarget.Process );
if ( GameFolder is null ) throw new Exception( "FACEPUNCH_ENGINE not found" );
NetCore.InitializeInterop( GameFolder );
var nativeDllPath = $"{GameFolder}\\bin\\win64\\";
//
// Put our native dll path first so that when looking up native dlls we'll
// always use the ones from our folder first
//
var path = System.Environment.GetEnvironmentVariable( "PATH" );
path = $"{nativeDllPath};{path}";
System.Environment.SetEnvironmentVariable( "PATH", path );
CreateGame();
var createInfo = new AppSystemCreateInfo()
{
Flags = AppSystemFlags.IsGameApp | AppSystemFlags.IsUnitTest
};
InitGame( createInfo, "" );
}
}

View File

@@ -25,12 +25,6 @@ public static class Application
/// </summary>
public static bool IsUnitTest { get; private set; }
/// <summary>
/// True if we're running a live unit test.
/// </summary>
internal static bool IsLiveUnitTest { get; private set; }
/// <summary>
/// True if running without a graphics window, such as in a terminal.
/// </summary>
@@ -98,106 +92,7 @@ public static class Application
/// </summary>
public static bool IsVR => VRSystem.IsActive; // garry: I think this is right? But feels like this should be set at startup and never change?
static CMaterialSystem2AppSystemDict AppSystem;
/// <summary>
/// Called from unit test projects to initialize the engine
/// </summary>
public static void InitUnitTest<T>( bool withtools = true, bool withRendering = false )
{
if ( IsInitialized )
throw new InvalidOperationException( "Already Initialized" );
SyncContext.Init();
SyncContext.Reset();
ThreadSafe.MarkMainThread();
var callingAssembly = Assembly.GetCallingAssembly();
var GameFolder = System.Environment.GetEnvironmentVariable( "FACEPUNCH_ENGINE", IsLiveUnitTest ? EnvironmentVariableTarget.User : EnvironmentVariableTarget.Process );
if ( GameFolder is null ) throw new Exception( "FACEPUNCH_ENGINE not found" );
var nativeDllPath = $"{GameFolder}\\bin\\win64\\";
var native = NativeLibrary.Load( $"{nativeDllPath}engine2.dll" );
//
// Put our native dll path first so that when looking up native dlls we'll
// always use the ones from our folder first
//
var path = System.Environment.GetEnvironmentVariable( "PATH" );
path = $"{nativeDllPath};{path}";
System.Environment.SetEnvironmentVariable( "PATH", path );
Api.Init();
EngineFileSystem.Initialize( GameFolder );
Application.InitializeGame( false, false, false, true, false );
NetCore.InitializeInterop( GameFolder );
Game.InitUnitTest<T>();
AppSystem = CMaterialSystem2AppSystemDict.Create( new NativeEngine.MaterialSystem2AppSystemDictCreateInfo()
{
iFlags = NativeEngine.MaterialSystem2AppSystemDictFlags.IsGameApp
} );
AppSystem.SuppressCOMInitialization();
AppSystem.SuppressStartupManifestLoad( true );
AppSystem.SetModGameSubdir( "core" );
AppSystem.SetInTestMode();
if ( withRendering )
{
AppSystem.SetDefaultRenderSystemOption( "-vulkan" );
}
if ( !NativeEngine.EngineGlobal.SourceEnginePreInit( "", AppSystem ) )
{
throw new System.Exception( "SourceEnginePreInit failed" );
}
AppSystem.InitFinishSetupMaterialSystem();
AppSystem.AddSystem( "engine2", "SceneSystem_002" );
AppSystem.AddSystem( "engine2", "SceneUtils_001" );
AppSystem.AddSystem( "engine2", "WorldRendererMgr001" );
NativeLibrary.Free( native );
if ( withtools )
{
var sandboxGame = Assembly.Load( "Sandbox.Tools" );
sandboxGame.GetType( "Editor.AssemblyInitialize", true, true )
.GetMethod( "InitializeUnitTest", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic )
.Invoke( null, new[] { callingAssembly } );
}
}
internal static void InitLiveUnitTest<T>( bool withtools = true, bool withRendering = false )
{
Application.IsLiveUnitTest = true;
InitUnitTest<T>( withtools, withRendering );
}
/// <summary>
/// Called from unit test projects to politely shut down the engine
/// </summary>
public static void ShutdownUnitTest()
{
if ( !IsUnitTest )
{
throw new InvalidOperationException( "Not running a unit test" );
}
if ( AppSystem.IsValid )
{
NativeEngine.EngineGlobal.SourceEngineShutdown( AppSystem, false );
AppSystem.Destroy();
AppSystem = default;
}
}
internal static void InitializeGame( bool dedicated, bool headless, bool toolsMode, bool testMode, bool isRetail )
internal static void Initialize( bool dedicated, bool headless, bool toolsMode, bool testMode, bool isRetail )
{
if ( IsInitialized )
throw new InvalidOperationException( "Already Initialized" );
@@ -213,6 +108,11 @@ public static class Application
IsBenchmark = Environment.GetEnvironmentVariable( "SBOX_MODE" ) == "BENCHMARK";
}
internal static void Shutdown()
{
IsInitialized = false;
}
internal static void TryLoadVersionInfo( string gameFolder )
{
Version = "0000000";

View File

@@ -28,7 +28,7 @@ internal static class Bootstrap
/// </summary>
internal static void PreInit( CMaterialSystem2AppSystemDict appDict )
{
Application.InitializeGame( appDict.IsDedicatedServer(), appDict.IsConsoleApp(), appDict.IsInToolsMode(), appDict.IsInTestMode(), EngineGlobal.IsRetail() );
Application.Initialize( appDict.IsDedicatedServer(), appDict.IsConsoleApp(), appDict.IsInToolsMode(), appDict.IsInTestMode(), EngineGlobal.IsRetail() );
try
{
@@ -91,16 +91,6 @@ internal static class Bootstrap
Mounting.Directory.LoadAssemblies();
}
//
// In testmode (-test) we want to build the .sln files now because we'll be closed
// down after this call.
//
if ( Application.IsUnitTest )
{
SyncContext.RunBlocking( Project.InitializeBuiltIn() );
SyncContext.RunBlocking( Project.GenerateSolution() );
}
}
catch ( Exception ex )
{
@@ -199,9 +189,12 @@ internal static class Bootstrap
//
{
Screen.UpdateFromEngine();
Material.UI.Init();
Model.Init();
Texture.InitStaticTextures();
Material.UI.InitStatic();
Gizmo.GizmoDraw.InitStatic();
Model.InitStatic();
Texture.InitStatic();
CubemapRendering.InitStatic();
Graphics.InitStatic();
}
if ( !Application.IsHeadless && !Application.IsStandalone )
@@ -294,8 +287,6 @@ internal static class Bootstrap
}
}
internal static void InitMinimal( string rootFolder )
{
Environment.CurrentDirectory = rootFolder;

View File

@@ -384,34 +384,6 @@ internal static class EngineLoop
}
}
/// <summary>
/// Called when the application is shutting down
/// </summary>
internal static void Exiting()
{
// Send shutdown event, should allow us to track successful shutdown vs crash
{
var analytic = new Api.Events.EventRecord( "Exit" );
analytic.SetValue( "uptime", RealTime.Now );
// We could record a bunch of stats during the session and
// submit them here. I'm thinking things like num games played
// menus visited, time in menus, time in game, files downloaded.
// Things to give us a whole session picture.
analytic.Submit();
}
ConVarSystem.SaveAll();
IToolsDll.Current?.Exiting();
IMenuDll.Current?.Exiting();
IGameInstanceDll.Current?.Exiting();
SoundFile.Shutdown();
SoundHandle.Shutdown();
DedicatedServer.Shutdown();
}
/// <summary>
/// A console command has arrived, or a convar has changed
/// </summary>

View File

@@ -124,6 +124,8 @@ internal partial class GlobalContext
oldCts?.Cancel();
oldCts?.Dispose();
ActiveScene = null;
TaskSource = new TaskSource( 1 );
EventSystem?.Dispose();
@@ -133,6 +135,9 @@ internal partial class GlobalContext
Cookies?.Dispose();
Cookies = null;
ResourceSystem?.Clear();
ResourceSystem = new ResourceSystem();
}
string _disabledReason;

View File

@@ -15,10 +15,30 @@ public static partial class Gizmo
/// </summary>
public sealed partial class GizmoDraw
{
static Material LineMaterial = Material.Load( "materials/gizmo/line.vmat" );
static Material SolidMaterial = Material.Load( "materials/gizmo/solid.vmat" );
static Material SpriteMaterial = Material.Load( "materials/gizmo/sprite.vmat" );
static Material GridMaterial = Material.Load( "materials/gizmo/grid.vmat" );
static Material LineMaterial;
static Material SolidMaterial;
static Material SpriteMaterial;
static Material GridMaterial;
internal static void InitStatic()
{
LineMaterial = Material.Load( "materials/gizmo/line.vmat" );
SolidMaterial = Material.Load( "materials/gizmo/solid.vmat" );
SpriteMaterial = Material.Load( "materials/gizmo/sprite.vmat" );
GridMaterial = Material.Load( "materials/gizmo/grid.vmat" );
}
internal static void DisposeStatic()
{
LineMaterial?.Dispose();
LineMaterial = null;
SolidMaterial?.Dispose();
SolidMaterial = null;
SpriteMaterial?.Dispose();
SpriteMaterial = null;
GridMaterial?.Dispose();
GridMaterial = null;
}
internal GizmoDraw()
{

View File

@@ -47,24 +47,6 @@ public static partial class Game
set => GlobalContext.Current.NodeLibrary = value;
}
/// <summary>
/// Initialize for a unit test
/// </summary>
internal static void InitUnitTest<T>()
{
GlobalContext.Current.Reset();
GlobalContext.Current.LocalAssembly = typeof( T ).Assembly;
GlobalContext.Current.UISystem = new UISystem();
GlobalContext.Current.InputContext = new InputContext();
GlobalContext.Current.InputContext.TargetUISystem = GlobalContext.Current.UISystem;
Game.InitHost();
TypeLibrary.AddAssembly( typeof( T ).Assembly, false );
}
private static void AddNodesFromAssembly( Assembly asm )
{
var result = NodeLibrary.AddAssembly( asm );

View File

@@ -141,21 +141,21 @@ public static partial class Game
return;
}
// Might want to queue this up. do it the next frame?
// Be aware that this could be called from the GameDll or the MenuDll
// So anything here needs to be safe to call from either
if ( IGameInstance.Current is not null )
{
IGameInstance.Current.Close();
LaunchArguments.Reset();
}
// Standalone mode and Dedicated Server only: exit whole app
if ( Application.IsStandalone || Application.IsDedicatedServer )
{
Application.Exit();
}
// Might want to queue this up. do it the next frame?
// Be aware that this could be called from the GameDll or the MenuDll
// So anything here needs to be safe to call from either
if ( IGameInstance.Current is null )
return;
IGameInstance.Current.Close();
LaunchArguments.Reset();
}
/// <summary>

View File

@@ -40,7 +40,7 @@ namespace Sandbox
/// </summary>
internal static Material DropShadow { get; set; }
internal static void Init()
internal static void InitStatic()
{
Basic = FromShader( "shaders/ui_basic.shader" );
Box = FromShader( "shaders/ui_cssbox.shader" );
@@ -51,6 +51,26 @@ namespace Sandbox
DropShadow = FromShader( "shaders/ui_dropshadow.shader" );
BorderWrap = FromShader( "shaders/ui_borderwrap.shader" );
}
internal static void DisposeStatic()
{
Basic?.Dispose();
Basic = null;
Box?.Dispose();
Box = null;
BoxShadow?.Dispose();
BoxShadow = null;
Text?.Dispose();
Text = null;
BackdropFilter?.Dispose();
BackdropFilter = null;
Filter?.Dispose();
Filter = null;
DropShadow?.Dispose();
DropShadow = null;
BorderWrap?.Dispose();
BorderWrap = null;
}
}
}
}

View File

@@ -41,17 +41,25 @@ public sealed partial class Material : Resource
}
~Material()
{
Dispose();
}
internal void Dispose()
{
// kill the native pointer - it does with the native material
// we want to reduce the risk that someone is holding on to it.
Attributes.Set( default );
Attributes?.Set( default );
Attributes = null;
if ( !native.IsNull )
{
var n = native;
native = default;
MainThread.Queue( () => n.DestroyStrongHandle() );
}
}
/// <summary>
/// Create a copy of this material

View File

@@ -58,11 +58,23 @@ public partial class Model
public static Model Error { get; internal set; }
internal static void Init()
internal static void InitStatic()
{
Cube = Load( "models/dev/box.vmdl" );
Sphere = Load( "models/dev/sphere.vmdl" );
Plane = Load( "models/dev/plane.vmdl" );
Error = Load( "models/dev/error.vmdl" );
}
internal static void DisposeStatic()
{
Cube?.Dispose();
Cube = null;
Sphere?.Dispose();
Sphere = null;
Plane?.Dispose();
Plane = null;
Error?.Dispose();
Error = null;
}
}

View File

@@ -28,13 +28,21 @@ public sealed partial class Model : Resource
SetIdFromResourcePath( Name );
}
~Model()
internal void Dispose()
{
if ( !native.IsNull )
{
var n = native;
native = default;
MainThread.Queue( () => n.DestroyStrongHandle() );
}
}
~Model()
{
Dispose();
}
/// <summary>
/// Called when the resource is reloaded. We should clear any cached values.

View File

@@ -39,13 +39,20 @@ public abstract partial class Resource : IValid, IJsonConvert, BytePack.ISeriali
/// </summary>
[Hide, JsonIgnore] public virtual bool HasUnsavedChanges => false;
~Resource()
internal void Destroy()
{
// Unregister on main thread
MainThread.Queue( () => { Game.Resources.Unregister( this ); } );
Manifest?.Dispose();
Manifest = default;
GC.SuppressFinalize( this );
}
~Resource()
{
Destroy();
}
internal static string FixPath( string filename )

View File

@@ -58,13 +58,19 @@ public class ResourceSystem
var toDispose = ResourceIndex.Values.ToArray();
ResourceIndex.Clear();
foreach ( var resource in toDispose.OfType<GameResource>() )
{
resource.DestroyInternal();
}
foreach ( var resource in toDispose )
{
// Don't wait/rely for finalizer get rid of this immediately
resource.Destroy();
}
ResourceIndex.Clear();
TypeCache.Clear();
}

View File

@@ -67,7 +67,7 @@ public partial class PrefabFile : GameResource
protected override void OnDestroy()
{
CachedScene?.DestroyImmediate();
CachedScene?.DestroyInternal();
CachedScene = null;
Unregister();

View File

@@ -23,7 +23,7 @@ public partial class Texture
/// </summary>
public static Texture Transparent { get; internal set; }
internal static void InitStaticTextures()
internal static void InitStatic()
{
Invalid = Create( 1, 1 ).WithData( new byte[4] { 255, 0, 255, 255 } ).Finish();
White = Create( 1, 1 ).WithData( new byte[4] { 255, 255, 255, 255 } ).Finish();
@@ -31,6 +31,18 @@ public partial class Texture
Black = Create( 1, 1 ).WithData( new byte[4] { 0, 0, 0, 255 } ).Finish();
}
internal static void DisposeStatic()
{
Invalid?.Dispose();
Invalid = default;
White?.Dispose();
White = default;
Transparent?.Dispose();
Transparent = default;
Black?.Dispose();
Black = default;
}
internal static Texture Create( string name, bool anonymous, TextureBuilder builder, IntPtr data, int dataSize )
{
var config = builder._config.GetWithFixes();

View File

@@ -52,8 +52,8 @@ public sealed partial class PolygonMesh : IJsonConvert
public int MaterialId { get; set; }
}
private static readonly Material DefaultMaterial = Material.Load( "materials/dev/reflectivity_30.vmat" );
private static readonly Vector2 DefaultTextureSize = CalculateTextureSize( DefaultMaterial );
private Material DefaultMaterial = Material.Load( "materials/dev/reflectivity_30.vmat" );
private Vector2 DefaultTextureSize => CalculateTextureSize( DefaultMaterial );
private VertexData<Vector3> Positions { get; init; }
private HalfEdgeData<Vector2> TextureCoord { get; init; }

View File

@@ -110,6 +110,7 @@ public partial class GameObject : IValid
/// </summary>
public void DestroyImmediate()
{
Assert.False( this is Scene, "Don't call DestroyImmediate on a scene." );
ThreadSafe.AssertIsMainThread();
Term();

View File

@@ -234,10 +234,10 @@ public partial class SceneNetworkSystem : GameNetworkSystem
LoadingScreen.Title = "Loading Scene";
}
// Go ahead and destroy the scene immediately (if it exists.)
// Go ahead and destroy the scene
if ( Game.ActiveScene is not null )
{
Game.ActiveScene?.DestroyImmediate();
Game.ActiveScene?.Destroy();
Game.ActiveScene = null;
}
@@ -408,7 +408,7 @@ public partial class SceneNetworkSystem : GameNetworkSystem
if ( Game.ActiveScene is not null )
{
Game.ActiveScene?.DestroyImmediate();
Game.ActiveScene?.Destroy();
Game.ActiveScene = null;
}

View File

@@ -100,6 +100,8 @@ public partial class Scene : GameObject
internal virtual void DestroyInternal()
{
_all.Remove( this );
// Clearing the object index now means we can save time
// because we don't have to do it for each object.
// Note that we can't do this in Clear because we don't want to
@@ -154,7 +156,6 @@ public partial class Scene : GameObject
public IDisposable Push()
{
ThreadSafe.AssertIsMainThread();
var old = Game.ActiveScene;
Game.ActiveScene = this;

View File

@@ -170,9 +170,6 @@ internal static partial class PackageManager
MountedFileSystem.Mount( FileSystem );
MountedFileSystem.Mount( AssemblyFileSystem );
if ( Application.IsUnitTest ) // todo: fully init the engine for unit test
return;
// Reload any already resident resources with the ones we've just mounted
NativeEngine.g_pResourceSystem.ReloadSymlinkedResidentResources();
@@ -199,9 +196,6 @@ internal static partial class PackageManager
AssemblyFileSystem.Dispose();
AssemblyFileSystem = null;
if ( Application.IsUnitTest ) // todo: fully init the engine for unit test
return;
// Reload any resident resources that were just unmounted (they shouldn't be used & will appear as an error, or a local variant)
NativeEngine.g_pResourceSystem.ReloadSymlinkedResidentResources();
}

View File

@@ -20,12 +20,6 @@ internal static partial class PackageManager
/// </summary>
public static event Action<ActivePackage, string> OnPackageInstalledToContext;
internal static void ResetForUnitTest()
{
ActivePackages = new();
MountedFileSystem = new AggregateFileSystem();
}
static async Task<Package> FetchPackageAsync( string ident, bool localPriority )
{
if ( localPriority && Package.TryParseIdent( ident, out var parts ) && !parts.local )
@@ -131,6 +125,15 @@ internal static partial class PackageManager
}
}
internal static void UnmountAll()
{
foreach ( var item in ActivePackages.ToArray() )
{
item.Delete();
ActivePackages.Remove( item );
}
}
private static async Task InstallDependencies( Package package, PackageLoadOptions options )
{
HashSet<string> dependancies = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

View File

@@ -19,6 +19,31 @@ internal static partial class ConVarSystem
var command = new NativeCommand( value );
AddCommand( command );
}
internal static void ClearNativeCommands()
{
if ( Members.Count == 0 )
return;
System.Collections.Generic.List<string> nativeKeys = null;
foreach ( var (name, command) in Members )
{
if ( command is NativeCommand || command is NativeConVar )
{
nativeKeys ??= new System.Collections.Generic.List<string>();
nativeKeys.Add( name );
}
}
if ( nativeKeys is null )
return;
foreach ( var name in nativeKeys )
{
Members.Remove( name );
}
}
}

View File

@@ -44,7 +44,7 @@ internal static class EngineFileSystem
/// <summary>
/// Don't try to use the filesystem until you've called this!
/// </summary>
internal static void Initialize( string rootFolder )
internal static void Initialize( string rootFolder, bool skipBaseFolderInit = false )
{
if ( Root != null )
throw new System.Exception( "Filesystem Multi-Initialize" );
@@ -52,8 +52,7 @@ internal static class EngineFileSystem
Root = new LocalFileSystem( rootFolder );
Temporary = new MemoryFileSystem();
if ( Application.IsUnitTest )
return;
if ( skipBaseFolderInit ) return;
if ( Application.IsEditor )
{

View File

@@ -9,7 +9,8 @@ internal partial class NetworkSystem
public void InitializeGameSystem()
{
if ( IGameInstanceDll.Current is null )
// If we are unit testing we dont want to do any of this for now, this only works with a gamepackage loaded
if ( IGameInstanceDll.Current is null || Application.IsUnitTest )
return;
GameSystem = IGameInstanceDll.Current.CreateGameNetworking( this );

View File

@@ -11,7 +11,7 @@ namespace Sandbox;
/// </summary>
internal class ServerPackages
{
public static ServerPackages Current { get; private set; }
public static ServerPackages Current { get; private set; } = new();
internal record struct ServerPackageInfo();
internal StringTable StringTable;
@@ -86,7 +86,7 @@ internal class ServerPackages
internal ServerPackages()
{
Assert.IsNull( Current );
// WTF???
Current = this;
StringTable = new StringTable( "ServerPackages", true );

View File

@@ -1,4 +1,5 @@
using NativeEngine;
using Sandbox.VR;
namespace Sandbox;
@@ -25,6 +26,11 @@ public class ComputeShader
ComputeMaterial = material;
}
internal void Dispose()
{
ComputeMaterial.Dispose();
}
/// <summary>
/// Dispatch this compute shader using explicit thread counts.
/// </summary>

View File

@@ -1,5 +1,3 @@
using NativeEngine;
using System.Linq;
using Sandbox.Rendering;
namespace Sandbox;
@@ -10,7 +8,18 @@ namespace Sandbox;
/// </summary>
internal static class CubemapRendering
{
static ComputeShader EnvmapFilter = new( "envmap_filtering_cs" );
static ComputeShader EnvmapFilter;
internal static void InitStatic()
{
EnvmapFilter = new( "envmap_filtering_cs" );
}
internal static void DisposeStatic()
{
EnvmapFilter?.Dispose();
EnvmapFilter = null;
}
/// <summary>
/// Specifies the quality level for GGX filtering of environment maps.

View File

@@ -4,7 +4,18 @@ namespace Sandbox;
public static partial class Graphics
{
internal static ComputeShader MipMapGeneratorShader = new ComputeShader( "downsample_cs" );
internal static ComputeShader MipMapGeneratorShader;
internal static void InitStatic()
{
MipMapGeneratorShader = new ComputeShader( "downsample_cs" );
}
internal static void DisposeStatic()
{
MipMapGeneratorShader?.Dispose();
MipMapGeneratorShader = null;
}
/// <summary>
/// Which method to use when downsampling a texture

View File

@@ -46,7 +46,7 @@ internal class BloomDownsampleLayer : ProceduralRenderLayer
}
internal class QuarterDepthDownsampleLayer : ProceduralRenderLayer
{
private static Material DepthResolve = Material.Create( "depthresolve", "shaders/depthresolve.shader" );
private Material DepthResolve;
private bool MSAAInput;
public QuarterDepthDownsampleLayer()
@@ -55,6 +55,7 @@ internal class QuarterDepthDownsampleLayer : ProceduralRenderLayer
Flags |= LayerFlags.NeverRemove | LayerFlags.DoesntModifyColorBuffers;
ClearFlags = ClearFlags.Depth | ClearFlags.Stencil;
LayerType = SceneLayerType.Opaque;
DepthResolve = Material.Create( "depthresolve", "shaders/depthresolve.shader" );
}
public void Setup( ISceneView view, RenderViewport viewport, SceneViewRenderTargetHandle rtDepth, bool msaaInput, RenderTarget rtOutDepth )

View File

@@ -43,4 +43,10 @@ internal partial class RenderPipeline
// Return to pool
Pool.Enqueue( renderPipeline );
}
internal static void ClearPool()
{
Pool.Clear();
ActivePipelines.Clear();
}
}

View File

@@ -95,4 +95,13 @@ public static partial class TextRendering
//Log.Info( $"TextManager: {total} ({deleted} deleted)" );
}
internal static void ClearCache()
{
foreach ( var item in Dictionary )
{
item.Value.Dispose();
}
Dictionary.Clear();
}
}

View File

@@ -10,3 +10,4 @@ using System.Runtime.CompilerServices;
[assembly: TasksPersistOnContextReset]
[assembly: InternalsVisibleTo( "Sandbox.Tools" )]
[assembly: InternalsVisibleTo( "Sandbox.AppSystem" )]
[assembly: InternalsVisibleTo( "Sandbox.Test" )]

View File

@@ -110,8 +110,8 @@ internal partial class GameInstance : IGameInstance
if ( activePackage != null && !Application.IsStandalone )
{
Game.Language.Shutdown();
FileSystem.Mounted.UnMount( activePackage.FileSystem );
Game.Language?.Shutdown();
FileSystem.Mounted?.UnMount( activePackage.FileSystem );
activePackage = null;
}
@@ -121,7 +121,7 @@ internal partial class GameInstance : IGameInstance
// Is this the right place for it? Map packages are marked with "game" so they never get unmounted
PackageManager.UnmountTagged( "game" );
GameInstanceDll.Current.OnGameInstanceClosed( this );
GameInstanceDll.Current.Shutdown( this );
Game.Shutdown();

View File

@@ -66,7 +66,7 @@ internal partial class GameInstanceDll : Engine.IGameInstanceDll
}
}
Assert.IsNull( PackageLoader );
PackageLoader?.Dispose();
PackageLoader = new PackageLoader( "GameMenu", typeof( GameInstanceDll ).Assembly );
PackageLoader.HotloadWatch( Game.GameAssembly ); // Sandbox.Game is per instance
PackageLoader.OnAfterHotload = OnAfterHotload;
@@ -234,11 +234,14 @@ internal partial class GameInstanceDll : Engine.IGameInstanceDll
}
};
// Clear resource library so resources don't leak between games,
// then let IMenuDll reload whatever resources it needs
Game.Resources.Clear();
IMenuDll.Current?.Reset();
// Run GC and finalizers to clear any native resources held
GC.Collect();
GC.WaitForPendingFinalizers();
// Run the queue one more time, since some finalizers queue tasks
MainThread.RunQueues();
}
/// <summary>
@@ -700,7 +703,7 @@ internal partial class GameInstanceDll : Engine.IGameInstanceDll
/// Called when the game menu is closed
/// </summary>
/// <param name="instance"></param>
public void OnGameInstanceClosed( IGameInstance instance )
public void Shutdown( IGameInstance instance )
{
NativeErrorReporter.Breadcrumb( true, "game", "Closed game instance" );
NativeErrorReporter.SetTag( "game", null );

View File

@@ -211,8 +211,44 @@ internal sealed class MenuDll : IMenuDll
public void Exiting()
{
using var scope = PushScope();
using ( PushScope() )
{
// Shutdown menu system
IMenuSystem.Current?.Shutdown();
IMenuSystem.Current = null;
// Unregister messaging
Sandbox.Services.Messaging.OnMessage -= OnMessageFromBackend;
// Save and dispose cookies
Game.Cookies?.Save();
Game.Cookies = null;
// Cleanup scene
MenuScene.Scene?.Destroy();
MenuScene.Scene = null;
// Dispose package loader and enroller
Enroller?.Dispose();
Enroller = null;
Loader?.Dispose();
Loader = null;
// Shutdown Steamworks interfaces
if ( !Application.IsEditor )
{
Steamworks.SteamClient.Cleanup();
}
// Expire async context to prevent lingering tasks
AsyncContext.Expire( null );
// Clear global context
GlobalContext.Current.Reset();
IMenuDll.Current = null;
}
}
void LoadResources()

View File

@@ -2,100 +2,59 @@
using Sandbox.ActionGraphs;
using Sandbox.Engine;
using Sandbox.Internal;
using Sandbox.Tasks;
using System;
using System.Collections.Generic;
using System.Threading;
namespace ActionGraphs;
[TestClass]
public class LiveGamePackage
{
[TestInitialize]
public void TestInitialize()
{
Project.Clear();
PackageManager.ResetForUnitTest();
AssetDownloadCache.Initialize( $"{Environment.CurrentDirectory}/.source2/package_manager_folder" );
}
[TestCleanup]
public void TestCleanup()
{
Game.NodeLibrary = null;
GlobalContext.Current.FileMount = null;
Game.Shutdown();
}
/// <summary>
/// Asserts that all the ActionGraphs referenced by a given scene in a downloaded
/// package have no errors.
/// </summary>
[TestMethod]
[DataRow( "fish.sauna", 76972L, "scenes/finland.scene", 140,
[DataRow( "fish.sauna", 76972L, "scenes/finland.scene", 14,
"d174cab5-7a05-476c-a545-4db2fd685032", // Prefab references game object from other scene
"e9ac7c29-ff9f-4c3c-8d9d-7228c4711248", // Inventory method changed parameter types
"462927b9-1f01-4ba8-9f6b-2e1e6a5934e4" // Inventory method changed parameter types
)]
public async Task AssertNoGraphErrorsInScene( string packageName, long? version, string scenePath, int graphCount, params string[] ignoreGuids )
public void AssertNoGraphErrorsInScene( string packageName, long? version, string scenePath, int graphCount, params string[] ignoreGuids )
{
AssetDownloadCache.Initialize( $"{Environment.CurrentDirectory}/.source2/package_manager_folder" );
PackageManager.UnmountAll();
// Let's make sure we have base content mounted
IGameInstanceDll.Current?.Bootstrap();
var ignoreGuidSet = new HashSet<Guid>( ignoreGuids.Select( Guid.Parse ) );
using ( var packageLoader = new Sandbox.PackageLoader( "Test", GetType().Assembly ) )
{
using var enroller = packageLoader.CreateEnroller( "test-enroller" );
var packageIdent = version is { } v ? $"{packageName}#{v}" : packageName;
GlobalContext.Current.FileMount = PackageManager.MountedFileSystem;
// Use the production loading logic - run blocking to ensure it completes
var loadTask = GameInstanceDll.Current.LoadGamePackageAsync( packageIdent, GameLoadingFlags.Host, CancellationToken.None );
SyncContext.RunBlocking( loadTask );
enroller.OnAssemblyAdded = ( a ) =>
{
Game.TypeLibrary.AddAssembly( a.Assembly, true );
Game.NodeLibrary.AddAssembly( a.Assembly );
};
Assert.AreEqual( 0, PackageManager.MountedFileSystem.FileCount, "No package files mounted" );
var downloadOptions = new PackageLoadOptions
{
PackageIdent = version is { } v ? $"{packageName}#{v}" : packageName,
ContextTag = "client",
AllowLocalPackages = false
};
var activePackage = await PackageManager.InstallAsync( downloadOptions );
if ( version is not null )
{
Assert.AreEqual( version.Value, activePackage.Package.Revision.VersionId );
}
Assert.IsNotNull( activePackage );
Assert.IsNotNull( GameInstanceDll.gameInstance, "Game instance should be loaded" );
Assert.AreNotEqual( 0, PackageManager.MountedFileSystem.FileCount, "We have package files mounted" );
// Load the assemblies into the context
enroller.LoadPackage( packageName );
Assert.AreNotEqual( 0, GlobalGameNamespace.TypeLibrary.Types.Count, "Library has classes" );
JsonUpgrader.UpdateUpgraders( GlobalGameNamespace.TypeLibrary );
ResourceLoader.LoadAllGameResource( PackageManager.MountedFileSystem );
var sceneFile = ResourceLibrary.Get<SceneFile>( scenePath );
Assert.IsNotNull( sceneFile, "Target scene exists" );
ActionGraphDebugger.Enabled = true;
var anyErrors = false;
Game.ActiveScene = new Scene();
Game.ActiveScene.LoadFromFile( sceneFile.ResourcePath );
var graphs = ActionGraphDebugger.GetAllGraphs();
Assert.AreEqual( graphCount, graphs.Count, "Scene has expected graph count" );
var anyErrors = false;
foreach ( var graph in graphs )
{
Console.WriteLine( $"{graph.Guid}: {graph.Title} {(ignoreGuidSet.Contains( graph.Guid ) ? "(IGNORED)" : "")}" );
@@ -107,18 +66,14 @@ public class LiveGamePackage
if ( !ignoreGuidSet.Contains( graph.Guid ) )
{
anyErrors |= graph.HasErrors();
}
}
ActionGraphDebugger.Enabled = false;
PackageManager.UnmountTagged( "client" );
Assert.IsFalse( anyErrors, "No unexpected graph errors" );
Assert.IsFalse( anyErrors );
}
Assert.AreEqual( 0, PackageManager.MountedFileSystem.FileCount, "Unmounted everything" );
GameInstanceDll.Current?.CloseGame();
}
}

View File

@@ -0,0 +1,24 @@
namespace Misc;
[TestClass]
public class Shutdown
{
[TestMethod]
public void Single()
{
// We already initialized the app for testing, so we can directly shutdown
TestInit.TestAppSystem.Shutdown();
// We need to re-init because other tests still need it
TestInit.TestAppSystem.Init();
}
[TestMethod]
public void Multiple()
{
TestInit.TestAppSystem.Shutdown();
TestInit.TestAppSystem.Init();
TestInit.TestAppSystem.Shutdown();
TestInit.TestAppSystem.Init();
}
}

View File

@@ -18,7 +18,7 @@ public partial class FileSystem
System.IO.Directory.CreateDirectory( ".source2/TestFolder" );
Sandbox.EngineFileSystem.Shutdown();
Sandbox.EngineFileSystem.Initialize( ".source2/TestFolder" );
Sandbox.EngineFileSystem.Initialize( ".source2/TestFolder", true );
Sandbox.EngineFileSystem.Root.WriteAllText( "root_text_file.txt", "Hello" );

View File

@@ -5,12 +5,6 @@ namespace Packages;
[TestClass]
public class PackageDownload
{
[TestInitialize]
public void TestInitialize()
{
PackageManager.ResetForUnitTest();
}
[TestMethod]
[DataRow( "facepunch.sandbox" )]
[DataRow( "garry.grassworld" )]

View File

@@ -10,7 +10,6 @@ public partial class PackageLoader
public void TestInitialize()
{
Project.Clear();
PackageManager.ResetForUnitTest();
AssetDownloadCache.Initialize( $"{Environment.CurrentDirectory}/.source2/package_manager_folder" );
}
@@ -94,7 +93,7 @@ public partial class PackageLoader
var addonName = "garry.grassworld";
var addonClass = "GrassSpawner";
var (library, _, enroller) = Preamble();
var (library, packageLoader, enroller) = Preamble();
Assert.AreEqual( 0, PackageManager.MountedFileSystem.FileCount, "No package files mounted" );
@@ -115,13 +114,16 @@ public partial class PackageLoader
PackageManager.UnmountTagged( "client" );
enroller.Dispose();
packageLoader.Dispose();
Assert.AreEqual( 0, PackageManager.MountedFileSystem.FileCount, "Unmounted everything" );
}
//[TestMethod]
public async Task LoadPackageWithAddonWithLibrary( string packageName )
{
var (library, _, enroller) = Preamble();
var (library, packageLoader, enroller) = Preamble();
Assert.AreEqual( 0, PackageManager.MountedFileSystem.FileCount, "No package files mounted" );
@@ -138,6 +140,9 @@ public partial class PackageLoader
PackageManager.UnmountTagged( "client" );
enroller.Dispose();
packageLoader.Dispose();
Assert.AreEqual( 0, PackageManager.MountedFileSystem.FileCount, "Unmounted everything" );
}
@@ -150,7 +155,7 @@ public partial class PackageLoader
[TestMethod]
public async Task LoadRuntimeGamePackage()
{
var (library, _, enroller) = Preamble();
var (library, packageLoader, enroller) = Preamble();
Project.AddFromFileBuiltIn( "addons/base" );
var project = Project.AddFromFile( "unittest/addons/spacewars" );
@@ -170,8 +175,11 @@ public partial class PackageLoader
var gameClass = library.GetType( "SpaceWarsGameManager" );
Assert.IsNotNull( gameClass, "Found game class" );
// cleanup
PackageManager.UnmountTagged( "client" );
enroller.Dispose();
packageLoader.Dispose();
PackageManager.UnmountAll();
Assert.AreEqual( 0, PackageManager.MountedFileSystem.FileCount );
}
/// <summary>
@@ -203,6 +211,12 @@ public partial class PackageLoader
Assert.IsInstanceOfType<InvalidOperationException>( exception );
Assert.IsTrue( exception.Message.Contains( "Disabled during static constructors." ) );
enroller.Dispose();
packageLoader.Dispose();
PackageManager.UnmountAll();
Assert.AreEqual( 0, PackageManager.MountedFileSystem.FileCount );
}
//
@@ -328,10 +342,10 @@ public partial class PackageLoader
//Assert.IsTrue( Project.CompileAsync )
// cleanup
PackageManager.UnmountTagged( "client" );
packageLoader.Dispose();
PackageManager.UnmountAll();
Assert.AreEqual( 0, PackageManager.MountedFileSystem.FileCount );
}
/// <summary>

View File

@@ -10,7 +10,7 @@ public class PackageManagement
[TestInitialize]
public void TestInitialize()
{
PackageManager.ResetForUnitTest();
PackageManager.UnmountAll();
var dir = $"{Environment.CurrentDirectory}/.source2/package_manager_folder";
@@ -33,12 +33,6 @@ public class PackageManagement
AssetDownloadCache.Initialize( dir );
}
[TestCleanup]
public void TestCleanup()
{
}
/// <summary>
/// Should throw an exception on invalid/missing package
/// </summary>
@@ -179,9 +173,14 @@ public class PackageManagement
[TestMethod]
public async Task DownloadPackagesWithMatchingFiles()
{
var pm = PackageManager.ActivePackages;
var a = PackageManager.InstallAsync( new PackageLoadOptions( "titanovsky.ufrts_archery2_3", "fff" ) );
var b = PackageManager.InstallAsync( new PackageLoadOptions( "titanovsky.ufrts_crates2", "fff" ) );
await Task.WhenAll( a, b );
PackageManager.UnmountAll();
Assert.AreEqual( 0, PackageManager.MountedFileSystem.FileCount );
}
}

View File

@@ -7,21 +7,18 @@ global using Assert = Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
[TestClass]
public class TestInit
{
public static Sandbox.AppSystem TestAppSystem;
[AssemblyInitialize]
public static void ClassInitialize( TestContext context )
{
#if LIVE_UNIT_TEST
Sandbox.Application.InitLiveUnitTest<TestInit>();
#else
Sandbox.Application.InitUnitTest<TestInit>();
#endif
TestAppSystem = new TestAppSystem();
TestAppSystem.Init();
}
[AssemblyCleanup]
public static void AssemblyCleanup()
{
Sandbox.Application.ShutdownUnitTest();
TestAppSystem.Shutdown();
}
}

View File

@@ -50,6 +50,7 @@
<ProjectReference Include="..\Sandbox.Engine\Sandbox.Engine.csproj" />
<ProjectReference Include="..\Sandbox.Tools\Sandbox.Tools.csproj" />
<ProjectReference Include="..\Sandbox.Menu\Sandbox.Menu.csproj" />
<ProjectReference Include="..\Sandbox.AppSystem\Sandbox.AppSystem.csproj" />
</ItemGroup>
</Project>

View File

@@ -5,7 +5,7 @@ namespace GameObjects.Components;
[TestClass]
public class ModelPhysicsTests
{
private static readonly Model CitizenModel = Model.Load( "models/citizen/citizen.vmdl" );
private static Model CitizenModel => Model.Load( "models/citizen/citizen.vmdl" );
[TestMethod]
public void ComponentCreation()

View File

@@ -13,7 +13,28 @@ internal class BuildAddons( string name ) : Step( name )
string rootDir = Directory.GetCurrentDirectory();
string gameDir = Path.Combine( rootDir, "game" );
Log.Info( "Step 1: Building Addons" );
Log.Info( "Step 1: Generate solution" );
string sboxDevPath = Path.Combine( gameDir, "sbox-dev.exe" );
if ( !File.Exists( sboxDevPath ) )
{
Log.Error( $"Error: sbox-dev.exe not found at {sboxDevPath}" );
return ExitCode.Failure;
}
bool gameTestSuccess = Utility.RunProcess(
sboxDevPath,
"-generatesolution",
gameDir
);
if ( !gameTestSuccess )
{
Log.Error( "Solution generation failed!" );
return ExitCode.Failure;
}
Log.Info( "Step 2: Building Addons" );
bool addonsSuccess = Utility.RunDotnetCommand(
gameDir,
@@ -26,7 +47,7 @@ internal class BuildAddons( string name ) : Step( name )
return ExitCode.Failure;
}
Log.Info( "Step 2: Building Menu" );
Log.Info( "Step 3: Building Menu" );
string menuBuildPath = Path.Combine( gameDir, "bin", "managed", "MenuBuild.exe" );
if ( !File.Exists( menuBuildPath ) )

View File

@@ -72,27 +72,6 @@ internal class Test( string name ) : Step( name )
return ExitCode.Failure;
}
Log.Info( "Step 3: Testing Game" );
string sboxDevPath = Path.Combine( gameDir, "sbox-dev.exe" );
if ( !File.Exists( sboxDevPath ) )
{
Log.Error( $"Error: sbox-dev.exe not found at {sboxDevPath}" );
return ExitCode.Failure;
}
bool gameTestSuccess = Utility.RunProcess(
sboxDevPath,
"-test",
gameDir
);
if ( !gameTestSuccess )
{
Log.Error( "Game tests failed!" );
return ExitCode.Failure;
}
Log.Info( "All tests completed successfully!" );
return ExitCode.Success;
}

View File

@@ -48,6 +48,9 @@ public partial class MenuSystem : IMenuSystem
Dev?.Delete();
Dev = null;
// Null so GC can have it's way
Instance = null;
}
Package oldGamePackage;