diff --git a/Models/IoTesting/IoFatSourceModels.cs b/Models/IoTesting/IoFatSourceModels.cs
index d2e0d8a27..1bbe713f9 100644
--- a/Models/IoTesting/IoFatSourceModels.cs
+++ b/Models/IoTesting/IoFatSourceModels.cs
@@ -93,6 +93,63 @@ public static string ProjectSourceFingerprint(IoTestProject project)
return string.Empty;
}
+ ///
+ /// Content-only fingerprint used exclusively for SCL workspace continuation.
+ /// File names and SourceId values are presentation/staging metadata and must not make
+ /// the same IEC 61850 source bytes look like a different FAT project after reopening
+ /// Engineering and switching back to FAT.
+ ///
+ /// Do not use this fingerprint for workbook identity or package integrity. Those paths
+ /// retain the strict source-set contract above.
+ ///
+ public static string ComputeContentFingerprint(IEnumerable sources)
+ {
+ ArgumentNullException.ThrowIfNull(sources);
+ var canonical = sources
+ .Select(Normalize)
+ .OrderBy(source => source.Kind, StringComparer.Ordinal)
+ .ThenBy(source => source.Sha256, StringComparer.Ordinal)
+ .Select(source => $"{source.Kind}\t{source.Sha256}")
+ .ToArray();
+ if (canonical.Length == 0)
+ return string.Empty;
+ var bytes = Encoding.UTF8.GetBytes(string.Join("\n", canonical));
+ return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+ }
+
+ public static bool SameSclContentSet(
+ IEnumerable left,
+ IEnumerable right)
+ {
+ ArgumentNullException.ThrowIfNull(left);
+ ArgumentNullException.ThrowIfNull(right);
+
+ var leftSources = left.Select(Normalize).ToArray();
+ var rightSources = right.Select(Normalize).ToArray();
+ if (leftSources.Length == 0 || rightSources.Length == 0 ||
+ leftSources.Length != rightSources.Length ||
+ leftSources.Any(source => !source.Kind.Equals(IoFatSourceKinds.Scl, StringComparison.OrdinalIgnoreCase)) ||
+ rightSources.Any(source => !source.Kind.Equals(IoFatSourceKinds.Scl, StringComparison.OrdinalIgnoreCase)))
+ {
+ return false;
+ }
+
+ return ComputeContentFingerprint(leftSources)
+ .Equals(ComputeContentFingerprint(rightSources), StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static bool CompatibleContinuationSchema(string? savedSchema, string? currentSchema)
+ {
+ var saved = (savedSchema ?? string.Empty).Trim();
+ var current = (currentSchema ?? string.Empty).Trim();
+ if (saved.Equals(current, StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ return TryGetSclSchemaMajor(saved, out var savedMajor) &&
+ TryGetSclSchemaMajor(current, out var currentMajor) &&
+ savedMajor == currentMajor;
+ }
+
public static string ProjectStorageFingerprint(IoTestProject project)
{
ArgumentNullException.ThrowIfNull(project);
@@ -204,6 +261,23 @@ private static string NormalizeHash(string sha256)
return normalized;
}
+ private static bool TryGetSclSchemaMajor(string schema, out int major)
+ {
+ major = 0;
+ const string prefix = "ARSAS-FAT-SCL-";
+ if (!schema.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ var version = schema[prefix.Length..].Trim();
+ var separator = version.IndexOf('.');
+ var majorText = separator >= 0 ? version[..separator] : version;
+ return int.TryParse(
+ majorText,
+ System.Globalization.NumberStyles.None,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out major);
+ }
+
private static string SafeFileName(string? value, string fallback)
{
var name = Path.GetFileName(string.IsNullOrWhiteSpace(value) ? fallback : value.Trim());
diff --git a/RcbExportFilterWindow.VirtualizedSelectionAuthority.cs b/RcbExportFilterWindow.VirtualizedSelectionAuthority.cs
new file mode 100644
index 000000000..26520deb4
--- /dev/null
+++ b/RcbExportFilterWindow.VirtualizedSelectionAuthority.cs
@@ -0,0 +1,53 @@
+using System.Runtime.CompilerServices;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester;
+
+///
+/// Keeps the P1 multi-RCB selection model authoritative when the legacy DataGrid virtualizes
+/// and recycles checkbox containers. A recycled CheckBox re-applies IsChecked from the bound
+/// RcbExportRow.IsSelected value, which raises Checked/Unchecked even though the operator did
+/// not change selection. The original single-select instance handlers interpret that visual
+/// rehydration as a user action and call SelectOnly, silently clearing the other selected RCBs.
+///
+/// P1 pointer/keyboard handling already owns real operator selection changes, so these routed
+/// state-change events are presentation-only for this grid and must not reach the stale
+/// single-select handlers.
+///
+public partial class RcbExportFilterWindow
+{
+ [ModuleInitializer]
+ internal static void RegisterLegacyRcbVirtualizedSelectionAuthority()
+ {
+ EventManager.RegisterClassHandler(
+ typeof(CheckBox),
+ ToggleButton.CheckedEvent,
+ new RoutedEventHandler(SuppressLegacyRcbSingleSelectStateHandler),
+ handledEventsToo: true);
+ EventManager.RegisterClassHandler(
+ typeof(CheckBox),
+ ToggleButton.UncheckedEvent,
+ new RoutedEventHandler(SuppressLegacyRcbSingleSelectStateHandler),
+ handledEventsToo: true);
+ }
+
+ private static void SuppressLegacyRcbSingleSelectStateHandler(object sender, RoutedEventArgs e)
+ {
+ if (sender is not CheckBox checkBox ||
+ checkBox.DataContext is not RcbExportRow ||
+ Window.GetWindow(checkBox) is not RcbExportFilterWindow window ||
+ FindP1LegacyRcbAncestor(checkBox) is not { } grid ||
+ !ReferenceEquals(grid, window.RcbGrid))
+ {
+ return;
+ }
+
+ // Do not allow the original XAML Checked/Unchecked instance handlers to translate
+ // DataGrid container materialization/recycling into a single-selection mutation.
+ // RcbExportRow.IsSelected remains the durable selection authority.
+ e.Handled = true;
+ }
+}
diff --git a/Resources/ArvrelMiniIedFascia.xaml b/Resources/ArvrelMiniIedFascia.xaml
index e9d3e2aa9..5e47e4a64 100644
--- a/Resources/ArvrelMiniIedFascia.xaml
+++ b/Resources/ArvrelMiniIedFascia.xaml
@@ -3,87 +3,89 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
-