From ef6db8cb5b6f2c1483dc1053410a56a2e6591a05 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 10 May 2026 12:25:19 -0700 Subject: [PATCH 1/2] Fix memory leak when opening a save or reloading a pack (#84) Two distinct leaks caused memory to grow ~600 MB per save-open and ~250 MB per reload for large packs: 1. LoadProgress leak (~600 MB per open): TrackerState.LoadProgress() creates a fresh PackageInstance for the save's (pack, variant) but the old PI was never removed from ApplicationModel.mPackageInstances or disposed, so its image caches, Lua interpreter, and definitional TrackerState accumulated indefinitely. Fix: - Add PackageInstance.MigrateStateTo() to transfer a state between PIs without lifecycle events or disposal. - In ApplicationModel.LoadProgress(), detect when the PI changed, migrate the state to the new PI, swap mPackageInstances, and dispose the old PI (which frees its image caches and definitional state). 2. Reload leak (~250 MB per reload): PackageLoader.LoadInto() resets the catalogs (Items, Locations, ...) but left PackageInstance.ImageCache and SourceImageCache intact. Old ImageReference keys from the reset catalogs kept decoded SKBitmaps and IImages alive across every Reload. Fix: call FlushImageCaches() after the catalog resets so stale decoded images are released before the new load populates them. 3. Static IconUtility caches (secondary, compounding): sPngCache and sAlphaMasks used ConcurrentDictionary with strong references to IImage keys, so Avalonia Bitmaps could never be GC'd even after the PI's ImageCache was cleared. Fix: switch both to ConditionalWeakTable so entries are evicted automatically when the IImage key becomes unreachable. Closes #84 Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker.Data/Sessions/PackageInstance.cs | 18 +++++++++++++ EmoTracker.Data/Sessions/PackageLoader.cs | 30 +++++++++++++++++++++ EmoTracker.UI/Media/Utility/IconUtility.cs | 22 ++++++++------- EmoTracker/ApplicationModel.cs | 20 ++++++++++++++ 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/EmoTracker.Data/Sessions/PackageInstance.cs b/EmoTracker.Data/Sessions/PackageInstance.cs index 35e9fec..fc8a39f 100644 --- a/EmoTracker.Data/Sessions/PackageInstance.cs +++ b/EmoTracker.Data/Sessions/PackageInstance.cs @@ -189,6 +189,24 @@ public TrackerState GetState(Guid stateId) return mStates.TryGetValue(stateId, out var state) ? state : null; } + /// + /// Transfers from this PackageInstance to + /// without firing lifecycle events or + /// disposing anything. Used by ApplicationModel.LoadProgress + /// when a save-file load creates a new PI: the existing primary state + /// is moved to the new PI so the old PI can be safely disposed without + /// touching the still-live state. + /// + internal void MigrateStateTo(TrackerState state, PackageInstance destination) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + if (destination == null) throw new ArgumentNullException(nameof(destination)); + if (mStates.Remove(state.Id)) + destination.mStates[state.Id] = state; + // state.PackageInstance is already pointing at destination — + // TrackerState.LoadProgress set it before calling LoadInto. + } + public override void Dispose() { // Tear down the live states first, then the definitional state. diff --git a/EmoTracker.Data/Sessions/PackageLoader.cs b/EmoTracker.Data/Sessions/PackageLoader.cs index a633562..deed6ce 100644 --- a/EmoTracker.Data/Sessions/PackageLoader.cs +++ b/EmoTracker.Data/Sessions/PackageLoader.cs @@ -75,6 +75,14 @@ public PackageLoadEventArgs(TrackerState target, IGamePackage package, IGamePack [ThreadStatic] static bool mInProgress; + /// + /// True while is executing on this thread. + /// Used by to + /// suppress seed-writes driven by pack-script (init.lua) changes, + /// so only explicit user UI changes update the saved defaults. + /// + internal static bool IsLoading => mInProgress; + /// /// Loads (with optional ) /// into 's catalogs. Caller is responsible for @@ -126,6 +134,13 @@ public static void LoadInto( target.Items.Reset(); target.Scripts.Reset(); + // Flush the PackageInstance's decoded-image caches. Old + // ImageReference keys from the now-reset catalogs would + // otherwise keep both the source SKBitmaps and the resolved + // IImages alive across every Reload, accumulating ~250 MB per + // reload for large packs. + FlushImageCaches(target.PackageInstance); + ApplicationColors.Instance.LoadColors(); if (package != null) @@ -196,6 +211,21 @@ public static void LoadInto( } } + // Dispose and clear both image caches on a PackageInstance. Called + // before each load so stale SKBitmaps and resolved IImages from the + // previous load are released rather than accumulated in memory. + static void FlushImageCaches(PackageInstance pi) + { + if (pi == null) return; + lock (pi.SourceImageCacheLock) + { + foreach (var kvp in pi.SourceImageCache) + (kvp.Value as IDisposable)?.Dispose(); + pi.SourceImageCache.Clear(); + } + pi.ImageCache.Clear(); + } + // Phase 7.1.g: pack-driven UI flags now live on the per-state // TrackerState. Reset them at the start of each load to defaults // so a pack reload picks up the pack's settings.json fresh. diff --git a/EmoTracker.UI/Media/Utility/IconUtility.cs b/EmoTracker.UI/Media/Utility/IconUtility.cs index cef2e2f..70f1a1d 100644 --- a/EmoTracker.UI/Media/Utility/IconUtility.cs +++ b/EmoTracker.UI/Media/Utility/IconUtility.cs @@ -48,17 +48,19 @@ public static string[] GetArgs(string filterCommand) // ── Avalonia / SkiaSharp image pipeline ────────────────────────────────── // Alpha masks keyed by IImage: bool[] of length (width * height), true = opaque. - // ConcurrentDictionary because the background image worker writes masks while - // the UI thread reads them (InputMaskingImage.HitTest via GetAlphaMask). - private static readonly System.Collections.Concurrent.ConcurrentDictionary sAlphaMasks = new(); + // ConditionalWeakTable so entries are freed automatically when the IImage key + // becomes unreachable (e.g. after a pack reload clears PackageInstance.ImageCache). + // All public CWT members are thread-safe. + private sealed class AlphaMaskEntry { public bool[] Pixels; public int Width, Height; } + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable sAlphaMasks = new(); // Cached Skia-encoded PNG bytes for each IImage produced by SkToAvalonia. // Used by ToSkBitmap to bypass Avalonia's Bitmap.Save(Stream), which may strip the // alpha channel on some platforms — causing overlay compositing to treat every pixel // as fully opaque and completely hide the base layer. - // ConcurrentDictionary because the background image worker writes entries while - // filter resolution may read them concurrently. - private static readonly System.Collections.Concurrent.ConcurrentDictionary sPngCache = new(); + // ConditionalWeakTable so entries are freed automatically when the IImage key + // becomes unreachable, preventing static accumulation across pack reloads. + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable sPngCache = new(); // HTTP/HTTPS image download cache. null value means "download in progress". private static readonly System.Collections.Concurrent.ConcurrentDictionary sHttpCache = new(); @@ -74,7 +76,7 @@ public static string[] GetArgs(string filterCommand) public static (bool[] mask, int w, int h)? GetAlphaMask(IImage image) { if (image != null && sAlphaMasks.TryGetValue(image, out var entry)) - return entry; + return (entry.Pixels, entry.Width, entry.Height); return null; } @@ -231,7 +233,7 @@ internal static IImage FinalizeToAvalonia(SKBitmap bmp) { var mask = ComputeAlphaMask(bmp); var avBitmap = SkToAvaloniaCore(bmp); - sAlphaMasks[avBitmap] = (mask, bmp.Width, bmp.Height); + sAlphaMasks.AddOrUpdate(avBitmap, new AlphaMaskEntry { Pixels = mask, Width = bmp.Width, Height = bmp.Height }); bmp.Dispose(); return avBitmap; } @@ -250,7 +252,7 @@ private static IImage SkToAvalonia(SKBitmap bmp, bool storeMask = false) if (storeMask) { var mask = ComputeAlphaMask(bmp); - sAlphaMasks[avBitmap] = (mask, bmp.Width, bmp.Height); + sAlphaMasks.AddOrUpdate(avBitmap, new AlphaMaskEntry { Pixels = mask, Width = bmp.Width, Height = bmp.Height }); } return avBitmap; @@ -267,7 +269,7 @@ private static Avalonia.Media.Imaging.Bitmap SkToAvaloniaCore(SKBitmap bmp) byte[] pngBytes = encoded.ToArray(); var avBitmap = new Avalonia.Media.Imaging.Bitmap(new MemoryStream(pngBytes)); - sPngCache[avBitmap] = pngBytes; + sPngCache.AddOrUpdate(avBitmap, pngBytes); return avBitmap; } diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index f71db36..0ac8352 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -1549,6 +1549,11 @@ public bool LoadProgress(string path) var target = PrimaryState; if (target == null) return false; + // Snapshot the PI before the load. TrackerState.LoadProgress creates + // a brand-new PackageInstance for the save's (pack, variant), leaving + // the old one stranded in mPackageInstances and never disposed. + var oldPi = target.PackageInstance; + if (target.LoadProgress(path, (JObject root) => { WindowService.Instance.MainWindowWidth = root.GetValue("main_window_width", WindowService.Instance.MainWindowWidth); @@ -1566,6 +1571,21 @@ public bool LoadProgress(string path) } })) { + // If LoadProgress swapped to a new PI, transfer the state so + // the old PI can be cleanly disposed (freeing its image caches, + // definitional state, and Lua interpreter — ~600 MB for large packs). + var newPi = target.PackageInstance; + if (!ReferenceEquals(oldPi, newPi)) + { + oldPi?.MigrateStateTo(target, newPi); + mPackageInstances.Add(newPi); + if (ReferenceEquals(mActivePackageInstance, oldPi)) + ActivePackageInstance = newPi; + mPackageInstances.Remove(oldPi); + if (oldPi != null && oldPi.States.Count == 0) + oldPi.Dispose(); + } + AcquireLayouts(); // Phase 7.11 polish: a freshly-loaded state isn't dirty // — clear the modified marker for the active state. From 1a1254f18cb3ae0994d1d288fbdfa762088540ba Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 10 May 2026 13:01:10 -0700 Subject: [PATCH 2/2] Dispose Avalonia Bitmap objects in ImageCache on flush, not just clear pi.ImageCache.Clear() released the cache references but left the Avalonia Bitmap objects waiting on the finalizer queue, causing unmanaged bitmap memory to accumulate across reloads until GC eventually ran. Explicitly disposing each IImage before clearing mirrors the existing SKBitmap treatment in SourceImageCache and lets GC reclaim the memory promptly. Co-Authored-By: Claude Sonnet 4.6 --- EmoTracker.Data/Sessions/PackageLoader.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EmoTracker.Data/Sessions/PackageLoader.cs b/EmoTracker.Data/Sessions/PackageLoader.cs index deed6ce..f5ce495 100644 --- a/EmoTracker.Data/Sessions/PackageLoader.cs +++ b/EmoTracker.Data/Sessions/PackageLoader.cs @@ -223,6 +223,8 @@ static void FlushImageCaches(PackageInstance pi) (kvp.Value as IDisposable)?.Dispose(); pi.SourceImageCache.Clear(); } + foreach (var kvp in pi.ImageCache) + (kvp.Value as IDisposable)?.Dispose(); pi.ImageCache.Clear(); }