From 57ed126d6cd2a1ac116cdc8df080388731f5aee7 Mon Sep 17 00:00:00 2001 From: Torje Amundsen Date: Mon, 27 Apr 2026 23:41:09 +0200 Subject: [PATCH 01/12] fix(capture): atomic state swap to fix torn-ref race UI thread wrote refPixels, refMask, required, crop as separate fields; capture loop read them unsynchronized and could observe a new refPixels paired with an old refMask of different size, causing index-out-of-range or wrong similarity scores. - Pack the four fields into an immutable CaptureState record and swap it via Interlocked.CompareExchange so the pair is always consistent. - _highest max-update via CAS; resets via Interlocked.Exchange. - _active read/written with Volatile to ensure capture loop sees source swaps. - Length-mismatch guard before L2NormComparer.Compare as a belt-and-suspenders against any state slipping through. --- AutoMask/Capture/CaptureController.cs | 92 ++++++++++++++++++--------- 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/AutoMask/Capture/CaptureController.cs b/AutoMask/Capture/CaptureController.cs index fcd02f1..17232fc 100644 --- a/AutoMask/Capture/CaptureController.cs +++ b/AutoMask/Capture/CaptureController.cs @@ -12,6 +12,15 @@ public sealed class CaptureController : IAsyncDisposable public readonly record struct CropRect(int X, int Y, int W, int H); + // Immutable snapshot of all fields that the UI thread writes and the capture loop reads. + // Swapped as a single reference so the loop can never observe a torn (refPixels, refMask) + // pair from different reference images. + private sealed record CaptureState( + byte[]? RefPixels, + byte[]? RefMask, + double Required, + CropRect Crop); + // Pixel buffer is BGRA, tightly packed, CompareWidth * CompareHeight * 4 bytes. public event Action? FrameReady; public event Action? ErrorReported; @@ -23,42 +32,53 @@ public sealed class CaptureController : IAsyncDisposable private ICaptureSource? _active; - private byte[]? _refPixels; - private byte[]? _refMask; - private double _required; - - private CropRect _crop; + private CaptureState _state = new(null, null, 0.0, new CropRect(0, 0, 0, 0)); private double _highest; private int _uiPostPending; public void UpdateReference(byte[]? refPixels, byte[]? refMask, double required) { - _refPixels = refPixels; - _refMask = refMask; - _required = required; - _highest = 0.0; + UpdateState(s => s with { RefPixels = refPixels, RefMask = refMask, Required = required }); + Interlocked.Exchange(ref _highest, 0.0); + } + + public void UpdateCrop(CropRect rect) + { + UpdateState(s => s with { Crop = rect }); } - public void UpdateCrop(CropRect rect) => _crop = rect; + public void ResetHighest() => Interlocked.Exchange(ref _highest, 0.0); - public void ResetHighest() => _highest = 0.0; + private void UpdateState(Func mutate) + { + while (true) + { + var current = Volatile.Read(ref _state); + var next = mutate(current); + if (Interlocked.CompareExchange(ref _state, next, current) == current) + { + return; + } + } + } public async Task SetSourceAsync(ICaptureSource? newSource, CancellationToken ct) { await _swapLock.WaitAsync(ct); try { - if (_active is not null) + var current = Volatile.Read(ref _active); + if (current is not null) { - try { await _active.StopAsync(); } catch { /* ignore */ } - try { await _active.DisposeAsync(); } catch { /* ignore */ } - _active = null; + Volatile.Write(ref _active, null); + try { await current.StopAsync(); } catch { /* ignore */ } + try { await current.DisposeAsync(); } catch { /* ignore */ } } if (newSource is not null) { await newSource.StartAsync(ct); - _active = newSource; + Volatile.Write(ref _active, newSource); } } finally @@ -101,11 +121,12 @@ private void Loop(CancellationToken ct) while (!ct.IsCancellationRequested) { - ICaptureSource? src = _active; - byte[]? refPixels = _refPixels; - byte[]? refMask = _refMask; - double required = _required; - CropRect crop = _crop; + ICaptureSource? src = Volatile.Read(ref _active); + CaptureState state = Volatile.Read(ref _state); + byte[]? refPixels = state.RefPixels; + byte[]? refMask = state.RefMask; + double required = state.Required; + CropRect crop = state.Crop; if (src is null) { @@ -129,21 +150,18 @@ private void Loop(CancellationToken ct) } double cur = 0; - double high = _highest; + double high = Volatile.Read(ref _highest); byte[] livePixels = ReadBgraBytes(scaled); - if (refPixels is not null && refMask is not null) + if (refPixels is not null && refMask is not null + && refPixels.Length == livePixels.Length + && refMask.Length * 4 == livePixels.Length) { double similarity = Comparison.L2NormComparer.Compare(refPixels, refMask, livePixels); - if (similarity > _highest) - { - _highest = similarity; - } - + high = UpdateHighest(similarity); cur = similarity; - high = _highest; } if (Interlocked.CompareExchange(ref _uiPostPending, 1, 0) == 0) @@ -221,6 +239,22 @@ private void Loop(CancellationToken ct) new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None)); } + private double UpdateHighest(double similarity) + { + while (true) + { + double current = Volatile.Read(ref _highest); + if (similarity <= current) + { + return current; + } + if (Interlocked.CompareExchange(ref _highest, similarity, current) == current) + { + return similarity; + } + } + } + private static byte[] ReadBgraBytes(SKBitmap bitmap) { int byteCount = bitmap.ByteCount; From d290f68f2d8fdd54b31709e43458c71ad91d7b47 Mon Sep 17 00:00:00 2001 From: Torje Amundsen Date: Mon, 27 Apr 2026 23:44:46 +0200 Subject: [PATCH 02/12] fix(presets): prevent mask/savestate name collision on save Two splits referencing different external files with the same filename overwrote each other in the target folder, and an external copy could clobber an internal mask of the same name when processed first. Pre-pass reserves filenames already locked in by inside-folder splits; ReserveUniqueName disambiguates copies with ' (n)' suffixes. --- AutoMask/PresetService.cs | 70 +++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/AutoMask/PresetService.cs b/AutoMask/PresetService.cs index 2a120f8..34e2ad4 100644 --- a/AutoMask/PresetService.cs +++ b/AutoMask/PresetService.cs @@ -107,6 +107,30 @@ public static string BuildFilename(string name, int splitIndex, int totalSplits, return output + ".png"; } + /// + /// Returns a filename that is not yet present in , using + /// "name (1).ext", "name (2).ext", ... if the preferred name is already taken. + /// The chosen name is added to the set so subsequent calls won't pick it. + /// + private static string ReserveUniqueName(string preferred, HashSet used) + { + if (used.Add(preferred)) + { + return preferred; + } + + string ext = Path.GetExtension(preferred); + string baseName = Path.GetFileNameWithoutExtension(preferred); + for (int i = 1; ; i++) + { + string candidate = $"{baseName} ({i}){ext}"; + if (used.Add(candidate)) + { + return candidate; + } + } + } + /// /// Replaces spaces with underscores and strips characters that are invalid in directory names. /// @@ -138,6 +162,40 @@ internal static async Task SavePresetToFolderAsync(EditablePreset preset, string var splitRelPaths = new List(); var splitSavestateRelPaths = new List(); + // Pre-pass: reserve filenames already locked in by splits whose mask/savestate is + // already inside the target folder. Two splits referencing different external files + // that share a filename would otherwise overwrite each other, and an external copy + // could overwrite an internal mask of the same name when processed first. + var usedMaskNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var usedSavestateNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var split in preset.Splits) + { + if (!string.IsNullOrEmpty(split.MaskAbsolutePath)) + { + string maskFull = Path.GetFullPath(split.MaskAbsolutePath); + if (maskFull.StartsWith(targetFolderPrefix, StringComparison.OrdinalIgnoreCase)) + { + string rel = Path.GetRelativePath(targetFolderFull, maskFull); + // Only top-level mask filenames can collide with copy destinations + // (which always land at the target folder root). + if (!rel.Contains(Path.DirectorySeparatorChar) && !rel.Contains(Path.AltDirectorySeparatorChar)) + { + usedMaskNames.Add(rel); + } + } + } + + if (!string.IsNullOrEmpty(split.SavestateAbsolutePath)) + { + string savestateFull = Path.GetFullPath(split.SavestateAbsolutePath); + if (savestateFull.StartsWith(savestatesPrefix, StringComparison.OrdinalIgnoreCase)) + { + usedSavestateNames.Add(Path.GetFileName(savestateFull)); + } + } + } + foreach (var split in preset.Splits) { if (string.IsNullOrEmpty(split.MaskAbsolutePath)) @@ -154,7 +212,7 @@ internal static async Task SavePresetToFolderAsync(EditablePreset preset, string } else { - string destFilename = Path.GetFileName(maskFull); + string destFilename = ReserveUniqueName(Path.GetFileName(maskFull), usedMaskNames); string destPath = Path.Combine(targetFolderFull, destFilename); File.Copy(maskFull, destPath, overwrite: true); // Update the model so subsequent saves treat this file as already in place @@ -169,18 +227,18 @@ internal static async Task SavePresetToFolderAsync(EditablePreset preset, string continue; } - string savestateFull = Path.GetFullPath(split.SavestateAbsolutePath); + string savestateFullPath = Path.GetFullPath(split.SavestateAbsolutePath); - if (savestateFull.StartsWith(savestatesPrefix, StringComparison.OrdinalIgnoreCase)) + if (savestateFullPath.StartsWith(savestatesPrefix, StringComparison.OrdinalIgnoreCase)) { - splitSavestateRelPaths.Add(Path.GetRelativePath(targetFolderFull, savestateFull)); + splitSavestateRelPaths.Add(Path.GetRelativePath(targetFolderFull, savestateFullPath)); } else { Directory.CreateDirectory(savestatesFolder); - string destFilename = Path.GetFileName(savestateFull); + string destFilename = ReserveUniqueName(Path.GetFileName(savestateFullPath), usedSavestateNames); string destPath = Path.Combine(savestatesFolder, destFilename); - File.Copy(savestateFull, destPath, overwrite: true); + File.Copy(savestateFullPath, destPath, overwrite: true); split.SavestateAbsolutePath = destPath; splitSavestateRelPaths.Add(Path.Combine("savestates", destFilename)); } From 3912962e780dfc48ab901d8923dcb5a152e48e89 Mon Sep 17 00:00:00 2001 From: Torje Amundsen Date: Mon, 27 Apr 2026 23:47:25 +0200 Subject: [PATCH 03/12] perf(image): cache decoded masks across calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApplyScaledAlphaChannel looked up alphaPath in the supplied cache but never wrote back, so every call re-decoded the PNG. Cache populated on miss under a lock so concurrent Task.Run callers can't double-decode and leak. PresetEditor's _previewMaskCache started empty and was the main victim — every threshold/preview tweak hit the disk. MainWindow's pre-populated cache continues to hit immediately; on-demand misses now cache too. --- AutoMask/ImageProcessor.cs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/AutoMask/ImageProcessor.cs b/AutoMask/ImageProcessor.cs index b6e12b2..34b1237 100644 --- a/AutoMask/ImageProcessor.cs +++ b/AutoMask/ImageProcessor.cs @@ -11,10 +11,21 @@ public static SKBitmap ApplyScaledAlphaChannel(string inputPath, string alphaPat { using var inputBitmap = SKBitmap.Decode(inputPath); - bool ownAlpha = !maskCache.TryGetValue(alphaPath, out var alphaBitmap); - alphaBitmap ??= SKBitmap.Decode(alphaPath); + SKBitmap alphaBitmap; + // Decode under lock so concurrent callers (multiple Task.Run consumers) can't both + // decode the same mask and leak one. Dictionary<,> is not thread-safe to read during + // a writer either, so the lookup must also be inside the lock. + lock (maskCache) + { + if (!maskCache.TryGetValue(alphaPath, out var cached)) + { + cached = SKBitmap.Decode(alphaPath); + maskCache[alphaPath] = cached; + } + alphaBitmap = cached; + } - using var scaledAlpha = alphaBitmap!.Resize( + using var scaledAlpha = alphaBitmap.Resize( new SKImageInfo(inputBitmap.Width, inputBitmap.Height), new SKSamplingOptions(SKFilterMode.Linear))!; @@ -41,11 +52,6 @@ public static SKBitmap ApplyScaledAlphaChannel(string inputPath, string alphaPat outputBitmap.Pixels = outputPixels; - if (ownAlpha) - { - alphaBitmap.Dispose(); - } - return outputBitmap; } From 9dfc51594ff6773787e51de7eadf82e4260a0096 Mon Sep 17 00:00:00 2001 From: Torje Amundsen Date: Mon, 27 Apr 2026 23:50:46 +0200 Subject: [PATCH 04/12] fix(presets): write preset.json atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WriteAllTextAsync truncates the destination before writing, so a crash mid-write left preset.json empty or partial — next load deserialized to null and the preset silently vanished from the UI. Write to a sibling .tmp and rename via File.Move(overwrite: true); NTFS makes the rename atomic on the same volume. Stale-savestate cleanup also wrapped in try/catch per file: the json is already committed at that point, and a locked leftover file shouldn't fail the whole save. --- AutoMask/PresetService.cs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/AutoMask/PresetService.cs b/AutoMask/PresetService.cs index 34e2ad4..7b89b8f 100644 --- a/AutoMask/PresetService.cs +++ b/AutoMask/PresetService.cs @@ -306,7 +306,21 @@ internal static async Task SavePresetToFolderAsync(EditablePreset preset, string jsonObj["splits"] = splitsArray; string json = jsonObj.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); - await File.WriteAllTextAsync(Path.Combine(targetFolderFull, "preset.json"), json); + string finalPath = Path.Combine(targetFolderFull, "preset.json"); + // Write to a sibling temp file, then atomically replace. File.WriteAllTextAsync + // truncates the destination before writing, so a crash mid-write would leave + // preset.json empty or partial; a rename on the same volume is atomic on NTFS. + string tmpPath = finalPath + ".tmp"; + try + { + await File.WriteAllTextAsync(tmpPath, json); + File.Move(tmpPath, finalPath, overwrite: true); + } + catch + { + try { if (File.Exists(tmpPath)) { File.Delete(tmpPath); } } catch { /* best-effort */ } + throw; + } if (Directory.Exists(savestatesFolder)) { @@ -320,7 +334,9 @@ internal static async Task SavePresetToFolderAsync(EditablePreset preset, string { if (!referencedNames.Contains(Path.GetFileName(file))) { - File.Delete(file); + // Best-effort cleanup; preset.json is already committed and a stale + // savestate file is harmless on disk, so don't fail the whole save. + try { File.Delete(file); } catch { /* ignore */ } } } } From b28397a028b50f38eaa941ea8c3baf04b8918185 Mon Sep 17 00:00:00 2001 From: Torje Amundsen Date: Tue, 28 Apr 2026 00:17:47 +0200 Subject: [PATCH 05/12] perf(compare): vectorize L2 norm with AVX2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Process 8 pixels per iteration: 8 mask bytes broadcast 4× with PSHUFB, |diff| via paired SubtractSaturate+OR, PMADDWD to square and pair-sum into int32, widened to int64 each chunk to avoid overflow at the 9600 chunks of a 320×240 frame. Alpha bytes explicitly zeroed via a constant mask so the BGR-only sum matches the scalar formula even where ref/live alphas diverge in masked-out regions. Scalar fallback retained for non-AVX2 hosts and the n%8 tail. --- AutoMask/Comparison/L2NormComparer.cs | 119 ++++++++++++++++++++++++-- AutoMask/MainWindow.axaml.cs | 9 +- AutoMask/PresetEditor.axaml.cs | 10 ++- AutoMask/PresetService.cs | 66 ++++++++++---- 4 files changed, 176 insertions(+), 28 deletions(-) diff --git a/AutoMask/Comparison/L2NormComparer.cs b/AutoMask/Comparison/L2NormComparer.cs index 91d9fe7..3152c74 100644 --- a/AutoMask/Comparison/L2NormComparer.cs +++ b/AutoMask/Comparison/L2NormComparer.cs @@ -1,3 +1,7 @@ +using System.Numerics; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; + namespace AutoSplit_AutoMask.Comparison; public static class L2NormComparer @@ -10,35 +14,132 @@ public static class L2NormComparer // Comparison is symmetric under channel permutation, so BGRA and RGBA produce identical results. public static double Compare(byte[] refPixels, byte[] refMask, byte[] livePixels) { - if (refMask.Length == 0) + int n = refMask.Length; + if (n == 0) + { + return 0.0; + } + + long sumSq; + int maskCount; + + // The AVX2 fast path consumes 8 mask bytes / 32 pixel bytes per iteration. Capture + // sizes (320×240 = 76800 mask bytes, divisible by 8) hit this exclusively in practice. + if (Avx2.IsSupported && Ssse3.IsSupported && n >= 8 + && refPixels.Length >= n * 4 && livePixels.Length >= n * 4) + { + (sumSq, maskCount) = CompareAvx2(refPixels, refMask, livePixels); + } + else + { + (sumSq, maskCount) = CompareScalar(refPixels, refMask, livePixels, 0, n); + } + + if (maskCount == 0) { return 0.0; } + double error = Math.Sqrt((double)sumSq); + double maxError = Math.Sqrt(maskCount * 3.0 * 255.0 * 255.0); + return 1.0 - error / maxError; + } + + private static (long sumSq, int maskCount) CompareScalar( + byte[] refPixels, byte[] refMask, byte[] livePixels, int start, int end) + { long sumSq = 0; int maskCount = 0; - - for (int i = 0, px = 0; i < refMask.Length; i++, px += 4) + for (int i = start, px = start * 4; i < end; i++, px += 4) { if (refMask[i] == 0) { continue; } - maskCount++; int d0 = refPixels[px] - livePixels[px]; int d1 = refPixels[px + 1] - livePixels[px + 1]; int d2 = refPixels[px + 2] - livePixels[px + 2]; sumSq += d0 * d0 + d1 * d1 + d2 * d2; } + return (sumSq, maskCount); + } - if (maskCount == 0) + private static unsafe (long sumSq, int maskCount) CompareAvx2( + byte[] refPixels, byte[] refMask, byte[] livePixels) + { + int n = refMask.Length; + int simdEnd = n - (n % 8); + + // PSHUFB index vectors: replicate each of 8 source mask bytes by 4 to cover all 4 + // BGRA bytes of its pixel. PSHUFB operates inside each 128-bit lane, so the source + // half is duplicated below into both halves of a 128-bit register. + var lowExpand = Vector128.Create( + (byte)0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3); + var highExpand = Vector128.Create( + (byte)4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6, 7, 7, 7, 7); + // Zero alpha bytes so the L2 sum stays BGR-only, matching the scalar formula even + // when ref alpha differs from live alpha in masked-out regions. + var alphaKill = Vector256.Create( + (byte)0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x00, + 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x00, + 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x00, + 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x00); + + Vector256 sumVec = Vector256.Zero; + int maskCount = 0; + + fixed (byte* refP = refPixels, mP = refMask, livP = livePixels) { - return 0.0; + for (int i = 0; i < simdEnd; i += 8) + { + ulong m8 = *(ulong*)(mP + i); + // Mask bytes are exactly 0x00 or 0xFF, so 8 active bytes contribute 64 bits. + maskCount += BitOperations.PopCount(m8) >> 3; + + // Broadcast 8 mask bytes -> 32 bytes (one Vector256 covering 8 BGRA pixels). + var maskV128 = Vector128.Create(m8, m8).AsByte(); + var lowLane = Ssse3.Shuffle(maskV128, lowExpand); + var highLane = Ssse3.Shuffle(maskV128, highExpand); + var pixelActive = Vector256.Create(lowLane, highLane); + + var laneMask = Avx2.And(pixelActive, alphaKill); + + int px = i * 4; + var refV = Avx.LoadVector256(refP + px); + var livV = Avx.LoadVector256(livP + px); + + // |refV - livV| via two saturating subtractions OR'd together. + var sub1 = Avx2.SubtractSaturate(refV, livV); + var sub2 = Avx2.SubtractSaturate(livV, refV); + var diffAbs = Avx2.Or(sub1, sub2); + + var diffMasked = Avx2.And(diffAbs, laneMask); + + // Widen byte -> ushort (per 128-bit lane), then PMADDWD to square-and-sum + // adjacent pairs into 32-bit lanes. + var lo = Avx2.UnpackLow(diffMasked, Vector256.Zero).AsInt16(); + var hi = Avx2.UnpackHigh(diffMasked, Vector256.Zero).AsInt16(); + var sqLo = Avx2.MultiplyAddAdjacent(lo, lo); + var sqHi = Avx2.MultiplyAddAdjacent(hi, hi); + var perChunk = Avx2.Add(sqLo, sqHi); + + // Promote int -> long every iteration; an int accumulator overflows around + // ~1300 chunks (76800-pixel comparisons run 9600). + sumVec = Avx2.Add(sumVec, Avx2.ConvertToVector256Int64(perChunk.GetLower())); + sumVec = Avx2.Add(sumVec, Avx2.ConvertToVector256Int64(perChunk.GetUpper())); + } } - double error = Math.Sqrt((double)sumSq); - double maxError = Math.Sqrt(maskCount * 3.0 * 255.0 * 255.0); - return 1.0 - error / maxError; + long sumSq = Vector256.Sum(sumVec); + + if (simdEnd < n) + { + var (tailSum, tailCount) = CompareScalar(refPixels, refMask, livePixels, simdEnd, n); + sumSq += tailSum; + maskCount += tailCount; + } + + return (sumSq, maskCount); } } diff --git a/AutoMask/MainWindow.axaml.cs b/AutoMask/MainWindow.axaml.cs index de7aaba..f220763 100644 --- a/AutoMask/MainWindow.axaml.cs +++ b/AutoMask/MainWindow.axaml.cs @@ -152,7 +152,14 @@ private void CheckSavePossible() private async Task RefreshPresetsAsync() { - var foundPresets = await PresetService.LoadPresetsAsync(_currentPresetsDirectory); + var (foundPresets, loadFailures) = await PresetService.LoadPresetsAsync(_currentPresetsDirectory); + + if (loadFailures.Count > 0) + { + string detail = string.Join("\n", loadFailures.Select(f => $"{f.Path} - {f.Reason}")); + await ShowMessage("Preset load errors", + $"{loadFailures.Count} preset(s) could not be loaded:\n\n{detail}"); + } DebugLog($"Found {foundPresets.Count} presets:"); foreach (var preset in foundPresets) diff --git a/AutoMask/PresetEditor.axaml.cs b/AutoMask/PresetEditor.axaml.cs index 811c028..a9589d7 100644 --- a/AutoMask/PresetEditor.axaml.cs +++ b/AutoMask/PresetEditor.axaml.cs @@ -980,9 +980,10 @@ private async void BtnImportSplits_Click(object? sender, Avalonia.Interactivity. } List premadeSplits; + List loadFailures; try { - premadeSplits = await PresetService.LoadPremadeSplitsAsync(_splitsDirectory); + (premadeSplits, loadFailures) = await PresetService.LoadPremadeSplitsAsync(_splitsDirectory); } catch (Exception ex) { @@ -990,6 +991,13 @@ private async void BtnImportSplits_Click(object? sender, Avalonia.Interactivity. return; } + if (loadFailures.Count > 0) + { + string detail = string.Join("\n", loadFailures.Select(f => $"• {f.Path} - {f.Reason}")); + await MessageBox.Show(this, "Pre-made splits load errors", + $"{loadFailures.Count} file(s) could not be loaded:\n\n{detail}"); + } + if (premadeSplits.Count == 0) { await MessageBox.Show(this, "No Splits Found", diff --git a/AutoMask/PresetService.cs b/AutoMask/PresetService.cs index 7b89b8f..6ad474f 100644 --- a/AutoMask/PresetService.cs +++ b/AutoMask/PresetService.cs @@ -3,37 +3,56 @@ namespace AutoSplit_AutoMask; +/// +/// One JSON file that failed to load. is either an exception message or +/// "deserialized to null" when the JSON parsed but produced no model. +/// +public sealed record LoadFailure(string Path, string Reason); + public static class PresetService { - public static async Task> LoadPresetsAsync(string presetsDirectory) + public static async Task<(List Presets, List Failures)> LoadPresetsAsync(string presetsDirectory) { var presetPaths = Directory.EnumerateDirectories(presetsDirectory) .Where(dir => Directory.EnumerateFiles(dir, "preset.json", SearchOption.TopDirectoryOnly).Any()) .ToArray(); List foundPresets = []; + List failures = []; foreach (string presetPath in presetPaths) { - SplitPreset? preset = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(Path.Combine(presetPath, "preset.json")), - AppJsonContext.Default.SplitPreset); + string filePath = Path.Combine(presetPath, "preset.json"); + try + { + SplitPreset? preset = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(filePath), + AppJsonContext.Default.SplitPreset); - if (preset is not null) + if (preset is not null) + { + preset.PresetFolder = presetPath; + foundPresets.Add(preset); + } + else + { + failures.Add(new LoadFailure(filePath, "deserialized to null")); + } + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) { - preset.PresetFolder = presetPath; - foundPresets.Add(preset); + failures.Add(new LoadFailure(filePath, ex.Message)); } } - return foundPresets; + return (foundPresets, failures); } - public static async Task> LoadPremadeSplitsAsync(string splitsDirectory) + public static async Task<(List Files, List Failures)> LoadPremadeSplitsAsync(string splitsDirectory) { if (!Directory.Exists(splitsDirectory)) { - return []; + return ([], []); } var splitPaths = Directory.EnumerateDirectories(splitsDirectory) @@ -41,21 +60,34 @@ public static async Task> LoadPremadeSplitsAsync(string .ToArray(); List foundSplitFiles = []; + List failures = []; foreach (string splitPath in splitPaths) { - PremadeSplitsFile? splitsFile = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(Path.Combine(splitPath, "splits.json")), - AppJsonContext.Default.PremadeSplitsFile); + string filePath = Path.Combine(splitPath, "splits.json"); + try + { + PremadeSplitsFile? splitsFile = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(filePath), + AppJsonContext.Default.PremadeSplitsFile); - if (splitsFile is not null) + if (splitsFile is not null) + { + splitsFile.FolderPath = splitPath; + foundSplitFiles.Add(splitsFile); + } + else + { + failures.Add(new LoadFailure(filePath, "deserialized to null")); + } + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) { - splitsFile.FolderPath = splitPath; - foundSplitFiles.Add(splitsFile); + failures.Add(new LoadFailure(filePath, ex.Message)); } } - return foundSplitFiles.OrderBy(f => f.GameName).ToList(); + return (foundSplitFiles.OrderBy(f => f.GameName).ToList(), failures); } public static string CreateFilenameForSplit(SplitPreset preset, int splitIndex) From 4b609a5351cc0eff7fa81c6e30fa2109618fdc28 Mon Sep 17 00:00:00 2001 From: Torje Amundsen Date: Tue, 28 Apr 2026 07:36:54 +0200 Subject: [PATCH 06/12] perf(capture): reuse frame buffers via ping-pong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-frame byte[] allocation produced ~18 MB/s of Gen0 churn at 60 fps (320×240×4). Two pre-allocated buffers cover the single- in-flight invariant enforced by _uiPostPending: capture writes to _frameBuffers[_writeIndex], flips on successful post so the UI handler keeps exclusive access until it clears _uiPostPending, and a dropped post leaves _writeIndex unchanged so the same buffer is reused next iteration. --- AutoMask/Capture/CaptureController.cs | 34 +++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/AutoMask/Capture/CaptureController.cs b/AutoMask/Capture/CaptureController.cs index 17232fc..fd58719 100644 --- a/AutoMask/Capture/CaptureController.cs +++ b/AutoMask/Capture/CaptureController.cs @@ -36,6 +36,16 @@ private sealed record CaptureState( private double _highest; private int _uiPostPending; + // Double-buffer the live pixels so the capture loop can refill one buffer while the + // UI handler still holds the previous one. _uiPostPending gates a single post in flight, + // so two buffers are sufficient. Allocated once at construction; no per-frame GC churn. + private readonly byte[][] _frameBuffers = + { + new byte[CompareWidth * CompareHeight * 4], + new byte[CompareWidth * CompareHeight * 4], + }; + private int _writeIndex; + public void UpdateReference(byte[]? refPixels, byte[]? refMask, double required) { UpdateState(s => s with { RefPixels = refPixels, RefMask = refMask, Required = required }); @@ -152,7 +162,12 @@ private void Loop(CancellationToken ct) double cur = 0; double high = Volatile.Read(ref _highest); - byte[] livePixels = ReadBgraBytes(scaled); + byte[] livePixels = _frameBuffers[_writeIndex]; + if (!ReadBgraBytesInto(scaled, livePixels)) + { + Thread.Sleep(2); + continue; + } if (refPixels is not null && refMask is not null && refPixels.Length == livePixels.Length @@ -181,6 +196,10 @@ private void Loop(CancellationToken ct) } }, DispatcherPriority.Background); + + // Flip only on a successful post so the UI handler keeps exclusive access + // to the buffer it received until it sets _uiPostPending back to 0. + _writeIndex ^= 1; } } catch (Exception ex) @@ -255,12 +274,17 @@ private double UpdateHighest(double similarity) } } - private static byte[] ReadBgraBytes(SKBitmap bitmap) + private static bool ReadBgraBytesInto(SKBitmap bitmap, byte[] destination) { int byteCount = bitmap.ByteCount; - byte[] result = new byte[byteCount]; - System.Runtime.InteropServices.Marshal.Copy(bitmap.GetPixels(), result, 0, byteCount); - return result; + if (byteCount != destination.Length) + { + // Source resized between iterations or a non-target format slipped through; + // skip this frame rather than tear the destination buffer. + return false; + } + System.Runtime.InteropServices.Marshal.Copy(bitmap.GetPixels(), destination, 0, byteCount); + return true; } public async ValueTask DisposeAsync() From 56eb0513b60af19533f213fc8ebb7e44461cfaee Mon Sep 17 00:00:00 2001 From: Torje Amundsen Date: Tue, 28 Apr 2026 08:20:02 +0200 Subject: [PATCH 07/12] perf(image): byte-loop ApplyScaledAlphaChannel Three SKColor[] round-trips per call (input.Pixels, scaled.Pixels, output.Pixels assignment) allocated ~24 MB of managed arrays for a 1080p mask. Replaced with an unsafe byte loop over GetPixels() IntPtrs and a native blit only if the decoder picked a non-BGRA color type. Hard alpha threshold (==255) preserved exactly so the edge behavior of the linear-resized mask matches the previous SKColor comparison. --- AutoMask/ImageProcessor.cs | 65 ++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/AutoMask/ImageProcessor.cs b/AutoMask/ImageProcessor.cs index 34b1237..a4f7532 100644 --- a/AutoMask/ImageProcessor.cs +++ b/AutoMask/ImageProcessor.cs @@ -9,7 +9,14 @@ public static class ImageProcessor { public static SKBitmap ApplyScaledAlphaChannel(string inputPath, string alphaPath, Dictionary maskCache) { - using var inputBitmap = SKBitmap.Decode(inputPath); + using var rawInput = SKBitmap.Decode(inputPath); + // Native blit if the decoder picked a non-BGRA layout (rare for PNG on Windows, + // possible for JPEG). Cheaper than the three SKColor[] copies the previous version + // performed and avoids managed allocations of (width*height*4) bytes per call. + using var inputDisposable = rawInput.ColorType == SKColorType.Bgra8888 + ? null + : rawInput.Copy(SKColorType.Bgra8888); + SKBitmap inputBitmap = inputDisposable ?? rawInput; SKBitmap alphaBitmap; // Decode under lock so concurrent callers (multiple Task.Run consumers) can't both @@ -25,34 +32,58 @@ public static SKBitmap ApplyScaledAlphaChannel(string inputPath, string alphaPat alphaBitmap = cached; } + // Resizing into an explicit BGRA8888 SKImageInfo guarantees the byte loop's layout. using var scaledAlpha = alphaBitmap.Resize( - new SKImageInfo(inputBitmap.Width, inputBitmap.Height), + new SKImageInfo(inputBitmap.Width, inputBitmap.Height, SKColorType.Bgra8888), new SKSamplingOptions(SKFilterMode.Linear))!; int width = inputBitmap.Width; int height = inputBitmap.Height; - var outputBitmap = new SKBitmap(width, height); + var outputBitmap = new SKBitmap( + new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Unpremul)); - SKColor[] inputPixels = inputBitmap.Pixels; - SKColor[] alphaPixels = scaledAlpha.Pixels; - SKColor[] outputPixels = new SKColor[width * height]; + ApplyAlphaThreshold(inputBitmap, scaledAlpha, outputBitmap, width, height); + + return outputBitmap; + } + + private static unsafe void ApplyAlphaThreshold(SKBitmap input, SKBitmap alpha, SKBitmap output, + int width, int height) + { + IntPtr inPtr = input.GetPixels(); + IntPtr alpPtr = alpha.GetPixels(); + IntPtr outPtr = output.GetPixels(); + int rowIn = input.RowBytes; + int rowAlp = alpha.RowBytes; + int rowOut = output.RowBytes; Parallel.For(0, height, y => { - int row = y * width; - for (int x = 0; x < width; x++) + unsafe { - int i = row + x; - var src = inputPixels[i]; - outputPixels[i] = alphaPixels[i].Alpha == 255 - ? new SKColor(src.Red, src.Green, src.Blue) - : SKColors.Transparent; + byte* inRow = (byte*)inPtr + y * rowIn; + byte* alpRow = (byte*)alpPtr + y * rowAlp; + byte* outRow = (byte*)outPtr + y * rowOut; + for (int x = 0; x < width; x++) + { + int o = x * 4; + if (alpRow[o + 3] == 255) + { + // Hard threshold: keep input pixel only when scaled mask is fully opaque. + // Resize with linear sampling produces partial alphas at edges, which the + // original SKColor[] loop also rejected. + outRow[o] = inRow[o]; + outRow[o + 1] = inRow[o + 1]; + outRow[o + 2] = inRow[o + 2]; + outRow[o + 3] = 255; + } + else + { + *(uint*)(outRow + o) = 0u; + } + } } }); - - outputBitmap.Pixels = outputPixels; - - return outputBitmap; } public static Bitmap CreateCheckerBitmap(int width, int height) From 601807fe7ae783cae58374533099f99a1db4ba51 Mon Sep 17 00:00:00 2001 From: Torje Amundsen Date: Tue, 28 Apr 2026 18:19:09 +0200 Subject: [PATCH 08/12] fix: cross-cutting nit sweep - MainWindow: AppContext.BaseDirectory replaces Process.MainModule (AOT-safe under self-contained NativeAOT publish), drop redundant Dispatcher.Post wrap in CheckSavePossible (all callers UI-thread), add OnClosed override that disposes input/thumbnail/mask caches + _maskedImage + _previewBitmap, narrow temp-cleanup catches to IOException/UnauthorizedAccessException. - TestOutputWindow: LoadPrefs/SavePrefs go async + Encoding.UTF8 + typed catches; sync File I/O on the UI thread is gone. - PresetEditor: rename prompt no longer overwrites an explicit OK/Enter result with the Closed handler's null (Commit() guard). Empty/whitespace split names rejected in ValidateSplitName and UpdateSaveButtonState so Save can't write a malformed preset. - ImportSplitsDialog: thumbnail load catches IOException/ UnauthorizedAccessException/ArgumentException only, logs via Utils.LogError instead of swallowing silently. - WebcamCapture: ErrorReported event surfaces OpenCV throws to UI instead of breaking out of the read loop with no signal. - PresetService: explicit Encoding.UTF8 on every File.*Async call. - MessageBox: ShowDialog Task observed via ContinueWith; a synchronous throw now faults tcs.Task instead of leaving callers awaiting a never-completing TCS. --- AutoMask/AutoMask.csproj | 2 +- AutoMask/Capture/WebcamCapture.cs | 9 +++- AutoMask/ImportSplitsDialog.axaml.cs | 4 +- AutoMask/MainWindow.axaml.cs | 71 ++++++++++++++++++---------- AutoMask/MessageBox.cs | 13 ++++- AutoMask/PresetEditor.axaml.cs | 34 +++++++++---- AutoMask/PresetService.cs | 7 +-- AutoMask/TestOutputWindow.axaml.cs | 14 +++--- 8 files changed, 106 insertions(+), 48 deletions(-) diff --git a/AutoMask/AutoMask.csproj b/AutoMask/AutoMask.csproj index eacc403..1d6ce2e 100644 --- a/AutoMask/AutoMask.csproj +++ b/AutoMask/AutoMask.csproj @@ -13,7 +13,7 @@ true $(NoWarn);AVLN3001 Assets/icon.ico - 0.9.2-alpha + 0.9.3-alpha false diff --git a/AutoMask/Capture/WebcamCapture.cs b/AutoMask/Capture/WebcamCapture.cs index 57845fd..45e1eb4 100644 --- a/AutoMask/Capture/WebcamCapture.cs +++ b/AutoMask/Capture/WebcamCapture.cs @@ -14,6 +14,8 @@ public sealed class CamDeviceInfo [SupportedOSPlatform("windows")] public sealed class WebcamCapture : ICaptureSource { + public event Action? ErrorReported; + private readonly CamDeviceInfo _device; private readonly string _displayName; @@ -120,8 +122,13 @@ private void CaptureLoop() continue; } } - catch + catch (Exception ex) { + // OpenCV native errors (device unplugged, codec failure, OOM during + // frame allocation) — surface to UI so the live tester can show why + // the feed stopped instead of silently freezing. + var msg = $"Webcam read failed: {ex.Message}"; + Avalonia.Threading.Dispatcher.UIThread.Post(() => ErrorReported?.Invoke(msg)); break; } diff --git a/AutoMask/ImportSplitsDialog.axaml.cs b/AutoMask/ImportSplitsDialog.axaml.cs index 329e649..bc410d3 100644 --- a/AutoMask/ImportSplitsDialog.axaml.cs +++ b/AutoMask/ImportSplitsDialog.axaml.cs @@ -125,9 +125,9 @@ private Border BuildSplitCard(PremadeSplitsFile splitsFile, PremadeSplit split) Grid.SetColumn(imageBorder, 1); cardContent.Children.Add(imageBorder); } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) { - // Image failed to load - skip thumbnail + Utils.LogError($"ImportSplitsDialog: failed to load thumbnail '{baseImagePath}': {ex.Message}"); } } } diff --git a/AutoMask/MainWindow.axaml.cs b/AutoMask/MainWindow.axaml.cs index f220763..6959063 100644 --- a/AutoMask/MainWindow.axaml.cs +++ b/AutoMask/MainWindow.axaml.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using System.Collections.ObjectModel; -using System.Diagnostics; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Input; @@ -54,12 +53,9 @@ public MainWindow() _splitPresets = []; _createdFilename = "Output preview"; - var rootDir = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule!.FileName); - - if (string.IsNullOrEmpty(rootDir)) - { - throw new InvalidOperationException("Could not locate root directory"); - } + // AppContext.BaseDirectory is the canonical app root; works under NativeAOT + // self-contained where Process.MainModule may be platform-specific or null. + string rootDir = AppContext.BaseDirectory; _currentPresetsDirectory = Path.Combine(rootDir, "presets") + Path.DirectorySeparatorChar; _currentSplitsDirectory = Path.Combine(rootDir, "splits") + Path.DirectorySeparatorChar; @@ -111,43 +107,70 @@ public MainWindow() Opened += async (_, _) => await RefreshPresetsAsync(); - Closed += (_, _) => CleanupSavestateTempDirs(); + } + + protected override void OnClosed(EventArgs e) + { + base.OnClosed(e); + + // Dispose all bitmap caches; the window owns these and they don't outlive it. + foreach (var b in _inputPreviewCache.Values) + { + b.Dispose(); + } + _inputPreviewCache.Clear(); + + foreach (var b in _inputThumbnailCache.Values) + { + b.Dispose(); + } + _inputThumbnailCache.Clear(); + + foreach (var b in _maskSkBitmapCache.Values) + { + b.Dispose(); + } + _maskSkBitmapCache.Clear(); + + _maskedImage?.Dispose(); + _maskedImage = null; + _previewBitmap?.Dispose(); + _previewBitmap = null; + + CleanupSavestateTempDirs(); } private static void CleanupSavestateTempDirs() { + // Best-effort cleanup. Failures here are non-actionable for the user (locked + // files / TOCTOU on the temp enumeration / permission edge cases) and would + // only spam an error dialog at shutdown. try { foreach (string dir in Directory.EnumerateDirectories(Path.GetTempPath(), "AutoMask_savestates_*")) { - try - { - Directory.Delete(dir, recursive: true); - } - catch - { - } + try { Directory.Delete(dir, recursive: true); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } } } - catch - { - } + catch (IOException) { } + catch (UnauthorizedAccessException) { } } private void CheckSavePossible() { + // All callers run on the UI thread (event handlers, post-await continuations); + // the previous Dispatcher.UIThread.Post wrap was redundant. bool hasOutputDir = !string.IsNullOrEmpty(_outputDirectoryPath); bool hasPreview = !string.IsNullOrEmpty(_selectedInputImagePath) && !string.IsNullOrEmpty(_alphaImagePath); bool saveAllAllowed = hasOutputDir && selectedPresetIndex >= 0 && selectedPresetIndex < _splitPresets.Count && _splitPresets[selectedPresetIndex].Splits?.Count == _inputImagePaths?.Count; - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - BtnSave.IsEnabled = hasOutputDir; - BtnSaveAs.IsEnabled = hasPreview; - BtnSaveAllSplits.IsEnabled = saveAllAllowed; - }); + BtnSave.IsEnabled = hasOutputDir; + BtnSaveAs.IsEnabled = hasPreview; + BtnSaveAllSplits.IsEnabled = saveAllAllowed; } private async Task RefreshPresetsAsync() diff --git a/AutoMask/MessageBox.cs b/AutoMask/MessageBox.cs index cf7cf3c..c89daa7 100644 --- a/AutoMask/MessageBox.cs +++ b/AutoMask/MessageBox.cs @@ -87,7 +87,18 @@ void AddButton(string content, MessageBoxResult result, bool isDefault = false) var tcs = new TaskCompletionSource(); msgBox.Closed += (_, _) => tcs.TrySetResult(msgBox._result); - msgBox.ShowDialog(owner); + // ShowDialog returns a Task that completes when the dialog closes; without observing + // it, a synchronous throw (window-create failure, threading mismatch) would leave + // the caller awaiting tcs.Task forever. Forward any exception to the TCS instead. + _ = msgBox.ShowDialog(owner).ContinueWith( + t => + { + if (t.IsFaulted && t.Exception is { } ex) + { + tcs.TrySetException(ex.InnerExceptions); + } + }, + TaskScheduler.Default); return tcs.Task; } } diff --git a/AutoMask/PresetEditor.axaml.cs b/AutoMask/PresetEditor.axaml.cs index a9589d7..43d9033 100644 --- a/AutoMask/PresetEditor.axaml.cs +++ b/AutoMask/PresetEditor.axaml.cs @@ -706,7 +706,7 @@ protected override void OnClosed(EventArgs e) private void ValidateSplitName(string name) { - bool isInvalid = InvalidNameCharsRegex().IsMatch(name); + bool isInvalid = string.IsNullOrWhiteSpace(name) || InvalidNameCharsRegex().IsMatch(name); if (isInvalid) { @@ -734,7 +734,8 @@ private void UpdateSaveButtonState() return; } - bool anyInvalidName = _selectedPreset.Splits.Any(s => InvalidNameCharsRegex().IsMatch(s.Name)); + bool anyInvalidName = _selectedPreset.Splits.Any(s => + string.IsNullOrWhiteSpace(s.Name) || InvalidNameCharsRegex().IsMatch(s.Name)); bool nameValid = !string.IsNullOrWhiteSpace(_selectedPreset.PresetName); BtnSave.IsEnabled = nameValid && !anyInvalidName; @@ -1527,20 +1528,35 @@ private async Task SaveToNewFolder(EditablePreset preset) }, }; - okBtn.Click += (_, _) => { tcs.TrySetResult(textBox.Text); dialog.Close(); }; - cancelBtn.Click += (_, _) => { tcs.TrySetResult(null); dialog.Close(); }; - dialog.Closed += (_, _) => tcs.TrySetResult(null); + // Closed fires after every code path (OK/Cancel/Enter/Escape/window-close). + // Without a guard, an explicit OK/Enter result was overwritten by the Closed + // handler's null on the way out — TrySetResult silently no-ops the second call, + // but the order of subscription mattered. Setting the result inside Closed only + // when nothing else has run avoids the order coupling entirely. + string? pendingResult = null; + bool resultSet = false; + void Commit(string? value) + { + if (!resultSet) + { + resultSet = true; + pendingResult = value; + } + dialog.Close(); + } + + okBtn.Click += (_, _) => Commit(textBox.Text); + cancelBtn.Click += (_, _) => Commit(null); + dialog.Closed += (_, _) => tcs.TrySetResult(resultSet ? pendingResult : null); textBox.KeyDown += (_, e) => { if (e.Key == Avalonia.Input.Key.Enter) { - tcs.TrySetResult(textBox.Text); - dialog.Close(); + Commit(textBox.Text); } else if (e.Key == Avalonia.Input.Key.Escape) { - tcs.TrySetResult(null); - dialog.Close(); + Commit(null); } }; dialog.Opened += (_, _) => { textBox.Focus(); textBox.SelectAll(); }; diff --git a/AutoMask/PresetService.cs b/AutoMask/PresetService.cs index 6ad474f..d11a5a1 100644 --- a/AutoMask/PresetService.cs +++ b/AutoMask/PresetService.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -26,7 +27,7 @@ public static class PresetService try { SplitPreset? preset = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(filePath), + await File.ReadAllTextAsync(filePath, Encoding.UTF8), AppJsonContext.Default.SplitPreset); if (preset is not null) @@ -68,7 +69,7 @@ await File.ReadAllTextAsync(filePath), try { PremadeSplitsFile? splitsFile = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(filePath), + await File.ReadAllTextAsync(filePath, Encoding.UTF8), AppJsonContext.Default.PremadeSplitsFile); if (splitsFile is not null) @@ -345,7 +346,7 @@ internal static async Task SavePresetToFolderAsync(EditablePreset preset, string string tmpPath = finalPath + ".tmp"; try { - await File.WriteAllTextAsync(tmpPath, json); + await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8); File.Move(tmpPath, finalPath, overwrite: true); } catch diff --git a/AutoMask/TestOutputWindow.axaml.cs b/AutoMask/TestOutputWindow.axaml.cs index c86198f..782aee8 100644 --- a/AutoMask/TestOutputWindow.axaml.cs +++ b/AutoMask/TestOutputWindow.axaml.cs @@ -111,7 +111,7 @@ public void InitializeFromMainWindow( private async Task InitializeAsync() { - _loadedPrefs = LoadPrefs(); + _loadedPrefs = await LoadPrefsAsync(); await RefreshFeedListAsync(selectAfter: _loadedPrefs?.FeedName); @@ -634,7 +634,7 @@ private void ApplyCropFromPrefs(CapturePreferences prefs) } } - private CapturePreferences? LoadPrefs() + private async Task LoadPrefsAsync() { if (string.IsNullOrEmpty(_prefsPath) || !File.Exists(_prefsPath)) { @@ -643,16 +643,16 @@ private void ApplyCropFromPrefs(CapturePreferences prefs) try { - var json = File.ReadAllText(_prefsPath); + var json = await File.ReadAllTextAsync(_prefsPath, System.Text.Encoding.UTF8); return JsonSerializer.Deserialize(json, AppJsonContext.Default.CapturePreferences); } - catch + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) { return null; } } - private void SavePrefs(CapturePreferences prefs) + private async Task SavePrefsAsync(CapturePreferences prefs) { if (string.IsNullOrEmpty(_prefsPath)) { @@ -660,7 +660,7 @@ private void SavePrefs(CapturePreferences prefs) } var json = JsonSerializer.Serialize(prefs, AppJsonContext.Default.CapturePreferences); - File.WriteAllText(_prefsPath, json); + await File.WriteAllTextAsync(_prefsPath, json, System.Text.Encoding.UTF8); } private async Task PromptSaveAndClose() @@ -670,7 +670,7 @@ private async Task PromptSaveAndClose() if (result == MessageBoxResult.Yes) { - SavePrefs(BuildCurrentPrefs()); + await SavePrefsAsync(BuildCurrentPrefs()); } _isClosing = true; From 541db832676c4b1552697cf347877f1210486646 Mon Sep 17 00:00:00 2001 From: Torje Amundsen Date: Tue, 28 Apr 2026 18:34:03 +0200 Subject: [PATCH 09/12] chore: AOT json types, crash handlers, comment + rename sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppJsonContext: register Split, List, List explicitly so trimming/AOT can't drop reflection metadata that source-gen relies on. - Program: AppDomain.UnhandledException + TaskScheduler .UnobservedTaskException write a timestamped crash log to %LOCALAPPDATA%\AutoMask\crashes (XDG equivalent on Linux). Self- contained AOT publishes have no console and no debugger, so fatal exceptions previously produced a silent exit. - Cut 'what' / banner comments where the code already reads itself (cache-dispose loop, 'Commit the switch', 'Reference inputs captured from MainWindow', BitBltCaptureBase plumbing summary). Keep WHY comments — race rationales, AOT contracts, SIMD algorithm steps, Win32 quirks, Avalonia binding priority etc. - Rename short identifiers in tight loops: _cap → _videoCapture, dst/s/d → targetPixels/sourceRow/targetRow, sw → stopwatch, cur → currentSimilarity, high → highestSimilarity, src → source. - Narrow two bare catches: WebcamCapture.StopAsync to ObjectDisposedException; PresetEditor.UpdateOutputPreview to IOException/UnauthorizedAccessException/ArgumentException with Utils.LogError instead of swallowing. --- AutoMask/AppJsonContext.cs | 6 ++++ AutoMask/Capture/BitBltCaptureBase.cs | 2 -- AutoMask/Capture/CaptureController.cs | 20 +++++------ AutoMask/Capture/WebcamCapture.cs | 49 +++++++++++++-------------- AutoMask/MainWindow.axaml.cs | 2 -- AutoMask/PresetEditor.axaml.cs | 5 ++- AutoMask/PresetEditorModels.cs | 1 - AutoMask/Program.cs | 48 ++++++++++++++++++++++++-- AutoMask/TestOutputWindow.axaml.cs | 1 - 9 files changed, 88 insertions(+), 46 deletions(-) diff --git a/AutoMask/AppJsonContext.cs b/AutoMask/AppJsonContext.cs index 9fc8fdf..d1a4018 100644 --- a/AutoMask/AppJsonContext.cs +++ b/AutoMask/AppJsonContext.cs @@ -2,9 +2,15 @@ namespace AutoSplit_AutoMask; +// Nested generic and element types are registered explicitly so trimming/AOT can't remove +// reflection metadata the source-gen serializer relies on. Keep this in sync with any new +// model field types. [JsonSerializable(typeof(SplitPreset))] +[JsonSerializable(typeof(Split))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(PremadeSplitsFile))] [JsonSerializable(typeof(PremadeSplit))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(CapturePreferences))] [JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] internal partial class AppJsonContext : JsonSerializerContext; diff --git a/AutoMask/Capture/BitBltCaptureBase.cs b/AutoMask/Capture/BitBltCaptureBase.cs index 4799609..123cf38 100644 --- a/AutoMask/Capture/BitBltCaptureBase.cs +++ b/AutoMask/Capture/BitBltCaptureBase.cs @@ -5,8 +5,6 @@ namespace AutoSplit_AutoMask.Capture; -// Shared plumbing for window + region capture. Each BitBlt grabs the current subject rect into -// a reusable GDI compatible bitmap, then GetDIBits copies it into a SkiaSharp-backed buffer. [SupportedOSPlatform("windows")] public abstract class BitBltCaptureBase : ICaptureSource { diff --git a/AutoMask/Capture/CaptureController.cs b/AutoMask/Capture/CaptureController.cs index fd58719..31971d3 100644 --- a/AutoMask/Capture/CaptureController.cs +++ b/AutoMask/Capture/CaptureController.cs @@ -126,19 +126,19 @@ public void Stop() private void Loop(CancellationToken ct) { double frameMs = 1000.0 / TargetFps; - var sw = Stopwatch.StartNew(); + var stopwatch = Stopwatch.StartNew(); double nextDueMs = 0.0; while (!ct.IsCancellationRequested) { - ICaptureSource? src = Volatile.Read(ref _active); + ICaptureSource? source = Volatile.Read(ref _active); CaptureState state = Volatile.Read(ref _state); byte[]? refPixels = state.RefPixels; byte[]? refMask = state.RefMask; double required = state.Required; CropRect crop = state.Crop; - if (src is null) + if (source is null) { Thread.Sleep(20); continue; @@ -146,7 +146,7 @@ private void Loop(CancellationToken ct) try { - if (!src.TryGrabFrame(out var raw) || raw is null) + if (!source.TryGrabFrame(out var raw) || raw is null) { Thread.Sleep(2); continue; @@ -159,8 +159,8 @@ private void Loop(CancellationToken ct) continue; } - double cur = 0; - double high = Volatile.Read(ref _highest); + double currentSimilarity = 0; + double highestSimilarity = Volatile.Read(ref _highest); byte[] livePixels = _frameBuffers[_writeIndex]; if (!ReadBgraBytesInto(scaled, livePixels)) @@ -175,8 +175,8 @@ private void Loop(CancellationToken ct) { double similarity = Comparison.L2NormComparer.Compare(refPixels, refMask, livePixels); - high = UpdateHighest(similarity); - cur = similarity; + highestSimilarity = UpdateHighest(similarity); + currentSimilarity = similarity; } if (Interlocked.CompareExchange(ref _uiPostPending, 1, 0) == 0) @@ -188,7 +188,7 @@ private void Loop(CancellationToken ct) { try { - FrameReady?.Invoke(frameBuffer, cur, high, required); + FrameReady?.Invoke(frameBuffer, currentSimilarity, highestSimilarity, required); } finally { @@ -209,7 +209,7 @@ private void Loop(CancellationToken ct) Thread.Sleep(200); } - double elapsed = sw.Elapsed.TotalMilliseconds; + double elapsed = stopwatch.Elapsed.TotalMilliseconds; nextDueMs += frameMs; double sleep = nextDueMs - elapsed; if (sleep > 1) diff --git a/AutoMask/Capture/WebcamCapture.cs b/AutoMask/Capture/WebcamCapture.cs index 45e1eb4..8969ebb 100644 --- a/AutoMask/Capture/WebcamCapture.cs +++ b/AutoMask/Capture/WebcamCapture.cs @@ -19,7 +19,7 @@ public sealed class WebcamCapture : ICaptureSource private readonly CamDeviceInfo _device; private readonly string _displayName; - private VideoCapture? _cap; + private VideoCapture? _videoCapture; private Thread? _thread; private CancellationTokenSource? _cts; @@ -77,20 +77,20 @@ public static Task> EnumerateDevicesAsync() public Task StartAsync(CancellationToken ct) { - _cap = new VideoCapture(_device.Index, VideoCaptureAPIs.DSHOW); - if (!_cap.IsOpened()) + _videoCapture = new VideoCapture(_device.Index, VideoCaptureAPIs.DSHOW); + if (!_videoCapture.IsOpened()) { - _cap.Dispose(); - _cap = null; + _videoCapture.Dispose(); + _videoCapture = null; throw new InvalidOperationException($"Could not open webcam: {_device.Name}"); } - _cap.Set(VideoCaptureProperties.FrameWidth, 1920); - _cap.Set(VideoCaptureProperties.FrameHeight, 1080); - _cap.Set(VideoCaptureProperties.Fps, 60); + _videoCapture.Set(VideoCaptureProperties.FrameWidth, 1920); + _videoCapture.Set(VideoCaptureProperties.FrameHeight, 1080); + _videoCapture.Set(VideoCaptureProperties.Fps, 60); - _widthPx = (int)_cap.Get(VideoCaptureProperties.FrameWidth); - _heightPx = (int)_cap.Get(VideoCaptureProperties.FrameHeight); + _widthPx = (int)_videoCapture.Get(VideoCaptureProperties.FrameWidth); + _heightPx = (int)_videoCapture.Get(VideoCaptureProperties.FrameHeight); if (_widthPx <= 0 || _heightPx <= 0) { _widthPx = 640; @@ -116,7 +116,7 @@ private void CaptureLoop() { try { - if (!_cap!.Read(frame) || frame.Empty()) + if (!_videoCapture!.Read(frame) || frame.Empty()) { Thread.Sleep(5); continue; @@ -146,20 +146,20 @@ private unsafe void CopyBgrMatToLatest(Mat frame) } var target = new SKBitmap(new SKImageInfo(w, h, SKColorType.Bgra8888, SKAlphaType.Opaque)); - byte* dst = (byte*)target.GetPixels(); + byte* targetPixels = (byte*)target.GetPixels(); for (int y = 0; y < h; y++) { - byte* s = (byte*)frame.Ptr(y); - byte* d = dst + (long)y * w * 4; + byte* sourceRow = (byte*)frame.Ptr(y); + byte* targetRow = targetPixels + (long)y * w * 4; for (int x = 0; x < w; x++) { - d[0] = s[0]; - d[1] = s[1]; - d[2] = s[2]; - d[3] = 255; - s += 3; - d += 4; + targetRow[0] = sourceRow[0]; + targetRow[1] = sourceRow[1]; + targetRow[2] = sourceRow[2]; + targetRow[3] = 255; + sourceRow += 3; + targetRow += 4; } } @@ -206,17 +206,16 @@ public Task StopAsync() { _cts?.Cancel(); } - catch + catch (ObjectDisposedException) { - // ignored } _thread?.Join(1000); _thread = null; - _cap?.Release(); - _cap?.Dispose(); - _cap = null; + _videoCapture?.Release(); + _videoCapture?.Dispose(); + _videoCapture = null; return Task.CompletedTask; } diff --git a/AutoMask/MainWindow.axaml.cs b/AutoMask/MainWindow.axaml.cs index 6959063..6db25ed 100644 --- a/AutoMask/MainWindow.axaml.cs +++ b/AutoMask/MainWindow.axaml.cs @@ -113,7 +113,6 @@ protected override void OnClosed(EventArgs e) { base.OnClosed(e); - // Dispose all bitmap caches; the window owns these and they don't outlive it. foreach (var b in _inputPreviewCache.Values) { b.Dispose(); @@ -271,7 +270,6 @@ private async void ComboBoxSelectPreset_SelectionChanged(object sender, Selectio int? dataIdx = _presetDisplayMap[displayIdx]; if (dataIdx == null) { - // Group header - not selectable, nothing to do return; } diff --git a/AutoMask/PresetEditor.axaml.cs b/AutoMask/PresetEditor.axaml.cs index 43d9033..4b3caa5 100644 --- a/AutoMask/PresetEditor.axaml.cs +++ b/AutoMask/PresetEditor.axaml.cs @@ -687,9 +687,9 @@ private async Task UpdateOutputPreview() skResult.Dispose(); OutputPreviewImage.Source = _outputPreviewBitmap; } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) { - // If preview generation fails, leave the output box empty + Utils.LogError($"PresetEditor: preview generation failed: {ex.Message}"); } } @@ -804,7 +804,6 @@ private async void PresetListBox_SelectionChanged(object? sender, SelectionChang } } - // Commit the switch _suppressPresetSelection = true; PresetListBox.SelectedIndex = displayIdx; _suppressPresetSelection = false; diff --git a/AutoMask/PresetEditorModels.cs b/AutoMask/PresetEditorModels.cs index b9ee797..ca1f951 100644 --- a/AutoMask/PresetEditorModels.cs +++ b/AutoMask/PresetEditorModels.cs @@ -62,7 +62,6 @@ public static PresetDisplayItem ForHeader(string gameName, bool isCollapsed = fa public static PresetDisplayItem ForPreset(EditablePreset p) => new() { Preset = p }; - // Properties forwarded to DataTemplate bindings public string PresetName => Preset?.PresetName ?? ""; public string GameName => IsHeader ? GroupName : Preset?.GameName ?? ""; public int SplitCount => Preset?.SplitCount ?? 0; diff --git a/AutoMask/Program.cs b/AutoMask/Program.cs index c795082..f0ef84b 100644 --- a/AutoMask/Program.cs +++ b/AutoMask/Program.cs @@ -1,3 +1,4 @@ +using System.Text; using Avalonia; namespace AutoSplit_AutoMask; @@ -5,12 +6,55 @@ namespace AutoSplit_AutoMask; class Program { [STAThread] - public static void Main(string[] args) => BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + public static void Main(string[] args) + { + // For self-contained AOT publishes there is no console attached and no debugger to + // observe a crash; without these handlers, fatal exceptions produce a silent exit. + // Crash logs land in %LOCALAPPDATA%\AutoMask\crashes (or the XDG equivalent on Linux) + // so users can attach one to a bug report. + AppDomain.CurrentDomain.UnhandledException += (_, e) => + LogCrash(e.ExceptionObject as Exception, "AppDomain.UnhandledException"); + TaskScheduler.UnobservedTaskException += (_, e) => + { + LogCrash(e.Exception, "TaskScheduler.UnobservedTaskException"); + e.SetObserved(); + }; + + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .LogToTrace(); + + private static void LogCrash(Exception? ex, string source) + { + if (ex is null) + { + return; + } + + try + { + string dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "AutoMask", + "crashes"); + Directory.CreateDirectory(dir); + + string path = Path.Combine(dir, $"crash-{DateTime.Now:yyyyMMdd-HHmmss-fff}-{source}.log"); + string body = $"Timestamp: {DateTime.Now:O}\n" + + $"Source: {source}\n" + + $"Version: {Utils.AutoMaskVersion}\n\n" + + ex.ToString(); + File.WriteAllText(path, body, Encoding.UTF8); + } + catch + { + // Nothing more to do — the process is already on its way out for AppDomain. + } + } } diff --git a/AutoMask/TestOutputWindow.axaml.cs b/AutoMask/TestOutputWindow.axaml.cs index 782aee8..9a3c1ce 100644 --- a/AutoMask/TestOutputWindow.axaml.cs +++ b/AutoMask/TestOutputWindow.axaml.cs @@ -33,7 +33,6 @@ private sealed class FeedOption private readonly CaptureController _controller = new(); private readonly ObservableCollection _feedOptions = []; - // Reference inputs captured from MainWindow. private SplitPreset? _presetFromMain; private int _splitIndexFromMain = -1; private string? _inputPathFromMain; From 9ecfc9fc9bebd3d50736fd2d7bda3b885613bea2 Mon Sep 17 00:00:00 2001 From: Torje Amundsen Date: Tue, 28 Apr 2026 18:41:56 +0200 Subject: [PATCH 10/12] fix: recover from UI-thread exceptions, plug GDI leaks - BitBltCaptureBase.TryGrabFrame releases srcDc via try/finally so a throw between Acquire and Release (EnsureBuffers GDI exhaustion, BitBlt fault, OOM during SKBitmap alloc) can't strand the DC. EnsureBuffers returns bool with explicit rollback of partial GDI allocations on each early-out path. A GetDIBits == 0 now disposes the staging _frame so a transient failure doesn't leave a half- populated buffer that a size-matched call would skip re-allocating. - App.OnFrameworkInitializationCompleted registers Dispatcher.UIThread.UnhandledException: logs to disk, shows a MessageBox on the main window, sets Handled = true. Catches every async-void event handler's faulted continuation since they all run on the UI dispatcher, so an exception in a button click no longer bubbles to AppDomain.UnhandledException and terminates the process. - Crash-log writer hoisted from Program.cs to Utils.LogCrashToDisk so all three unhandled-exception hooks (AppDomain, TaskScheduler, Dispatcher) share one path. --- AutoMask/App.axaml.cs | 26 ++++++++++ AutoMask/Capture/BitBltCaptureBase.cs | 70 +++++++++++++++++++++------ AutoMask/Program.cs | 40 ++------------- AutoMask/Utils.cs | 35 ++++++++++++++ 4 files changed, 120 insertions(+), 51 deletions(-) diff --git a/AutoMask/App.axaml.cs b/AutoMask/App.axaml.cs index 64f8d02..88d0a9d 100644 --- a/AutoMask/App.axaml.cs +++ b/AutoMask/App.axaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Threading; namespace AutoSplit_AutoMask; @@ -16,6 +17,31 @@ public override void OnFrameworkInitializationCompleted() if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = new MainWindow(); + + // Catches uncaught exceptions raised on the UI dispatcher (including async-void + // event handlers' continuations). Setting Handled = true keeps the app running + // instead of bubbling to AppDomain.UnhandledException and terminating; the user + // gets a MessageBox and can retry the action that failed. + Dispatcher.UIThread.UnhandledException += (_, e) => + { + Utils.LogCrashToDisk(e.Exception, "Dispatcher.UnhandledException"); + + try + { + if (desktop.MainWindow is { IsVisible: true } mainWindow) + { + _ = MessageBox.Show(mainWindow, "Something went wrong", e.Exception.Message); + } + } + catch + { + // Avalonia's documentation explicitly warns against allocating or + // performing resource-heavy work in this handler — secondary failures + // here can't be reported anywhere useful. + } + + e.Handled = true; + }; } base.OnFrameworkInitializationCompleted(); diff --git a/AutoMask/Capture/BitBltCaptureBase.cs b/AutoMask/Capture/BitBltCaptureBase.cs index 123cf38..d0c9f80 100644 --- a/AutoMask/Capture/BitBltCaptureBase.cs +++ b/AutoMask/Capture/BitBltCaptureBase.cs @@ -40,22 +40,33 @@ public bool TryGrabFrame(out SKBitmap? frame) return false; } - if (w <= 0 || h <= 0) + // try/finally so any throw between acquire and release (EnsureBuffers GDI exhaustion, + // BitBlt fault, allocator OOM) doesn't strand srcDc. ReleaseDC must run for caret-DC + // and window-DC paths or the system DC pool fills up. + bool blitOk; + try { - Win32.ReleaseDC(owningHwnd, srcDc); - return false; - } - - SourceWidth = w; - SourceHeight = h; + if (w <= 0 || h <= 0) + { + return false; + } - EnsureBuffers(srcDc, w, h); + SourceWidth = w; + SourceHeight = h; - bool ok = Win32.BitBlt(_memDc, 0, 0, w, h, srcDc, ox, oy, Win32.SRCCOPY | Win32.CAPTUREBLT); + if (!EnsureBuffers(srcDc, w, h)) + { + return false; + } - Win32.ReleaseDC(owningHwnd, srcDc); + blitOk = Win32.BitBlt(_memDc, 0, 0, w, h, srcDc, ox, oy, Win32.SRCCOPY | Win32.CAPTUREBLT); + } + finally + { + Win32.ReleaseDC(owningHwnd, srcDc); + } - if (!ok) + if (!blitOk) { return false; } @@ -85,6 +96,11 @@ public bool TryGrabFrame(out SKBitmap? frame) if (scans == 0) { + // Drop the bitmap so a transient GetDIBits failure (e.g. driver glitch on + // resolution change) doesn't leave a half-populated buffer that a later + // size-match call would skip re-allocating. + _frame.Dispose(); + _frame = null; return false; } @@ -92,20 +108,42 @@ public bool TryGrabFrame(out SKBitmap? frame) return true; } - private void EnsureBuffers(IntPtr srcDc, int w, int h) + private bool EnsureBuffers(IntPtr srcDc, int w, int h) { if (_memDc != IntPtr.Zero && w == _allocatedWidth && h == _allocatedHeight) { - return; + return true; } ReleaseGdiHandles(); - _memDc = Win32.CreateCompatibleDC(srcDc); - _gdiBitmap = Win32.CreateCompatibleBitmap(srcDc, w, h); - _oldObject = Win32.SelectObject(_memDc, _gdiBitmap); + IntPtr memDc = Win32.CreateCompatibleDC(srcDc); + if (memDc == IntPtr.Zero) + { + return false; + } + + IntPtr gdiBitmap = Win32.CreateCompatibleBitmap(srcDc, w, h); + if (gdiBitmap == IntPtr.Zero) + { + Win32.DeleteDC(memDc); + return false; + } + + IntPtr oldObject = Win32.SelectObject(memDc, gdiBitmap); + if (oldObject == IntPtr.Zero) + { + Win32.DeleteObject(gdiBitmap); + Win32.DeleteDC(memDc); + return false; + } + + _memDc = memDc; + _gdiBitmap = gdiBitmap; + _oldObject = oldObject; _allocatedWidth = w; _allocatedHeight = h; + return true; } private void ReleaseGdiHandles() diff --git a/AutoMask/Program.cs b/AutoMask/Program.cs index f0ef84b..312ba73 100644 --- a/AutoMask/Program.cs +++ b/AutoMask/Program.cs @@ -1,4 +1,3 @@ -using System.Text; using Avalonia; namespace AutoSplit_AutoMask; @@ -8,15 +7,14 @@ class Program [STAThread] public static void Main(string[] args) { - // For self-contained AOT publishes there is no console attached and no debugger to - // observe a crash; without these handlers, fatal exceptions produce a silent exit. - // Crash logs land in %LOCALAPPDATA%\AutoMask\crashes (or the XDG equivalent on Linux) - // so users can attach one to a bug report. + // Self-contained AOT publishes have no console attached and no debugger to observe + // a crash; without these handlers a fatal exception silently exits. Crash logs land + // in %LOCALAPPDATA%\AutoMask\crashes (XDG equivalent on Linux) for bug reports. AppDomain.CurrentDomain.UnhandledException += (_, e) => - LogCrash(e.ExceptionObject as Exception, "AppDomain.UnhandledException"); + Utils.LogCrashToDisk(e.ExceptionObject as Exception, "AppDomain.UnhandledException"); TaskScheduler.UnobservedTaskException += (_, e) => { - LogCrash(e.Exception, "TaskScheduler.UnobservedTaskException"); + Utils.LogCrashToDisk(e.Exception, "TaskScheduler.UnobservedTaskException"); e.SetObserved(); }; @@ -29,32 +27,4 @@ public static AppBuilder BuildAvaloniaApp() .UsePlatformDetect() .WithInterFont() .LogToTrace(); - - private static void LogCrash(Exception? ex, string source) - { - if (ex is null) - { - return; - } - - try - { - string dir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "AutoMask", - "crashes"); - Directory.CreateDirectory(dir); - - string path = Path.Combine(dir, $"crash-{DateTime.Now:yyyyMMdd-HHmmss-fff}-{source}.log"); - string body = $"Timestamp: {DateTime.Now:O}\n" + - $"Source: {source}\n" + - $"Version: {Utils.AutoMaskVersion}\n\n" + - ex.ToString(); - File.WriteAllText(path, body, Encoding.UTF8); - } - catch - { - // Nothing more to do — the process is already on its way out for AppDomain. - } - } } diff --git a/AutoMask/Utils.cs b/AutoMask/Utils.cs index ee47fc3..469f7fb 100644 --- a/AutoMask/Utils.cs +++ b/AutoMask/Utils.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Reflection; +using System.Text; namespace AutoSplit_AutoMask; @@ -27,4 +28,38 @@ public static void OpenInFileManager(string path) [Conditional("DEBUG")] public static void LogError(string message) => Console.Error.WriteLine(message); + + /// + /// Best-effort write of an exception to %LOCALAPPDATA%\AutoMask\crashes (XDG + /// equivalent on Linux). Used by the AppDomain, TaskScheduler, and Dispatcher + /// unhandled-exception hooks so a self-contained AOT publish has something to attach + /// to a bug report when there's no console or debugger available. + /// + public static void LogCrashToDisk(Exception? ex, string source) + { + if (ex is null) + { + return; + } + + try + { + string dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "AutoMask", + "crashes"); + Directory.CreateDirectory(dir); + + string path = Path.Combine(dir, $"crash-{DateTime.Now:yyyyMMdd-HHmmss-fff}-{source}.log"); + string body = $"Timestamp: {DateTime.Now:O}\n" + + $"Source: {source}\n" + + $"Version: {AutoMaskVersion}\n\n" + + ex.ToString(); + File.WriteAllText(path, body, Encoding.UTF8); + } + catch + { + // Nothing more to do — secondary failures during crash logging are not actionable. + } + } } From b14266ef91d443938764c43c841add299d407063 Mon Sep 17 00:00:00 2001 From: Torje Amundsen Date: Tue, 28 Apr 2026 18:54:20 +0200 Subject: [PATCH 11/12] perf: parallel preset load, single-pass crop+scale, bounded swap - CaptureController.SetSourceAsync: extracted ShutdownSourceAsync with a 5s Task.WaitAsync timeout per call so a hung StopAsync (OpenCV native deadlock during webcam release, GDI driver stall) cannot block the swap forever and prevent the user from switching to a working source. Errors and timeouts surface via ErrorReported with the source's DisplayName instead of being silently swallowed. - CaptureController.CropAndScaleNearest cropped path: single allocation now. SKCanvas.DrawImage with srcRect/dstRect performs crop and scale in one operation, replacing the canvasBitmap + Resize pair. Out-of-bounds areas stay black via pre-Clear, matching the previous behavior. - PresetService.LoadPresetsAsync and LoadPremadeSplitsAsync: read every preset.json / splits.json in parallel via Task.WhenAll over per-file Load helpers. Per-file try/catch keeps WhenAll from fail-fasting; the failure tuple feeds the same LoadFailure list the UI surfaces. --- AutoMask/Capture/CaptureController.cs | 66 ++++++++++++++--- AutoMask/PresetService.cs | 101 ++++++++++++++++---------- 2 files changed, 117 insertions(+), 50 deletions(-) diff --git a/AutoMask/Capture/CaptureController.cs b/AutoMask/Capture/CaptureController.cs index 31971d3..7601d6f 100644 --- a/AutoMask/Capture/CaptureController.cs +++ b/AutoMask/Capture/CaptureController.cs @@ -72,6 +72,8 @@ private void UpdateState(Func mutate) } } + private static readonly TimeSpan SourceShutdownTimeout = TimeSpan.FromSeconds(5); + public async Task SetSourceAsync(ICaptureSource? newSource, CancellationToken ct) { await _swapLock.WaitAsync(ct); @@ -81,8 +83,7 @@ public async Task SetSourceAsync(ICaptureSource? newSource, CancellationToken ct if (current is not null) { Volatile.Write(ref _active, null); - try { await current.StopAsync(); } catch { /* ignore */ } - try { await current.DisposeAsync(); } catch { /* ignore */ } + await ShutdownSourceAsync(current); } if (newSource is not null) @@ -97,6 +98,45 @@ public async Task SetSourceAsync(ICaptureSource? newSource, CancellationToken ct } } + private async Task ShutdownSourceAsync(ICaptureSource source) + { + // Bounded shutdown so a hung native source (OpenCV deadlock during webcam release, + // GDI driver stall) doesn't block the swap forever and prevent the user from + // switching to a working source. Errors and timeouts surface via ErrorReported so + // the UI can show why the previous source might still be holding resources. + try + { + await source.StopAsync().WaitAsync(SourceShutdownTimeout); + } + catch (TimeoutException) + { + ReportShutdownIssue(source, "stop timed out"); + } + catch (Exception ex) + { + ReportShutdownIssue(source, $"stop failed: {ex.Message}"); + } + + try + { + await source.DisposeAsync().AsTask().WaitAsync(SourceShutdownTimeout); + } + catch (TimeoutException) + { + ReportShutdownIssue(source, "dispose timed out"); + } + catch (Exception ex) + { + ReportShutdownIssue(source, $"dispose failed: {ex.Message}"); + } + } + + private void ReportShutdownIssue(ICaptureSource source, string detail) + { + var msg = $"Capture source '{source.DisplayName}' {detail}"; + Dispatcher.UIThread.Post(() => ErrorReported?.Invoke(msg)); + } + public void Start() { if (_thread is not null) @@ -243,19 +283,21 @@ private void Loop(CancellationToken ct) new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None)); } - // Paint the source into a fixed w×h canvas at offset (−X, −Y) so the crop size - // defines the output rectangle and parts outside the source stay black. - using var canvasBitmap = new SKBitmap( - new SKImageInfo(w, h, SKColorType.Bgra8888, SKAlphaType.Opaque)); - using (var canvas = new SKCanvas(canvasBitmap)) + // Single-pass crop+scale: draw the crop rect of the source straight into the + // output bitmap. Skia clamps src rects that fall outside source bounds and the + // pre-cleared black background fills any uncovered area, matching the previous + // canvas-then-resize behavior without the intermediate w×h SKBitmap allocation. + var output = new SKBitmap(new SKImageInfo(outW, outH, SKColorType.Bgra8888, SKAlphaType.Opaque)); + using (var canvas = new SKCanvas(output)) + using (var sourceImage = SKImage.FromBitmap(source)) { canvas.Clear(SKColors.Black); - canvas.DrawBitmap(source, -crop.X, -crop.Y); + var srcRect = new SKRect(crop.X, crop.Y, crop.X + w, crop.Y + h); + var dstRect = new SKRect(0, 0, outW, outH); + canvas.DrawImage(sourceImage, srcRect, dstRect, + new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None)); } - - return canvasBitmap.Resize( - new SKImageInfo(outW, outH, SKColorType.Bgra8888, SKAlphaType.Opaque), - new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None)); + return output; } private double UpdateHighest(double similarity) diff --git a/AutoMask/PresetService.cs b/AutoMask/PresetService.cs index d11a5a1..00f6c1b 100644 --- a/AutoMask/PresetService.cs +++ b/AutoMask/PresetService.cs @@ -18,37 +18,51 @@ public static class PresetService .Where(dir => Directory.EnumerateFiles(dir, "preset.json", SearchOption.TopDirectoryOnly).Any()) .ToArray(); + // Reading and deserializing each preset.json in parallel — sequential await on a + // slow disk (e.g. networked drive, large preset library) summed into noticeable + // startup latency. + var results = await Task.WhenAll(presetPaths.Select(LoadOnePresetAsync)); + List foundPresets = []; List failures = []; - - foreach (string presetPath in presetPaths) + foreach (var (preset, failure) in results) { - string filePath = Path.Combine(presetPath, "preset.json"); - try + if (preset is not null) { - SplitPreset? preset = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(filePath, Encoding.UTF8), - AppJsonContext.Default.SplitPreset); - - if (preset is not null) - { - preset.PresetFolder = presetPath; - foundPresets.Add(preset); - } - else - { - failures.Add(new LoadFailure(filePath, "deserialized to null")); - } + foundPresets.Add(preset); } - catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + else if (failure is not null) { - failures.Add(new LoadFailure(filePath, ex.Message)); + failures.Add(failure); } } return (foundPresets, failures); } + private static async Task<(SplitPreset? Preset, LoadFailure? Failure)> LoadOnePresetAsync(string presetPath) + { + string filePath = Path.Combine(presetPath, "preset.json"); + try + { + var preset = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(filePath, Encoding.UTF8), + AppJsonContext.Default.SplitPreset); + + if (preset is null) + { + return (null, new LoadFailure(filePath, "deserialized to null")); + } + + preset.PresetFolder = presetPath; + return (preset, null); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + return (null, new LoadFailure(filePath, ex.Message)); + } + } + public static async Task<(List Files, List Failures)> LoadPremadeSplitsAsync(string splitsDirectory) { if (!Directory.Exists(splitsDirectory)) @@ -60,37 +74,48 @@ await File.ReadAllTextAsync(filePath, Encoding.UTF8), .Where(dir => Directory.EnumerateFiles(dir, "splits.json", SearchOption.TopDirectoryOnly).Any()) .ToArray(); + var results = await Task.WhenAll(splitPaths.Select(LoadOnePremadeSplitsAsync)); + List foundSplitFiles = []; List failures = []; - - foreach (string splitPath in splitPaths) + foreach (var (file, failure) in results) { - string filePath = Path.Combine(splitPath, "splits.json"); - try + if (file is not null) { - PremadeSplitsFile? splitsFile = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(filePath, Encoding.UTF8), - AppJsonContext.Default.PremadeSplitsFile); - - if (splitsFile is not null) - { - splitsFile.FolderPath = splitPath; - foundSplitFiles.Add(splitsFile); - } - else - { - failures.Add(new LoadFailure(filePath, "deserialized to null")); - } + foundSplitFiles.Add(file); } - catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + else if (failure is not null) { - failures.Add(new LoadFailure(filePath, ex.Message)); + failures.Add(failure); } } return (foundSplitFiles.OrderBy(f => f.GameName).ToList(), failures); } + private static async Task<(PremadeSplitsFile? File, LoadFailure? Failure)> LoadOnePremadeSplitsAsync(string splitPath) + { + string filePath = Path.Combine(splitPath, "splits.json"); + try + { + var splitsFile = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(filePath, Encoding.UTF8), + AppJsonContext.Default.PremadeSplitsFile); + + if (splitsFile is null) + { + return (null, new LoadFailure(filePath, "deserialized to null")); + } + + splitsFile.FolderPath = splitPath; + return (splitsFile, null); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + return (null, new LoadFailure(filePath, ex.Message)); + } + } + public static string CreateFilenameForSplit(SplitPreset preset, int splitIndex) { var split = preset.Splits![splitIndex]; From 9d47de8a3c951e1cc054b837c6987212952f2633 Mon Sep 17 00:00:00 2001 From: Torje Amundsen Date: Tue, 28 Apr 2026 19:34:49 +0200 Subject: [PATCH 12/12] fix(close): handle parallel taskbar close requests Windows taskbar 'Close all windows' fires Close on every top-level window in parallel, which exposed three issues: - TestOutputWindow.Closing set _isClosing only after the save dialog completed, so a second Closing event raced past the gate and spawned a duplicate dialog. _closingStarted is set synchronously before any await, and every re-entry now sets e.Cancel = true so Avalonia can't tear the window down while the prompt is still up. - MainWindow.OnClosing now defers when a live-tester is still shutting down and re-issues the parent close from the child's Closed event. Without this, MainWindow closing immediately ended the desktop lifetime and killed the dialog mid-flight. - CaptureController.DisposeAsync is idempotent via Interlocked guard. The double-close path was awaiting the disposed _swapLock and surfacing 'Cannot access a disposed object' through the new Dispatcher.UnhandledException hook. --- AutoMask/Capture/CaptureController.cs | 11 +++++++++++ AutoMask/MainWindow.axaml.cs | 19 +++++++++++++++++++ AutoMask/TestOutputWindow.axaml.cs | 24 ++++++++++++++++++++---- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/AutoMask/Capture/CaptureController.cs b/AutoMask/Capture/CaptureController.cs index 7601d6f..bfdf3c8 100644 --- a/AutoMask/Capture/CaptureController.cs +++ b/AutoMask/Capture/CaptureController.cs @@ -329,8 +329,19 @@ private static bool ReadBgraBytesInto(SKBitmap bitmap, byte[] destination) return true; } + private int _disposed; + public async ValueTask DisposeAsync() { + // Idempotent: a second call would await the already-disposed _swapLock and throw + // ObjectDisposedException. The TestOutputWindow Closing handler can fire twice + // when the OS issues simultaneous close requests (Windows taskbar 'Close all + // windows'), so the controller has to tolerate it. + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + Stop(); await SetSourceAsync(null, CancellationToken.None); _swapLock.Dispose(); diff --git a/AutoMask/MainWindow.axaml.cs b/AutoMask/MainWindow.axaml.cs index 6db25ed..6f1658f 100644 --- a/AutoMask/MainWindow.axaml.cs +++ b/AutoMask/MainWindow.axaml.cs @@ -109,6 +109,25 @@ public MainWindow() } + protected override void OnClosing(Avalonia.Controls.WindowClosingEventArgs e) + { + // Windows taskbar 'Close all windows' fires Close on every top-level window in + // parallel. MainWindow closing ends the desktop lifetime and tears down any open + // child window, including TestOutputWindow's save-prefs dialog. Defer until the + // child window has finished its own close so the prompt isn't killed mid-flight. + if (OperatingSystem.IsWindows() && _testOutputWindow is { } child && !child.HasShutdownCompleted) + { + e.Cancel = true; + // Re-issue the close once the child has finished, so the parent's close + // proceeds without the user having to click again. + child.Closed += (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(Close); + child.Activate(); + return; + } + + base.OnClosing(e); + } + protected override void OnClosed(EventArgs e) { base.OnClosed(e); diff --git a/AutoMask/TestOutputWindow.axaml.cs b/AutoMask/TestOutputWindow.axaml.cs index 9a3c1ce..99b37b1 100644 --- a/AutoMask/TestOutputWindow.axaml.cs +++ b/AutoMask/TestOutputWindow.axaml.cs @@ -48,6 +48,14 @@ private sealed class FeedOption private CapturePreferences? _loadedPrefs; private bool _hasUserChanges; private bool _isClosing; + private bool _closingStarted; + + /// + /// True once the window has finished its own shutdown sequence (capture stopped, + /// resources released, save prompt resolved). MainWindow checks this so a parallel + /// 'Close all windows' from the taskbar doesn't tear this window down mid-prompt. + /// + public bool HasShutdownCompleted => _isClosing; public TestOutputWindow() { @@ -70,17 +78,25 @@ public TestOutputWindow() Opened += async (_, _) => await InitializeAsync(); Closing += async (_, e) => { + // Once shutdown completed via the explicit Close() at the end of the flow, + // _isClosing is true and the close should proceed unimpeded. if (_isClosing) { return; } - // Always cancel and run shutdown on a fresh turn - the async lambda is not - // awaited by Avalonia's close pipeline, so without Cancel the window tears - // down while ShutdownAsync is still stopping the capture thread / webcam / - // GDI handles. + // Every re-entry while the first close is still running must cancel too — + // returning without setting Cancel=true lets Avalonia tear the window down + // mid-prompt, which was killing the save dialog when Windows taskbar + // 'Close all windows' fired a second Closing event. e.Cancel = true; + if (_closingStarted) + { + return; + } + _closingStarted = true; + if (_hasUserChanges) { await PromptSaveAndClose();