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..f5ce495 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,23 @@ 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(); + } + foreach (var kvp in pi.ImageCache) + (kvp.Value as IDisposable)?.Dispose(); + 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.