From 7244c68abf0b46d35d777a7e259c9ee325acfe49 Mon Sep 17 00:00:00 2001 From: wartori Date: Tue, 14 Oct 2025 18:16:36 +0200 Subject: [PATCH 01/31] Remove last MonoModIfFlag("XNA") dependent code --- .../Mod/Everest/ContentExtensions.cs | 156 ------------------ 1 file changed, 156 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs b/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs index cc37aeaaa..0e6dd3ec6 100644 --- a/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs +++ b/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs @@ -219,162 +219,6 @@ public static void UnloadTextureRaw(IntPtr dataPtr) { [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] From 8bf1c81a99fb774f0e311699c186349256ad507e Mon Sep 17 00:00:00 2001 From: wartori Date: Tue, 14 Oct 2025 18:24:19 +0200 Subject: [PATCH 02/31] Remove superfluous MonoMod patching --- .../Mod/Everest/ContentExtensions.cs | 40 +++---------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs b/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs index 0e6dd3ec6..615e6a543 100644 --- a/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs +++ b/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs @@ -204,32 +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("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); @@ -237,10 +216,7 @@ 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; @@ -251,10 +227,7 @@ private static unsafe void _LoadTextureRawFNA(GraphicsDevice gd, Stream stream, } - [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); @@ -282,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); } From d484b9901f6f00f862295fff0dfeb1eeb3b68a73 Mon Sep 17 00:00:00 2001 From: wartori Date: Tue, 21 Oct 2025 00:41:52 +0200 Subject: [PATCH 03/31] Go back to an unoptimized and single threaded texture loading --- Celeste.Mod.mm/Celeste.Mod.mm.csproj | 2 +- Celeste.Mod.mm/Mod/Core/CoreModule.cs | 2 +- .../Mod/Everest/ContentExtensions.cs | 4 +- Celeste.Mod.mm/Mod/Everest/Everest.Flags.cs | 5 +- .../Mod/Helpers/TextureContentHelper.cs | 338 +++++++++ Celeste.Mod.mm/Patches/Celeste.cs | 4 +- Celeste.Mod.mm/Patches/GameLoader.cs | 2 +- Celeste.Mod.mm/Patches/LevelLoader.cs | 3 +- .../Patches/Monocle/VirtualContent.cs | 6 +- .../Patches/Monocle/VirtualTexture.cs | 718 ++---------------- 10 files changed, 398 insertions(+), 686 deletions(-) create mode 100644 Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs 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..16c187558 100644 --- a/Celeste.Mod.mm/Mod/Core/CoreModule.cs +++ b/Celeste.Mod.mm/Mod/Core/CoreModule.cs @@ -58,7 +58,7 @@ public override void LoadSettings() { } // If we're running in an environment that prefers this flag, forcibly enable them. - Settings.LazyLoading |= Everest.Flags.PreferLazyLoading; + // Settings.LazyLoading |= Everest.Flags.PreferLazyLoading; } public override void SaveSettings() { diff --git a/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs b/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs index 615e6a543..0ebb43a53 100644 --- a/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs +++ b/Celeste.Mod.mm/Mod/Everest/ContentExtensions.cs @@ -221,7 +221,7 @@ private static unsafe void _LoadTextureRaw(GraphicsDevice gd, Stream stream, out 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 _); } } @@ -234,7 +234,7 @@ private static unsafe void _LoadTextureLazyPremultiply(GraphicsDevice gd, Stream dataPtr = IntPtr.Zero; length = data.Length; } else { - data = Array.Empty(); + data = []; dataPtr = FNA3D_ReadImageStream(stream, out w, out h, out length); } diff --git a/Celeste.Mod.mm/Mod/Everest/Everest.Flags.cs b/Celeste.Mod.mm/Mod/Everest/Everest.Flags.cs index 80b931ce7..6fe36e1e1 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 platform, which are 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/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs new file mode 100644 index 000000000..f303e9f5b --- /dev/null +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -0,0 +1,338 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Monocle; +using MonoMod; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +#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; + private static bool inGC = true; + private static readonly SpanPool spanPool = new(atlasSize * 2, inGC); + + // 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); + + public static unsafe Func LoadFromPath(string path) { + int w, h; + switch (Path.GetExtension(path)) { + case ".data": + SpanPool.SegmentIdentifier seg; + 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; + bool rent = spanPool.TryRent(size, out seg); + Span buffer; + if (rent) { + buffer = seg.Memory.Span; + } else { + // Fall back to gc allocs + // TODO: Improve this to bound max mem usage + buffer = new byte[size]; + } + + if (hasAlpha) { + ReadDataFile(stream, read, buffer); + } else { + ReadDataFile(stream, read, buffer); + } + } + return () => { + Texture2D tex = new(Celeste.Instance.GraphicsDevice, w, h); + fixed (byte* ptr = seg.Memory.Span) + tex.SetData((IntPtr)ptr); + + spanPool.Return(seg); + return tex; + }; + case ".png": + return LoadFromStream(File.OpenRead(Path.Combine(Engine.ContentDirectory, path)), false /* pngs are never premultiplied */); + case ".xnb": + return () => Engine.Instance.Content.Load(path.Replace(".xnb", "")); + default: + return () => { + using FileStream stream = File.OpenRead(Path.Combine(Engine.ContentDirectory, path)); + return Texture2D.FromStream(Engine.Graphics.GraphicsDevice, stream); + }; + } + } + + public static Func LoadFromStream(Stream stream, bool premul) { + using (stream) { + int w, h; + 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); + ContentExtensions.UnloadTextureRaw(dataPtr); + return tex; + }; + } + } + + public static unsafe Func 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 + Memory data; + if (spanPool.TryRent(width * height * sizeof(Color), out SpanPool.SegmentIdentifier seg)) { + data = seg.Memory; + } else { + data = new byte[width * height * sizeof(Color)]; + } + fixed (Color* ptr = MemoryMarshal.Cast(data.Span)) { + for (int i = 0; i < data.Length; i++) { + ptr[i] = color; + } + } + return () => { + Texture2D tex = new(Engine.Instance.GraphicsDevice, width, height); + fixed (byte* ptr = data.Span) { + tex.SetData((IntPtr)ptr); + } + // Fall back to gc allocs + // TODO: Improve this to bound max mem usage + spanPool.Return(seg); + return tex; + }; + } + + // 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 + [PatchInitblk] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + 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; + 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(int) && zeroSplat) { + // If alpha was zero, bulk write 0 to the whole line + _initblk(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 sealed class HasAlpha : AlphaMode; + + private sealed class NoAlpha : AlphaMode; + + private 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 size, out SegmentIdentifier seg) { + lock (@lock) { + (int start, int end)? freeSegment = NextFreeSegmentAndReserve(size); + 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 void Dispose() { + 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; + } + + 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; + } + } + + private sealed class 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/Patches/Celeste.cs b/Celeste.Mod.mm/Patches/Celeste.cs index 7accea80b..00cecd51e 100644 --- a/Celeste.Mod.mm/Patches/Celeste.cs +++ b/Celeste.Mod.mm/Patches/Celeste.cs @@ -359,7 +359,7 @@ protected override void LoadContent() { if (limit <= (128L * 1024L * 1024L)) limit = (128L * 1024L * 1024L); - patch_VirtualTexture.StartFastTextureLoading(limit); + // patch_VirtualTexture.StartFastTextureLoading(limit); } orig_LoadContent(); @@ -367,7 +367,7 @@ protected override void LoadContent() { foreach (EverestModule mod in Everest._Modules) mod.LoadContent(firstLoad); - patch_VirtualTexture.StopFastTextureLoading(); + // patch_VirtualTexture.StopFastTextureLoading(); Everest._ContentLoaded = true; } diff --git a/Celeste.Mod.mm/Patches/GameLoader.cs b/Celeste.Mod.mm/Patches/GameLoader.cs index c0def4232..e43726c7e 100644 --- a/Celeste.Mod.mm/Patches/GameLoader.cs +++ b/Celeste.Mod.mm/Patches/GameLoader.cs @@ -115,7 +115,7 @@ private void LoadThread() { timer = Stopwatch.StartNew(); MainThreadHelper.Boost = 50; - patch_VirtualTexture.WaitFinishFastTextureLoading(); + // 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"); 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/VirtualContent.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs index 4fe524887..ee746030e 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs @@ -11,6 +11,7 @@ namespace Monocle { static class patch_VirtualContent { + // TODO: add locking around this field // We're effectively in VirtualContent, but still need to "expose" private fields to our mod. private static List assets; /// @@ -76,8 +77,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..a685a2d3a 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -1,122 +1,49 @@ #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.Diagnostics.CodeAnalysis; using System.IO; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; + +#nullable enable namespace Monocle { class patch_VirtualTexture : patch_VirtualAsset { // We're effectively in VirtualAsset, but still need to "expose" private fields to our mod. + public string? Path { get; private set; } + private Color color; - // 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 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; [MonoModLinkFrom("Microsoft.Xna.Framework.Graphics.Texture2D Monocle.VirtualTexture::Texture")] 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; - } - } - - if (!MainThreadHelper.IsMainThread) { - // Otherwise wait for it to get loaded. (Don't wait locked!) - return _Texture_QueuedLoad.Result; - } - } - - if (_Texture_Reloading || !(CoreModule.Settings.LazyLoading || lazyForce)) - 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(); - } - return Texture_Unsafe; } set { @@ -124,173 +51,16 @@ public Texture2D Texture_Safe { } } - 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 ModAsset? Metadata; - 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; - } - 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 VirtualTexture? Fallback; [MonoModConstructor] [MonoModReplace] internal patch_VirtualTexture(string path) { Path = path; Name = path; - if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) + // if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) Reload(); if (Everest.Flags.IsHeadless) Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, Width, Height); @@ -303,7 +73,7 @@ internal patch_VirtualTexture(string name, int width, int height, Color color) { Width = width; Height = height; this.color = color; - if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) + // if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) Reload(); if (Everest.Flags.IsHeadless) Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, Width, Height); @@ -313,451 +83,51 @@ internal patch_VirtualTexture(string name, int width, int height, Color color) { internal patch_VirtualTexture(ModAsset metadata) { Metadata = metadata; Name = metadata.PathVirtual; - if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) + // if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) Reload(); if (Everest.Flags.IsHeadless) Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, Width, Height); } - [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; - } - } - - Texture_Unsafe = null; - if (tex == null || tex.IsDisposed) - return; - - if ((!CoreModule.Settings.ThreadedGL ?? true) && !MainThreadHelper.IsMainThread) { - MainThreadHelper.Schedule(() => tex.Dispose()); - } else { - tex.Dispose(); - } + [MemberNotNull(nameof(Texture_Unsafe))] + private void Load(Func cb) { + Texture_Unsafe = cb(); } - internal bool LoadImmediately => - !_Texture_FTLLoading && ((CoreModule.Settings.ThreadedGL ?? false) || MainThreadHelper.IsMainThread); - internal bool Load(bool wait, Func load) { - if (LoadImmediately) { - Texture_Unsafe?.Dispose(); - Texture_Unsafe = load(); - FreeFastTextureLoad(); - return true; - } + private bool LoadImmediately => MainThreadHelper.IsMainThread; - // 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); - } - - if (wait || _Texture_Requesting) - _ = _Texture_QueuedLoad.Result; - - return false; - } - - // 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); - - [MonoModReplace] - [PatchInitblk] - internal override unsafe void Reload() { + [MemberNotNull(nameof(Texture_Unsafe))] + internal override sealed void Reload() { if (Everest.Flags.IsHeadless) { Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, 1, 1); return; } - - // 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) - return; - - // If we still can, cancel the queued load, then proceed with lazy-loading. - if (MainThreadHelper.IsMainThread) - _Texture_QueuedLoadLock = null; - } - - if (!MainThreadHelper.IsMainThread) { - // Otherwise wait for it to get loaded, don't reload twice. (Don't wait locked!) - _ = _Texture_QueuedLoad.Result; - return; - } - } - - 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; - } - } - } - - _Texture_Reloading = false; - + + Unload(); + 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; - }); - } - - } else if (Fallback != null) { - ((patch_VirtualTexture) (object) Fallback).Reload(); - Texture_Unsafe = Fallback.Texture; - } - + Stream stream = Metadata.Stream; + if (stream != null) { + bool premul = false; // Assume unpremultiplied by default. + if (Metadata.TryGetMeta(out TextureMeta meta)) + premul = meta.Premultiplied; + Load(TextureContentHelper.LoadFromStream(stream, premul)); + } else if (Fallback != null) { + ((patch_VirtualTexture) (object) Fallback).Reload(); + Texture_Unsafe = Fallback.Texture!; } 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; - } - }); + throw new InvalidOperationException(); } - } 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; - }); - + Load(TextureContentHelper.LoadFromSizeAndColor(Width, Height, color)); } 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; - } + Load(TextureContentHelper.LoadFromPath(Path)); } - + Texture2D tex = Texture_Unsafe; - if (tex != null) { - Width = tex.Width; - Height = tex.Height; - } - - _Texture_Requesting = false; + Width = tex.Width; + Height = tex.Height; } private bool CanPreload { @@ -790,7 +160,7 @@ private bool Preload(bool force = false) { return false; } - // Preload the width / height, and if needed, the entire texture. + // Preload the width / height, and if needed, the entire texture (not actually done currently though). if (!string.IsNullOrEmpty(Path)) { string extension = System.IO.Path.GetExtension(Path); From 1db69f4dc90f7dfd13eeacce20c9adab38646e56 Mon Sep 17 00:00:00 2001 From: wartori Date: Thu, 30 Oct 2025 20:05:38 +0100 Subject: [PATCH 04/31] Fix some bugs and polish code --- .../Mod/Helpers/TextureContentHelper.cs | 39 +++--- .../Patches/Monocle/VirtualContent.cs | 3 + .../Patches/Monocle/VirtualTexture.cs | 131 +++++++++++++++--- 3 files changed, 132 insertions(+), 41 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs index f303e9f5b..d5f955e9d 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -22,15 +22,11 @@ public static class TextureContentHelper { private static bool inGC = true; private static readonly SpanPool spanPool = new(atlasSize * 2, inGC); - // 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); - public static unsafe Func LoadFromPath(string path) { int w, h; switch (Path.GetExtension(path)) { case ".data": + bool hasSegment; SpanPool.SegmentIdentifier seg; using (FileStream stream = File.OpenRead(Path.Combine(Engine.ContentDirectory, path))) { @@ -46,9 +42,9 @@ public static unsafe Func LoadFromPath(string path) { bool hasAlpha = read[8] == 1; int size = w * h * 4; - bool rent = spanPool.TryRent(size, out seg); + hasSegment = spanPool.TryRent(size, out seg); Span buffer; - if (rent) { + if (hasSegment) { buffer = seg.Memory.Span; } else { // Fall back to gc allocs @@ -67,7 +63,8 @@ public static unsafe Func LoadFromPath(string path) { fixed (byte* ptr = seg.Memory.Span) tex.SetData((IntPtr)ptr); - spanPool.Return(seg); + if (hasSegment) + spanPool.Return(seg); return tex; }; case ".png": @@ -76,12 +73,14 @@ public static unsafe Func LoadFromPath(string path) { return () => Engine.Instance.Content.Load(path.Replace(".xnb", "")); default: return () => { + return LoadFromStream(File.OpenRead(Path.Combine(Engine.ContentDirectory, path)), false)(); using FileStream stream = File.OpenRead(Path.Combine(Engine.ContentDirectory, path)); return Texture2D.FromStream(Engine.Graphics.GraphicsDevice, stream); }; } } + // stb path public static Func LoadFromStream(Stream stream, bool premul) { using (stream) { int w, h; @@ -102,12 +101,10 @@ public static Func LoadFromStream(Stream stream, bool premul) { public static unsafe Func 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 - Memory data; - if (spanPool.TryRent(width * height * sizeof(Color), out SpanPool.SegmentIdentifier seg)) { - data = seg.Memory; - } else { - data = new byte[width * height * sizeof(Color)]; - } + bool hasSegment = spanPool.TryRent(width * height * sizeof(Color), out SpanPool.SegmentIdentifier seg); + Memory data = hasSegment ? seg.Memory : + // TODO: Improve this to bound max mem usage + new byte[width * height * sizeof(Color)]; fixed (Color* ptr = MemoryMarshal.Cast(data.Span)) { for (int i = 0; i < data.Length; i++) { ptr[i] = color; @@ -120,7 +117,8 @@ public static unsafe Func LoadFromSizeAndColor(int width, int height, } // Fall back to gc allocs // TODO: Improve this to bound max mem usage - spanPool.Return(seg); + if (hasSegment) + spanPool.Return(seg); return tex; }; } @@ -130,8 +128,7 @@ public static unsafe Func LoadFromSizeAndColor(int width, int height, // 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 - [PatchInitblk] - [MethodImpl(MethodImplOptions.AggressiveInlining)] + [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) @@ -139,7 +136,7 @@ private static unsafe void ReadDataFile(Stream stream, byte[] read, Span(Stream stream, byte[] read, Span 1) { - if (typeof(T) == typeof(int) && zeroSplat) { + if (typeof(T) == typeof(HasAlpha) && zeroSplat) { // If alpha was zero, bulk write 0 to the whole line - _initblk(to + toIdxB + 4, 0, lineSize * 4 - 4); + Unsafe.InitBlockUnaligned(to + toIdxB + 4, 0, lineSize * 4 - 4); } else { // Write via integers for performance int splatValue = toI[toIdxI]; @@ -309,7 +306,7 @@ public void Dispose() { /// /// A simple MemoryManager for unmanaged memory arrays. /// - /// Number of elements of the array. + /// Number of elements of the array. /// Type of the array. private sealed unsafe class UnmanagedMemoryManager(int itemCount) : MemoryManager where T : unmanaged { diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs index ee746030e..920788516 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs @@ -63,6 +63,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. /// diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index a685a2d3a..0e12078ce 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -10,8 +10,8 @@ using MonoMod; using System; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Threading.Tasks; #nullable enable @@ -21,9 +21,6 @@ class patch_VirtualTexture : patch_VirtualAsset { // We're effectively in VirtualAsset, but still need to "expose" private fields to our mod. public string? Path { get; private set; } private Color color; - - internal const int bytesSize = 512 * 1024; // 524288 - internal const int bytesCheckSize = 512 * 1024 - 32; // 524256 private static extern void orig_cctor(); [MonoModConstructor] @@ -39,27 +36,62 @@ private static void cctor() { public Texture2D? Texture; [MonoModRemove] - private Texture2D Texture_Unsafe; + private Texture2D? Texture_Unsafe; [MonoModLinkFrom("Microsoft.Xna.Framework.Graphics.Texture2D Monocle.VirtualTexture::Texture")] - public Texture2D Texture_Safe { + public Texture2D? Texture_Safe { get { - return Texture_Unsafe; + // Return the texture if it is ready + if (Texture_Unsafe != null) + return Texture_Unsafe; + + // Otherwise wait for the queued load + if (_queuedLoad == null) { + Reload(); + if (Texture_Unsafe != null) + return Texture_Unsafe; + // if reload doesn't set a texture, _queuedLoad must be non-null + Debug.Assert(_queuedLoad != null); + } + // Deadlock prevention for main thread + if (MainThreadHelper.IsMainThread) { + if (!_waitingForLoadOnMainThread) { + _waitingForLoadOnMainThread = true; + } else { + throw new Exception("This is a bug! Deadlock prevented! Task would be waiting for itself to complete!"); + } + } + + _queuedLoad.Wait(); + if (MainThreadHelper.IsMainThread) { + _waitingForLoadOnMainThread = false; + } + return Texture_Unsafe!; } set { - Texture_Unsafe = value; + if (value == null) { + Unload(); + } else { + AssignTexture(value); + } } } - public ModAsset? Metadata; + public readonly ModAsset? Metadata; public VirtualTexture? Fallback; + private readonly object _textureLock; + private Task? _queuedLoad; + // Deadlock prevention for main thread + private bool _waitingForLoadOnMainThread; + [MonoModConstructor] [MonoModReplace] internal patch_VirtualTexture(string path) { Path = path; Name = path; + _textureLock = new object(); // if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) Reload(); if (Everest.Flags.IsHeadless) @@ -73,6 +105,7 @@ internal patch_VirtualTexture(string name, int width, int height, Color color) { Width = width; Height = height; this.color = color; + _textureLock = new object(); // if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) Reload(); if (Everest.Flags.IsHeadless) @@ -83,6 +116,7 @@ internal patch_VirtualTexture(string name, int width, int height, Color color) { internal patch_VirtualTexture(ModAsset metadata) { Metadata = metadata; Name = metadata.PathVirtual; + _textureLock = new object(); // if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) Reload(); if (Everest.Flags.IsHeadless) @@ -90,14 +124,41 @@ internal patch_VirtualTexture(ModAsset metadata) { } - [MemberNotNull(nameof(Texture_Unsafe))] - private void Load(Func cb) { - Texture_Unsafe = cb(); + /// + /// Runs a callback in the main thread. + /// + /// The callback. + /// Whether to wait. + private void RunSafely(Func cb, bool wait = true) { + if (LoadImmediately) { + AssignTexture(cb()); + return; + } + + ValueTask vt = MainThreadHelper.Schedule((Action)RunAndStore); + // This is somewhat pointless, IsCompleted cant be true with the current LoadImmediately criteria + if (vt.IsCompleted) { // TODO: What if the completion was not successful + return; + } + + Task t = vt.AsTask(); + lock (_textureLock) + _queuedLoad = t; + if (wait) { + t.Wait(); + } + + return; + + void RunAndStore() { + Texture2D tex = cb(); + AssignTexture(tex); + } } private bool LoadImmediately => MainThreadHelper.IsMainThread; - [MemberNotNull(nameof(Texture_Unsafe))] + [MonoModReplace] internal override sealed void Reload() { if (Everest.Flags.IsHeadless) { Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, 1, 1); @@ -105,6 +166,7 @@ internal override sealed void Reload() { } Unload(); + // Important, this is the main entrypoint, and any code-path should lead to an eventual unique call to RunSafely or AssignTexture if (Metadata != null) { Stream stream = Metadata.Stream; @@ -112,22 +174,51 @@ internal override sealed void Reload() { bool premul = false; // Assume unpremultiplied by default. if (Metadata.TryGetMeta(out TextureMeta meta)) premul = meta.Premultiplied; - Load(TextureContentHelper.LoadFromStream(stream, premul)); + // If we have async streams, read async, otherwise, wrap everything in the RunSafely call and run the returned cb immediately + RunSafely(Metadata.StreamAsync ? + TextureContentHelper.LoadFromStream(stream, premul) : + () => TextureContentHelper.LoadFromStream(stream, premul)()); } else if (Fallback != null) { + // ReSharper disable once SuspiciousTypeConversion.Global ((patch_VirtualTexture) (object) Fallback).Reload(); - Texture_Unsafe = Fallback.Texture!; + AssignTexture(Fallback.Texture!); } else { throw new InvalidOperationException(); } } else if (string.IsNullOrEmpty(Path)) { - Load(TextureContentHelper.LoadFromSizeAndColor(Width, Height, color)); + RunSafely(TextureContentHelper.LoadFromSizeAndColor(Width, Height, color)); } else { - Load(TextureContentHelper.LoadFromPath(Path)); + RunSafely(TextureContentHelper.LoadFromPath(Path)); + } + } + + private void AssignTexture(Texture2D tex) { + ArgumentNullException.ThrowIfNull(tex); + lock (_textureLock) { + // if (Texture_Unsafe != null) { + // throw new InvalidOperationException("Double Texture_Unsafe assignment!"); + // } + Texture_Unsafe = tex; + Width = tex.Width; + Height = tex.Height; } + } - Texture2D tex = Texture_Unsafe; - Width = tex.Width; - Height = tex.Height; + // TODO: Get rid of the replace and ilpatch to use Texture_Unsafe + [MonoModReplace] + internal override void Unload() { + if (Texture_Unsafe is { IsDisposed: false }) { + Texture_Unsafe.Dispose(); + } + Texture_Unsafe = null; + } + + // TODO: Get rid of the replace and ilpatch to use Texture_Unsafe + [MonoModReplace] + public override void Dispose() { + Unload(); + Texture_Unsafe = null; + patch_VirtualContent.Remove(this); } private bool CanPreload { From 5c88d3d55ef5d2465e6d017fcdca16dbeff8a657 Mon Sep 17 00:00:00 2001 From: wartori Date: Sun, 2 Nov 2025 19:03:09 +0100 Subject: [PATCH 05/31] Make VirtualTexture thread-safe --- .../Patches/Monocle/VirtualTexture.cs | 122 ++++++++++++------ 1 file changed, 82 insertions(+), 40 deletions(-) diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index 0e12078ce..66e2b9e9a 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -9,8 +9,8 @@ using Microsoft.Xna.Framework.Graphics; using MonoMod; using System; -using System.Diagnostics; using System.IO; +using System.Threading; using System.Threading.Tasks; #nullable enable @@ -41,34 +41,45 @@ private static void cctor() { [MonoModLinkFrom("Microsoft.Xna.Framework.Graphics.Texture2D Monocle.VirtualTexture::Texture")] public Texture2D? Texture_Safe { get { - // Return the texture if it is ready - if (Texture_Unsafe != null) - return Texture_Unsafe; - - // Otherwise wait for the queued load - if (_queuedLoad == null) { - Reload(); - if (Texture_Unsafe != null) - return Texture_Unsafe; - // if reload doesn't set a texture, _queuedLoad must be non-null - Debug.Assert(_queuedLoad != null); - } - // Deadlock prevention for main thread - if (MainThreadHelper.IsMainThread) { - if (!_waitingForLoadOnMainThread) { - _waitingForLoadOnMainThread = true; - } else { - throw new Exception("This is a bug! Deadlock prevented! Task would be waiting for itself to complete!"); + do { + // Return the texture if it is ready + if (Texture_Unsafe != null) { + lock (_textureLock) { + if (Texture_Unsafe != null) + return Texture_Unsafe; + } } - } - _queuedLoad.Wait(); - if (MainThreadHelper.IsMainThread) { - _waitingForLoadOnMainThread = false; - } - return Texture_Unsafe!; + // Otherwise try queuing a reload + Reload(true); + + Task queuedLoad; + // 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; + + continue; // We have got no texture, and we cannot wait for anything so try again + } + // Wait for the _queuedLoad, but not locked + queuedLoad = _queuedLoad; + } + + // 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 + queuedLoad.RunSynchronously(); + } else { + queuedLoad.Wait(); + } + } while (true); } set { + // 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. if (value == null) { Unload(); } else { @@ -82,9 +93,10 @@ public Texture2D? Texture_Safe { public VirtualTexture? Fallback; private readonly object _textureLock; + // Queued upload to gpu of the texture, assumed to always run on main thread private Task? _queuedLoad; - // Deadlock prevention for main thread - private bool _waitingForLoadOnMainThread; + private readonly object _reloadLock; + private bool _reloadInProgress; [MonoModConstructor] [MonoModReplace] @@ -92,10 +104,10 @@ internal patch_VirtualTexture(string path) { Path = path; Name = path; _textureLock = new object(); + _reloadLock = new object(); // if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) Reload(); - if (Everest.Flags.IsHeadless) - Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, Width, Height); + // TODO: Headless } [MonoModConstructor] @@ -106,10 +118,9 @@ internal patch_VirtualTexture(string name, int width, int height, Color color) { Height = height; this.color = color; _textureLock = new object(); + _reloadLock = new object(); // if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) Reload(); - if (Everest.Flags.IsHeadless) - Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, Width, Height); } [MonoModConstructor] @@ -117,10 +128,9 @@ internal patch_VirtualTexture(ModAsset metadata) { Metadata = metadata; Name = metadata.PathVirtual; _textureLock = new object(); + _reloadLock = new object(); // if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) Reload(); - if (Everest.Flags.IsHeadless) - Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, Width, Height); } @@ -129,7 +139,7 @@ internal patch_VirtualTexture(ModAsset metadata) { /// /// The callback. /// Whether to wait. - private void RunSafely(Func cb, bool wait = true) { + private void RunSafely(Func cb, bool wait = false) { if (LoadImmediately) { AssignTexture(cb()); return; @@ -156,12 +166,13 @@ void RunAndStore() { } } + // Make sure that MainThreadHelper.IsMainThread == true implies LoadImmediately == true private bool LoadImmediately => MainThreadHelper.IsMainThread; - [MonoModReplace] - internal override sealed void Reload() { + // This function assumes it is executing on at most one thread at a time + private void InnerReload(bool block = false) { if (Everest.Flags.IsHeadless) { - Texture_Unsafe = new Texture2D(Engine.Graphics.GraphicsDevice, 1, 1); + AssignTexture(new Texture2D(Engine.Graphics.GraphicsDevice, 1, 1)); return; } @@ -177,7 +188,7 @@ internal override sealed void Reload() { // If we have async streams, read async, otherwise, wrap everything in the RunSafely call and run the returned cb immediately RunSafely(Metadata.StreamAsync ? TextureContentHelper.LoadFromStream(stream, premul) : - () => TextureContentHelper.LoadFromStream(stream, premul)()); + () => TextureContentHelper.LoadFromStream(stream, premul)(), block); } else if (Fallback != null) { // ReSharper disable once SuspiciousTypeConversion.Global ((patch_VirtualTexture) (object) Fallback).Reload(); @@ -186,12 +197,43 @@ internal override sealed void Reload() { throw new InvalidOperationException(); } } else if (string.IsNullOrEmpty(Path)) { - RunSafely(TextureContentHelper.LoadFromSizeAndColor(Width, Height, color)); + RunSafely(TextureContentHelper.LoadFromSizeAndColor(Width, Height, color), block); } else { - RunSafely(TextureContentHelper.LoadFromPath(Path)); + RunSafely(TextureContentHelper.LoadFromPath(Path), block); } } + // Attempts to start a Reload on the current thread or returns if one is already ongoing. + // If block is true it guarantees that either: a texture is assigned after its load or that _queuedLoad is not null and has pending work. + // If block is false it only guarantees that a Reload is happening on some thread. + private void Reload(bool block) { + 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 { + // Do not wait for the main thread to finish the load, it could deadlock if a blocking Reload is called on there + InnerReload(false); + } finally { + _reloadInProgress = false; + Monitor.Exit(_reloadLock); + } + } + + [MonoModReplace] + internal override sealed void Reload() { + Reload(false); + } + private void AssignTexture(Texture2D tex) { ArgumentNullException.ThrowIfNull(tex); lock (_textureLock) { From c98c1ff6ecba74456437fd3d2505120bfcc7df6e Mon Sep 17 00:00:00 2001 From: wartori Date: Fri, 7 Nov 2025 00:26:17 +0100 Subject: [PATCH 06/31] Initial FTL reimpl --- .../Mod/Helpers/TextureContentHelper.cs | 285 +++++++++++++++--- Celeste.Mod.mm/Patches/Celeste.cs | 10 +- .../Patches/Monocle/VirtualTexture.cs | 85 ++++-- 3 files changed, 311 insertions(+), 69 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs index d5f955e9d..d7c35163b 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -1,13 +1,17 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Monocle; -using MonoMod; using System; using System.Buffers; using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; using System.IO; +using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Threading; #nullable enable @@ -18,16 +22,21 @@ public static class TextureContentHelper { 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; - private static bool inGC = true; - private static readonly SpanPool spanPool = new(atlasSize * 2, inGC); + public const int atlasSize = 4096 * 4096 * 4; + internal static readonly FTLMemoryManger MemoryManager; + static TextureContentHelper() { + const bool inGC = true; // TODO: unhardcode + const int initialMemUsage = atlasSize * 4; // TODO: unhardcode + MemoryManager = new FTLMemoryManger(initialMemUsage, inGC); + } - public static unsafe Func LoadFromPath(string path) { + public static unsafe Func LoadFromPath(string path, int preW = -1, int preH = -1) { int w, h; switch (Path.GetExtension(path)) { case ".data": bool hasSegment; - SpanPool.SegmentIdentifier seg; + 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 @@ -42,16 +51,12 @@ public static unsafe Func LoadFromPath(string path) { bool hasAlpha = read[8] == 1; int size = w * h * 4; - hasSegment = spanPool.TryRent(size, out seg); - Span buffer; - if (hasSegment) { - buffer = seg.Memory.Span; - } else { - // Fall back to gc allocs - // TODO: Improve this to bound max mem usage - buffer = new byte[size]; + { + hasSegment = MemoryManager.GetChunkOrGcAlloc(size, out seg, out byte[] gcArray); + mem = hasSegment ? seg.SegId.Memory : gcArray; } + Span buffer = mem.Span; if (hasAlpha) { ReadDataFile(stream, read, buffer); } else { @@ -60,30 +65,35 @@ public static unsafe Func LoadFromPath(string path) { } return () => { Texture2D tex = new(Celeste.Instance.GraphicsDevice, w, h); - fixed (byte* ptr = seg.Memory.Span) + fixed (byte* ptr = mem.Span) tex.SetData((IntPtr)ptr); if (hasSegment) - spanPool.Return(seg); + MemoryManager.ReturnChunk(seg); return tex; }; case ".png": - return LoadFromStream(File.OpenRead(Path.Combine(Engine.ContentDirectory, path)), false /* pngs are never premultiplied */); + return LoadFromStream(File.OpenRead(Path.Combine(Engine.ContentDirectory, path)), false /* pngs are never premultiplied */, preW, preH); case ".xnb": + // TODO: Are xnbs worth accelerating? return () => Engine.Instance.Content.Load(path.Replace(".xnb", "")); default: - return () => { - return LoadFromStream(File.OpenRead(Path.Combine(Engine.ContentDirectory, path)), false)(); - using FileStream stream = File.OpenRead(Path.Combine(Engine.ContentDirectory, path)); - return Texture2D.FromStream(Engine.Graphics.GraphicsDevice, stream); - }; + return () => LoadFromStream(File.OpenRead(Path.Combine(Engine.ContentDirectory, path)), false)(); } } - // stb path - public static Func LoadFromStream(Stream stream, bool premul) { + public static Func LoadFromStream(Stream stream, bool premul, int w = -1, int h = -1) { using (stream) { - int w, h; + // 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); + MemoryManager.ClaimUnmanaged(unmanagedClaimed); + } + // 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); @@ -94,6 +104,8 @@ public static Func LoadFromStream(Stream stream, bool premul) { Texture2D tex = new(Celeste.Instance.GraphicsDevice, w, h); tex.SetData(dataPtr); ContentExtensions.UnloadTextureRaw(dataPtr); + if (unmanagedClaimed != 0) + MemoryManager.ReturnUnmanaged(unmanagedClaimed); return tex; }; } @@ -101,10 +113,9 @@ public static Func LoadFromStream(Stream stream, bool premul) { public static unsafe Func 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 = spanPool.TryRent(width * height * sizeof(Color), out SpanPool.SegmentIdentifier seg); - Memory data = hasSegment ? seg.Memory : - // TODO: Improve this to bound max mem usage - new byte[width * height * sizeof(Color)]; + bool hasSegment = MemoryManager.GetChunkOrGcAlloc(width*height*sizeof(Color), + out SpanPoolPool.SegmentIdentifier seg, out byte[] gcArray); + Memory data = hasSegment ? seg.SegId.Memory : gcArray; fixed (Color* ptr = MemoryMarshal.Cast(data.Span)) { for (int i = 0; i < data.Length; i++) { ptr[i] = color; @@ -118,7 +129,7 @@ public static unsafe Func LoadFromSizeAndColor(int width, int height, // Fall back to gc allocs // TODO: Improve this to bound max mem usage if (hasSegment) - spanPool.Return(seg); + MemoryManager.ReturnChunk(seg); return tex; }; } @@ -199,23 +210,212 @@ private sealed class HasAlpha : AlphaMode; private sealed class NoAlpha : AlphaMode; - private class SpanPool : IDisposable where T : unmanaged { - private readonly int Size; + internal class FTLMemoryManger(long initialMemUsage, bool inGC) { + private const double SplitPercent = 1 / 4D; + private readonly long initialMemUsage = initialMemUsage; + // Managed + private readonly ManualResetEventSlim waitingForSpace = new(); + private readonly SpanPoolPool spanPool = new(atlasSize, (long)(initialMemUsage*SplitPercent), inGC); + private volatile CancellationTokenSource waitingForSpaceCts = new(); + + // Unmanaged + private long unmanagedMemoryUsage = 0; + private readonly object unmanagedLock = new(); + + public long CurrMemUsage { get; private set; } = initialMemUsage; + + public bool GetChunkOrGcAlloc(int chunkSize, out SpanPoolPool.SegmentIdentifier seg, out byte[] gcArray) { + // The cts may be swapped at any point, so if the available space decreases after TryRent fails and a new cts + // is assigned before this gets to the Wait it would be waiting to never have space anyway (for now) + if (chunkSize > atlasSize) { // It's not going to fit + // Just gc alloc it, TODO: what should we do here? + Logger.Log(LogLevel.Warn, nameof(TextureContentHelper), $"Chunk size was too big for the current allocated memory ({chunkSize} > {spanPool.CurrMemUsage})!"); + gcArray = new byte[chunkSize]; + seg = default; + return false; + } + const int MainThreadTimeout = 500; + const int OtherThreadTimeout = -1; + bool isMainThread = MainThreadHelper.IsMainThread; + + while (true) { + bool hasSegment = spanPool.TryRent(chunkSize, out seg); + if (hasSegment) { + gcArray = []; + return true; + } + + try { + // TODO: this is sort of ugly, all threads should attempt to claim memory but we have no guarantee of that + if (waitingForSpace.Wait(isMainThread ? MainThreadTimeout : OtherThreadTimeout, waitingForSpaceCts.Token)) + waitingForSpace.Reset(); + else // On timeout just exit and gc alloc + break; + } catch (OperationCanceledException) { // The currently allocated memory changed, and we may be able to succeed now + continue; + } catch (ObjectDisposedException) { + // Very rare case, the current cts is disposed, just try again since a new one will be assigned very soon + continue; + } + + // ReSharper disable once LoopVariableIsNeverChangedInsideLoop + } + + Logger.Log(LogLevel.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"); + gcArray = new byte[chunkSize]; + seg = default; + return false; + } + + public void ReturnChunk(SpanPoolPool.SegmentIdentifier seg) { + spanPool.Return(seg); + waitingForSpace.Set(); + } + + public void ClaimUnmanaged(long amount) { + lock (unmanagedLock) { + while (true) { + if (unmanagedMemoryUsage + amount >= CurrMemUsage * (1 - SplitPercent)) { + Monitor.Wait(unmanagedLock); + } else { + unmanagedMemoryUsage += amount; + break; + } + } + } + } + + public void ReturnUnmanaged(long amount) { + lock (unmanagedLock) { + unmanagedMemoryUsage -= amount; + Debug.Assert(unmanagedMemoryUsage >= 0); + Monitor.PulseAll(unmanagedLock); + } + } + + public void SetAllocSize(long limit) { + CurrMemUsage = limit == -1 ? initialMemUsage : limit; + spanPool.CurrMemUsage = (long) (limit * SplitPercent); + // Make everyone waiting recheck + waitingForSpaceCts.Cancel(); + waitingForSpaceCts.Dispose(); + waitingForSpaceCts = new CancellationTokenSource(); + ReturnUnmanaged(0); + } + } + + // 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 = 0; + private long releasePending = 0; + private readonly bool _inGC; + + public long CurrMemUsage { + 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; + 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--; + } + } + } + + public readonly struct SegmentIdentifier(SpanPool.SegmentIdentifier segmentIdentifier, int poolId) { + public readonly SpanPool.SegmentIdentifier SegId = segmentIdentifier; + public readonly int PoolId = poolId; + } + } + + 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; + size = itemCount; arrayHolder = new ManagedOrNotArray(itemCount, inGC); array = arrayHolder.AsMemory(); } - public bool TryRent(int size, out SegmentIdentifier seg) { - lock (@lock) { - (int start, int end)? freeSegment = NextFreeSegmentAndReserve(size); + 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; @@ -225,11 +425,11 @@ public bool TryRent(int size, out SegmentIdentifier seg) { 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) { + // lock (@lock) { // Find the matching segment and remove it for (int i = 0; i < usedSegments.Count; i++) { if (seg.Start == usedSegments[i].start) { @@ -241,10 +441,15 @@ public void Return(SegmentIdentifier seg) { 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(); } @@ -261,7 +466,7 @@ public void Dispose() { prevIdx = usedSegments[i].Item2; } // No in-between segments, check remaining space - if (Size - prevIdx >= minSize) { + if (size - prevIdx >= minSize) { (int, int) newSegment = (prevIdx, prevIdx + minSize); usedSegments.Add(newSegment); return newSegment; diff --git a/Celeste.Mod.mm/Patches/Celeste.cs b/Celeste.Mod.mm/Patches/Celeste.cs index 00cecd51e..58c0ebfb7 100644 --- a/Celeste.Mod.mm/Patches/Celeste.cs +++ b/Celeste.Mod.mm/Patches/Celeste.cs @@ -346,7 +346,8 @@ protected override void LoadContent() { * -ade */ - if (CoreModule.Settings.FastTextureLoading ?? (Environment.ProcessorCount >= 4 && (!CoreModule.Settings.ThreadedGL ?? true))) { + Logger.Info("LoadContent", CoreModule.Settings.FastTextureLoading?.ToString() ?? "null"); + if (CoreModule.Settings.FastTextureLoading ?? Environment.ProcessorCount >= 4) { long limit = (long) (CoreModule.Settings.FastTextureLoadingMaxMB * 1024f * 1024f); if (limit <= 0) { @@ -359,7 +360,9 @@ protected override void LoadContent() { if (limit <= (128L * 1024L * 1024L)) limit = (128L * 1024L * 1024L); - // patch_VirtualTexture.StartFastTextureLoading(limit); + Logger.Info("LoadContent", $"Enabling FLT with {limit} bytes"); + patch_VirtualTexture.FtlToggle = true; + TextureContentHelper.MemoryManager.SetAllocSize(limit); } orig_LoadContent(); @@ -367,7 +370,8 @@ protected override void LoadContent() { foreach (EverestModule mod in Everest._Modules) mod.LoadContent(firstLoad); - // patch_VirtualTexture.StopFastTextureLoading(); + TextureContentHelper.MemoryManager.SetAllocSize(-1); + // There's no need to stop Ftl if it started in the first place ;) Everest._ContentLoaded = true; } diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index 66e2b9e9a..9b7a71b5c 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -54,6 +54,7 @@ public Texture2D? Texture_Safe { Reload(true); Task queuedLoad; + Action queuedLoadAct; // Prevent any texture swaps in the meanwhile lock (_textureLock) { if (_queuedLoad == null || _queuedLoad.IsCompleted) { @@ -65,19 +66,22 @@ public Texture2D? Texture_Safe { } // Wait for the _queuedLoad, but not locked queuedLoad = _queuedLoad; + queuedLoadAct = _queuedLoadAct!; } // 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 - queuedLoad.RunSynchronously(); + // queuedLoad.RunSynchronously(); + queuedLoadAct(); } else { queuedLoad.Wait(); } } while (true); } set { + // TODO: Add a flag to know if the texture was overriden, so that Reload is idempotent // 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. if (value == null) { @@ -95,8 +99,12 @@ public Texture2D? Texture_Safe { private readonly object _textureLock; // Queued upload to gpu of the texture, assumed to always run on main thread private Task? _queuedLoad; + private Action? _queuedLoadAct; private readonly object _reloadLock; - private bool _reloadInProgress; + private volatile bool _reloadInProgress; + private bool isPreloaded; + + public static bool FtlToggle { get; internal set; } [MonoModConstructor] [MonoModReplace] @@ -105,9 +113,7 @@ internal patch_VirtualTexture(string path) { Name = path; _textureLock = new object(); _reloadLock = new object(); - // if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) - Reload(); - // TODO: Headless + CtorLoad(); } [MonoModConstructor] @@ -119,8 +125,7 @@ internal patch_VirtualTexture(string name, int width, int height, Color color) { this.color = color; _textureLock = new object(); _reloadLock = new object(); - // if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) - Reload(); + CtorLoad(); } [MonoModConstructor] @@ -129,7 +134,17 @@ internal patch_VirtualTexture(ModAsset metadata) { Name = metadata.PathVirtual; _textureLock = new object(); _reloadLock = new object(); - // if (!Preload(force: Everest.Flags.IsHeadless) && !Everest.Flags.IsHeadless) + CtorLoad(); + } + + private void CtorLoad() { + if (Everest.Flags.IsHeadless) { + Preload(); + AssignTexture(new Texture2D(Engine.Graphics.GraphicsDevice, 1, 1)); + return; + } + // Only skip reloads with lazyloading and successful preloads + if (!Preload() || !CoreModule.Settings.LazyLoading) Reload(); } @@ -145,15 +160,19 @@ private void RunSafely(Func cb, bool wait = false) { return; } - ValueTask vt = MainThreadHelper.Schedule((Action)RunAndStore); + bool hasRunQueuedLoad = false; + Action act = RunAndStore; + ValueTask vt = MainThreadHelper.Schedule(act); // This is somewhat pointless, IsCompleted cant be true with the current LoadImmediately criteria if (vt.IsCompleted) { // TODO: What if the completion was not successful return; } - + Task t = vt.AsTask(); - lock (_textureLock) + lock (_textureLock) { _queuedLoad = t; + _queuedLoadAct = act; + } if (wait) { t.Wait(); } @@ -161,6 +180,8 @@ private void RunSafely(Func cb, bool wait = false) { return; void RunAndStore() { + if (hasRunQueuedLoad) return; + hasRunQueuedLoad = true; Texture2D tex = cb(); AssignTexture(tex); } @@ -171,13 +192,14 @@ void RunAndStore() { // This function assumes it is executing on at most one thread at a time private void InnerReload(bool block = false) { - if (Everest.Flags.IsHeadless) { - AssignTexture(new Texture2D(Engine.Graphics.GraphicsDevice, 1, 1)); - return; - } - Unload(); // 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; + } if (Metadata != null) { Stream stream = Metadata.Stream; @@ -186,9 +208,10 @@ private void InnerReload(bool block = false) { 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 + // TODO: This is a bit wasteful, especially if we moved out of main thread to load asynchronously, maybe check asynchronousness beforehand? RunSafely(Metadata.StreamAsync ? - TextureContentHelper.LoadFromStream(stream, premul) : - () => TextureContentHelper.LoadFromStream(stream, premul)(), block); + TextureContentHelper.LoadFromStream(stream, premul, preW, preH) : + () => TextureContentHelper.LoadFromStream(stream, premul, preW, preH)(), block); } else if (Fallback != null) { // ReSharper disable once SuspiciousTypeConversion.Global ((patch_VirtualTexture) (object) Fallback).Reload(); @@ -199,7 +222,7 @@ private void InnerReload(bool block = false) { } else if (string.IsNullOrEmpty(Path)) { RunSafely(TextureContentHelper.LoadFromSizeAndColor(Width, Height, color), block); } else { - RunSafely(TextureContentHelper.LoadFromPath(Path), block); + RunSafely(TextureContentHelper.LoadFromPath(Path, preW, preH), block); } } @@ -207,6 +230,20 @@ private void InnerReload(bool block = false) { // If block is true it guarantees that either: a texture is assigned after its load or that _queuedLoad is not null and has pending work. // If block is false it only guarantees that a Reload is happening on some thread. private void Reload(bool block) { + 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(() => { + // Logger.Log(LogLevel.Info, nameof(VirtualTexture), "Offloading texture: " + (Path ?? Name ?? $"{Width}x{Height}")); + Reload(false); + }); + // Since we are not blocking, we are free to return whenever we want + return; + } retry: bool got = Monitor.TryEnter(_reloadLock); if (!got) { @@ -289,10 +326,6 @@ private bool CanPreload { } private bool Preload(bool force = false) { - if (!CoreModule.Settings.LazyLoading && !force) { - return false; - } - // Preload the width / height, and if needed, the entire texture (not actually done currently though). if (!string.IsNullOrEmpty(Path)) { @@ -304,12 +337,12 @@ private bool Preload(bool force = false) { Width = reader.ReadInt32(); Height = reader.ReadInt32(); } - return true; + return isPreloaded = true; } else if (extension == ".png") { // Hard. using (FileStream stream = File.OpenRead(System.IO.Path.Combine(Engine.ContentDirectory, Path))) - return PreloadSizeFromPNG(stream, Path); + return isPreloaded = PreloadSizeFromPNG(stream, Path); } else { // .xnb and other file formats - impossible. @@ -321,7 +354,7 @@ private bool Preload(bool force = false) { if (Metadata.Format == "png") { // Hard. using (Stream stream = Metadata.Stream) - return PreloadSizeFromPNG(stream, $"{Metadata.PathVirtual} (mod {Metadata.Source.Mod?.Name ?? "*unknown*"})"); + return isPreloaded = PreloadSizeFromPNG(stream, $"{Metadata.PathVirtual} (mod {Metadata.Source.Mod?.Name ?? "*unknown*"})"); } else { // .xnb and other file formats - impossible. From eabee408bbcd372c8e9b00ba28a9fe848abb677f Mon Sep 17 00:00:00 2001 From: wartori Date: Fri, 7 Nov 2025 12:41:35 +0100 Subject: [PATCH 07/31] Fix out of bounds read and reduce unsafe scopes --- .../Mod/Helpers/TextureContentHelper.cs | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs index d7c35163b..892904cd9 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -30,7 +30,7 @@ static TextureContentHelper() { MemoryManager = new FTLMemoryManger(initialMemUsage, inGC); } - public static unsafe Func LoadFromPath(string path, int preW = -1, int preH = -1) { + public static Func LoadFromPath(string path, int preW = -1, int preH = -1) { int w, h; switch (Path.GetExtension(path)) { case ".data": @@ -65,9 +65,10 @@ public static unsafe Func LoadFromPath(string path, int preW = -1, in } return () => { Texture2D tex = new(Celeste.Instance.GraphicsDevice, w, h); - fixed (byte* ptr = mem.Span) - tex.SetData((IntPtr)ptr); - + unsafe { + fixed (byte* ptr = mem.Span) + tex.SetData((IntPtr) ptr); + } if (hasSegment) MemoryManager.ReturnChunk(seg); return tex; @@ -111,20 +112,19 @@ public static Func LoadFromStream(Stream stream, bool premul, int w = } } - public static unsafe Func LoadFromSizeAndColor(int width, int height, Color color) { + public static Func 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*sizeof(Color), + bool hasSegment = MemoryManager.GetChunkOrGcAlloc(width*height*Unsafe.SizeOf(), out SpanPoolPool.SegmentIdentifier seg, out byte[] gcArray); Memory data = hasSegment ? seg.SegId.Memory : gcArray; - fixed (Color* ptr = MemoryMarshal.Cast(data.Span)) { - for (int i = 0; i < data.Length; i++) { - ptr[i] = color; - } - } + Span colorData = MemoryMarshal.Cast(data.Span); + colorData.Fill(color); return () => { Texture2D tex = new(Engine.Instance.GraphicsDevice, width, height); - fixed (byte* ptr = data.Span) { - tex.SetData((IntPtr)ptr); + unsafe { + fixed (byte* ptr = data.Span) { + tex.SetData((IntPtr)ptr); + } } // Fall back to gc allocs // TODO: Improve this to bound max mem usage From c591480565acb0bb004b66e9627e7796ca961e36 Mon Sep 17 00:00:00 2001 From: wartori Date: Fri, 7 Nov 2025 16:07:02 +0100 Subject: [PATCH 08/31] Fix memory leak, locking and logging --- Celeste.Mod.mm/Patches/Celeste.cs | 5 ++--- Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs | 15 +++++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Celeste.Mod.mm/Patches/Celeste.cs b/Celeste.Mod.mm/Patches/Celeste.cs index 58c0ebfb7..5587aa497 100644 --- a/Celeste.Mod.mm/Patches/Celeste.cs +++ b/Celeste.Mod.mm/Patches/Celeste.cs @@ -346,7 +346,6 @@ protected override void LoadContent() { * -ade */ - Logger.Info("LoadContent", CoreModule.Settings.FastTextureLoading?.ToString() ?? "null"); if (CoreModule.Settings.FastTextureLoading ?? Environment.ProcessorCount >= 4) { long limit = (long) (CoreModule.Settings.FastTextureLoadingMaxMB * 1024f * 1024f); @@ -360,7 +359,7 @@ protected override void LoadContent() { if (limit <= (128L * 1024L * 1024L)) limit = (128L * 1024L * 1024L); - Logger.Info("LoadContent", $"Enabling FLT with {limit} bytes"); + Logger.Info("LoadContent", $"Enabling FTL with {limit} bytes"); patch_VirtualTexture.FtlToggle = true; TextureContentHelper.MemoryManager.SetAllocSize(limit); } @@ -370,13 +369,13 @@ protected override void LoadContent() { foreach (EverestModule mod in Everest._Modules) mod.LoadContent(firstLoad); - TextureContentHelper.MemoryManager.SetAllocSize(-1); // 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/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index 9b7a71b5c..96d8e9f94 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -184,6 +184,11 @@ void RunAndStore() { hasRunQueuedLoad = true; Texture2D tex = cb(); AssignTexture(tex); + // This callback may hold references to big memory arrays, so cut the references once we are done + lock (_textureLock) { + _queuedLoad = null; + _queuedLoadAct = null; + } } } @@ -286,17 +291,19 @@ private void AssignTexture(Texture2D tex) { // TODO: Get rid of the replace and ilpatch to use Texture_Unsafe [MonoModReplace] internal override void Unload() { - if (Texture_Unsafe is { IsDisposed: false }) { - Texture_Unsafe.Dispose(); + lock (_textureLock) { + if (Texture_Unsafe is { IsDisposed: false }) { + Texture_Unsafe.Dispose(); + } + Texture_Unsafe = null; } - Texture_Unsafe = null; } // TODO: Get rid of the replace and ilpatch to use Texture_Unsafe [MonoModReplace] public override void Dispose() { Unload(); - Texture_Unsafe = null; + // Texture_Unsafe = null; patch_VirtualContent.Remove(this); } From 39538361ea5f362dc473fce9a0aeaf94419bb8ed Mon Sep 17 00:00:00 2001 From: wartori Date: Thu, 13 Nov 2025 22:24:21 +0100 Subject: [PATCH 09/31] Add main thread timeout for unmanaged allocations --- .../Mod/Helpers/TextureContentHelper.cs | 87 +++++++++++-------- 1 file changed, 51 insertions(+), 36 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs index 892904cd9..e76b5198f 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -92,7 +92,8 @@ public static Func LoadFromStream(Stream stream, bool premul, int w = long unmanagedClaimed = 0; if (w > 0 && h > 0) { unmanagedClaimed = (long)((double)w * h * 4 * inflationCoef); - MemoryManager.ClaimUnmanaged(unmanagedClaimed); + // ClaimUnmanaged gets us the amount that we managed to claim + unmanagedClaimed = MemoryManager.ClaimUnmanaged(unmanagedClaimed); } // 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 @@ -206,21 +207,26 @@ private static unsafe void ReadDataFile(Stream stream, byte[] read, Span spanPool = new(atlasSize, (long)(initialMemUsage*SplitPercent), inGC); - private volatile CancellationTokenSource waitingForSpaceCts = new(); // Unmanaged private long unmanagedMemoryUsage = 0; private readonly object unmanagedLock = new(); + private readonly ResourceWaiter unmanagedWaitingForSpace = new(MainThreadTimeout, OtherThreadTimeout); public long CurrMemUsage { get; private set; } = initialMemUsage; @@ -234,9 +240,6 @@ public bool GetChunkOrGcAlloc(int chunkSize, out SpanPoolPool.SegmentIdent seg = default; return false; } - const int MainThreadTimeout = 500; - const int OtherThreadTimeout = -1; - bool isMainThread = MainThreadHelper.IsMainThread; while (true) { bool hasSegment = spanPool.TryRent(chunkSize, out seg); @@ -245,22 +248,11 @@ public bool GetChunkOrGcAlloc(int chunkSize, out SpanPoolPool.SegmentIdent return true; } - try { - // TODO: this is sort of ugly, all threads should attempt to claim memory but we have no guarantee of that - if (waitingForSpace.Wait(isMainThread ? MainThreadTimeout : OtherThreadTimeout, waitingForSpaceCts.Token)) - waitingForSpace.Reset(); - else // On timeout just exit and gc alloc - break; - } catch (OperationCanceledException) { // The currently allocated memory changed, and we may be able to succeed now - continue; - } catch (ObjectDisposedException) { - // Very rare case, the current cts is disposed, just try again since a new one will be assigned very soon - continue; - } - - // ReSharper disable once LoopVariableIsNeverChangedInsideLoop + if (!waitingForSpace.Wait()) // On timeout just exit and gc alloc + break; } + bool isMainThread = MainThreadHelper.IsMainThread; Logger.Log(LogLevel.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"); @@ -271,38 +263,61 @@ public bool GetChunkOrGcAlloc(int chunkSize, out SpanPoolPool.SegmentIdent public void ReturnChunk(SpanPoolPool.SegmentIdentifier seg) { spanPool.Return(seg); - waitingForSpace.Set(); + waitingForSpace.Pulse(); } - public void ClaimUnmanaged(long amount) { - lock (unmanagedLock) { - while (true) { - if (unmanagedMemoryUsage + amount >= CurrMemUsage * (1 - SplitPercent)) { - Monitor.Wait(unmanagedLock); - } else { + public long ClaimUnmanaged(long amount) { + while (true) { + lock (unmanagedLock) { + if (unmanagedMemoryUsage + amount <= CurrMemUsage * (1 - SplitPercent)) { unmanagedMemoryUsage += amount; - break; + Console.WriteLine($"Unmanaged usage: {unmanagedMemoryUsage}, cap: {CurrMemUsage * (1 - SplitPercent)}"); + return amount; } } + if (!unmanagedWaitingForSpace.Wait()) // On timeout just allocate without budget + break; } + return 0; } public void ReturnUnmanaged(long amount) { lock (unmanagedLock) { unmanagedMemoryUsage -= amount; + Console.WriteLine($"Unmanaged usage: {unmanagedMemoryUsage}, cap: {CurrMemUsage * (1 - SplitPercent)}"); Debug.Assert(unmanagedMemoryUsage >= 0); - Monitor.PulseAll(unmanagedLock); } + unmanagedWaitingForSpace.Pulse(); } public void SetAllocSize(long limit) { + long prevMemUsage = CurrMemUsage; CurrMemUsage = limit == -1 ? initialMemUsage : limit; spanPool.CurrMemUsage = (long) (limit * SplitPercent); - // Make everyone waiting recheck - waitingForSpaceCts.Cancel(); - waitingForSpaceCts.Dispose(); - waitingForSpaceCts = new CancellationTokenSource(); - ReturnUnmanaged(0); + 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(); + } } } From 9173b30fa7661d1ac82a2bf2bdd1363c9c6f4768 Mon Sep 17 00:00:00 2001 From: wartori Date: Mon, 17 Nov 2025 09:10:57 +0100 Subject: [PATCH 10/31] Add force lazy load event --- Celeste.Mod.mm/Mod/Everest/Everest.Events.cs | 10 ++++++++++ Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs b/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs index 55d5dfacd..ee862d2ac 100644 --- a/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs +++ b/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs @@ -382,6 +382,16 @@ 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); + } + } } } } diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index 96d8e9f94..2f33b2ab4 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -140,11 +140,12 @@ internal patch_VirtualTexture(ModAsset metadata) { private void CtorLoad() { if (Everest.Flags.IsHeadless) { Preload(); + Everest.Events.VirtualTexture.OnShouldForceLazyLoad((VirtualTexture) (object) this); AssignTexture(new Texture2D(Engine.Graphics.GraphicsDevice, 1, 1)); return; } // Only skip reloads with lazyloading and successful preloads - if (!Preload() || !CoreModule.Settings.LazyLoading) + if (!Preload() || (!Everest.Events.VirtualTexture.OnShouldForceLazyLoad((VirtualTexture) (object) this) && !CoreModule.Settings.LazyLoading)) Reload(); } From c00ce3965de8dab8e21b7e9687fcf93625b2bafb Mon Sep 17 00:00:00 2001 From: wartori Date: Mon, 17 Nov 2025 18:51:38 +0100 Subject: [PATCH 11/31] Fix wrong usage of memory limit --- Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs index e76b5198f..aa2673477 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -293,7 +293,7 @@ public void ReturnUnmanaged(long amount) { public void SetAllocSize(long limit) { long prevMemUsage = CurrMemUsage; CurrMemUsage = limit == -1 ? initialMemUsage : limit; - spanPool.CurrMemUsage = (long) (limit * SplitPercent); + spanPool.CurrMemUsage = (long) (CurrMemUsage * SplitPercent); if (prevMemUsage < CurrMemUsage) { // Make everyone waiting recheck waitingForSpace.Pulse(); From 8679173f4ccf17155dd7ef44787471f718dab834 Mon Sep 17 00:00:00 2001 From: wartori Date: Mon, 17 Nov 2025 18:54:28 +0100 Subject: [PATCH 12/31] Add tracing for gc and over budget allocs --- .../Mod/Helpers/SynchronizedZipEntryStream.cs | 2 +- .../Mod/Helpers/TextureContentHelper.cs | 66 +++++++++++++++---- 2 files changed, 56 insertions(+), 12 deletions(-) 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 index aa2673477..b4bcb48c9 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -1,3 +1,6 @@ +//#define TRACE_GC_ALLOCS + +using Celeste.Mod.Core; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Monocle; @@ -52,7 +55,11 @@ public static Func LoadFromPath(string path, int preW = -1, int preH int size = w * h * 4; { - hasSegment = MemoryManager.GetChunkOrGcAlloc(size, out seg, out byte[] gcArray); + hasSegment = MemoryManager.GetChunkOrGcAlloc(size, out seg, out byte[] gcArray +#if TRACE_GC_ALLOCS + , $"Path texture {path}" +#endif + ); mem = hasSegment ? seg.SegId.Memory : gcArray; } @@ -91,9 +98,19 @@ public static Func LoadFromStream(Stream stream, bool premul, int w = const double inflationCoef = 1.2; long unmanagedClaimed = 0; if (w > 0 && h > 0) { - unmanagedClaimed = (long)((double)w * h * 4 * inflationCoef); + unmanagedClaimed = (long) ((double) w * h * 4 * inflationCoef); // ClaimUnmanaged gets us the amount that we managed to claim - unmanagedClaimed = MemoryManager.ClaimUnmanaged(unmanagedClaimed); + 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 @@ -116,7 +133,11 @@ public static Func LoadFromStream(Stream stream, bool premul, int w = public static Func 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); + 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); @@ -230,12 +251,20 @@ internal class FTLMemoryManger(long initialMemUsage, bool inGC) { public long CurrMemUsage { get; private set; } = initialMemUsage; - public bool GetChunkOrGcAlloc(int chunkSize, out SpanPoolPool.SegmentIdentifier seg, out byte[] gcArray) { + public bool GetChunkOrGcAlloc(int chunkSize, out SpanPoolPool.SegmentIdentifier seg, out byte[] gcArray +#if TRACE_GC_ALLOCS + , string source +#endif + ) { // The cts may be swapped at any point, so if the available space decreases after TryRent fails and a new cts // is assigned before this gets to the Wait it would be waiting to never have space anyway (for now) if (chunkSize > atlasSize) { // It's not going to fit // Just gc alloc it, TODO: what should we do here? - Logger.Log(LogLevel.Warn, nameof(TextureContentHelper), $"Chunk size was too big for the current allocated memory ({chunkSize} > {spanPool.CurrMemUsage})!"); + Logger.Warn(nameof(TextureContentHelper), $"Chunk size was too big for the current allocated memory ({chunkSize} > {spanPool.CurrMemUsage})!"); +#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; @@ -253,9 +282,13 @@ public bool GetChunkOrGcAlloc(int chunkSize, out SpanPoolPool.SegmentIdent } bool isMainThread = MainThreadHelper.IsMainThread; - Logger.Log(LogLevel.Warn, nameof(TextureContentHelper), $"Allocating {chunkSize} bytes in the gc because " + + 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; @@ -266,17 +299,29 @@ public void ReturnChunk(SpanPoolPool.SegmentIdentifier seg) { waitingForSpace.Pulse(); } - public long ClaimUnmanaged(long amount) { + public long ClaimUnmanaged(long amount +#if TRACE_GC_ALLOCS + , string source +#endif + ) { while (true) { lock (unmanagedLock) { if (unmanagedMemoryUsage + amount <= CurrMemUsage * (1 - SplitPercent)) { unmanagedMemoryUsage += amount; - Console.WriteLine($"Unmanaged usage: {unmanagedMemoryUsage}, cap: {CurrMemUsage * (1 - SplitPercent)}"); return amount; } } - if (!unmanagedWaitingForSpace.Wait()) // On timeout just allocate without budget + 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; } @@ -284,7 +329,6 @@ public long ClaimUnmanaged(long amount) { public void ReturnUnmanaged(long amount) { lock (unmanagedLock) { unmanagedMemoryUsage -= amount; - Console.WriteLine($"Unmanaged usage: {unmanagedMemoryUsage}, cap: {CurrMemUsage * (1 - SplitPercent)}"); Debug.Assert(unmanagedMemoryUsage >= 0); } unmanagedWaitingForSpace.Pulse(); From 76720b1590dc31fd77297aa06b470710c745a5f6 Mon Sep 17 00:00:00 2001 From: wartori Date: Tue, 18 Nov 2025 22:48:52 +0100 Subject: [PATCH 13/31] Add exception handling to FTL --- .../Patches/Monocle/VirtualTexture.cs | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index 2f33b2ab4..8603b0f74 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -50,8 +50,27 @@ public Texture2D? Texture_Safe { } } + // 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); + + // 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); + } + } Task queuedLoad; Action queuedLoadAct; @@ -73,7 +92,6 @@ public Texture2D? Texture_Safe { 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 - // queuedLoad.RunSynchronously(); queuedLoadAct(); } else { queuedLoad.Wait(); @@ -83,7 +101,7 @@ public Texture2D? Texture_Safe { set { // TODO: Add a flag to know if the texture was overriden, so that Reload is idempotent // 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. + // 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 { @@ -103,6 +121,7 @@ public Texture2D? Texture_Safe { private readonly object _reloadLock; private volatile bool _reloadInProgress; private bool isPreloaded; + private Exception? asyncFault; public static bool FtlToggle { get; internal set; } @@ -198,6 +217,7 @@ void RunAndStore() { // This function assumes it is executing on at most one thread at a time private void InnerReload(bool block = false) { + asyncFault = null; Unload(); // Important, this is the main entrypoint, and any code-path should lead to an eventual unique call to RunSafely or AssignTexture int preW = -1; @@ -266,6 +286,11 @@ private void Reload(bool block) { try { // 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); @@ -370,6 +395,12 @@ private bool Preload(bool force = false) { } } + // If we have nothing to work with but the size is already there just roll with it + // this often happens with textures that use the width-height-color ctor + if (Width != 0 && Height != 0) { + return isPreloaded = true; + } + return false; } From ae141233dfc1adbee746444a221b9e468dde5a9d Mon Sep 17 00:00:00 2001 From: wartori Date: Tue, 18 Nov 2025 22:51:05 +0100 Subject: [PATCH 14/31] Fix Stopwatch not being started, and forceQueue not doing anything --- Celeste.Mod.mm/Mod/Helpers/MainThreadHelper.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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; } From de004e5a6fbaa50a2cf2b4331d13d993353f3939 Mon Sep 17 00:00:00 2001 From: wartori Date: Tue, 18 Nov 2025 22:53:34 +0100 Subject: [PATCH 15/31] Remove FTL timer --- Celeste.Mod.mm/Patches/GameLoader.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Celeste.Mod.mm/Patches/GameLoader.cs b/Celeste.Mod.mm/Patches/GameLoader.cs index e43726c7e..1ad8b2970 100644 --- a/Celeste.Mod.mm/Patches/GameLoader.cs +++ b/Celeste.Mod.mm/Patches/GameLoader.cs @@ -113,13 +113,10 @@ private void LoadThread() { Console.WriteLine(" - LEVELS LOAD: " + timer.ElapsedMilliseconds + "ms"); timer.Stop(); - 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"); - timer.Stop(); + // Flush the main thread queue to make sure all tasks related to FTL are completed + MainThreadHelper.Schedule(() => MainThreadHelper.Boost = 0, true).AsTask().Wait(); + // There is no reason to time this since likely the queue will be empty by now Everest.Events.GameLoader.LoadThread(); From 886f192d54580ba5760b3b6c358607b58e23807d Mon Sep 17 00:00:00 2001 From: wartori Date: Tue, 18 Nov 2025 22:56:21 +0100 Subject: [PATCH 16/31] Move FTL startup into a method so that it can be started earlier/later --- .../Mod/Helpers/TextureContentHelper.cs | 33 +++++++++++++++++++ Celeste.Mod.mm/Patches/Celeste.cs | 29 +--------------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs index b4bcb48c9..27552525a 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -32,6 +32,39 @@ static TextureContentHelper() { const int initialMemUsage = atlasSize * 4; // TODO: unhardcode MemoryManager = new FTLMemoryManger(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; + } public static Func LoadFromPath(string path, int preW = -1, int preH = -1) { int w, h; diff --git a/Celeste.Mod.mm/Patches/Celeste.cs b/Celeste.Mod.mm/Patches/Celeste.cs index 5587aa497..8c49566c4 100644 --- a/Celeste.Mod.mm/Patches/Celeste.cs +++ b/Celeste.Mod.mm/Patches/Celeste.cs @@ -335,34 +335,7 @@ 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) { - 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; - TextureContentHelper.MemoryManager.SetAllocSize(limit); - } + TextureContentHelper.TryEnableFTL(); orig_LoadContent(); From 660e7be9df24dd4e992359c4882586b99f2e8358 Mon Sep 17 00:00:00 2001 From: wartori Date: Wed, 19 Nov 2025 00:40:03 +0100 Subject: [PATCH 17/31] Disallow changing Path of VirtualTexture and make resizes thread-safe --- .../Patches/Monocle/VirtualAsset.cs | 32 ++++++-- .../Patches/Monocle/VirtualTexture.cs | 78 +++++++++++++++---- 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs index 8c4dc7f62..e2893d896 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs @@ -2,20 +2,40 @@ 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; } + // [MonoModReplace] + public int Width { + [MonoModReplace] + get => InnerWidth; + [MonoModReplace] + internal set => InnerWidth = value; + } + + protected virtual int InnerHeight { get; set; } + // [MonoModReplace] + 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 Matt, 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/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index 8603b0f74..9139fab75 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -18,10 +18,45 @@ namespace Monocle { class patch_VirtualTexture : patch_VirtualAsset { - // We're effectively in VirtualAsset, but still need to "expose" private fields to our mod. - public string? Path { get; private set; } + 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; + + 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; + } + Reload(false); + } + } + + 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; + } + Reload(false); + } + } + private static extern void orig_cctor(); [MonoModConstructor] private static void cctor() { @@ -114,6 +149,8 @@ public Texture2D? Texture_Safe { public VirtualTexture? Fallback; + private readonly TextureKind _textureKind; + private readonly object _textureLock; // Queued upload to gpu of the texture, assumed to always run on main thread private Task? _queuedLoad; @@ -128,6 +165,7 @@ public Texture2D? Texture_Safe { [MonoModConstructor] [MonoModReplace] internal patch_VirtualTexture(string path) { + _textureKind = TextureKind.FileSystem; Path = path; Name = path; _textureLock = new object(); @@ -138,9 +176,10 @@ internal patch_VirtualTexture(string path) { [MonoModConstructor] [MonoModReplace] internal patch_VirtualTexture(string name, int width, int height, Color color) { + _textureKind = TextureKind.SizeDefined; Name = name; - Width = width; - Height = height; + _width = width; + _height = height; this.color = color; _textureLock = new object(); _reloadLock = new object(); @@ -149,6 +188,7 @@ internal patch_VirtualTexture(string name, int width, int height, Color color) { [MonoModConstructor] internal patch_VirtualTexture(ModAsset metadata) { + _textureKind = TextureKind.ModAsset; Metadata = metadata; Name = metadata.PathVirtual; _textureLock = new object(); @@ -223,8 +263,8 @@ private void InnerReload(bool block = false) { int preW = -1; int preH = -1; if (isPreloaded) { - preW = Width; - preH = Height; + preW = _width; + preH = _height; } if (Metadata != null) { @@ -246,7 +286,7 @@ private void InnerReload(bool block = false) { throw new InvalidOperationException(); } } else if (string.IsNullOrEmpty(Path)) { - RunSafely(TextureContentHelper.LoadFromSizeAndColor(Width, Height, color), block); + RunSafely(TextureContentHelper.LoadFromSizeAndColor(_width, _height, color), block); } else { RunSafely(TextureContentHelper.LoadFromPath(Path, preW, preH), block); } @@ -264,7 +304,6 @@ private void Reload(bool block) { // 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(() => { - // Logger.Log(LogLevel.Info, nameof(VirtualTexture), "Offloading texture: " + (Path ?? Name ?? $"{Width}x{Height}")); Reload(false); }); // Since we are not blocking, we are free to return whenever we want @@ -287,7 +326,7 @@ private void Reload(bool block) { // 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.Error(nameof(VirtualTexture), $"Failed loading texture {Name ?? $"{_width}x{_height}"}!"); Logger.LogDetailed(ex, nameof(VirtualTexture)); asyncFault = ex; throw; @@ -309,8 +348,8 @@ private void AssignTexture(Texture2D tex) { // throw new InvalidOperationException("Double Texture_Unsafe assignment!"); // } Texture_Unsafe = tex; - Width = tex.Width; - Height = tex.Height; + _width = tex.Width; + _height = tex.Height; } } @@ -367,8 +406,8 @@ private bool Preload(bool force = false) { // 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(); + _width = reader.ReadInt32(); + _height = reader.ReadInt32(); } return isPreloaded = true; @@ -397,7 +436,7 @@ private bool Preload(bool force = false) { // If we have nothing to work with but the size is already there just roll with it // this often happens with textures that use the width-height-color ctor - if (Width != 0 && Height != 0) { + else if (_width != 0 && _height != 0) { return isPreloaded = true; } @@ -421,8 +460,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; } } @@ -435,6 +474,11 @@ private static int SwapEndian(int data) { ((data >> 24) & 0xFF); } + private enum TextureKind { + FileSystem, + ModAsset, + SizeDefined + } } public static class VirtualTextureExt { From bc793aa36c40a3575a8cfa9ac941e13edf677a5a Mon Sep 17 00:00:00 2001 From: wartori Date: Wed, 19 Nov 2025 00:49:45 +0100 Subject: [PATCH 18/31] Remove leftover TODO --- Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index 9139fab75..3a15bd9d0 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -224,7 +224,7 @@ private void RunSafely(Func cb, bool wait = false) { Action act = RunAndStore; ValueTask vt = MainThreadHelper.Schedule(act); // This is somewhat pointless, IsCompleted cant be true with the current LoadImmediately criteria - if (vt.IsCompleted) { // TODO: What if the completion was not successful + if (vt.IsCompleted) { return; } From 4cbf2095e2cd9e2c3c16e5538c18dd60e5277dca Mon Sep 17 00:00:00 2001 From: wartori Date: Sat, 22 Nov 2025 23:18:28 +0100 Subject: [PATCH 19/31] Use TextureKind for more clear code and some cleanups --- .../Mod/Helpers/TextureContentHelper.cs | 3 - .../Patches/Monocle/VirtualTexture.cs | 159 +++++++++--------- 2 files changed, 82 insertions(+), 80 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs index 27552525a..6269af658 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -8,10 +8,7 @@ using System.Buffers; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.Contracts; using System.IO; -using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index 3a15bd9d0..a2e124cc9 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -9,9 +9,11 @@ using Microsoft.Xna.Framework.Graphics; using MonoMod; using System; +using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; +using Debug = System.Diagnostics.Debug; #nullable enable @@ -57,6 +59,17 @@ protected override int InnerHeight { } } + public Point Size { + get => new(InnerWidth, InnerHeight); + set { + lock (_reloadLock) { + InnerWidth = value.X; + InnerHeight = value.Y; + } + Reload(false); + } + } + private static extern void orig_cctor(); [MonoModConstructor] private static void cctor() { @@ -165,6 +178,7 @@ public Texture2D? Texture_Safe { [MonoModConstructor] [MonoModReplace] internal patch_VirtualTexture(string path) { + ArgumentException.ThrowIfNullOrEmpty(path, nameof(path)); _textureKind = TextureKind.FileSystem; Path = path; Name = path; @@ -176,6 +190,8 @@ internal patch_VirtualTexture(string path) { [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; @@ -188,6 +204,7 @@ internal patch_VirtualTexture(string name, int width, int height, Color color) { [MonoModConstructor] internal patch_VirtualTexture(ModAsset metadata) { + ArgumentNullException.ThrowIfNull(metadata); _textureKind = TextureKind.ModAsset; Metadata = metadata; Name = metadata.PathVirtual; @@ -208,7 +225,6 @@ private void CtorLoad() { Reload(); } - /// /// Runs a callback in the main thread. /// @@ -266,29 +282,40 @@ private void InnerReload(bool block = false) { preW = _width; preH = _height; } - - if (Metadata != 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 - // TODO: This is a bit wasteful, especially if we moved out of main thread to load asynchronously, maybe check asynchronousness beforehand? - RunSafely(Metadata.StreamAsync ? - TextureContentHelper.LoadFromStream(stream, premul, preW, preH) : - () => TextureContentHelper.LoadFromStream(stream, premul, preW, preH)(), block); - } else if (Fallback != null) { - // ReSharper disable once SuspiciousTypeConversion.Global - ((patch_VirtualTexture) (object) Fallback).Reload(); - AssignTexture(Fallback.Texture!); - } else { - throw new InvalidOperationException(); + + 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 + // TODO: This is a bit wasteful, especially if we moved out of main thread to load asynchronously, maybe check asynchronousness beforehand? + RunSafely(Metadata.StreamAsync ? TextureContentHelper.LoadFromStream(stream, premul, preW, preH) : () => TextureContentHelper.LoadFromStream(stream, premul, preW, preH)(), block); + return; + } else if (Fallback != null) { + // ReSharper disable once SuspiciousTypeConversion.Global + ((patch_VirtualTexture) (object) Fallback).Reload(); + AssignTexture(Fallback.Texture!); + return; + } else { + throw new InvalidOperationException("Cannot have null ModAsset stream without Fallback texture!"); + } + } + case TextureKind.FileSystem: { + Debug.Assert(Path is not null); + RunSafely(TextureContentHelper.LoadFromPath(Path, preW, preH), block); + return; + } + case TextureKind.SizeDefined: { + Debug.Assert(_width > 0 && _height > 0); + RunSafely(TextureContentHelper.LoadFromSizeAndColor(_width, _height, color), block); + return; } - } else if (string.IsNullOrEmpty(Path)) { - RunSafely(TextureContentHelper.LoadFromSizeAndColor(_width, _height, color), block); - } else { - RunSafely(TextureContentHelper.LoadFromPath(Path, preW, preH), block); + default: + throw new UnreachableException(); } } @@ -372,75 +399,53 @@ public override void Dispose() { patch_VirtualContent.Remove(this); } - private bool CanPreload { - get { - if (!string.IsNullOrEmpty(Path)) { + private bool Preload(bool force = false) { + // 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) { - // Preload the width / height, and if needed, the entire texture (not actually done currently though). - - 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(); - } + case TextureKind.SizeDefined: { + Debug.Assert(_width != 0 && _height != 0); + // SizeDefined textures are already pre-loaded by definition return isPreloaded = true; - - } else if (extension == ".png") { - // 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) { - if (Metadata.Format == "png") { - // 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; } + default: + throw new UnreachableException(); } - - // If we have nothing to work with but the size is already there just roll with it - // this often happens with textures that use the width-height-color ctor - else if (_width != 0 && _height != 0) { - return isPreloaded = true; - } - - return false; } private bool PreloadSizeFromPNG(Stream stream, string path) { From 48c01939273f658cb1662d1a04facf72ae4e4772 Mon Sep 17 00:00:00 2001 From: wartori Date: Sun, 23 Nov 2025 02:06:03 +0100 Subject: [PATCH 20/31] Make texture overrides possible, safe and consistent --- .../Mod/Helpers/TextureContentHelper.cs | 49 +++-- .../Patches/Monocle/VirtualTexture.cs | 177 +++++++++++++----- 2 files changed, 159 insertions(+), 67 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs index 6269af658..5def5d7e2 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -63,7 +63,7 @@ public static bool TryEnableFTL() { return false; } - public static Func LoadFromPath(string path, int preW = -1, int preH = -1) { + public static (Func, Action?) LoadFromPath(string path, int preW = -1, int preH = -1) { int w, h; switch (Path.GetExtension(path)) { case ".data": @@ -100,27 +100,34 @@ public static Func LoadFromPath(string path, int preW = -1, int preH ReadDataFile(stream, read, buffer); } } - return () => { + 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); - return tex; - }; + }); case ".png": return LoadFromStream(File.OpenRead(Path.Combine(Engine.ContentDirectory, path)), false /* pngs are never premultiplied */, preW, preH); case ".xnb": // TODO: Are xnbs worth accelerating? - return () => Engine.Instance.Content.Load(path.Replace(".xnb", "")); + return (() => Engine.Instance.Content.Load(path.Replace(".xnb", "")), null); default: - return () => LoadFromStream(File.OpenRead(Path.Combine(Engine.ContentDirectory, path)), false)(); + 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); } } - public static Func LoadFromStream(Stream stream, bool premul, int w = -1, int h = -1) { + 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 @@ -149,18 +156,19 @@ public static Func LoadFromStream(Stream stream, bool premul, int w = else ContentExtensions.LoadTextureLazyPremultiply(Celeste.Instance.GraphicsDevice, stream, out w, out h, out dataPtr); stream.Dispose(); - return () => { + return (() => { Texture2D tex = new(Celeste.Instance.GraphicsDevice, w, h); tex.SetData(dataPtr); + return tex; + }, () => { ContentExtensions.UnloadTextureRaw(dataPtr); if (unmanagedClaimed != 0) MemoryManager.ReturnUnmanaged(unmanagedClaimed); - return tex; - }; + }); } } - public static Func LoadFromSizeAndColor(int width, int height, Color color) { + 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 @@ -171,19 +179,18 @@ public static Func LoadFromSizeAndColor(int width, int height, Color Memory data = hasSegment ? seg.SegId.Memory : gcArray; Span colorData = MemoryMarshal.Cast(data.Span); colorData.Fill(color); - return () => { + return (() => { Texture2D tex = new(Engine.Instance.GraphicsDevice, width, height); unsafe { fixed (byte* ptr = data.Span) { tex.SetData((IntPtr)ptr); } } - // Fall back to gc allocs - // TODO: Improve this to bound max mem usage + return tex; + }, () => { if (hasSegment) MemoryManager.ReturnChunk(seg); - return tex; - }; + }); } // Abuse generics in order to get dead code elimination for optimal code on both cases @@ -260,8 +267,10 @@ 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; @@ -275,7 +284,7 @@ internal class FTLMemoryManger(long initialMemUsage, bool inGC) { private readonly SpanPoolPool spanPool = new(atlasSize, (long)(initialMemUsage*SplitPercent), inGC); // Unmanaged - private long unmanagedMemoryUsage = 0; + private long unmanagedMemoryUsage; private readonly object unmanagedLock = new(); private readonly ResourceWaiter unmanagedWaitingForSpace = new(MainThreadTimeout, OtherThreadTimeout); @@ -401,8 +410,8 @@ internal class SpanPoolPool where T : unmanaged { private readonly int size; private long currMemUsage; private readonly Dictionary> pools = []; - private int poolId = 0; - private long releasePending = 0; + private int poolId; + private long releasePending; private readonly bool _inGC; public long CurrMemUsage { diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index a2e124cc9..a78c05d0a 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -43,6 +43,7 @@ protected override int InnerWidth { lock (_reloadLock) { _width = value; } + Unload(); Reload(false); } } @@ -55,6 +56,7 @@ protected override int InnerHeight { lock (_reloadLock) { _height = value; } + Unload(); Reload(false); } } @@ -63,9 +65,10 @@ public Point Size { get => new(InnerWidth, InnerHeight); set { lock (_reloadLock) { - InnerWidth = value.X; - InnerHeight = value.Y; + _width = value.X; + _height = value.Y; } + Unload(); Reload(false); } } @@ -121,7 +124,7 @@ public Texture2D? Texture_Safe { } Task queuedLoad; - Action queuedLoadAct; + QueuedLoad queuedLoadAct; // Prevent any texture swaps in the meanwhile lock (_textureLock) { if (_queuedLoad == null || _queuedLoad.IsCompleted) { @@ -139,21 +142,29 @@ public Texture2D? Texture_Safe { // 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 - queuedLoadAct(); + // so just run it directly, we are on the main thread anyway (the action is idempotent) + queuedLoadAct.Run(); } else { queuedLoad.Wait(); } } while (true); } set { - // TODO: Add a flag to know if the texture was overriden, so that Reload is idempotent // 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 { - AssignTexture(value); + lock (_textureLock) { + _textureOverridden = true; + if (_queuedLoadAct != null) { + _queuedLoadAct.Cancel(); + _queuedLoadAct = null; + _queuedLoad = null; + } + QueuedLoad.ImmediateAssign(this, value, true, _reloadVersion); + _reloadVersion++; + } } } } @@ -167,11 +178,13 @@ public Texture2D? Texture_Safe { private readonly object _textureLock; // Queued upload to gpu of the texture, assumed to always run on main thread private Task? _queuedLoad; - private Action? _queuedLoadAct; + private QueuedLoad? _queuedLoadAct; private readonly object _reloadLock; private volatile bool _reloadInProgress; private bool isPreloaded; private Exception? asyncFault; + private bool _textureOverridden; + private ulong _reloadVersion; public static bool FtlToggle { get; internal set; } @@ -217,7 +230,7 @@ private void CtorLoad() { if (Everest.Flags.IsHeadless) { Preload(); Everest.Events.VirtualTexture.OnShouldForceLazyLoad((VirtualTexture) (object) this); - AssignTexture(new Texture2D(Engine.Graphics.GraphicsDevice, 1, 1)); + QueuedLoad.ImmediateAssign(this, new Texture2D(Engine.Graphics.GraphicsDevice, 1, 1), true, _reloadVersion); return; } // Only skip reloads with lazyloading and successful preloads @@ -228,17 +241,18 @@ private void CtorLoad() { /// /// Runs a callback in the main thread. /// - /// The callback. + /// The queued load. /// Whether to wait. - private void RunSafely(Func cb, bool wait = false) { + 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) { - AssignTexture(cb()); + ql.Run(); return; } - bool hasRunQueuedLoad = false; - Action act = RunAndStore; - ValueTask vt = MainThreadHelper.Schedule(act); + ValueTask vt = MainThreadHelper.Schedule(ql.Run); // This is somewhat pointless, IsCompleted cant be true with the current LoadImmediately criteria if (vt.IsCompleted) { return; @@ -247,25 +261,13 @@ private void RunSafely(Func cb, bool wait = false) { Task t = vt.AsTask(); lock (_textureLock) { _queuedLoad = t; - _queuedLoadAct = act; + _queuedLoadAct = ql; } if (wait) { t.Wait(); } return; - - void RunAndStore() { - if (hasRunQueuedLoad) return; - hasRunQueuedLoad = true; - Texture2D tex = cb(); - AssignTexture(tex); - // This callback may hold references to big memory arrays, so cut the references once we are done - lock (_textureLock) { - _queuedLoad = null; - _queuedLoadAct = null; - } - } } // Make sure that MainThreadHelper.IsMainThread == true implies LoadImmediately == true @@ -274,7 +276,9 @@ void RunAndStore() { // This function assumes it is executing on at most one thread at a time private void InnerReload(bool block = false) { asyncFault = null; - Unload(); + // 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; @@ -292,13 +296,24 @@ private void InnerReload(bool block = false) { 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 - // TODO: This is a bit wasteful, especially if we moved out of main thread to load asynchronously, maybe check asynchronousness beforehand? - RunSafely(Metadata.StreamAsync ? TextureContentHelper.LoadFromStream(stream, premul, preW, preH) : () => TextureContentHelper.LoadFromStream(stream, premul, preW, preH)(), block); + QueuedLoad ql; + if (Metadata.StreamAsync) { + ql = new QueuedLoad(this, TextureContentHelper.LoadFromStream(stream, premul, preW, preH), reloadVersion); + } else { + // TODO: This is a bit wasteful, especially if we moved out of main thread to load asynchronously, maybe check asynchronousness beforehand? + 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; } else if (Fallback != null) { // ReSharper disable once SuspiciousTypeConversion.Global - ((patch_VirtualTexture) (object) Fallback).Reload(); - AssignTexture(Fallback.Texture!); + ((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!"); @@ -306,12 +321,12 @@ private void InnerReload(bool block = false) { } case TextureKind.FileSystem: { Debug.Assert(Path is not null); - RunSafely(TextureContentHelper.LoadFromPath(Path, preW, preH), block); + RunSafely(new QueuedLoad(this, TextureContentHelper.LoadFromPath(Path, preW, preH), reloadVersion), block); return; } case TextureKind.SizeDefined: { Debug.Assert(_width > 0 && _height > 0); - RunSafely(TextureContentHelper.LoadFromSizeAndColor(_width, _height, color), block); + RunSafely(new QueuedLoad(this, TextureContentHelper.LoadFromSizeAndColor(_width, _height, color), reloadVersion), block); return; } default: @@ -367,27 +382,33 @@ private void Reload(bool block) { internal override sealed void Reload() { Reload(false); } - - private void AssignTexture(Texture2D tex) { - ArgumentNullException.ThrowIfNull(tex); - lock (_textureLock) { - // if (Texture_Unsafe != null) { - // throw new InvalidOperationException("Double Texture_Unsafe assignment!"); - // } - Texture_Unsafe = tex; - _width = tex.Width; - _height = tex.Height; - } - } // TODO: Get rid of the replace and ilpatch to use Texture_Unsafe [MonoModReplace] internal override void Unload() { + SoftUnload(true, out _); + } + + private bool SoftUnload(bool force, out ulong reloadVersion) { lock (_textureLock) { + reloadVersion = 0; + if (!force) { + if (_textureOverridden || _queuedLoadAct != null) { + return false; + } + } + 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; } } @@ -478,6 +499,68 @@ private static int SwapEndian(int data) { (((data >> 16) & 0xFF) << 8) | ((data >> 24) & 0xFF); } + + public 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, From f851c2139fabe60c4b114c21ba91f86deba14634 Mon Sep 17 00:00:00 2001 From: wartori Date: Sun, 23 Nov 2025 02:16:31 +0100 Subject: [PATCH 21/31] (Try to) Get headless right again --- Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index a78c05d0a..6fcedb5b4 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -228,9 +228,18 @@ internal patch_VirtualTexture(ModAsset metadata) { private void CtorLoad() { if (Everest.Flags.IsHeadless) { - Preload(); + bool preload = Preload(); Everest.Events.VirtualTexture.OnShouldForceLazyLoad((VirtualTexture) (object) this); - QueuedLoad.ImmediateAssign(this, new Texture2D(Engine.Graphics.GraphicsDevice, 1, 1), true, _reloadVersion); + // 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; + } + // 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; } // Only skip reloads with lazyloading and successful preloads From 068ac8ce15d817d806cb5892ff1dddf7a79b7f3f Mon Sep 17 00:00:00 2001 From: wartori Date: Tue, 25 Nov 2025 00:26:56 +0100 Subject: [PATCH 22/31] Fix remaining TODOs --- Celeste.Mod.mm/Mod/Core/CoreModuleSettings.cs | 5 ++ .../Mod/Helpers/TextureContentHelper.cs | 61 +++++++++++++++---- .../Patches/Monocle/VirtualTexture.cs | 5 +- 3 files changed, 56 insertions(+), 15 deletions(-) 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/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs index 5def5d7e2..d212b8202 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -22,11 +22,22 @@ public static class TextureContentHelper { private static byte[]? bytes; private const int bytesSize = 512 * 1024; // 524288 private const int bytesCheckSize = 512 * 1024 - 32; // 524256 - public const int atlasSize = 4096 * 4096 * 4; + 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 FTLMemoryManger MemoryManager; static TextureContentHelper() { - const bool inGC = true; // TODO: unhardcode - const int initialMemUsage = atlasSize * 4; // TODO: unhardcode + 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 FTLMemoryManger(initialMemUsage, inGC); } @@ -115,7 +126,7 @@ public static (Func, Action?) LoadFromPath(string path, int preW = -1 case ".png": return LoadFromStream(File.OpenRead(Path.Combine(Engine.ContentDirectory, path)), false /* pngs are never premultiplied */, preW, preH); case ".xnb": - // TODO: Are xnbs worth accelerating? + // 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 (() => { @@ -279,27 +290,37 @@ internal class FTLMemoryManger(long initialMemUsage, bool inGC) { 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(atlasSize, (long)(initialMemUsage*SplitPercent), inGC); + 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); - - public long CurrMemUsage { get; private set; } = initialMemUsage; + + 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 ) { - // The cts may be swapped at any point, so if the available space decreases after TryRent fails and a new cts - // is assigned before this gets to the Wait it would be waiting to never have space anyway (for now) - if (chunkSize > atlasSize) { // It's not going to fit - // Just gc alloc it, TODO: what should we do here? - Logger.Warn(nameof(TextureContentHelper), $"Chunk size was too big for the current allocated memory ({chunkSize} > {spanPool.CurrMemUsage})!"); + 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()); @@ -343,6 +364,22 @@ public long ClaimUnmanaged(long amount , 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)) { diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index 6fcedb5b4..b2ee539ee 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -309,7 +309,7 @@ private void InnerReload(bool block = false) { if (Metadata.StreamAsync) { ql = new QueuedLoad(this, TextureContentHelper.LoadFromStream(stream, premul, preW, preH), reloadVersion); } else { - // TODO: This is a bit wasteful, especially if we moved out of main thread to load asynchronously, maybe check asynchronousness beforehand? + // 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(); @@ -392,7 +392,6 @@ internal override sealed void Reload() { Reload(false); } - // TODO: Get rid of the replace and ilpatch to use Texture_Unsafe [MonoModReplace] internal override void Unload() { SoftUnload(true, out _); @@ -421,7 +420,7 @@ private bool SoftUnload(bool force, out ulong reloadVersion) { } } - // TODO: Get rid of the replace and ilpatch to use Texture_Unsafe + // IL patch is possible here, is it worth it though? (IL should not change that much) [MonoModReplace] public override void Dispose() { Unload(); From ed327363fcce9c4e0eba95c03eadf276aa962e51 Mon Sep 17 00:00:00 2001 From: wartori Date: Tue, 25 Nov 2025 00:27:53 +0100 Subject: [PATCH 23/31] Add extra logging preprocessor directives for managed and unmanaged memory usage tracking --- .../Mod/Helpers/TextureContentHelper.cs | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs index d212b8202..e232e5abf 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -1,4 +1,5 @@ -//#define TRACE_GC_ALLOCS +//#define TRACE_GC_ALLOCS +//#define POOL_USAGE_LOGGING using Celeste.Mod.Core; using Microsoft.Xna.Framework; @@ -358,7 +359,10 @@ 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 @@ -384,6 +388,9 @@ public long ClaimUnmanaged(long amount lock (unmanagedLock) { if (unmanagedMemoryUsage + amount <= CurrMemUsage * (1 - SplitPercent)) { unmanagedMemoryUsage += amount; +#if POOL_USAGE_LOGGING + LogStatus("++"); +#endif return amount; } } @@ -405,11 +412,24 @@ public long ClaimUnmanaged(long amount 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; @@ -501,6 +521,9 @@ public bool TryRent(int chunkSize, out SegmentIdentifier segment) { 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; } @@ -525,8 +548,24 @@ public void Return(SegmentIdentifier segment) { 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; @@ -610,6 +649,13 @@ public void Dispose() { 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; From 015f4fa0721fa9aaf479fbfbb2f76880d76b9179 Mon Sep 17 00:00:00 2001 From: wartori Date: Wed, 26 Nov 2025 16:44:25 +0100 Subject: [PATCH 24/31] Add event for lazy loads on texture access --- Celeste.Mod.mm/Mod/Everest/Everest.Events.cs | 6 ++++++ Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs b/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs index ee862d2ac..8a9d5a88e 100644 --- a/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs +++ b/Celeste.Mod.mm/Mod/Everest/Everest.Events.cs @@ -391,6 +391,12 @@ public static class VirtualTexture { 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/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index b2ee539ee..38c22ef31 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -112,7 +112,7 @@ public Texture2D? Texture_Safe { // Otherwise try queuing a reload Logger.Debug(nameof(VirtualTexture), $"Loading texture {Name ?? "(Unnamed)"} on texture access!"); - Reload(true); + 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 @@ -346,7 +346,8 @@ private void InnerReload(bool block = false) { // Attempts to start a Reload on the current thread or returns if one is already ongoing. // If block is true it guarantees that either: a texture is assigned after its load or that _queuedLoad is not null and has pending work. // If block is false it only guarantees that a Reload is happening on some thread. - private void Reload(bool block) { + // isLazy is 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) { if (_reloadInProgress && !block) { // Someone has the lock, and we are not going to block anyway, so return early return; @@ -355,7 +356,7 @@ private void Reload(bool block) { // 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); + Reload(false, isLazy); }); // Since we are not blocking, we are free to return whenever we want return; @@ -374,6 +375,8 @@ private void Reload(bool block) { } _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) { From 4d81295ba140c9178bbbf3125c2544c963f4cd16 Mon Sep 17 00:00:00 2001 From: wartori Date: Wed, 26 Nov 2025 17:28:19 +0100 Subject: [PATCH 25/31] Add documentation to VirtualTexture --- .../Patches/Monocle/VirtualTexture.cs | 110 ++++++++++++++++-- 1 file changed, 102 insertions(+), 8 deletions(-) diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index 38c22ef31..3c56cde3e 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -17,6 +17,52 @@ #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 { @@ -34,6 +80,7 @@ private set { private int _width; private int _height; + // Intermediary overridable property to call reloads on change protected override int InnerWidth { get => _width; set { @@ -48,6 +95,7 @@ protected override int InnerWidth { } } + // Intermediary overridable property to call reloads on change protected override int InnerHeight { get => _height; set { @@ -61,6 +109,7 @@ protected override int InnerHeight { } } + // Helper property to modify both with and height without calling reload twice public Point Size { get => new(InnerWidth, InnerHeight); set { @@ -89,6 +138,10 @@ private static void cctor() { [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 { get { @@ -175,17 +228,26 @@ public Texture2D? Texture_Safe { 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; + // 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] @@ -226,6 +288,7 @@ internal patch_VirtualTexture(ModAsset metadata) { CtorLoad(); } + // Extra setup common in all constructors private void CtorLoad() { if (Everest.Flags.IsHeadless) { bool preload = Preload(); @@ -282,7 +345,12 @@ private void RunSafely(QueuedLoad ql, bool wait = false) { // Make sure that MainThreadHelper.IsMainThread == true implies LoadImmediately == true private bool LoadImmediately => MainThreadHelper.IsMainThread; - // This function assumes it is executing on at most one thread at a time + /// + /// 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 @@ -343,10 +411,14 @@ private void InnerReload(bool block = false) { } } - // Attempts to start a Reload on the current thread or returns if one is already ongoing. - // If block is true it guarantees that either: a texture is assigned after its load or that _queuedLoad is not null and has pending work. - // If block is false it only guarantees that a Reload is happening on some thread. - // isLazy is only used to fire an event for mods that care when a texture may be loaded on access. + /// + /// 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) { if (_reloadInProgress && !block) { // Someone has the lock, and we are not going to block anyway, so return early @@ -390,16 +462,28 @@ private void Reload(bool block, bool isLazy = false) { } } + /// + /// 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 _); } + /// + /// 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) { lock (_textureLock) { reloadVersion = 0; @@ -424,6 +508,9 @@ private bool SoftUnload(bool force, out ulong reloadVersion) { } // 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(); @@ -431,7 +518,11 @@ public override void Dispose() { patch_VirtualContent.Remove(this); } - private bool Preload(bool force = false) { + /// + /// 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) { @@ -510,8 +601,11 @@ private static int SwapEndian(int data) { (((data >> 16) & 0xFF) << 8) | ((data >> 24) & 0xFF); } - - public class QueuedLoad { + + /// + /// 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; From 33040ff6bdeda37391d42d0b14b836d803406c60 Mon Sep 17 00:00:00 2001 From: wartori Date: Thu, 27 Nov 2025 17:16:50 +0100 Subject: [PATCH 26/31] Add documentation to TextureContentHelper --- .../Mod/Helpers/TextureContentHelper.cs | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs index e232e5abf..0ea223f89 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -1,4 +1,6 @@ -//#define TRACE_GC_ALLOCS +// 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; @@ -10,6 +12,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; @@ -75,6 +78,13 @@ public static bool TryEnableFTL() { 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)) { @@ -138,7 +148,15 @@ public static (Func, Action?) LoadFromPath(string path, int preW = -1 }, 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 @@ -180,6 +198,13 @@ public static (Func, Action?) LoadFromStream(Stream stream, bool prem } } + /// + /// 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(), @@ -286,6 +311,12 @@ private struct HasAlpha : AlphaMode { 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 FTLMemoryManger(long initialMemUsage, bool inGC) { private const double SplitPercent = 1 / 4D; private readonly long initialMemUsage = initialMemUsage; @@ -461,7 +492,11 @@ public void Pulse() { } } - // This class is thread-safe + /// + /// 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; @@ -472,6 +507,7 @@ internal class SpanPoolPool where T : unmanaged { private readonly bool _inGC; public long CurrMemUsage { + // ReSharper disable once InconsistentlySynchronizedField get => Volatile.Read(ref currMemUsage); set { lock (@lock) { @@ -573,6 +609,11 @@ public readonly struct SegmentIdentifier(SpanPool.SegmentIdentifier segmentId } } + /// + /// 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(); @@ -663,7 +704,12 @@ public readonly struct SegmentIdentifier(Memory memory, int start, int end) { } } - private sealed class ManagedOrNotArray : IDisposable where T : unmanaged { + /// + /// 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; From 4ad0a29a871b1dfdc145863ca4167ec1f85432a0 Mon Sep 17 00:00:00 2001 From: wartori Date: Sun, 30 Nov 2025 23:15:57 +0100 Subject: [PATCH 27/31] Fix some typos and leftover comments --- Celeste.Mod.mm/Mod/Core/CoreModule.cs | 1 + Celeste.Mod.mm/Mod/Everest/Everest.Flags.cs | 2 +- Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs | 6 +++--- Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs | 4 +--- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Celeste.Mod.mm/Mod/Core/CoreModule.cs b/Celeste.Mod.mm/Mod/Core/CoreModule.cs index 16c187558..6967a9903 100644 --- a/Celeste.Mod.mm/Mod/Core/CoreModule.cs +++ b/Celeste.Mod.mm/Mod/Core/CoreModule.cs @@ -58,6 +58,7 @@ public override void LoadSettings() { } // If we're running in an environment that prefers this flag, forcibly enable them. + // Used to be used by android, which is not supported currently. // Settings.LazyLoading |= Everest.Flags.PreferLazyLoading; } diff --git a/Celeste.Mod.mm/Mod/Everest/Everest.Flags.cs b/Celeste.Mod.mm/Mod/Everest/Everest.Flags.cs index 6fe36e1e1..09edf312a 100644 --- a/Celeste.Mod.mm/Mod/Everest/Everest.Flags.cs +++ b/Celeste.Mod.mm/Mod/Everest/Everest.Flags.cs @@ -48,7 +48,7 @@ public static class Flags { public static bool AvoidRenderTargets { get; private set; } /// /// Does the environment (platform, ...) prefer lazy loading? - /// Used to be used by android platform, which are not supported currently. + /// Used to be used by android, which is not supported currently. /// [Obsolete("`PreferLazyLoading` is always false on Everest Core")] public static bool PreferLazyLoading => false; diff --git a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs index 0ea223f89..7b76aeead 100644 --- a/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs +++ b/Celeste.Mod.mm/Mod/Helpers/TextureContentHelper.cs @@ -30,7 +30,7 @@ public static class TextureContentHelper { // 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 FTLMemoryManger MemoryManager; + internal static readonly FTLMemoryManager MemoryManager; static TextureContentHelper() { bool inGC; if (CoreModule.Instance != null) { @@ -42,7 +42,7 @@ static TextureContentHelper() { 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 FTLMemoryManger(initialMemUsage, inGC); + MemoryManager = new FTLMemoryManager(initialMemUsage, inGC); } public static bool TryEnableFTL() { @@ -317,7 +317,7 @@ private struct NoAlpha : AlphaMode; /// /// Initial reserved memory usage. /// Whether the managed pool lives is a gc object or not. - internal class FTLMemoryManger(long initialMemUsage, bool inGC) { + internal class FTLMemoryManager(long initialMemUsage, bool inGC) { private const double SplitPercent = 1 / 4D; private readonly long initialMemUsage = initialMemUsage; private const int MainThreadTimeout = 500; diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs index e2893d896..7758c1d31 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualAsset.cs @@ -11,7 +11,6 @@ abstract class patch_VirtualAsset : VirtualAsset { // 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; } - // [MonoModReplace] public int Width { [MonoModReplace] get => InnerWidth; @@ -20,7 +19,6 @@ public int Width { } protected virtual int InnerHeight { get; set; } - // [MonoModReplace] public int Height { [MonoModReplace] get => InnerHeight; @@ -30,7 +28,7 @@ public int Height { # pragma warning restore CS0108 // 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! + // Noel or Maddy, if you see this, please change the visibility to protected. Thanks! [MonoModIgnore] internal virtual void Unload() { } From 18b309cb3ef4087bd8998904c3daf2486110f40e Mon Sep 17 00:00:00 2001 From: wartori Date: Sun, 30 Nov 2025 23:21:39 +0100 Subject: [PATCH 28/31] Add timing and logging for the MainThreadHelper queue flush --- Celeste.Mod.mm/Patches/GameLoader.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Celeste.Mod.mm/Patches/GameLoader.cs b/Celeste.Mod.mm/Patches/GameLoader.cs index 1ad8b2970..ba5d6d87d 100644 --- a/Celeste.Mod.mm/Patches/GameLoader.cs +++ b/Celeste.Mod.mm/Patches/GameLoader.cs @@ -113,10 +113,14 @@ private void LoadThread() { Console.WriteLine(" - LEVELS LOAD: " + timer.ElapsedMilliseconds + "ms"); timer.Stop(); + timer = Stopwatch.StartNew(); MainThreadHelper.Boost = 50; // 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(); - // There is no reason to time this since likely the queue will be empty by now + Console.WriteLine(" - MTH QUEUE FLUSH: " + timer.ElapsedMilliseconds + "ms"); + timer.Stop(); Everest.Events.GameLoader.LoadThread(); From 888e0cafb4b942dc2c140272355f5dfa1e44e799 Mon Sep 17 00:00:00 2001 From: wartori Date: Sun, 30 Nov 2025 23:47:22 +0100 Subject: [PATCH 29/31] Make VirtualContent synchronized --- Celeste.Mod.mm/MonoModRules.cs | 19 ++++++++++++++++++- .../Patches/Monocle/VirtualContent.cs | 3 ++- 2 files changed, 20 insertions(+), 2 deletions(-) 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/Monocle/VirtualContent.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs index 920788516..beb281e7c 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualContent.cs @@ -9,9 +9,10 @@ using System.IO; namespace Monocle { + // We may have concurrent usage of this class due to FTL + [MakeAllMethodsSynchronized] static class patch_VirtualContent { - // TODO: add locking around this field // We're effectively in VirtualContent, but still need to "expose" private fields to our mod. private static List assets; /// From f232dea3e97a1a52bf8c265fcf73913233273e1a Mon Sep 17 00:00:00 2001 From: Maddie <52103563+maddie480@users.noreply.github.com> Date: Mon, 1 Dec 2025 08:08:50 +0100 Subject: [PATCH 30/31] Rerun pipeline From 5ecc0b44400401152b7f424a26caf25ab7a6756a Mon Sep 17 00:00:00 2001 From: wartori Date: Sun, 14 Dec 2025 22:56:17 +0100 Subject: [PATCH 31/31] Make `vTex.Reload(); vTex.Unload();` deterministic --- Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs index 3c56cde3e..5967e4a76 100644 --- a/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs +++ b/Celeste.Mod.mm/Patches/Monocle/VirtualTexture.cs @@ -238,6 +238,8 @@ public Texture2D? Texture_Safe { 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 @@ -338,8 +340,6 @@ private void RunSafely(QueuedLoad ql, bool wait = false) { if (wait) { t.Wait(); } - - return; } // Make sure that MainThreadHelper.IsMainThread == true implies LoadImmediately == true @@ -420,6 +420,7 @@ private void InnerReload(bool block = false) { /// /// 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; @@ -460,6 +461,7 @@ private void Reload(bool block, bool isLazy = false) { _reloadInProgress = false; Monitor.Exit(_reloadLock); } + _justReloaded = false; } /// @@ -485,6 +487,13 @@ internal override void Unload() { /// 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) {