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.