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