Fix bitmap memory leaks, load concurrency, money clamp

Bitmap disposal (6 files):
- SlotModel.SetImage: dispose old Avalonia Bitmap + input SKBitmap
- PKMEditorVM: dispose old SpriteImage, LegalityImage, BallSprite
  and intermediate SKBitmaps on every update
- SAVEditorVM: dispose old BoxWallpaper + SKBitmap on box navigation
- WondercardVM: dispose old GiftSlotModel.Sprite on refresh
- QRDialogVM: dispose intermediate SKBitmaps during QR generation
- Added ToAvaloniaBitmapAndDispose helper for owned SKBitmap conversion

Concurrency:
- MainWindowVM: add _isLoading guard to prevent concurrent LoadFileAsync
  calls from drag-drop or rapid Open clicks

Money clamp:
- Trainer8/8a/8b/9/9a: clamp Money to sav.MaxMoney on save
  (was allowing values exceeding game maximums)
This commit is contained in:
montanon 2026-03-17 13:26:46 -03:00
parent ae206b80a0
commit 807007ad8a
12 changed files with 73 additions and 10 deletions

View File

@ -64,7 +64,10 @@ public partial class SlotModel : ObservableObject
public void SetImage(SKBitmap? skBitmap)
{
var old = Image;
Image = SKBitmapToAvaloniaBitmapConverter.ToAvaloniaBitmap(skBitmap);
old?.Dispose();
skBitmap?.Dispose();
IsEmpty = skBitmap is null;
}
}

View File

@ -46,4 +46,22 @@ public class SKBitmapToAvaloniaBitmapConverter : IValueConverter
stream.Position = 0;
return new Bitmap(stream);
}
/// <summary>
/// Converts an SKBitmap to an Avalonia Bitmap and disposes the input SKBitmap.
/// Use this when the caller owns the SKBitmap and will not reuse it after conversion.
/// </summary>
public static Bitmap? ToAvaloniaBitmapAndDispose(SKBitmap? skBitmap)
{
if (skBitmap is null)
return null;
try
{
return ToAvaloniaBitmap(skBitmap);
}
finally
{
skBitmap.Dispose();
}
}
}

View File

@ -27,6 +27,11 @@ public partial class MainWindowViewModel : ObservableObject
{
private readonly IDialogService _dialogService;
/// <summary>
/// Guards against concurrent invocations of <see cref="LoadFileAsync"/>.
/// </summary>
private bool _isLoading;
/// <summary>
/// The file path from which the current save file was loaded.
/// Used to create an automatic backup before overwriting.
@ -345,6 +350,9 @@ private async Task DumpAllBoxesAsync()
public async Task LoadFileAsync(string path)
{
if (_isLoading)
return;
_isLoading = true;
try
{
var data = await File.ReadAllBytesAsync(path);
@ -370,6 +378,10 @@ public async Task LoadFileAsync(string path)
{
await _dialogService.ShowErrorAsync("Load Error", ex.Message);
}
finally
{
_isLoading = false;
}
}
private void LoadSaveFile(SaveFile sav, string path)
@ -937,7 +949,7 @@ private async Task OpenQRDialogAsync()
public void HandleFileDrop(string[] files)
{
if (files.Length == 0)
if (files.Length == 0 || _isLoading)
return;
_ = LoadFileAsync(files[0]);

View File

@ -994,11 +994,15 @@ private void UpdateBallSprite()
try
{
var skBitmap = SpriteUtil.GetBallSprite(Ball);
BallSprite = SKBitmapToAvaloniaBitmapConverter.ToAvaloniaBitmap(skBitmap);
var old = BallSprite;
BallSprite = SKBitmapToAvaloniaBitmapConverter.ToAvaloniaBitmapAndDispose(skBitmap);
old?.Dispose();
}
catch
{
var old = BallSprite;
BallSprite = null;
old?.Dispose();
}
}
@ -2855,7 +2859,9 @@ private void UpdateLegality()
{
if (Entity is null)
{
var old = LegalityImage;
LegalityImage = null;
old?.Dispose();
Move1Legal = Move2Legal = Move3Legal = Move4Legal = true;
Relearn1Legal = Relearn2Legal = Relearn3Legal = Relearn4Legal = true;
return;
@ -2868,7 +2874,13 @@ private void UpdateLegality()
var color = valid ? SKColors.Green : SKColors.Red;
using var surface = SKSurface.Create(new SKImageInfo(24, 24));
if (surface is null) { LegalityImage = null; return; }
if (surface is null)
{
var old2 = LegalityImage;
LegalityImage = null;
old2?.Dispose();
return;
}
var canvas = surface.Canvas;
canvas.Clear(SKColors.Transparent);
using var paint = new SKPaint { Color = color, IsAntialias = true };
@ -2877,7 +2889,9 @@ private void UpdateLegality()
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
using var ms = new MemoryStream(data.ToArray());
var oldLegality = LegalityImage;
LegalityImage = new Bitmap(ms);
oldLegality?.Dispose();
// Move legality
var moves = la.Info.Moves;
@ -2909,7 +2923,9 @@ private void UpdateLegality()
}
catch
{
var old = LegalityImage;
LegalityImage = null;
old?.Dispose();
Move1Legal = Move2Legal = Move3Legal = Move4Legal = true;
Relearn1Legal = Relearn2Legal = Relearn3Legal = Relearn4Legal = true;
}
@ -2921,6 +2937,9 @@ private void UpdateSprite()
return;
var sprite = Entity.Sprite();
var old = SpriteImage;
SpriteImage = SKBitmapToAvaloniaBitmapConverter.ToAvaloniaBitmap(sprite);
old?.Dispose();
sprite.Dispose();
}
}

View File

@ -303,11 +303,15 @@ private void RefreshBox()
try
{
var wpBitmap = _sav.WallpaperImage(CurrentBox);
BoxWallpaper = SKBitmapToAvaloniaBitmapConverter.ToAvaloniaBitmap(wpBitmap);
var oldWp = BoxWallpaper;
BoxWallpaper = SKBitmapToAvaloniaBitmapConverter.ToAvaloniaBitmapAndDispose(wpBitmap);
oldWp?.Dispose();
}
catch
{
var oldWp = BoxWallpaper;
BoxWallpaper = null;
oldWp?.Dispose();
}
int slotCount = Math.Min(30, _sav.BoxSlotCount);

View File

@ -34,9 +34,14 @@ private void GenerateQR(PKM pk)
var qr = QREncode.GenerateQRCode(pk);
var sprite = pk.Sprite();
var composed = QRImageUtil.GetQRImage(qr, sprite);
qr.Dispose();
sprite.Dispose();
_qrBitmap?.Dispose();
_qrBitmap = composed;
var old = QrImage;
QrImage = SKBitmapToAvaloniaBitmapConverter.ToAvaloniaBitmap(composed);
old?.Dispose();
var lines = pk.GetQRLines();
SummaryText = string.Join("\n", lines);

View File

@ -119,7 +119,7 @@ private void Save()
_sav.Blocks.TrainerCard.TrainerID = int.TryParse(TrainerCardId, out var tcid) ? tcid : 0;
_sav.Blocks.TrainerCard.RotoRallyScore = int.TryParse(RotoRallyScore, out var rr) ? rr : 0;
_sav.Money = uint.TryParse(Money, out var money) ? money : 0u;
_sav.Money = uint.TryParse(Money, out var money) ? (uint)Math.Min(money, (uint)_sav.MaxMoney) : 0u;
var watt = uint.TryParse(Watt, out var w) ? w : 0u;
_sav.MyStatus.Watt = watt;

View File

@ -65,7 +65,7 @@ private void Save()
{
_sav.Gender = (byte)Gender;
_sav.OT = OtName;
_sav.Money = uint.TryParse(Money, out var money) ? money : 0u;
_sav.Money = uint.TryParse(Money, out var money) ? (uint)Math.Min(money, (uint)_sav.MaxMoney) : 0u;
_sav.PlayedHours = (ushort)Math.Clamp(PlayedHours, 0, ushort.MaxValue);
_sav.PlayedMinutes = (ushort)Math.Clamp(PlayedMinutes, 0, 59);

View File

@ -76,7 +76,7 @@ private void Save()
_sav.Gender = (byte)Gender;
_sav.OT = OtName;
_sav.Rival = Rival;
_sav.Money = uint.TryParse(Money, out var money) ? money : 0u;
_sav.Money = uint.TryParse(Money, out var money) ? (uint)Math.Min(money, (uint)_sav.MaxMoney) : 0u;
_sav.BattleTower.BP = (uint)Math.Max(0, Bp);
_sav.PlayedHours = (ushort)Math.Clamp(PlayedHours, 0, ushort.MaxValue);

View File

@ -182,7 +182,7 @@ private void Save()
_sav.Version = (GameVersion)(GameIndex + (byte)GameVersion.SL);
_sav.Gender = (byte)Gender;
_sav.OT = OtName;
_sav.Money = uint.TryParse(Money, out var m) ? m : 0u;
_sav.Money = uint.TryParse(Money, out var m) ? (uint)Math.Min(m, (uint)_sav.MaxMoney) : 0u;
_sav.LeaguePoints = uint.TryParse(LeaguePoints, out var lp) ? lp : 0u;
_sav.PlayedHours = (ushort)Math.Clamp(PlayedHours, 0, ushort.MaxValue);

View File

@ -113,7 +113,7 @@ private void Save()
}
_sav.OT = OtName;
_sav.Money = uint.TryParse(Money, out var m) ? m : 0u;
_sav.Money = uint.TryParse(Money, out var m) ? (uint)Math.Min(m, (uint)_sav.MaxMoney) : 0u;
_sav.PlayedHours = (ushort)Math.Clamp(PlayedHours, 0, ushort.MaxValue);
_sav.PlayedMinutes = (ushort)Math.Clamp(PlayedMinutes, 0, 59);

View File

@ -37,7 +37,9 @@ public void Refresh()
IsEmpty = Gift.IsEmpty;
Description = IsEmpty ? $"Slot {Index + 1}: (empty)" : $"Slot {Index + 1}: {Gift.CardHeader}";
var skBitmap = Gift.Sprite();
Sprite = SKBitmapToAvaloniaBitmapConverter.ToAvaloniaBitmap(skBitmap);
var old = Sprite;
Sprite = SKBitmapToAvaloniaBitmapConverter.ToAvaloniaBitmapAndDispose(skBitmap);
old?.Dispose();
}
public void SetGift(DataMysteryGift gift)