diff --git a/Celeste.Mod.mm/Celeste.Mod.mm.csproj b/Celeste.Mod.mm/Celeste.Mod.mm.csproj index 0c728d6d2..39e334c44 100644 --- a/Celeste.Mod.mm/Celeste.Mod.mm.csproj +++ b/Celeste.Mod.mm/Celeste.Mod.mm.csproj @@ -5,7 +5,7 @@ Celeste.Mod.mm Celeste true - 11 + 12.0 false diff --git a/Celeste.Mod.mm/Mod/Core/CoreModule.cs b/Celeste.Mod.mm/Mod/Core/CoreModule.cs index 530aa3365..6967a9903 100644 --- a/Celeste.Mod.mm/Mod/Core/CoreModule.cs +++ b/Celeste.Mod.mm/Mod/Core/CoreModule.cs @@ -58,7 +58,8 @@ public override void LoadSettings() { } // If we're running in an environment that prefers this flag, forcibly enable them. - Settings.LazyLoading |= Everest.Flags.PreferLazyLoading; + // Used to be used by android, which is not supported currently. + // Settings.LazyLoading |= Everest.Flags.PreferLazyLoading; } public override void SaveSettings() { diff --git a/Celeste.Mod.mm/Mod/Core/CoreModuleSettings.cs b/Celeste.Mod.mm/Mod/Core/CoreModuleSettings.cs index f5ca7814a..4b311bd7a 100644 --- a/Celeste.Mod.mm/Mod/Core/CoreModuleSettings.cs +++ b/Celeste.Mod.mm/Mod/Core/CoreModuleSettings.cs @@ -182,6 +182,11 @@ public bool? FastTextureLoading { [SettingIgnore] // TODO: Show as advanced setting. public float FastTextureLoadingMaxMB { get; set; } = 0; + [SettingNeedsRelaunch] + [SettingInGame(false)] + [SettingIgnore] // TODO: Show as advanced setting. + public bool? FastTextureLoadingPoolUseGC { get; set; } = null; + [SettingNeedsRelaunch] [SettingInGame(false)] [SettingIgnore] // TODO: Show as advanced setting. diff --git a/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs b/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs index cc37aeaaa..0ebb43a53 100644 --- a/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs +++ b/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs @@ -204,188 +204,11 @@ public static void UnloadTextureRaw(IntPtr dataPtr) { _UnloadTextureRaw(dataPtr); } - [MonoModIgnore] - private static extern void _SetTextureDataPtr(Texture2D tex, IntPtr ptr); - - [MonoModIgnore] - private static extern Texture2D _LoadTextureLazyPremultiplyFull(GraphicsDevice gd, Stream stream); - - [MonoModIgnore] - private static extern void _LoadTextureRaw(GraphicsDevice gd, Stream stream, out int w, out int h, out byte[] data, out IntPtr dataPtr, bool gc); - - [MonoModIgnore] - private static extern void _LoadTextureLazyPremultiply(GraphicsDevice gd, Stream stream, out int w, out int h, out byte[] data, out IntPtr dataPtr, bool gc); - - [MonoModIgnore] - private static extern void _UnloadTextureRaw(IntPtr dataPtr); - - [MonoModIfFlag("XNA")] - [MonoModPatch("_SetTextureDataPtr")] - [MonoModReplace] - private static unsafe void _SetTextureDataPtrXNA(Texture2D tex, IntPtr ptr) { - if(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - throw new PlatformNotSupportedException(); - - byte[] copy = new byte[tex.Width * tex.Height * 4]; - Marshal.Copy(ptr, copy, 0, copy.Length); - tex.SetData(copy); - } - - [MonoModIfFlag("XNA")] - [MonoModPatch("_LoadTextureLazyPremultiplyFull")] - [MonoModReplace] - private static unsafe Texture2D _LoadTextureLazyPremultiplyFullXNA(GraphicsDevice gd, Stream stream) { - if(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - throw new PlatformNotSupportedException(); - - _LoadTextureLazyPremultiply(gd, stream, out int w, out int h, out byte[] data, out _, true); - Texture2D tex = new Texture2D(gd, w, h, false, SurfaceFormat.Color); - tex.SetData(data); - return tex; - } - - [MonoModIfFlag("XNA")] - [MonoModPatch("_LoadTextureRaw")] - [MonoModReplace] - private static unsafe void _LoadTextureRawXNA(GraphicsDevice gd, Stream stream, out int w, out int h, out byte[] data, out IntPtr dataPtr, bool gc) { - if(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - throw new PlatformNotSupportedException(); - - using (SD.Bitmap bmp = new SD.Bitmap(stream)) { - w = bmp.Width; - h = bmp.Height; - int depth = SD.Image.GetPixelFormatSize(bmp.PixelFormat); - - SD.Bitmap copy = null; - if (depth != 32) - copy = bmp.Clone(new SD.Rectangle(0, 0, w, h), SDI.PixelFormat.Format32bppArgb); - using (copy) { - - SD.Bitmap src = copy ?? bmp; - - SDI.BitmapData srcData = src.LockBits( - new SD.Rectangle(0, 0, w, h), - SDI.ImageLockMode.ReadOnly, - src.PixelFormat - ); - - int length = w * h * 4; - if (gc) { - data = new byte[length]; - dataPtr = IntPtr.Zero; - } else { - data = Array.Empty(); - dataPtr = Marshal.AllocHGlobal(length); - } - - byte* from = (byte*) srcData.Scan0; - fixed (byte* dataPin = data) { - byte* to = gc ? dataPin : (byte*) dataPtr; - for (int i = length - 1 - 3; i > -1; i -= 4) { - to[i + 0] = from[i + 2]; - to[i + 1] = from[i + 1]; - to[i + 2] = from[i + 0]; - to[i + 3] = from[i + 3]; - } - } - - src.UnlockBits(srcData); - } - } - } - - [MonoModIfFlag("XNA")] - [MonoModPatch("_LoadTextureLazyPremultiply")] - [MonoModReplace] - private static unsafe void _LoadTextureLazyPremultiplyXNA(GraphicsDevice gd, Stream stream, out int w, out int h, out byte[] data, out IntPtr dataPtr, bool gc) { - if(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - throw new PlatformNotSupportedException(); - - using (SD.Bitmap bmp = new SD.Bitmap(stream)) { - w = bmp.Width; - h = bmp.Height; - int depth = SD.Image.GetPixelFormatSize(bmp.PixelFormat); - - SD.Bitmap copy = null; - if (depth != 32) - copy = bmp.Clone(new SD.Rectangle(0, 0, w, h), SDI.PixelFormat.Format32bppArgb); - using (copy) { - - SD.Bitmap src = copy ?? bmp; - - SDI.BitmapData srcData = src.LockBits( - new SD.Rectangle(0, 0, w, h), - SDI.ImageLockMode.ReadOnly, - src.PixelFormat - ); - - int length = w * h * 4; - if (gc) { - data = new byte[length]; - dataPtr = IntPtr.Zero; - } else { - data = Array.Empty(); - dataPtr = Marshal.AllocHGlobal(length); - } - - byte* from = (byte*) srcData.Scan0; - fixed (byte* dataPin = data) { - byte* to = gc ? dataPin : (byte*) dataPtr; - for (int i = length - 1 - 3; i > -1; i -= 4) { - byte r = from[i + 2]; - byte g = from[i + 1]; - byte b = from[i + 0]; - byte a = from[i + 3]; - - if (a == 0) { - to[i + 0] = 0; - to[i + 1] = 0; - to[i + 2] = 0; - to[i + 3] = 0; - continue; - } - - if (a == 255) { - to[i + 0] = r; - to[i + 1] = g; - to[i + 2] = b; - to[i + 3] = a; - continue; - } - - to[i + 0] = (byte) (r * a / 255D); - to[i + 1] = (byte) (g * a / 255D); - to[i + 2] = (byte) (b * a / 255D); - to[i + 3] = a; - } - } - - src.UnlockBits(srcData); - } - } - } - - [MonoModIfFlag("XNA")] - [MonoModPatch("_UnloadTextureRaw")] - [MonoModReplace] - private static unsafe void _UnloadTextureRawXNA(IntPtr dataPtr) { - if(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - throw new PlatformNotSupportedException(); - - Marshal.FreeHGlobal(dataPtr); - } - - [MonoModIfFlag("FNA")] - [MonoModPatch("_SetTextureDataPtr")] - [MonoModReplace] - private static unsafe void _SetTextureDataPtrFNA(Texture2D tex, IntPtr ptr) { + private static unsafe void _SetTextureDataPtr(Texture2D tex, IntPtr ptr) { tex.SetDataPointerEXT(0, null, ptr, tex.Width * tex.Height * 4); } - [MonoModIfFlag("FNA")] - [MonoModPatch("_LoadTextureLazyPremultiplyFull")] - [MonoModReplace] - private static unsafe Texture2D _LoadTextureLazyPremultiplyFullFNA(GraphicsDevice gd, Stream stream) { + private static unsafe Texture2D _LoadTextureLazyPremultiplyFull(GraphicsDevice gd, Stream stream) { _LoadTextureLazyPremultiply(gd, stream, out int w, out int h, out _, out IntPtr dataPtr, false); Texture2D tex = new Texture2D(gd, w, h, false, SurfaceFormat.Color); tex.SetDataPointerEXT(0, null, dataPtr, w * h * 4); @@ -393,31 +216,25 @@ private static unsafe Texture2D _LoadTextureLazyPremultiplyFullFNA(GraphicsDevic return tex; } - [MonoModIfFlag("FNA")] - [MonoModPatch("_LoadTextureRaw")] - [MonoModReplace] - private static unsafe void _LoadTextureRawFNA(GraphicsDevice gd, Stream stream, out int w, out int h, out byte[] data, out IntPtr dataPtr, bool gc) { + private static unsafe void _LoadTextureRaw(GraphicsDevice gd, Stream stream, out int w, out int h, out byte[] data, out IntPtr dataPtr, bool gc) { if (gc) { Texture2D.TextureDataFromStreamEXT(stream, out w, out h, out data); dataPtr = IntPtr.Zero; } else { - data = Array.Empty(); + data = []; dataPtr = FNA3D_ReadImageStream(stream, out w, out h, out _); } } - [MonoModIfFlag("FNA")] - [MonoModPatch("_LoadTextureLazyPremultiply")] - [MonoModReplace] - private static unsafe void _LoadTextureLazyPremultiplyFNA(GraphicsDevice gd, Stream stream, out int w, out int h, out byte[] data, out IntPtr dataPtr, bool gc) { + private static unsafe void _LoadTextureLazyPremultiply(GraphicsDevice gd, Stream stream, out int w, out int h, out byte[] data, out IntPtr dataPtr, bool gc) { int length; if (gc) { Texture2D.TextureDataFromStreamEXT(stream, out w, out h, out data); dataPtr = IntPtr.Zero; length = data.Length; } else { - data = Array.Empty(); + data = []; dataPtr = FNA3D_ReadImageStream(stream, out w, out h, out length); } @@ -438,10 +255,7 @@ private static unsafe void _LoadTextureLazyPremultiplyFNA(GraphicsDevice gd, Str } } - [MonoModIfFlag("FNA")] - [MonoModPatch("_UnloadTextureRaw")] - [MonoModReplace] - private static unsafe void _UnloadTextureRawFNA(IntPtr dataPtr) { + private static unsafe void _UnloadTextureRaw(IntPtr dataPtr) { FNA3D_Image_Free(dataPtr); } diff --git a/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs b/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs index 55d5dfacd..8a9d5a88e 100644 --- a/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs +++ b/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs @@ -382,6 +382,22 @@ public static class SubHudRenderer { internal static void BeforeRender(_SubHudRenderer renderer, Scene scene) => OnBeforeRender?.Invoke(renderer, scene); } + + public static class VirtualTexture { + public delegate bool ForceLazyLoadHandler(Monocle.VirtualTexture self); + + public static event ForceLazyLoadHandler ShouldForceLazyLoad; + + internal static bool OnShouldForceLazyLoad(Monocle.VirtualTexture self) { + return ShouldForceLazyLoad.InvokeWhileFalse(self); + } + + public delegate void LazyLoadHandler(Monocle.VirtualTexture self); + + public static event LazyLoadHandler OnLazyLoad; + internal static void LazyLoad(Monocle.VirtualTexture tex) + => OnLazyLoad?.Invoke(tex); + } } } } diff --git a/Celeste.Mod.mm/Mod/Everest/Everest.Flags.cs b/Celeste.Mod.mm/Mod/Everest/Everest.Flags.cs index 80b931ce7..09edf312a 100644 --- a/Celeste.Mod.mm/Mod/Everest/Everest.Flags.cs +++ b/Celeste.Mod.mm/Mod/Everest/Everest.Flags.cs @@ -48,8 +48,10 @@ public static class Flags { public static bool AvoidRenderTargets { get; private set; } /// /// Does the environment (platform, ...) prefer lazy loading? + /// Used to be used by android, which is not supported currently. /// - public static bool PreferLazyLoading { get; private set; } + [Obsolete("`PreferLazyLoading` is always false on Everest Core")] + public static bool PreferLazyLoading => false; /// /// Does the environment (renderer, framework ,...) prefer threaded GL? @@ -80,7 +82,6 @@ internal static void Initialize() { } AvoidRenderTargets = Environment.GetEnvironmentVariable("EVEREST_NO_RT") == "1"; - PreferLazyLoading = false; SupportRuntimeMods = true; SupportUpdatingEverest = true; diff --git a/Celeste.Mod.mm/Mod/Helpers/MainThreadHelper.cs b/Celeste.Mod.mm/Mod/Helpers/MainThreadHelper.cs index 5652e84d9..8beb2315a 100644 --- a/Celeste.Mod.mm/Mod/Helpers/MainThreadHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/MainThreadHelper.cs @@ -42,12 +42,13 @@ public void ExecuteTasksFor(int timeSlice) { return; // Execute tasks during our allocated time slice - _Stopwatch.Reset(); + _Stopwatch.Restart(); while (_Stopwatch.ElapsedMilliseconds < timeSlice) { if (!_TasksQueue.TryDequeue(out Task task)) break; TryExecuteTask(task); } + _Stopwatch.Stop(); } } @@ -84,7 +85,7 @@ public static ValueTask Schedule(Action act, bool forceQueue = false) { if (forceQueue && IsMainThread) { try { TaskScheduler.TaskIsForceQueued = true; - return Schedule(act); + return new ValueTask(TaskFactory.StartNew(act)); } finally { TaskScheduler.TaskIsForceQueued = false; } @@ -101,7 +102,7 @@ public static ValueTask Schedule(Func act, bool forceQueue = false) { if (forceQueue && IsMainThread) { try { TaskScheduler.TaskIsForceQueued = true; - return Schedule(act); + return new ValueTask(TaskFactory.StartNew(act)); } finally { TaskScheduler.TaskIsForceQueued = false; } @@ -117,7 +118,7 @@ public static Task Schedule(Func act, bool forceQueue = false) { if (forceQueue && IsMainThread) { try { TaskScheduler.TaskIsForceQueued = true; - return Schedule(act); + return TaskFactory.StartNew(act).Unwrap(); } finally { TaskScheduler.TaskIsForceQueued = false; } @@ -133,7 +134,7 @@ public static Task Schedule(Func> act, bool forceQueue = false) { if (forceQueue && IsMainThread) { try { TaskScheduler.TaskIsForceQueued = true; - return Schedule(act); + return TaskFactory.StartNew(act).Unwrap(); } finally { TaskScheduler.TaskIsForceQueued = false; } diff --git a/Celeste.Mod.mm/Mod/Helpers/SynchronizedZipEntryStream.cs b/Celeste.Mod.mm/Mod/Helpers/SynchronizedZipEntryStream.cs index 16e8a3d80..7310fb688 100644 --- a/Celeste.Mod.mm/Mod/Helpers/SynchronizedZipEntryStream.cs +++ b/Celeste.Mod.mm/Mod/Helpers/SynchronizedZipEntryStream.cs @@ -11,7 +11,7 @@ namespace Celeste.Mod.Helpers { /// public class SynchronizedZipEntryStream : Stream { private readonly ZipArchive archive; - private readonly ZipArchiveEntry entry; + internal readonly ZipArchiveEntry entry; private Stream wrappedStream; private long position; diff --git a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs new file mode 100644 index 000000000..7b76aeead --- /dev/null +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -0,0 +1,767 @@ +// Log when last resort gc allocations happen to not lock the threads for too long +//#define TRACE_GC_ALLOCS +// Log the memory usage of the managed and unmanaged pools +//#define POOL_USAGE_LOGGING + +using Celeste.Mod.Core; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Monocle; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; + +#nullable enable + +namespace Celeste.Mod.Helpers; + +public static class TextureContentHelper { + [ThreadStatic] + private static byte[]? bytes; + private const int bytesSize = 512 * 1024; // 524288 + private const int bytesCheckSize = 512 * 1024 - 32; // 524256 + private const int atlasSize = 4096 * 4096 * 4; + // The maximum texture size in bytes that is allowed, currently this 512 mb which is still unreasonably + // high, not all loads check it's size because not all of them have a known size before it occurs + private const int maxCheckedTextureSize = atlasSize * 8; + internal static readonly FTLMemoryManager MemoryManager; + static TextureContentHelper() { + bool inGC; + if (CoreModule.Instance != null) { + // Prefer no gc if unset, should give better performance + inGC = CoreModule.Settings.FastTextureLoadingPoolUseGC ?? false; + } else { + inGC = false; + // Looking for a cleaner way, adding an Initialize method would make ugly the other methods and fields (need to check if init-ed on each call) + Logger.Error(nameof(TextureContentHelper), "Static constructor called too early! FastTextureLoadingPoolUseGC config could not be read in time!"); + } + const int initialMemUsage = atlasSize * 4; // hardcoded for now, should be plenty to start and a good default + MemoryManager = new FTLMemoryManager(initialMemUsage, inGC); + } + + public static bool TryEnableFTL() { + /* Vanilla calls GFX.Load and MTN.Load in LoadContent on non-Stadia platforms. + * Sadly we can't load them in GameLoader.LoadThread as mods rely on them in LoadContent. + * + * Loading in a new thread with texture -> GPU ops on the main thread helps barely. + * Spawning a new thread just to wait for it to end doesn't make much sense, + * BUT delaying the slow texture load ops to happen lazy-async gets the game window to appear sooner. + * + * Note that on XNA, this dies both with and without threaded GL due to OOM exceptions. + * -ade + */ + if (patch_VirtualTexture.FtlToggle) return true; + if (CoreModule.Settings.FastTextureLoading ?? Environment.ProcessorCount >= 4) { + long limit = (long) (CoreModule.Settings.FastTextureLoadingMaxMB * 1024f * 1024f); + + if (limit <= 0) { + limit = (long) (Everest.SystemMemoryMB * 0.2f * 1024f * 1024f); + // Assume that even in the worst case with 4 GB system RAM, 512 MB (= 12.5% = 1/8) are still available for texture loads. + if (limit <= (512L * 1024L * 1024L)) + limit = (512L * 1024L * 1024L); + } + // ... and even if the user forcibly lowered it below 128 MB, fall back to 128 MB as even the vanilla gameplay atlas is 64MB. + if (limit <= (128L * 1024L * 1024L)) + limit = (128L * 1024L * 1024L); + + Logger.Info("LoadContent", $"Enabling FTL with {limit} bytes"); + patch_VirtualTexture.FtlToggle = true; + MemoryManager.SetAllocSize(limit); + return true; + } + return false; + } + + /// + /// Loads a texture from a filesystem path. + /// + /// The filesystem path. + /// The preloaded width. + /// The preloaded height. + /// A delegate which instantiates and loads the data onto a Texture2D, and a delegate which does cleanup. + public static (Func, Action?) LoadFromPath(string path, int preW = -1, int preH = -1) { + int w, h; + switch (Path.GetExtension(path)) { + case ".data": + bool hasSegment; + SpanPoolPool.SegmentIdentifier seg; + Memory mem; + using (FileStream stream = File.OpenRead(Path.Combine(Engine.ContentDirectory, path))) { + + // Vanilla has got a static readonly byte[] bytes of fixed length - currently 524288 + // Luckily we can read more chunks on demand. + byte[] read = bytes ??= new byte[bytesSize]; + + _ = stream.Read(read, 0, bytesSize); + + // Read the width, height and alpha mode + w = BitConverter.ToInt32(read, 0); + h = BitConverter.ToInt32(read, 4); + bool hasAlpha = read[8] == 1; + + int size = w * h * 4; + { + hasSegment = MemoryManager.GetChunkOrGcAlloc(size, out seg, out byte[] gcArray +#if TRACE_GC_ALLOCS + , $"Path texture {path}" +#endif + ); + mem = hasSegment ? seg.SegId.Memory : gcArray; + } + + Span buffer = mem.Span; + if (hasAlpha) { + ReadDataFile(stream, read, buffer); + } else { + ReadDataFile(stream, read, buffer); + } + } + return (() => { + Texture2D tex = new(Celeste.Instance.GraphicsDevice, w, h); + unsafe { + fixed (byte* ptr = mem.Span) + tex.SetData((IntPtr) ptr); + } + return tex; + }, + () => { + if (hasSegment) + MemoryManager.ReturnChunk(seg); + }); + case ".png": + return LoadFromStream(File.OpenRead(Path.Combine(Engine.ContentDirectory, path)), false /* pngs are never premultiplied */, preW, preH); + case ".xnb": + // xnbs are only really present in vanilla, not mods, so they are not really worth accelerating + return (() => Engine.Instance.Content.Load(path.Replace(".xnb", "")), null); + default: + return (() => { + (Func main, Action? cleanup) pair = LoadFromStream(File.OpenRead(Path.Combine(Engine.ContentDirectory, path)), false); + Texture2D tex = pair.main(); + pair.cleanup?.Invoke(); + return tex; + }, null); + } + } + + /// + /// Loads a texture from a stream. + /// + /// The stream to read from. + /// Whether the texture data read from the stream is premultiplied. + /// The preloaded width of the texture in the stream. + /// The preloaded height of the texture in the stream. + /// A delegate which instantiates and loads the data onto a Texture2D, and a delegate which does cleanup. + public static (Func, Action?) LoadFromStream(Stream stream, bool premul, int w = -1, int h = -1) { + using (stream) { + // This code will ultimately use stb_image to decode whatever is in stream + // we cannot control its allocation, so estimate it based on the image size + // and some arbitrary inflation coefficient + const double inflationCoef = 1.2; + long unmanagedClaimed = 0; + if (w > 0 && h > 0) { + unmanagedClaimed = (long) ((double) w * h * 4 * inflationCoef); + // ClaimUnmanaged gets us the amount that we managed to claim + unmanagedClaimed = MemoryManager.ClaimUnmanaged(unmanagedClaimed +#if TRACE_GC_ALLOCS + , $"Path texture { + stream switch { + FileStream fs => fs.Name, + SynchronizedZipEntryStream szes => szes.entry.FullName, + _ => "Unknown path" + } + }" +#endif + ); + } + // If we don't know the size beforehand VirtualTexture is in charge of not multithreading loads + IntPtr dataPtr; // assume Texture.SetData supports Ptr since we are using FNA + if (premul) + ContentExtensions.LoadTextureRaw(Celeste.Instance.GraphicsDevice, stream, out w, out h, out dataPtr); + else + ContentExtensions.LoadTextureLazyPremultiply(Celeste.Instance.GraphicsDevice, stream, out w, out h, out dataPtr); + stream.Dispose(); + return (() => { + Texture2D tex = new(Celeste.Instance.GraphicsDevice, w, h); + tex.SetData(dataPtr); + return tex; + }, () => { + ContentExtensions.UnloadTextureRaw(dataPtr); + if (unmanagedClaimed != 0) + MemoryManager.ReturnUnmanaged(unmanagedClaimed); + }); + } + } + + /// + /// Creates a texture from width, height and color. + /// + /// + /// + /// + /// A delegate which instantiates and loads the data onto a Texture2D, and a delegate which does cleanup. + public static (Func, Action?) LoadFromSizeAndColor(int width, int height, Color color) { + // Layout order for Color is unknown, but since it's guaranteed to be consistent everywhere this will work + bool hasSegment = MemoryManager.GetChunkOrGcAlloc(width*height*Unsafe.SizeOf(), + out SpanPoolPool.SegmentIdentifier seg, out byte[] gcArray +#if TRACE_GC_ALLOCS + , $"Sized texture {width}x{height}" +#endif + ); + Memory data = hasSegment ? seg.SegId.Memory : gcArray; + Span colorData = MemoryMarshal.Cast(data.Span); + colorData.Fill(color); + return (() => { + Texture2D tex = new(Engine.Instance.GraphicsDevice, width, height); + unsafe { + fixed (byte* ptr = data.Span) { + tex.SetData((IntPtr)ptr); + } + } + return tex; + }, () => { + if (hasSegment) + MemoryManager.ReturnChunk(seg); + }); + } + + // Abuse generics in order to get dead code elimination for optimal code on both cases + // This method simply reads from the `read` array and decodes to the `buffer` span, + // It also expects `read` to be prefilled with the first part of `stream` and it + // will keep reading from `stream` until all data is decoded. + // Assumptions: read.Length >= bytesCheckSize, stream.Position == read.Length + [MethodImpl(MethodImplOptions.AggressiveInlining)] // This used to be inlined manually + private static unsafe void ReadDataFile(Stream stream, byte[] read, Span buffer) where T : AlphaMode { + int size = buffer.Length; + fixed (byte* to = buffer) + fixed (byte* from = read) { + int* toI = (int*) to; + uint toIdxB = 0; + uint toIdxI = 0; + int readIdx = 9; // the first 9 bytes describe width, height and alpha mode (4+4+1), those have been read already + while (toIdxB < size) { + // Pixel values are run length encoded, this counts the number of pixels in this line + uint lineSize = from[readIdx]; + + bool zeroSplat = false; + if (typeof(T) == typeof(HasAlpha)) { + // If there is a nonzero alpha, all 4 bytes are stored, if alpha is zero, a single byte is + byte a = from[readIdx + 1]; + if (a > 0) { + to[toIdxB] = from[readIdx + 4]; + to[toIdxB + 1] = from[readIdx + 3]; + to[toIdxB + 2] = from[readIdx + 2]; + to[toIdxB + 3] = a; + readIdx += 1 + 4; + } else { + toI[toIdxI] = 0; + readIdx += 1 + 1; + zeroSplat = true; + } + } else { + to[toIdxB] = from[readIdx + 3]; + to[toIdxB + 1] = from[readIdx + 2]; + to[toIdxB + 2] = from[readIdx + 1]; + to[toIdxB + 3] = 255; + readIdx += 4; + } + + if (lineSize > 1) { + if (typeof(T) == typeof(HasAlpha) && zeroSplat) { + // If alpha was zero, bulk write 0 to the whole line + Unsafe.InitBlockUnaligned(to + toIdxB + 4, 0, lineSize * 4 - 4); + } else { + // Write via integers for performance + int splatValue = toI[toIdxI]; + for (uint jI = toIdxI + 1, end = toIdxI + lineSize; jI < end; jI++) + toI[jI] = splatValue; + } + } + + // Advance + toIdxI += lineSize; + toIdxB = toIdxI * 4; + + // If there is less than 32 bytes left, copy the remaining ones to the beginning and read from the stream again + if (readIdx > bytesCheckSize) { + int offset = read.Length - readIdx; + for (int oB = 0; oB < offset; oB++) { + from[oB] = from[readIdx + oB]; + } + _ = stream.Read(read, offset, read.Length - offset); + readIdx = 0; + } + } + } + } + + private interface AlphaMode; + + private struct HasAlpha : AlphaMode { + // The structs need to be of different sizes in order to force the JIT to compile two different versions +#pragma warning disable CS0169 // Field is never used + private int _; +#pragma warning restore CS0169 // Field is never used + } + + private struct NoAlpha : AlphaMode; + + /// + /// Helper class to manage memory allocations and limit those. + /// This class is thread-safe. + /// + /// Initial reserved memory usage. + /// Whether the managed pool lives is a gc object or not. + internal class FTLMemoryManager(long initialMemUsage, bool inGC) { + private const double SplitPercent = 1 / 4D; + private readonly long initialMemUsage = initialMemUsage; + private const int MainThreadTimeout = 500; + private const int OtherThreadTimeout = -1; + private const int PoolSize = atlasSize; + // Managed + private readonly ResourceWaiter waitingForSpace = new(MainThreadTimeout, OtherThreadTimeout); + private readonly SpanPoolPool spanPool = new(PoolSize, (long)(initialMemUsage*SplitPercent), inGC); + + // Unmanaged + private long unmanagedMemoryUsage; + private readonly object unmanagedLock = new(); + private readonly ResourceWaiter unmanagedWaitingForSpace = new(MainThreadTimeout, OtherThreadTimeout); + + private long _currMemUsage = initialMemUsage; + // Reads and writes to this are slightly racy, that's why it's marked as volatile, but adding sync to this would be too much effort given + // that it's value ever rarely changes + public long CurrMemUsage { get => Volatile.Read(ref _currMemUsage); private set => Volatile.Write(ref _currMemUsage, value); } + + public bool GetChunkOrGcAlloc(int chunkSize, out SpanPoolPool.SegmentIdentifier seg, out byte[] gcArray +#if TRACE_GC_ALLOCS + , string source +#endif + ) { + if (chunkSize > PoolSize) { // It's not going to fit + if (chunkSize > maxCheckedTextureSize) { // Just no + throw new InvalidOperationException($"Tried to obtain a chunk that is too large ({chunkSize})" +#if TRACE_GC_ALLOCS + + $" for texture {source}:" +#endif + ); + } + + // Just gc alloc it + Logger.Warn(nameof(TextureContentHelper), $"Chunk size was too big for the pool size ({chunkSize} > {PoolSize})!"); +#if TRACE_GC_ALLOCS + Logger.Warn(nameof(TextureContentHelper), $"For texture {source}:"); + Logger.Warn(nameof(TextureContentHelper), new StackTrace().ToString()); +#endif + gcArray = new byte[chunkSize]; + seg = default; + return false; + } + + while (true) { + bool hasSegment = spanPool.TryRent(chunkSize, out seg); + if (hasSegment) { + gcArray = []; + return true; + } + + if (!waitingForSpace.Wait()) // On timeout just exit and gc alloc + break; + } + + bool isMainThread = MainThreadHelper.IsMainThread; + Logger.Warn(nameof(TextureContentHelper), $"Allocating {chunkSize} bytes in the gc because " + + $"{(isMainThread ? "the main-thread" : "a worker thread")} was " + + $"blocked for more than {(isMainThread ? MainThreadTimeout : OtherThreadTimeout)}ms"); +#if TRACE_GC_ALLOCS + Logger.Warn(nameof(TextureContentHelper), $"For texture {source}:"); + Logger.Warn(nameof(TextureContentHelper), new StackTrace().ToString()); +#endif + gcArray = new byte[chunkSize]; + seg = default; + return false; + } + + public void ReturnChunk(SpanPoolPool.SegmentIdentifier seg) { + spanPool.Return(seg); + waitingForSpace.Pulse(); + } + +#if POOL_USAGE_LOGGING + private DateTime lastLog; +#endif + public long ClaimUnmanaged(long amount +#if TRACE_GC_ALLOCS + , string source +#endif + ) { + if (amount > maxCheckedTextureSize) { // Just no + throw new InvalidOperationException($"Tried to obtain a chunk that is too large ({amount})" +#if TRACE_GC_ALLOCS + + $" for texture {source}:" +#endif + ); + } + if (amount > CurrMemUsage * (1 - SplitPercent)) { + // Just roll with it, it is not going to fit + Logger.Warn(nameof(TextureContentHelper), $"Chunk size was too big for the unmanaged budget ({amount} > {CurrMemUsage * (1 - SplitPercent)})!"); +#if TRACE_GC_ALLOCS + Logger.Warn(nameof(TextureContentHelper), $"For texture {source}:"); + Logger.Warn(nameof(TextureContentHelper), new StackTrace().ToString()); +#endif + return 0; + } + while (true) { + lock (unmanagedLock) { + if (unmanagedMemoryUsage + amount <= CurrMemUsage * (1 - SplitPercent)) { + unmanagedMemoryUsage += amount; +#if POOL_USAGE_LOGGING + LogStatus("++"); +#endif + return amount; + } + } + if (!unmanagedWaitingForSpace.Wait()) { // On timeout just allocate without budget + bool isMainThread = MainThreadHelper.IsMainThread; + Logger.Warn(nameof(TextureContentHelper), $"Allocating {amount} bytes over the unmanaged budget because" + + $"{(isMainThread ? "the main-thread" : "a worker thread")} was " + + $"blocked for more than {(isMainThread ? MainThreadTimeout : OtherThreadTimeout)}ms"); +#if TRACE_GC_ALLOCS + Logger.Warn(nameof(TextureContentHelper), $"For texture {source}:"); + Logger.Warn(nameof(TextureContentHelper), new StackTrace().ToString()); +#endif + break; + } + } + return 0; + } + + public void ReturnUnmanaged(long amount) { + lock (unmanagedLock) { + unmanagedMemoryUsage -= amount; +#if POOL_USAGE_LOGGING + LogStatus("--"); +#endif + Debug.Assert(unmanagedMemoryUsage >= 0); + } + unmanagedWaitingForSpace.Pulse(); + } + +#if POOL_USAGE_LOGGING + private void LogStatus(string postfix) { + if (DateTime.Now - lastLog > TimeSpan.FromMilliseconds(100)) { + long cap = (long)(CurrMemUsage * (1 - SplitPercent)); + Logger.Info(nameof(TextureContentHelper), $"UnmanagedMemory: {{used: {unmanagedMemoryUsage} ({(double)unmanagedMemoryUsage/cap:P}), cap: {cap}}} {postfix}"); + lastLog = DateTime.Now; + } + } +#endif + + public void SetAllocSize(long limit) { + long prevMemUsage = CurrMemUsage; + CurrMemUsage = limit == -1 ? initialMemUsage : limit; + spanPool.CurrMemUsage = (long) (CurrMemUsage * SplitPercent); + if (prevMemUsage < CurrMemUsage) { + // Make everyone waiting recheck + waitingForSpace.Pulse(); + unmanagedWaitingForSpace.Pulse(); + } + } + + private class ResourceWaiter(int MainThreadTimeout, int OtherThreadTimeout) { + private readonly ManualResetEventSlim mre = new(); + + public bool Wait() { + bool isMainThread = MainThreadHelper.IsMainThread; + // TODO: this is sort of ugly, all threads should attempt to claim memory but we have no guarantee of that + // What about our own impl of a better version? + if (mre.Wait(isMainThread ? MainThreadTimeout : OtherThreadTimeout)) { + mre.Reset(); + return true; + } + return false; + } + + public void Pulse() { + mre.Set(); + } + } + } + + /// + /// Pool of SpanPools, used to dynamically create and destroy span pools to adjust to the currently allowed memory usage. + /// This class is thread-safe + /// + /// + internal class SpanPoolPool where T : unmanaged { + private readonly object @lock = new(); + private readonly int size; + private long currMemUsage; + private readonly Dictionary> pools = []; + private int poolId; + private long releasePending; + private readonly bool _inGC; + + public long CurrMemUsage { + // ReSharper disable once InconsistentlySynchronizedField + get => Volatile.Read(ref currMemUsage); + set { + lock (@lock) { + currMemUsage = value; + CheckAlloc(); + } + } + } + + public SpanPoolPool(int poolSize, long initialMemUsage, bool gc) { + size = poolSize; + currMemUsage = initialMemUsage; + _inGC = gc; + CheckAlloc(); + } + + // Must be called from a lock + private void CheckAlloc() { + releasePending = 0; + long minPoolCount = currMemUsage / size + (currMemUsage % size != 0 ? 1 : 0); + if (pools.Count < minPoolCount) { + for (int i = pools.Count; i < minPoolCount; i++) { + pools.Add(poolId++, new SpanPool(size, _inGC)); + } + } else if (pools.Count > minPoolCount) { + int count = pools.Count; + List deallocating = []; + foreach ((int id, SpanPool pool) in pools) { + if (!pool.IsEmpty()) continue; + deallocating.Add(id); + count--; + if (count == minPoolCount) break; + } + foreach (int id in deallocating) { + pools[id].Dispose(); + pools.Remove(id); + } + releasePending = count - minPoolCount; + } + } + + public bool TryRent(int chunkSize, out SegmentIdentifier segment) { + lock (@lock) { + for (int i = 0; i < 2; i++) { + // First try non-empty pools, then try empty ones + foreach ((int id, SpanPool pool) in pools) { + if (pool.IsEmpty() == (i == 0)) continue; + bool hasSegment = pool.TryRent(chunkSize, out SpanPool.SegmentIdentifier seg); + if (!hasSegment) continue; +#if POOL_USAGE_LOGGING + LogUsage("++"); +#endif + segment = new SegmentIdentifier(seg, id); + return true; + } + } + + // There's no space anywhere + segment = default; + return false; + } + } + + public void Return(SegmentIdentifier segment) { + lock (@lock) { + // Just return it to the correct pool + if (!pools.TryGetValue(segment.PoolId, out SpanPool? pool)) { + throw new ArgumentException($"Unknown pool with id {segment.PoolId}", nameof(segment)); + } + pool.Return(segment.SegId); + // But if we are looking to deallocate more, do so now + if (pool.IsEmpty() && releasePending > 0) { + pool.Dispose(); + pools.Remove(segment.PoolId); + releasePending--; + } + +#if POOL_USAGE_LOGGING + LogUsage("--"); +#endif + } + } +#if POOL_USAGE_LOGGING + private DateTime lastLog; + private void LogUsage(string postfix) { + if (DateTime.Now - lastLog > TimeSpan.FromMilliseconds(100)) { + Logger.Info(nameof(TextureContentHelper), $"ManagedMemory: {{cap: {currMemUsage}, releasePending: {releasePending}}} {postfix}"); + foreach ((int pId, SpanPool pool) in pools) { + Logger.Info(nameof(TextureContentHelper), $" {pId}: {pool.StatusString()}"); + } + lastLog = DateTime.Now; + } + } +#endif + + public readonly struct SegmentIdentifier(SpanPool.SegmentIdentifier segmentIdentifier, int poolId) { + public readonly SpanPool.SegmentIdentifier SegId = segmentIdentifier; + public readonly int PoolId = poolId; + } + } + + /// + /// This class holds large arrays from which then hands segments of it as (for later to be used as a ). + /// This class is not thread safe. + /// + /// + internal class SpanPool : IDisposable where T : unmanaged { + private readonly int size; + // private readonly object @lock = new(); + + private readonly ManagedOrNotArray arrayHolder; + private readonly Memory array; + private readonly List<(int start, int end)> usedSegments = new(); + + public SpanPool(int itemCount, bool inGC) { + size = itemCount; + arrayHolder = new ManagedOrNotArray(itemCount, inGC); + array = arrayHolder.AsMemory(); + } + + public bool TryRent(int chunkSize, out SegmentIdentifier seg) { + // lock (@lock) { + (int start, int end)? freeSegment = NextFreeSegmentAndReserve(chunkSize); + if (!freeSegment.HasValue) { // No space + seg = default; + return false; + } + + // We have a spot + Memory memory = array[freeSegment.Value.start..freeSegment.Value.end]; + seg = new SegmentIdentifier(memory, freeSegment.Value.start, freeSegment.Value.end); + return true; + // } + } + + public void Return(SegmentIdentifier seg) { + // lock (@lock) { + // Find the matching segment and remove it + for (int i = 0; i < usedSegments.Count; i++) { + if (seg.Start == usedSegments[i].start) { + // Some extra verification to prevent corruption + if (seg.End != usedSegments[i].end) { + throw new ArgumentException("Invalid segment!"); + } + usedSegments.RemoveAt(i); + break; + } + } + // } + } + + public bool IsEmpty() => usedSegments.Count == 0; + + public void Dispose() { + if (!IsEmpty()) { + throw new Exception("Attempted to deallocate with segments potentially in use!"); + } + arrayHolder.Dispose(); + } + + // Should always be called in a lock + private (int, int)? NextFreeSegmentAndReserve(int minSize) { + int prevIdx = 0; + for (int i = 0; i < usedSegments.Count; i++) { + int currIdx = usedSegments[i].Item1; + if (currIdx - prevIdx >= minSize) { // Found a spot + (int, int) newSegment = (prevIdx, prevIdx+minSize); + usedSegments.Insert(i, newSegment); + return newSegment; + } + prevIdx = usedSegments[i].Item2; + } + // No in-between segments, check remaining space + if (size - prevIdx >= minSize) { + (int, int) newSegment = (prevIdx, prevIdx + minSize); + usedSegments.Add(newSegment); + return newSegment; + } + // No space + return null; + } + +#if POOL_USAGE_LOGGING + internal string StatusString() { + long used = usedSegments.Select(s => s.end - s.start).Sum(); + return $"{{Segments: {usedSegments.Count}, Used: {used} ({(double) used / size:P})}}"; + } +#endif + + public readonly struct SegmentIdentifier(Memory memory, int start, int end) { + public readonly Memory Memory = memory; + public readonly int Start = start; + public readonly int End = end; + } + } + + /// + /// Helper struct to abstract the logic behind having a managed array or an unmanaged array. + /// The arrays are used in the span pools. + /// + /// + private readonly struct ManagedOrNotArray : IDisposable where T : unmanaged { + private readonly bool Managed; + private readonly T[]? gcBuffer; + private readonly UnmanagedMemoryManager? ptrBuffer; + + public ManagedOrNotArray(int itemCount, bool managed) { + Managed = managed; + if (managed) { + gcBuffer = new T[itemCount]; + } else { + ptrBuffer = new UnmanagedMemoryManager(itemCount); + } + } + + public Memory AsMemory() { + return Managed ? + new Memory(gcBuffer!) : + // Can't be null because Managed is false + ptrBuffer!.Memory; + } + + public void Dispose() { + ((IDisposable?)ptrBuffer)?.Dispose(); + } + } + + /// + /// A simple MemoryManager for unmanaged memory arrays. + /// + /// Number of elements of the array. + /// Type of the array. + private sealed unsafe class UnmanagedMemoryManager(int itemCount) : MemoryManager + where T : unmanaged { + private readonly T* ptr = (T*)Marshal.AllocHGlobal(itemCount*Marshal.SizeOf()); + private bool disposed; + + protected override void Dispose(bool disposing) { + if (disposed) throw new InvalidOperationException("Double free!"); + Marshal.FreeHGlobal((IntPtr)ptr); + disposed = true; + } + + public override Span GetSpan() { + return new Span(ptr, itemCount); + } + + public override MemoryHandle Pin(int elementIndex = 0) { + // ptr won't ever move because it's not managed, so no pin needed. + return new MemoryHandle(ptr + elementIndex); + } + + public override void Unpin() { + // No work needed, pinning doesnt happen + } + } +} diff --git a/Celeste.Mod.mm/MonoModRules.cs b/Celeste.Mod.mm/MonoModRules.cs index d8f3bf84a..9f72b43c4 100644 --- a/Celeste.Mod.mm/MonoModRules.cs +++ b/Celeste.Mod.mm/MonoModRules.cs @@ -61,7 +61,14 @@ class ForceAggressiveInliningAttribute : Attribute { } /// = [MonoModCustomAttribute(nameof(MonoModRules.ForceSealed))] class ForceSealedAttribute : Attribute { } -#endregion + + /// + /// Makes all methods on a type have [MethodImpl(MethodImplOptions.Synchronized)], static or not. + /// Can only be applied on types. + /// + [MonoModCustomAttribute(nameof(MonoModRules.MakeAllMethodsSynchronized))] + class MakeAllMethodsSynchronizedAttribute : Attribute { } + #endregion static partial class MonoModRules { @@ -356,6 +363,16 @@ public static void ForceSealed(ICustomAttributeProvider provider, CustomAttribut provider?.CustomAttributes?.Remove(attrib); } + public static void MakeAllMethodsSynchronized(ICustomAttributeProvider provider, CustomAttribute attrib) { + if (provider is not TypeDefinition typeDef) throw new ArgumentException("Must be of type TypeDefinition", nameof(provider)); + + foreach (MethodDefinition methodDef in typeDef.Methods) { + methodDef.IsSynchronized = true; + } + + provider?.CustomAttributes?.Remove(attrib); + } + /// /// Fix ILSpy unable to decompile enumerator after MonoMod patching
/// (yield-return decompiler failed: Unexpected instruction in Iterator.Dispose()) diff --git a/Celeste.Mod.mm/Patches/Celeste.cs b/Celeste.Mod.mm/Patches/Celeste.cs index 7accea80b..8c49566c4 100644 --- a/Celeste.Mod.mm/Patches/Celeste.cs +++ b/Celeste.Mod.mm/Patches/Celeste.cs @@ -335,44 +335,20 @@ protected override void LoadContent() { // DON'T! The original method is orig_LoadContent bool firstLoad = this.firstLoad; - /* Vanilla calls GFX.Load and MTN.Load in LoadContent on non-Stadia platforms. - * Sadly we can't load them in GameLoader.LoadThread as mods rely on them in LoadContent. - * - * Loading in a new thread with texture -> GPU ops on the main thread helps barely. - * Spawning a new thread just to wait for it to end doesn't make much sense, - * BUT delaying the slow texture load ops to happen lazy-async gets the game window to appear sooner. - * - * Note that on XNA, this dies both with and without threaded GL due to OOM exceptions. - * -ade - */ - - if (CoreModule.Settings.FastTextureLoading ?? (Environment.ProcessorCount >= 4 && (!CoreModule.Settings.ThreadedGL ?? true))) { - long limit = (long) (CoreModule.Settings.FastTextureLoadingMaxMB * 1024f * 1024f); - - if (limit <= 0) { - limit = (long) (Everest.SystemMemoryMB * 0.2f * 1024f * 1024f); - // Assume that even in the worst case with 4 GB system RAM, 512 MB (= 12.5% = 1/8) are still available for texture loads. - if (limit <= (512L * 1024L * 1024L)) - limit = (512L * 1024L * 1024L); - } - // ... and even if the user forcibly lowered it below 128 MB, fall back to 128 MB as even the vanilla gameplay atlas is 64MB. - if (limit <= (128L * 1024L * 1024L)) - limit = (128L * 1024L * 1024L); - - patch_VirtualTexture.StartFastTextureLoading(limit); - } + TextureContentHelper.TryEnableFTL(); orig_LoadContent(); foreach (EverestModule mod in Everest._Modules) mod.LoadContent(firstLoad); - patch_VirtualTexture.StopFastTextureLoading(); + // There's no need to stop Ftl if it started in the first place ;) Everest._ContentLoaded = true; } protected override void BeginRun() { + TextureContentHelper.MemoryManager.SetAllocSize(-1); base.BeginRun(); // This is as close as we can get to the showwindow call EverestSplashHandler.StopSplash(); diff --git a/Celeste.Mod.mm/Patches/GameLoader.cs b/Celeste.Mod.mm/Patches/GameLoader.cs index c0def4232..ba5d6d87d 100644 --- a/Celeste.Mod.mm/Patches/GameLoader.cs +++ b/Celeste.Mod.mm/Patches/GameLoader.cs @@ -115,10 +115,11 @@ private void LoadThread() { timer = Stopwatch.StartNew(); MainThreadHelper.Boost = 50; - patch_VirtualTexture.WaitFinishFastTextureLoading(); - MainThreadHelper.Schedule(() => MainThreadHelper.Boost = 0).AsTask().Wait(); - // FIXME: There could be ongoing tasks which add to the main thread queue right here. - Console.WriteLine(" - FASTTEXTURELOADING LOAD: " + timer.ElapsedMilliseconds + "ms"); + // Flush the main thread queue to make sure all tasks related to FTL are completed + // Note: this will often be empty already but on extreme scenarios (massive amount of textures or + // very old hardware) it may not + MainThreadHelper.Schedule(() => MainThreadHelper.Boost = 0, true).AsTask().Wait(); + Console.WriteLine(" - MTH QUEUE FLUSH: " + timer.ElapsedMilliseconds + "ms"); timer.Stop(); Everest.Events.GameLoader.LoadThread(); diff --git a/Celeste.Mod.mm/Patches/LevelLoader.cs b/Celeste.Mod.mm/Patches/LevelLoader.cs index 5334289f9..61e70dc39 100644 --- a/Celeste.Mod.mm/Patches/LevelLoader.cs +++ b/Celeste.Mod.mm/Patches/LevelLoader.cs @@ -52,7 +52,8 @@ public void ctor(Session session, Vector2? startPosition = default) { } if (CoreModule.Settings.LazyLoading) { - MainThreadHelper.Schedule(() => patch_VirtualContent.UnloadOverworld()); + // Saves memory at the cost of having to reload them each time + MainThreadHelper.Schedule(patch_VirtualContent.UnloadOverworld); } // Vanilla TileToIndex mappings. diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs index 8c4dc7f62..7758c1d31 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs @@ -2,20 +2,38 @@ using System; namespace Monocle { - // This is only required as VirtualAsset's members are internal or even private, not protected. - // Noel or Matt, if you see this, please change the visibility to protected. Thanks! - [MonoModIgnore] abstract class patch_VirtualAsset : VirtualAsset { #pragma warning disable CS0108 + [MonoModIgnore] public string Name { get; internal set; } - public int Width { get; internal set; } - public int Height { get; internal set; } + + // Making Width and Height virtual is a breaking change, so lets just add new virtual properties and make the + // old ones just wrap the new ones :) + protected virtual int InnerWidth { get; set; } + public int Width { + [MonoModReplace] + get => InnerWidth; + [MonoModReplace] + internal set => InnerWidth = value; + } + + protected virtual int InnerHeight { get; set; } + public int Height { + [MonoModReplace] + get => InnerHeight; + [MonoModReplace] + internal set => InnerHeight = value; + } + # pragma warning restore CS0108 - + // This is only required as VirtualAsset's members are internal or even private, not protected. + // Noel or Maddy, if you see this, please change the visibility to protected. Thanks! + [MonoModIgnore] internal virtual void Unload() { } + [MonoModIgnore] internal virtual void Reload() { } diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs index 4fe524887..beb281e7c 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs @@ -9,6 +9,8 @@ using System.IO; namespace Monocle { + // We may have concurrent usage of this class due to FTL + [MakeAllMethodsSynchronized] static class patch_VirtualContent { // We're effectively in VirtualContent, but still need to "expose" private fields to our mod. @@ -62,6 +64,9 @@ public static VirtualTexture CreateTexture(ModAsset metadata) { [MonoModLinkFrom("System.Void Monocle.VirtualContent::_Unload()")] public static extern void Unload(); + [MonoModIgnore] + public static extern void Remove(VirtualAsset asset); + /// /// Forcibly unload and reload all content. /// @@ -76,8 +81,9 @@ public static void ForceReload() { public static void UnloadOverworld() { foreach (patch_VirtualAsset asset in assets) { string path = asset.Name.Replace('\\', '/'); - if (asset is patch_VirtualTexture && path.StartsWith("Graphics/Atlases/")) { - path = path.Substring(17); + const string pfx = "Graphics/Atlases/"; + if (asset is patch_VirtualTexture && path.StartsWith(pfx)) { + path = path[pfx.Length..]; if (path.StartsWith("Opening") || path.StartsWith("Overworld") || path.StartsWith("Mountain") || path.StartsWith("Journal")) { asset.Unload(); } diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index 528de0e07..5967e4a76 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -1,832 +1,583 @@ #pragma warning disable CS0626 // Method, operator, or accessor is marked external and has no attributes on it #pragma warning disable CS0649 // Field is never assigned to, and will always have its default value null -// #define FTL_DEBUG -// #define FTL_VERIFY - -#if DEBUG || FTL_DEBUG -#define FTL_VERIFY -#endif - using Celeste.Mod; using Celeste.Mod.Core; +using Celeste.Mod.Helpers; using Celeste.Mod.Meta; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using MonoMod; using System; -using System.Collections.Generic; +using System.Diagnostics; using System.IO; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; - +using Debug = System.Diagnostics.Debug; + +#nullable enable + +// FTL v2: +// This file hosts the implementation of FTL v2, Fast Texture Loading version 2. +// +// Its main goal is to offload the work to other threads of reading the texture data, unpacking it +// and copying it to an array for upload to the GPU, so that the loading process is sped up by +// asynchronously loading textures. Although the GPU uploads are synced back to the main thread. +// This last detail also fixes a bug in vanilla where GPU uploads could crash the game on rare occasions +// on Nvidia gpus. +// +// It is enabled via the static field FtlToggle, so while that is false all loads will happen synchronously. +// It is important to note that only loads invoked from the main thread will be offloaded to other threads, +// this due to the assumption that if loading happens intentionally on a separate thread it is because loads +// are meant to happen on that separate thread only. +// +// You will see that most work is sent to the TextureContentHelper, a helper class whose goal is to do the +// actual loading of the data, as well as capping the current memory usage to not freeze the system (since +// FTL simply offloads all loads onto other threads without checking system pressure). +// +// FTL also implements the ability to lazy load all textures: when a texture is created only its size is read +// and no loading actually occurs, that only when its Texture2D is accessed for the first time. This leads to +// massive gains on the game loading speed but at the cost of constant stutters on gameplay, thus it is not recommended +// unless there are massive memory constraints. Although some mods may use the event +// Everest.Events.VirtualTexture.ShouldForceLazyLoad to force a lazy load for specific textures, in case it has some extra +// knowledge of when the texture will be used. +// There's also an event to track when a lazily loaded texture is loaded too late (on access) and may cause a gameplay stutter: +// Everest.Events.VirtualTexture.OnLazyLoad. +// It is important to note that not all textures can be preloaded (have its size loaded without fully loading the texture +// itself), for those textures lazy loading will simply not happen. +// +// The FTL v2 implementation also has the added benefit of making VirtualTexture completely thread-safe. +// This does not prevent race conditions on user code though. +// +// For backwards-compatibility sake it is allowed to resize textures (change its Width and Height) properties if and only if +// those were made using the (string name, int width, int height, Color color) constructor. All resizes will implicitly call +// a reload and consecutively erase the contents if those were somehow modified. An additional property is provided so both +// dimensions can be changed without calling two separate reloads: VirtualTexture.Size. +// For backwards-compatibility sake it is also allowed to override the Texture2D that this VirtualTexture owns, doing so will +// grant ownership of the newly given Texture2D to the VirtualTexture meaning if it were to be `Unload`ed the new Texture2D +// would be disposed. While the texture is overriden Reloads are nullified. If a reload were to be in progress when the texture +// is overriden, it will be canceled and have no effect. If the texture is overriden while already being overriden the +// VirtualTexture will take ownership of the new one and leave ownership of the old one. Finally, if the texture is overriden +// with a null texture it would have the exact same effect as an Unload call. +// +// Finally, this class is also tasked with the headless mode loading optimizations, where all textures which can be preloaded +// will have its Texture2D set to a 1x1 texture, this is purely for performance’s sake. Textures which cannot be preloaded will +// be loaded as usual. namespace Monocle { class patch_VirtualTexture : patch_VirtualAsset { - // We're effectively in VirtualAsset, but still need to "expose" private fields to our mod. - - // Let's make the fixed buffers non-readonly and thread-static. - // FIXME for MonoMod: Replacing fields is broken? Not critical though. - // [MonoModRemove] - // internal static readonly byte[] bytes; - [ThreadStatic] - [MonoModLinkFrom("System.Byte[] Monocle.VirtualTexture::bytes")] - internal static byte[] bytesSafe; - internal const int bytesSize = 512 * 1024; // 524288 - internal const int bytesCheckSize = 512 * 1024 - 32; // 524256 - - // This one isn't thread-static as it's borrowed whenever necessary. - // [MonoModRemove] - // internal static readonly byte[] buffer; - [MonoModLinkFrom("System.Byte[] Monocle.VirtualTexture::buffer")] - internal static byte[] bufferSafe; - internal static object bufferLock; - internal const int bufferSize = 4096 * 4096 * 4; // 67108864 - - private static bool ftlEnabled; - private static object ftlLock; - private static ManualResetEventSlim ftlFree; - private static ManualResetEventSlim ftlFinish; - private static volatile uint ftlLimit; - private static volatile uint ftlUsed; - private static volatile int ftlTotalCount; - private static volatile int ftlUsedCount; -#if FTL_VERIFY - private static HashSet ftlTotalCountVerify; - private static HashSet ftlUsedCountVerify; -#endif + private string? _path; + public string? Path { + [MonoModReplace] get => _path; + [MonoModReplace] + private set { + if (_textureKind != TextureKind.FileSystem || _path != null) + throw new InvalidOperationException("Cannot assign to path!"); + _path = value; + } + } + private Color color; + private int _width; + private int _height; + + // Intermediary overridable property to call reloads on change + protected override int InnerWidth { + get => _width; + set { + // It makes no sense to write to this on the other texture kinds since the value will get overwritten after the reload anyway + if (_textureKind != TextureKind.SizeDefined) + throw new InvalidOperationException("Resizing a VirtualTexture is only allowed for size defined textures!"); + lock (_reloadLock) { + _width = value; + } + Unload(); + Reload(false); + } + } + + // Intermediary overridable property to call reloads on change + protected override int InnerHeight { + get => _height; + set { + if (_textureKind != TextureKind.SizeDefined) + throw new InvalidOperationException("Resizing a VirtualTexture is only allowed for size defined textures!"); + lock (_reloadLock) { + _height = value; + } + Unload(); + Reload(false); + } + } + + // Helper property to modify both with and height without calling reload twice + public Point Size { + get => new(InnerWidth, InnerHeight); + set { + lock (_reloadLock) { + _width = value.X; + _height = value.Y; + } + Unload(); + Reload(false); + } + } private static extern void orig_cctor(); [MonoModConstructor] private static void cctor() { orig_cctor(); - - bufferLock = new object(); - - ftlLock = new object(); - ftlFree = new ManualResetEventSlim(true); - ftlFinish = new ManualResetEventSlim(true); -#if FTL_VERIFY - ftlTotalCountVerify = new HashSet(); - ftlUsedCountVerify = new HashSet(); -#endif } - public string Path { get; private set; } - private Color color; + // The following maps all refs Texture_Unsafe to Texture + // and maps all vanilla Texture refs to Texture_Safe + // Texture_Unsafe gets erased after [MonoModLinkFrom("Microsoft.Xna.Framework.Graphics.Texture2D Monocle.VirtualTexture::Texture_Unsafe")] - public Texture2D Texture; + public Texture2D? Texture; - [MonoModRemove] - public Texture2D Texture_Unsafe; + [MonoModRemove] + private Texture2D? Texture_Unsafe; + /// + /// Returns the current texture, and forces a reload if necessary. + /// + /// Thrown if the reload happened asynchronously and there was an exception during it. [MonoModLinkFrom("Microsoft.Xna.Framework.Graphics.Texture2D Monocle.VirtualTexture::Texture")] - public Texture2D Texture_Safe { + public Texture2D? Texture_Safe { get { - // Handle already queued loads appropriately. - object queuedLoadLock = _Texture_QueuedLoadLock; - bool lazyForce = false; - if (queuedLoadLock != null) { - lock (queuedLoadLock) { - // Queued task finished just in time. - if (_Texture_QueuedLoadLock != queuedLoadLock) - return Texture_Unsafe; - - // If we still can, cancel the queued load, then proceed with lazy-loading. - if (MainThreadHelper.IsMainThread) { - _Texture_QueuedLoadLock = null; - _Texture_Reloading = false; - lazyForce = true; + do { + // Return the texture if it is ready + if (Texture_Unsafe != null) { + lock (_textureLock) { + if (Texture_Unsafe != null) + return Texture_Unsafe; } } - if (!MainThreadHelper.IsMainThread) { - // Otherwise wait for it to get loaded. (Don't wait locked!) - return _Texture_QueuedLoad.Result; + // Check if the texture is null because failure + if (asyncFault != null) { + lock (_reloadLock) { + if (asyncFault != null) + throw new AggregateException("Exception during asynchronous texture load", asyncFault); + } + } + // If asyncFault is null but is about to become non-null we will check after the reload + + // Otherwise try queuing a reload + Logger.Debug(nameof(VirtualTexture), $"Loading texture {Name ?? "(Unnamed)"} on texture access!"); + Reload(true, true); + + // We could get a reload that sets this to null, this is an unfortunate case where we will void the error + // regardless we cannot do much about it, and it will likely error again + if (asyncFault != null) { + lock (_reloadLock) { + if (asyncFault != null) + throw new AggregateException("Exception during asynchronous texture load", asyncFault); + } } - } - if (_Texture_Reloading || !(CoreModule.Settings.LazyLoading || lazyForce)) - return Texture_Unsafe; + Task queuedLoad; + QueuedLoad queuedLoadAct; + // Prevent any texture swaps in the meanwhile + lock (_textureLock) { + if (_queuedLoad == null || _queuedLoad.IsCompleted) { + // If there's no task to use Texture_Unsafe cannot be swapped anymore here so this check is thread-safe + if (Texture_Unsafe != null) + return Texture_Unsafe; - // If we're accessing the texture from elsewhere (render), load lazily if required. - if (Texture_Unsafe?.IsDisposed ?? true) { - _Texture_Requesting = true; - Reload(); - } + continue; // We have got no texture, and we cannot wait for anything so try again + } + // Wait for the _queuedLoad, but not locked + queuedLoad = _queuedLoad; + queuedLoadAct = _queuedLoadAct!; + } - return Texture_Unsafe; + // Wait for the texture load, and check again if we have got anything to return + if (MainThreadHelper.IsMainThread) { + // But waiting for the load on the main thread would be disastrous (deadlock) + // so just run it directly, we are on the main thread anyway (the action is idempotent) + queuedLoadAct.Run(); + } else { + queuedLoad.Wait(); + } + } while (true); } set { - Texture_Unsafe = value; - } - } - - private bool _Texture_Reloading; - private bool _Texture_Requesting; - private bool _Texture_UnloadAfterReload; - private object _Texture_QueuedLoadLock; - private ValueTask _Texture_QueuedLoad; - private bool _Texture_FTLCount; - private bool _Texture_FTLLoading; - private uint _Texture_FTLSize; - - public ModAsset Metadata; - - public VirtualTexture Fallback; - - public static void StartFastTextureLoading(long limit) { - lock (ftlLock) { - if (limit >= uint.MaxValue) - ftlLimit = uint.MaxValue; - else - ftlLimit = (uint) limit; - ftlEnabled = true; - } - } - - public static void StopFastTextureLoading() { - lock (ftlLock) { - ftlEnabled = false; - } - } - - private void CountFastTextureLoad() { - // This should ALWAYS be called from the main thread. - lock (ftlLock) { - if (_Texture_FTLCount) - return; -#if FTL_VERIFY - if (!ftlTotalCountVerify.Add(this)) - throw new Exception($"FTL count total verify failed for texture \"{Name}\""); -#endif -#if FTL_DEBUG - Console.WriteLine($"FTL Count {Name} (queue: {ftlCount + 1})"); -#endif - - _Texture_FTLCount = true; - ftlTotalCount++; - } - } - - private void GrabFastTextureLoad() { - // This should ALWAYS be called from the task. - long size = Width * Height * 4; - - if (size == 0) - throw new Exception($"Fast texture loading encountered zero-size texture: {Name}"); - - // Smaller textures might contribute to fragmentation. - // Let's allow squeezing small textures into small holes, but make them take up lots of space. - // 512 * 512 * 4 = 1 MB - if (size < 512 * 512 * 4) - size = 512 * 512 * 4; - // Add some artificial overhead (DotNetZip, .data file buffer, thread local data, ...). - size += 1024 * 1024; - // Guesstimate somewhere between the already estimated size and the next power of two. - long sizePOT = size; - sizePOT--; - sizePOT |= sizePOT >> 1; - sizePOT |= sizePOT >> 2; - sizePOT |= sizePOT >> 4; - sizePOT |= sizePOT >> 8; - sizePOT |= sizePOT >> 16; - sizePOT |= sizePOT >> 32; - sizePOT++; - size += (long) ((sizePOT - size) * 0.3); - - if (size >= ftlLimit || size >= uint.MaxValue) - // throw new Exception($"Fast texture loading encountered an oversized texture: {Name} (estimated {size} bytes with overhead)"); - size = ftlLimit; - - // Don't wait inside of the lock or we will risk making other textures wait, even those which would fit. - // Note that this could theoretically hold back fitting textures waiting to be tasked. - Rewait: - while (size <= ftlLimit ? ftlUsed + size > ftlLimit : ftlUsedCount > 1) { - ftlFree.Wait(); - ftlFree.Reset(); - } - - lock (ftlLock) { - if (size <= ftlLimit ? ftlUsed + size > ftlLimit : ftlUsedCount > 1) - goto Rewait; - -#if FTL_VERIFY - if (!ftlUsedCountVerify.Add(this)) - throw new Exception($"FTL grab used verify failed for texture \"{Name}\""); -#endif -#if FTL_DEBUG - Console.WriteLine($"FTL TryGrab {Name} {size} {ftlUsed + size <= ftlLimit} (avail: {ftlLimit - ftlUsed - size})"); -#endif - - ftlFinish.Reset(); - ftlUsedCount++; - ftlUsed += _Texture_FTLSize = (uint) size; - _Texture_FTLLoading = true; - } - } - - private void FreeFastTextureLoad() { - // This should ALWAYS be called from the main thread. - lock (ftlLock) { - if (!_Texture_FTLLoading) { - if (_Texture_FTLCount) { -#if FTL_VERIFY - if (ftlUsedCountVerify.Contains(this)) - throw new Exception($"FTL cancel unused verify failed for texture \"{Name}\""); - if (!ftlTotalCountVerify.Remove(this)) - throw new Exception($"FTL cancel total verify failed for texture \"{Name}\""); -#endif -#if FTL_DEBUG - Console.WriteLine($"FTL Cancel {Name} (remain: {ftlCount - 1})"); -#endif - ftlTotalCount--; - if (ftlTotalCount == 0) - ftlFinish.Set(); - _Texture_FTLCount = false; + // It does not make much sense to assign to the texture, but some mods do, and vanilla allows for that to happen. + // Un-synchronized assignments will often lead to race conditions, but there's not much we can do other than keep the state of this object valid. + if (value == null) { + Unload(); + } else { + lock (_textureLock) { + _textureOverridden = true; + if (_queuedLoadAct != null) { + _queuedLoadAct.Cancel(); + _queuedLoadAct = null; + _queuedLoad = null; + } + QueuedLoad.ImmediateAssign(this, value, true, _reloadVersion); + _reloadVersion++; } - return; } - -#if FTL_VERIFY - if (!ftlUsedCountVerify.Remove(this)) - throw new Exception($"FTL free used verify failed for texture \"{Name}\""); - if (!ftlTotalCountVerify.Remove(this)) - throw new Exception($"FTL free total verify failed for texture \"{Name}\""); -#endif -#if FTL_DEBUG - Console.WriteLine($"FTL Free {Name} {size} (remain: {ftlCount - 1})"); -#endif - - uint size = _Texture_FTLSize; - _Texture_FTLLoading = false; - _Texture_FTLSize = 0; - ftlUsed -= size; - ftlUsedCount--; - ftlTotalCount--; - if (ftlTotalCount == 0) - ftlFinish.Set(); - ftlFree.Set(); - _Texture_FTLCount = false; } } - public static void WaitFinishFastTextureLoading() { - // This should ALWAYS be called from the loader thread. - lock (ftlLock) { - ftlEnabled = false; - // Don't set the limit as there could still be grabs happening afterwards. - // ftlLimit = 0; - } - // Lock shouldn't be necessary, all TryGrabs should've happened beforehand. - // On the contrary, lock would make this / TryGrab / Free prone to deadlocks. - ftlFinish.Wait(); - } + public readonly ModAsset? Metadata; + + public VirtualTexture? Fallback; + + private readonly TextureKind _textureKind; + + // Lock used to synchronize Texture_Unsafe reads and writes + private readonly object _textureLock; + // Queued upload to gpu of the texture, assumed to always run on main thread + private Task? _queuedLoad; + // The action of the task above, used to "steal" it when on main thread and run it directly + private QueuedLoad? _queuedLoadAct; + // Main lock used to synchronize loads and wait for them + private readonly object _reloadLock; + // Flag to know whether a reload is currently happening + private volatile bool _reloadInProgress; + // Flag to indicate when a Reload call just happened, uniquely serves to make `vTex.Reload(); vTex.Unload();` deterministic + private volatile bool _justReloaded; + // Whether the texture width and height could be determined ahead of time + private bool isPreloaded; + // Any exceptions thrown during the asynchronous load + private Exception? asyncFault; + // Whether the current Texture2D is overriden + private bool _textureOverridden; + // The reload version, used to track whether any more reloads (or texture overrides) happened in the meantime + private ulong _reloadVersion; + + // The main FTL toggle, see this class header for all the details + public static bool FtlToggle { get; internal set; } [MonoModConstructor] [MonoModReplace] internal patch_VirtualTexture(string path) { + ArgumentException.ThrowIfNullOrEmpty(path, nameof(path)); + _textureKind = TextureKind.FileSystem; Path = path; Name = path; - if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) - Reload(); - if (Everest.Flags.IsHeadless) - Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, Width, Height); + _textureLock = new object(); + _reloadLock = new object(); + CtorLoad(); } [MonoModConstructor] [MonoModReplace] internal patch_VirtualTexture(string name, int width, int height, Color color) { + ArgumentOutOfRangeException.ThrowIfLessThan(width, 1, nameof(width)); + ArgumentOutOfRangeException.ThrowIfLessThan(height, 1, nameof(height)); + _textureKind = TextureKind.SizeDefined; Name = name; - Width = width; - Height = height; + _width = width; + _height = height; this.color = color; - if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) - Reload(); - if (Everest.Flags.IsHeadless) - Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, Width, Height); + _textureLock = new object(); + _reloadLock = new object(); + CtorLoad(); } [MonoModConstructor] internal patch_VirtualTexture(ModAsset metadata) { + ArgumentNullException.ThrowIfNull(metadata); + _textureKind = TextureKind.ModAsset; Metadata = metadata; Name = metadata.PathVirtual; - if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) - Reload(); - if (Everest.Flags.IsHeadless) - Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, Width, Height); + _textureLock = new object(); + _reloadLock = new object(); + CtorLoad(); } - [MonoModReplace] - internal override void Unload() { - Texture2D tex = Texture_Unsafe; - - // Handle already queued loads appropriately. - object queuedLoadLock = _Texture_QueuedLoadLock; - if (queuedLoadLock != null) { - bool gotLock = false; - try { - Monitor.TryEnter(queuedLoadLock, ref gotLock); - if (gotLock) { - if (_Texture_QueuedLoadLock != null) { - // If we still can, cancel the queued load. - _Texture_QueuedLoadLock = null; - } - } else { - // Welp. - _Texture_UnloadAfterReload = true; - Monitor.TryEnter(queuedLoadLock, ref gotLock); - if (gotLock) { - // It might be too late - let's unload ourselves. - _Texture_UnloadAfterReload = false; - } else { - // The loader will still handle the request. - return; - } - } - } finally { - if (gotLock) - Monitor.Exit(queuedLoadLock); - } - - if (!MainThreadHelper.IsMainThread) { - // Otherwise wait for it to get loaded. (Don't wait locked!) - tex = _Texture_QueuedLoad.Result; + // Extra setup common in all constructors + private void CtorLoad() { + if (Everest.Flags.IsHeadless) { + bool preload = Preload(); + Everest.Events.VirtualTexture.OnShouldForceLazyLoad((VirtualTexture) (object) this); + // If a preload is not possible just load the texture, even on headless + // otherwise we risk having the wrong size, skipping loads entirely is just a + // performance optimization + if (!preload) { + Reload(); + return; } - } - - Texture_Unsafe = null; - if (tex == null || tex.IsDisposed) + // Big special case, this is the only other place where Texture_Unsafe gets assigned to + // we are in the ctor, so there's no need to do anything safely + Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, 1, 1); return; - - if ((!CoreModule.Settings.ThreadedGL ?? true) && !MainThreadHelper.IsMainThread) { - MainThreadHelper.Schedule(() => tex.Dispose()); - } else { - tex.Dispose(); } + // Only skip reloads with lazyloading and successful preloads + if (!Preload() || (!Everest.Events.VirtualTexture.OnShouldForceLazyLoad((VirtualTexture) (object) this) && !CoreModule.Settings.LazyLoading)) + Reload(); } - internal bool LoadImmediately => - !_Texture_FTLLoading && ((CoreModule.Settings.ThreadedGL ?? false) || MainThreadHelper.IsMainThread); - internal bool Load(bool wait, Func load) { + /// + /// Runs a callback in the main thread. + /// + /// The queued load. + /// Whether to wait. + private void RunSafely(QueuedLoad ql, bool wait = false) { + // InnerReload checks makes sure _queuedLoad is null, and it maintains exclusive execution after that check + // so this must hold + if (_queuedLoad != null) throw new InvalidOperationException(); if (LoadImmediately) { - Texture_Unsafe?.Dispose(); - Texture_Unsafe = load(); - FreeFastTextureLoad(); - return true; + ql.Run(); + return; } - // Let's queue a reload onto the main thread and call it a day. - // Make sure to read the texture size immediately though! - object queuedLoadLock; - lock (queuedLoadLock = new object()) { - _Texture_QueuedLoadLock = queuedLoadLock; - - Func _load = load; - load = () => { - Texture2D tex; - lock (queuedLoadLock) { - if (_Texture_QueuedLoadLock != queuedLoadLock) { - _load = null; - FreeFastTextureLoad(); - return Texture_Unsafe; - } - // NOTE: If something dares to change texture info on the fly, GOOD LUCK. - Texture_Unsafe?.Dispose(); - Texture_Unsafe = tex = _load(); - FreeFastTextureLoad(); - _Texture_QueuedLoadLock = null; - } - if (_Texture_UnloadAfterReload) { - tex?.Dispose(); - tex = Texture_Unsafe; - // ... can anything even swap the texture here? - Texture_Unsafe = null; - tex?.Dispose(); - _Texture_UnloadAfterReload = false; - } - return tex; - }; - - _Texture_QueuedLoad = MainThreadHelper.Schedule(load, forceQueue: !_Texture_FTLLoading); + ValueTask vt = MainThreadHelper.Schedule(ql.Run); + // This is somewhat pointless, IsCompleted cant be true with the current LoadImmediately criteria + if (vt.IsCompleted) { + return; } - if (wait || _Texture_Requesting) - _ = _Texture_QueuedLoad.Result; - - return false; + Task t = vt.AsTask(); + lock (_textureLock) { + _queuedLoad = t; + _queuedLoadAct = ql; + } + if (wait) { + t.Wait(); + } } - // Same signature as .NET Core Unsafe variant, which also matches IL expectations. - // This should get replaced with initblk at the call site in Reload as PatchInitblk is used - [MonoModIgnore] - private static unsafe extern void _initblk(void* startAddress, byte value, uint byteCount); + // Make sure that MainThreadHelper.IsMainThread == true implies LoadImmediately == true + private bool LoadImmediately => MainThreadHelper.IsMainThread; - [MonoModReplace] - [PatchInitblk] - internal override unsafe void Reload() { - if (Everest.Flags.IsHeadless) { - Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, 1, 1); + /// + /// Critical part of a reload. + /// This function assumes it is executing on at most one thread at a time. + /// + /// Whether to wait for the main thread transaction to complete before returning. + /// On invalid cases. + private void InnerReload(bool block = false) { + asyncFault = null; + // If the texture is overriden, reloads are pointless + if (!SoftUnload(false, out ulong reloadVersion)) return; + // Important, this is the main entrypoint, and any code-path should lead to an eventual unique call to RunSafely or AssignTexture + int preW = -1; + int preH = -1; + if (isPreloaded) { + preW = _width; + preH = _height; } - // Unload task might end up conflicting with Reload - let's instead force-unload in Load. - // Unload(); - - // Handle already queued loads appropriately. - object queuedLoadLock = _Texture_QueuedLoadLock; - if (queuedLoadLock != null && !_Texture_Reloading) { - lock (queuedLoadLock) { - // Queued task finished just in time. - if (_Texture_QueuedLoadLock != queuedLoadLock) + switch (_textureKind) { + case TextureKind.ModAsset: { + Debug.Assert(Metadata is not null); + Stream stream = Metadata.Stream; + if (stream != null) { + bool premul = false; // Assume unpremultiplied by default. + if (Metadata.TryGetMeta(out TextureMeta meta)) + premul = meta.Premultiplied; + // If we have async streams, read async, otherwise, wrap everything in the RunSafely call and run the returned cb immediately + QueuedLoad ql; + if (Metadata.StreamAsync) { + ql = new QueuedLoad(this, TextureContentHelper.LoadFromStream(stream, premul, preW, preH), reloadVersion); + } else { + // This is a bit wasteful, especially if we moved out of main thread to load asynchronously, it's a rare edge case though and makes the code simpler + ql = new QueuedLoad(this, () => { + (Func main, Action? cleanup) pair = TextureContentHelper.LoadFromStream(stream, premul, preW, preH); + Texture2D tex = pair.main(); + pair.cleanup?.Invoke(); + return tex; + }, null, reloadVersion); + } + RunSafely(ql, block); return; - - // If we still can, cancel the queued load, then proceed with lazy-loading. - if (MainThreadHelper.IsMainThread) - _Texture_QueuedLoadLock = null; + } else if (Fallback != null) { + // ReSharper disable once SuspiciousTypeConversion.Global + ((patch_VirtualTexture) (object) Fallback).Reload(true); + QueuedLoad.ImmediateAssign(this, Fallback.Texture!, false, reloadVersion); + return; + } else { + throw new InvalidOperationException("Cannot have null ModAsset stream without Fallback texture!"); + } } - - if (!MainThreadHelper.IsMainThread) { - // Otherwise wait for it to get loaded, don't reload twice. (Don't wait locked!) - _ = _Texture_QueuedLoad.Result; + case TextureKind.FileSystem: { + Debug.Assert(Path is not null); + RunSafely(new QueuedLoad(this, TextureContentHelper.LoadFromPath(Path, preW, preH), reloadVersion), block); + return; + } + case TextureKind.SizeDefined: { + Debug.Assert(_width > 0 && _height > 0); + RunSafely(new QueuedLoad(this, TextureContentHelper.LoadFromSizeAndColor(_width, _height, color), reloadVersion), block); return; } + default: + throw new UnreachableException(); } + } - if (ftlEnabled && CanPreload && (Metadata?.StreamAsync ?? true) && - !_Texture_Reloading && !_Texture_Requesting) { - // Preload as we need to know the texture size WITHOUT ALLOCATING SPACE. - // ... also because the texture size is required in the calling ctx past Reload. - if (Preload(true)) { - CountFastTextureLoad(); - lock (queuedLoadLock = new object()) { - _Texture_QueuedLoadLock = queuedLoadLock; - _Texture_QueuedLoad = new ValueTask(Task.Run(() => { - try { - lock (queuedLoadLock) { - // Queued load cancelled or replaced with another queued load. - if (_Texture_QueuedLoadLock != queuedLoadLock) { - FreeFastTextureLoad(); - return Texture_Unsafe; - } - - GrabFastTextureLoad(); - - // NOTE: If something dares to change texture info on the fly, GOOD LUCK. - _Texture_Reloading = true; - Reload(); - if (_Texture_QueuedLoadLock == queuedLoadLock) - _Texture_QueuedLoadLock = null; - Texture2D tex = Texture_Unsafe; - if (_Texture_UnloadAfterReload) { - tex?.Dispose(); - tex = Texture_Unsafe; - // ... can anything even swap the texture here? - Texture_Unsafe = null; - tex?.Dispose(); - _Texture_UnloadAfterReload = false; - } - return tex; - } - } catch (Exception e) { - Celeste.patch_Celeste.CriticalFailureHandler(e); - throw; - } - })); - return; - } + /// + /// Attempts to start a Reload on the current thread or returns if one is already ongoing. + /// + /// + /// When true it guarantees that either: a texture is assigned after its load or that is not null and has pending work. + /// When false it only guarantees that a Reload is happening on some thread. + /// + /// Only used to fire an event for mods that care when a texture may be loaded on access. + private void Reload(bool block, bool isLazy = false) { + _justReloaded = true; + if (_reloadInProgress && !block) { + // Someone has the lock, and we are not going to block anyway, so return early + return; + } + if (FtlToggle && isPreloaded && !block && MainThreadHelper.IsMainThread) { + // This is the main asynchronous FTL entry point + // isPreloaded is required to be true so we can have some knowledge of the memory usage of the load + Task.Run(() => { + Reload(false, isLazy); + }); + // Since we are not blocking, we are free to return whenever we want + return; + } + retry: + bool got = Monitor.TryEnter(_reloadLock); + if (!got) { + // Failing to acquire the lock does not guarantee a reload is going to happen since there are other acquires down below + if (!_reloadInProgress) goto retry; + if (block) { + // There has to be a better way to do this + Monitor.Enter(_reloadLock); + Monitor.Exit(_reloadLock); } + return; } + _reloadInProgress = true; + try { + if (isLazy) + Everest.Events.VirtualTexture.LazyLoad((VirtualTexture)(object)this); + // Do not wait for the main thread to finish the load, it could deadlock if a blocking Reload is called on there + InnerReload(false); + } catch (Exception ex) { + Logger.Error(nameof(VirtualTexture), $"Failed loading texture {Name ?? $"{_width}x{_height}"}!"); + Logger.LogDetailed(ex, nameof(VirtualTexture)); + asyncFault = ex; + throw; + } finally { + _reloadInProgress = false; + Monitor.Exit(_reloadLock); + } + _justReloaded = false; + } - _Texture_Reloading = false; - - if (Metadata != null) { - if (Metadata.StreamAsync || MainThreadHelper.IsMainThread) { - Stream stream = Metadata.Stream; - if (stream != null) { - using (stream) { - bool premul = false; // Assume unpremultiplied by default. - if (Metadata.TryGetMeta(out TextureMeta meta)) - premul = meta.Premultiplied; - - int w, h; - IntPtr dataPtr; // assume Texture.SetData supports Ptr since we are using FNA - if (premul) - ContentExtensions.LoadTextureRaw(Celeste.Celeste.Instance.GraphicsDevice, stream, out w, out h, out dataPtr); - else - ContentExtensions.LoadTextureLazyPremultiply(Celeste.Celeste.Instance.GraphicsDevice, stream, out w, out h, out dataPtr); - stream.Dispose(); - Width = w; - Height = h; - Load(false, () => { - Texture2D tex = new Texture2D(Celeste.Celeste.Instance.GraphicsDevice, w, h, false, SurfaceFormat.Color); - tex.SetData(dataPtr); - ContentExtensions.UnloadTextureRaw(dataPtr); - return tex; - }); - } + /// + /// Causes a reload (or just load) of the texture, it may complete asynchronously. + /// + [MonoModReplace] + internal override sealed void Reload() { + Reload(false); + } + + /// + /// Unloads the texture from video memory. + /// + [MonoModReplace] + internal override void Unload() { + SoftUnload(true, out _); + } - } else if (Fallback != null) { - ((patch_VirtualTexture) (object) Fallback).Reload(); - Texture_Unsafe = Fallback.Texture; + /// + /// Tries to unload the texture, may fail if the texture is overriden or there's a load waiting to complete. + /// + /// Forces the unload to succeed even if the texture is overridden or there's a pending load + /// Returns the current reload version after the reload + /// Whether the unload was successful. + private bool SoftUnload(bool force, out ulong reloadVersion) { + // Makes vTex.Reload(); vTex.Unload(); deterministic by just waiting for the reload to happen if we know one happened soon enough + // _justReloaded == true does not mean a reload is in progress, only that one was started soon enough, and likely did not complete yet + if (_justReloaded && force) { + _ = Texture_Safe; + } + // Hold the _reloadLock to prevent queuedLoads during unload + lock (_reloadLock) + lock (_textureLock) { + reloadVersion = 0; + if (!force) { + if (_textureOverridden || _queuedLoadAct != null) { + return false; } - - } else { - // This is ugly but if the asset doesn't like multithreading, so be it. - // Not even preloading will be beneficial here, and forget about GetMeta. - Load(true, () => { - using (Stream stream = Metadata.Stream) { - if (stream != null) { - bool premul = false; // Assume unpremultiplied by default. - if (Metadata.TryGetMeta(out TextureMeta meta)) - premul = meta.Premultiplied; - - if (premul) { - Texture2D tex = Texture2D.FromStream(Celeste.Celeste.Instance.GraphicsDevice, stream); - return tex; - } else { - ContentExtensions.LoadTextureLazyPremultiply(Celeste.Celeste.Instance.GraphicsDevice, stream, out int w, out int h, out IntPtr dataPtr); - Texture2D tex = new Texture2D(Celeste.Celeste.Instance.GraphicsDevice, w, h, false, SurfaceFormat.Color); - // assume Texture.SetData supports Ptr since we are using FNA - tex.SetData(dataPtr); - ContentExtensions.UnloadTextureRaw(dataPtr); - return tex; - } - - } else if (Fallback != null) { - ((patch_VirtualTexture) (object) Fallback).Reload(); - return Fallback.Texture; - } - - return null; - } - }); } - - } else if (string.IsNullOrEmpty(Path)) { - Color[] data = new Color[Width * Height]; - fixed (Color* ptr = data) - for (int i = 0; i < data.Length; i++) - ptr[i] = color; - Load(false, () => { - Texture2D tex = new Texture2D(Engine.Instance.GraphicsDevice, Width, Height); - tex.SetData(data); - data = null; - return tex; - }); - - } else { - int w, h; - bool bufferGC = false; - byte[] buffer = null; - IntPtr bufferPtr = IntPtr.Zero; - bool bufferStolen = false; - switch (System.IO.Path.GetExtension(Path)) { - case ".data": - using (FileStream stream = File.OpenRead(System.IO.Path.Combine(Engine.ContentDirectory, Path))) { - // Vanilla has got a static readonly byte[] bytes of fixed length - currently 524288 - // Luckily we can read more chunks on demand. - byte[] read = bytesSafe ??= new byte[bytesSize]; - stream.Read(read, 0, read.Length); - - int pB = 0; - w = BitConverter.ToInt32(read, pB); - h = BitConverter.ToInt32(read, pB + 4); - bool hasAlpha = read[pB + 8] == 1; - pB += 9; - - Width = w; - Height = h; - - int size = w * h * 4; - // Vanilla has got a static readonly byte[] buffer of fixed length - currently 67108864 - // Ideally there should be only a single texture using the max-sized buffer. - if (size == bufferSize) { - lock (bufferLock) { - buffer = bufferSafe; - bufferSafe = null; - bufferStolen = true; - bufferGC = true; - } - } - if (buffer == null) { - if (bufferGC) { - buffer = new byte[size]; - } else { - buffer = null; - bufferPtr = Marshal.AllocHGlobal(size); - } - bufferStolen = false; - } - - fixed (byte* from = read) - fixed (byte* bufferPin = buffer) { - byte* to = bufferGC ? bufferPin : (byte*) bufferPtr; - int* toI = (int*) to; - uint iB = 0; - uint iI = 0; - - // Let's dupe the loop and move hasAlpha out, otherwise hasAlpha gets checked often. - if (hasAlpha) { - while (iB < size) { - uint linesize = from[pB]; - - byte a = from[pB + 1]; - if (a > 0) { - to[iB] = from[pB + 4]; - to[iB + 1] = from[pB + 3]; - to[iB + 2] = from[pB + 2]; - to[iB + 3] = a; - pB += 5; - } else { - toI[iI] = 0; - pB += 2; - } - - if (linesize > 1) { - if (a == 0) { - _initblk(to + iB + 4, 0, linesize * 4 - 4); - } else { - for (uint jI = iI + 1, end = iI + linesize; jI < end; jI++) - toI[jI] = toI[iI]; - } - } - - iI += linesize; - iB = iI * 4; - - if (pB > read.Length - 32) { - int offset = read.Length - pB; - for (int oB = 0; oB < offset; oB++) { - from[oB] = from[pB + oB]; - } - stream.Read(read, offset, read.Length - offset); - pB = 0; - } - } - - } else { - while (iB < size) { - uint linesize = from[pB]; - - to[iB] = from[pB + 3]; - to[iB + 1] = from[pB + 2]; - to[iB + 2] = from[pB + 1]; - to[iB + 3] = 255; - pB += 4; - - if (linesize > 1) - for (uint jI = iI + 1, end = iI + linesize; jI < end; jI++) - toI[jI] = toI[iI]; - - iI += linesize; - iB = iI * 4; - - if (pB > bytesCheckSize) { - int offset = read.Length - pB; - for (int oB = 0; oB < offset; oB++) { - from[oB] = from[pB + oB]; - } - stream.Read(read, offset, read.Length - offset); - pB = 0; - } - } - } - } - } - Load(false, () => { - Texture2D tex = new Texture2D(Celeste.Celeste.Instance.GraphicsDevice, w, h); - // This is on the main thread so buffer should be consumed by SetData, reusable afterwards. - if (bufferGC) { - tex.SetData(buffer); - if (bufferStolen) - bufferSafe = buffer; - buffer = null; - } else { - tex.SetData(bufferPtr); - Marshal.FreeHGlobal(bufferPtr); - } - return tex; - }); - break; - - case ".png": - if (bufferGC) { - using (FileStream stream = File.OpenRead(System.IO.Path.Combine(Engine.ContentDirectory, Path))) - ContentExtensions.LoadTextureLazyPremultiply(Celeste.Celeste.Instance.GraphicsDevice, stream, out w, out h, out buffer); - Width = w; - Height = h; - Load(false, () => { - Texture2D tex = new Texture2D(Celeste.Celeste.Instance.GraphicsDevice, w, h, false, SurfaceFormat.Color); - tex.SetData(buffer); - buffer = null; - return tex; - }); - } else { - using (FileStream stream = File.OpenRead(System.IO.Path.Combine(Engine.ContentDirectory, Path))) - ContentExtensions.LoadTextureLazyPremultiply(Celeste.Celeste.Instance.GraphicsDevice, stream, out w, out h, out bufferPtr); - Width = w; - Height = h; - Load(false, () => { - Texture2D tex = new Texture2D(Celeste.Celeste.Instance.GraphicsDevice, w, h, false, SurfaceFormat.Color); - tex.SetData(bufferPtr); - ContentExtensions.UnloadTextureRaw(bufferPtr); - return tex; - }); - } - break; - - case ".xnb": - Load(true, () => Engine.Instance.Content.Load(Path.Replace(".xnb", ""))); - break; - - default: - Load(true, () => { - using (FileStream stream = File.OpenRead(System.IO.Path.Combine(Engine.ContentDirectory, Path))) - return Texture2D.FromStream(Engine.Graphics.GraphicsDevice, stream); - }); - break; + if (_queuedLoadAct != null) { + _queuedLoadAct.Cancel(); + _queuedLoadAct = null; + _queuedLoad = null; } + if (Texture_Unsafe is { IsDisposed: false }) { + Texture_Unsafe.Dispose(); + } + Texture_Unsafe = null; + _textureOverridden = false; + reloadVersion = ++_reloadVersion; + return true; } + } - Texture2D tex = Texture_Unsafe; - if (tex != null) { - Width = tex.Width; - Height = tex.Height; - } - - _Texture_Requesting = false; + // IL patch is possible here, is it worth it though? (IL should not change that much) + /// + /// Disposes the native resources and unregisters itself. + /// + [MonoModReplace] + public override void Dispose() { + Unload(); + // Texture_Unsafe = null; + patch_VirtualContent.Remove(this); } - private bool CanPreload { - get { - if (!string.IsNullOrEmpty(Path)) { + /// + /// Attempts to load the width and height of the texture without loading it as a whole. + /// + /// Whether the preload was successful + private bool Preload() { + // Preload the width / height, and if needed, the entire texture (not actually done currently though). + + switch (_textureKind) { + case TextureKind.FileSystem: { + Debug.Assert(Path is not null); string extension = System.IO.Path.GetExtension(Path); if (extension == ".data") { - return true; + // Easy. + using (FileStream stream = File.OpenRead(System.IO.Path.Combine(Engine.ContentDirectory, Path))) + using (BinaryReader reader = new BinaryReader(stream)) { + _width = reader.ReadInt32(); + _height = reader.ReadInt32(); + } + return isPreloaded = true; + } else if (extension == ".png") { - return true; + // Hard. + using (FileStream stream = File.OpenRead(System.IO.Path.Combine(Engine.ContentDirectory, Path))) + return isPreloaded = PreloadSizeFromPNG(stream, Path); + } else { + // .xnb and other file formats - impossible. return false; } - - } else if (Metadata != null) { + } + case TextureKind.ModAsset: { + Debug.Assert(Metadata is not null); if (Metadata.Format == "png") { - return true; + // Hard. + using (Stream stream = Metadata.Stream) + return isPreloaded = PreloadSizeFromPNG(stream, $"{Metadata.PathVirtual} (mod {Metadata.Source.Mod?.Name ?? "*unknown*"})"); + } else { + // .xnb and other file formats - impossible. return false; } } - - return false; - } - } - - private bool Preload(bool force = false) { - if (!CoreModule.Settings.LazyLoading && !force) { - return false; - } - - // Preload the width / height, and if needed, the entire texture. - - if (!string.IsNullOrEmpty(Path)) { - string extension = System.IO.Path.GetExtension(Path); - if (extension == ".data") { - // Easy. - using (FileStream stream = File.OpenRead(System.IO.Path.Combine(Engine.ContentDirectory, Path))) - using (BinaryReader reader = new BinaryReader(stream)) { - Width = reader.ReadInt32(); - Height = reader.ReadInt32(); - } - return true; - - } else if (extension == ".png") { - // Hard. - using (FileStream stream = File.OpenRead(System.IO.Path.Combine(Engine.ContentDirectory, Path))) - return PreloadSizeFromPNG(stream, Path); - - } else { - // .xnb and other file formats - impossible. - return false; - - } - - } else if (Metadata != null) { - if (Metadata.Format == "png") { - // Hard. - using (Stream stream = Metadata.Stream) - return PreloadSizeFromPNG(stream, $"{Metadata.PathVirtual} (mod {Metadata.Source.Mod?.Name ?? "*unknown*"})"); - - } else { - // .xnb and other file formats - impossible. - return false; + case TextureKind.SizeDefined: { + Debug.Assert(_width != 0 && _height != 0); + // SizeDefined textures are already pre-loaded by definition + return isPreloaded = true; } + default: + throw new UnreachableException(); } - - return false; } private bool PreloadSizeFromPNG(Stream stream, string path) { @@ -846,8 +597,8 @@ private bool PreloadSizeFromPNG(Stream stream, string path) { Logger.Error("vtex", $"Failed preloading PNG: Expected IHDR marker 0x52444849, got 0x{chunk.ToString("X8")} - {path}"); return false; } - Width = SwapEndian(reader.ReadInt32()); - Height = SwapEndian(reader.ReadInt32()); + _width = SwapEndian(reader.ReadInt32()); + _height = SwapEndian(reader.ReadInt32()); return true; } } @@ -860,6 +611,76 @@ private static int SwapEndian(int data) { ((data >> 24) & 0xFF); } + /// + /// Helper class to assign to Texture_Unsafe in a safe manner + /// + private class QueuedLoad { + private bool hasRun; + private readonly patch_VirtualTexture _vtex; + private readonly Func? _main; + private readonly Texture2D? _immediateTexture; + private readonly Action? _cleanup; + private readonly ulong _reloadVersion; + + public QueuedLoad(patch_VirtualTexture vtex, (Func main, Action? cleanup) pair, ulong reloadVersion) : this(vtex, pair.main, pair.cleanup, reloadVersion) {} + public QueuedLoad(patch_VirtualTexture vtex, Func main, Action? cleanup, ulong reloadVersion) { + _vtex = vtex; + _main = main; + _cleanup = cleanup; + _reloadVersion = reloadVersion; + } + + public void Run() { + lock (_vtex._textureLock) { + if (hasRun) return; + hasRun = true; + if (_vtex._textureOverridden || _vtex._reloadVersion != _reloadVersion) { + _cleanup?.Invoke(); + return; + } + Texture2D tex = _main?.Invoke() ?? _immediateTexture!; + AssignTexture(tex); + _cleanup?.Invoke(); + } + } + + // Must be called in the appropriate vtex lock + public void Cancel() { + if (hasRun) return; + hasRun = true; + _cleanup?.Invoke(); + } + + private void AssignTexture(Texture2D tex) { + ArgumentNullException.ThrowIfNull(tex); + _vtex.Texture_Unsafe = tex; + _vtex._width = tex.Width; + _vtex._height = tex.Height; + // These callbacks may hold references to big memory arrays, so cut the references once we are done + _vtex._queuedLoad = null; + _vtex._queuedLoadAct = null; + } + + public static void ImmediateAssign(patch_VirtualTexture vtex, Texture2D tex, bool force, ulong reloadVersion) { + if (!force) { + lock (vtex._textureLock) { + if (vtex._textureOverridden) return; + ImmediateAssign(vtex, tex, true, reloadVersion); + } + } + ArgumentNullException.ThrowIfNull(tex); + if (vtex._reloadVersion != reloadVersion) return; + vtex.Texture_Unsafe = tex; + vtex._width = tex.Width; + vtex._height = tex.Height; + } + } + + private enum TextureKind { + FileSystem, + ModAsset, + SizeDefined + } } public static class VirtualTextureExt {