Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions EmoTracker.Data/Sessions/PackageInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,24 @@ public TrackerState GetState(Guid stateId)
return mStates.TryGetValue(stateId, out var state) ? state : null;
}

/// <summary>
/// Transfers <paramref name="state"/> from this PackageInstance to
/// <paramref name="destination"/> without firing lifecycle events or
/// disposing anything. Used by <c>ApplicationModel.LoadProgress</c>
/// 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.
/// </summary>
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.
Expand Down
32 changes: 32 additions & 0 deletions EmoTracker.Data/Sessions/PackageLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ public PackageLoadEventArgs(TrackerState target, IGamePackage package, IGamePack
[ThreadStatic]
static bool mInProgress;

/// <summary>
/// True while <see cref="LoadInto"/> is executing on this thread.
/// Used by <see cref="ApplicationSettings.SyncSeedsFromSession"/> to
/// suppress seed-writes driven by pack-script (init.lua) changes,
/// so only explicit user UI changes update the saved defaults.
/// </summary>
internal static bool IsLoading => mInProgress;

/// <summary>
/// Loads <paramref name="package"/> (with optional <paramref name="variant"/>)
/// into <paramref name="target"/>'s catalogs. Caller is responsible for
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 12 additions & 10 deletions EmoTracker.UI/Media/Utility/IconUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IImage, (bool[] mask, int w, int h)> 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<IImage, AlphaMaskEntry> 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<IImage, byte[]> 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<IImage, byte[]> sPngCache = new();

// HTTP/HTTPS image download cache. null value means "download in progress".
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, IImage?> sHttpCache = new();
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
20 changes: 20 additions & 0 deletions EmoTracker/ApplicationModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<double>("main_window_width", WindowService.Instance.MainWindowWidth);
Expand All @@ -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.
Expand Down
Loading