diff --git a/docs/IEC101_LIVE_TRACE_ASE2000_PARITY_20261029.md b/docs/IEC101_LIVE_TRACE_ASE2000_PARITY_20261029.md
new file mode 100644
index 0000000..e1f18af
--- /dev/null
+++ b/docs/IEC101_LIVE_TRACE_ASE2000_PARITY_20261029.md
@@ -0,0 +1,24 @@
+# Live Trace recovery and IEC-101 interpretive line monitor — 2026-10-29
+
+## Problem reproduced in screenshots
+Active IEC-101 TX/RX counters and LED traffic are live, but the Trace workspace remains visually blank until changing tabs. The old UI retained Hold/selected-row freeze state across connections; first-batch range notifications to the still-mounted virtualized WPF ListBox could leave the initially cleared viewport unrealized. In addition, Balanced verbosity *silently omitted* routine polling when the Trace workspace was not active.
+
+## Corrected architecture
+- **PrepareLiveTraceForConnect** resets selection, Hold and deferred view flags only at connection start (active, dual-link and passive), never on every frame.
+- Exactly one dispatcher-priority initial evidence flush after Connect. Continuous traffic remains handled by the existing bounded 75ms timer, adaptive processing budget and deferred UI batch updates, without a per-frame `Dispatcher.Invoke`.
+- The first Trace batch is materialized atomically from the authoritative 1200-row bounded ring and schedules one deferred layout invalidation. Subsequent batches append/trim only. Switching workspaces still snapshots that same ring; there is no separate tab-dependent decoder or storage.
+- **Every TX/RX raw frame** is retained in the bounded Trace store regardless of which workspace is visible. Backpressure protection remains separately counted and does not silently discard raw bytes from the capture journal.
+- Physical poll/reply interpretation uses the existing typed FT1.2 and ASDU decode; no regex/string guess of PRM, ACD, DFC or COT. IEC-101 Class 2 FC11 request, secondary FC9 no-data, FC8 user data with type/COT/CA/IOA/value/quality/time, link ACK/NACK and link reset are explained as engineer-readable lines. E5 remains contextual: never inferred as physical command success.
+- Compact TX ➤ and RX ◀ markers; raw hex and selected-byte Inspector remain directly below/alongside the line. No heavy graphic controls/animations, wide grid columns, or per-frame WPF layout refreshes.
+
+## FAT acceptance
+1. Start IEC-101 Active Master on COM at 1200 bps with Trace already selected; first Reset Link TX and RX ACK must display **without switching tabs**.
+2. Keep Trace selected during GI, FC10/11 polls and FC8/9 replies; every raw frame has a direction, protocol meaning and selectable original HEX.
+3. Switch to Values/Events while polling, then back to Trace: ring snapshot must show recent traffic from the inactive period.
+4. In Trace select one row: deliberate Hold preserves inspection; choose Auto or new Connect to resume live. Prior session Hold must never freeze the next session.
+5. FC9 with ACD=1/DFC=0 prints that requested class has no data but Class 1 pending. Never conflate it with RTU application error.
+6. Run same capture alongside ASE2000 V2 and compare FC, COT, CA, IOA, quality and CP56Time2a. Verify linked selected raw field highlights and command lifecycle separately.
+7. Run stress 4-COM passive and long duration Windows integrated sessions; verify no unbounded RAM/GUI repaint. CI does not substitute real-device FAT.
+
+## Scope
+This milestone corrects initial WPF Trace rendering and per-frame human-readable decoding for IEC-101. It does not redesign the rest of the GUI, implement a complete bidirectional byte-field highlight for every IEC-104/103 frame, or claim full ASE2000 V2 parity yet.
diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs b/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs
index ba4add9..2ec758a 100644
--- a/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs
+++ b/src/ARIEC60870.Desktop/Features/MainWindow.LiveEvidencePipeline.cs
@@ -221,6 +221,17 @@ private void OnEvidenceReceived(object? sender, Iec103MasterEvidenceEvent item)
}
_pendingEvidence.Enqueue(item);
+
+ // First event after Connect gets one prompt dispatcher flush; normal traffic
+ // continues on the 75ms bounded timer. Never queue a UI callback per telegram.
+ if (System.Threading.Interlocked.Exchange(ref _initialLiveTraceFlushPending, 0) == 1 &&
+ !Dispatcher.HasShutdownStarted)
+ {
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (_pendingEvidence.Count > 0) FlushUiQueues();
+ }), DispatcherPriority.Background);
+ }
}
private void OnFindingRaised(object? sender, Iec103MasterFinding finding)
@@ -526,7 +537,7 @@ private bool IsProtocolTraceViewFrozen()
return IsLineMonitorHoldRequested()
|| _isProtocolTraceDragSelecting
|| _isProtocolTraceSelectionBatching
- || (FrameTraceGrid?.SelectedItems.Count ?? 0) > 0
+ || (_protocolTraceSelectionAnchorIndex >= 0 && (FrameTraceGrid?.SelectedItems.Count ?? 0) > 0)
|| FrameTraceGrid?.ContextMenu?.IsOpen == true;
}
@@ -573,6 +584,30 @@ private void AddProtocolTraceRow(EvidenceRow row)
}
}
+ ///
+ /// A new connection must never inherit a stale Hold/selection state from a
+ /// previous session. Trace remains live without forcing navigation to another tab.
+ ///
+ private void PrepareLiveTraceForConnect()
+ {
+ _initialLiveTraceFlushPending = 1;
+ _protocolTraceSelectionAnchorIndex = -1;
+ _evidenceSummarySelectionAnchorIndex = -1;
+ _isProtocolTraceDragSelecting = false;
+ _isProtocolTraceSelectionBatching = false;
+ _isEvidenceSummaryDragSelecting = false;
+ _pendingProtocolTraceSelectionInspectorRefresh = false;
+ _protocolTraceViewDirtyWhileFrozen = false;
+ _evidenceSummaryViewDirtyWhileFrozen = false;
+ _protocolTraceRowsDeferredWhileFrozen = 0;
+ _evidenceSummaryRowsDeferredWhileFrozen = 0;
+ FrameTraceGrid?.SelectedItems.Clear();
+ EvidenceSummaryList?.SelectedItems.Clear();
+ if (AutoScrollLatestToggleButton is not null)
+ AutoScrollLatestToggleButton.IsChecked = true;
+ UpdateAutoScrollLatestRailVisual();
+ }
+
private void FlushVisibleUiBatches()
{
var batchRows = _pendingEvidenceSummaryUiRows.Count
@@ -613,8 +648,25 @@ private void FlushVisibleUiBatches()
else
{
ApplyDeferredProtocolTraceSnapshotIfNeeded();
- FrameTraceRows.AddRange(_pendingProtocolTraceUiRows);
- _pendingProtocolTraceUiRows.Clear();
+ if (FrameTraceRows.Count == 0)
+ {
+ // WPF ListBox can retain an unrealized empty viewport when an
+ // existing ItemsSource was cleared while the session changed.
+ // Reconcile the first batch from the authoritative bounded ring,
+ // once, instead of waiting for a tab switch to force a rebind.
+ FrameTraceRows.ReplaceRange(_protocolTraceStore.Snapshot());
+ _pendingProtocolTraceUiRows.Clear();
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (IsProtocolTraceTabActive())
+ FrameTraceGrid?.InvalidateMeasure();
+ }), DispatcherPriority.Loaded);
+ }
+ else
+ {
+ FrameTraceRows.AddRange(_pendingProtocolTraceUiRows);
+ _pendingProtocolTraceUiRows.Clear();
+ }
_visibleEvidenceDropped += FrameTraceRows.TrimStart(MaxVisibleFrameTraceRows);
ScrollLatestProtocolTraceIfAutoScroll();
}
@@ -702,56 +754,13 @@ private TraceVerbosityMode GetTraceVerbosityMode()
private bool ShouldShowInFrameTrace(Iec103MasterEvidenceEvent item, EvidenceRow row)
{
- // Monitor mode must show real SCADA↔RTU traffic, including routine polls,
- // independently of the Active Master Balanced/Proof trace suppression rules.
- if (item.PollingReason == "PASSIVE-IEC101") return true;
- if (row.RawHex == "-" ||
- (!row.Direction.Equals("TX", StringComparison.OrdinalIgnoreCase) &&
- !row.Direction.Equals("RX", StringComparison.OrdinalIgnoreCase)))
- {
- return false;
- }
-
- var mode = GetTraceVerbosityMode();
- if (mode == TraceVerbosityMode.Full)
- {
- return true;
- }
-
- if (IsProtectedTraceEvidence(item, row))
- {
- return true;
- }
-
- var combined = string.Join(" ", item.Category, item.State, item.Summary, item.Detail, item.OperatorMessage, item.OperatorAction, item.ProtocolMeaning, item.DataClass, row.ReadableMeaning);
- var routinePoll = ContainsAny(combined, "Request Class 1", "Request Class 2", "Class 2 poll", "background poll", "no data", "ACK", "single-character ACK");
- var supervisory = ContainsAny(combined, "TESTFR", "S-frame", "STARTDT", "STOPDT");
-
- if (mode == TraceVerbosityMode.Proof)
- {
- if (routinePoll || supervisory)
- {
- CountTraceVerbositySuppression(routinePoll ? "routine" : "supervisory");
- return false;
- }
- }
-
- if (mode == TraceVerbosityMode.Balanced)
- {
- if (supervisory)
- {
- CountTraceVerbositySuppression("supervisory");
- return false;
- }
-
- if (routinePoll && !IsProtocolTraceTabActive())
- {
- CountTraceVerbositySuppression("routine");
- return false;
- }
- }
-
- return true;
+ // Trace is the byte-for-byte communication line monitor, not a proof
+ // summary. A valid TX/RX raw frame MUST be inspectable even if the user
+ // is currently in Values, Events or Report. The bounded ring limits RAM.
+ // UI queue backpressure remains a separate, explicitly counted safeguard.
+ return row.RawHex != "-" &&
+ (row.Direction.Equals("TX", StringComparison.OrdinalIgnoreCase) ||
+ row.Direction.Equals("RX", StringComparison.OrdinalIgnoreCase));
}
private static bool IsProtectedTraceEvidence(Iec103MasterEvidenceEvent item, EvidenceRow row)
diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs
index 5bd5a22..fb9d3d1 100644
--- a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs
+++ b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveMonitor.cs
@@ -398,6 +398,7 @@ private async Task RunPassiveRecordingAsync()
_passiveMonitorSession = session;
_passiveCaptureResult = null;
ClearSessionView(clearLog: true);
+ PrepareLiveTraceForConnect();
_passiveRawRows.Clear();
_passiveFrameSequence = 0;
_passiveUndecodableShown = 0;
diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveReplay.cs b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveReplay.cs
index 541f58c..ad201a8 100644
--- a/src/ARIEC60870.Desktop/Features/MainWindow.PassiveReplay.cs
+++ b/src/ARIEC60870.Desktop/Features/MainWindow.PassiveReplay.cs
@@ -82,6 +82,7 @@ private async Task OpenPassiveRawJournalAsync(string manifestPath)
try
{
ClearSessionView(clearLog: true);
+ PrepareLiveTraceForConnect();
_passiveFrameSequence = 0;
_passiveTransactionNotes.Clear();
_passiveFindingLastShown.Clear();
diff --git a/src/ARIEC60870.Desktop/Features/MainWindow.Session.cs b/src/ARIEC60870.Desktop/Features/MainWindow.Session.cs
index d6ef694..e8e2a61 100644
--- a/src/ARIEC60870.Desktop/Features/MainWindow.Session.cs
+++ b/src/ARIEC60870.Desktop/Features/MainWindow.Session.cs
@@ -101,6 +101,7 @@ private async void Start_Click(object sender, RoutedEventArgs e)
}
ClearSessionView(clearLog: false);
+ PrepareLiveTraceForConnect();
SeedValueViewerFromIoaProfile(settings.ProtocolMode);
_stopRequested = false;
_operatorDisconnectInProgress = false;
diff --git a/src/ARIEC60870.Desktop/MainWindow.xaml.cs b/src/ARIEC60870.Desktop/MainWindow.xaml.cs
index 25fa419..cb09bd5 100644
--- a/src/ARIEC60870.Desktop/MainWindow.xaml.cs
+++ b/src/ARIEC60870.Desktop/MainWindow.xaml.cs
@@ -93,6 +93,8 @@ public partial class MainWindow : Window
private readonly ConcurrentQueue _pendingEvidence = new();
+ // One early dispatcher flush per Connect; normal traffic uses the bounded UI timer.
+ private int _initialLiveTraceFlushPending;
private readonly ConcurrentQueue _pendingFindings = new();
private readonly Queue _sessionLogLines = new();
private readonly DispatcherTimer _uiFlushTimer;
diff --git a/src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs b/src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs
index 8cd3b89..9c04077 100644
--- a/src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs
+++ b/src/ARIEC60870.Desktop/ViewModels/EvidenceRow.cs
@@ -75,8 +75,14 @@ public EvidenceRow(Iec103MasterEvidenceEvent item, Iec10xPointMappingEntry? ioaP
ProtocolAddress = BuildProtocolAddress();
ProtocolService = BuildProtocolService();
ReadableMeaning = BuildReadableMeaning(item);
- ProtocolTraceTitle = BuildProtocolTraceTitle();
- ProtocolTraceMeaning = BuildProtocolTraceMeaning();
+ // Render the already-decoded IEC-101 frame once, reusing its compact
+ // narration for both line title and explanation (no duplicate formatting).
+ (string Title, string Meaning)? narration =
+ item.ProtocolMode == Iec60870ProtocolMode.Iec101 && item.Frame is not null
+ ? Iec101TraceNarrator.Describe(item)
+ : null;
+ ProtocolTraceTitle = narration?.Title ?? BuildProtocolTraceTitle();
+ ProtocolTraceMeaning = narration?.Meaning ?? BuildProtocolTraceMeaning();
ProtocolTraceRaw = BuildProtocolTraceRaw();
ProtocolTraceMeta = $"#{Sequence} {Time} {ProtocolName}";
TrafficTone = ResolveTrafficTone();
@@ -221,7 +227,8 @@ public EvidenceRow(CaptureFrameSnapshot capture)
private string BuildProtocolTraceTitle()
{
- var direction = string.IsNullOrWhiteSpace(Direction) ? "STATE" : Direction;
+ var direction = Direction == "TX" ? "➤ TX" : Direction == "RX" ? "◀ RX" :
+ string.IsNullOrWhiteSpace(Direction) ? "STATE" : Direction;
var service = CleanTracePart(ProtocolService);
var address = CleanTracePart(ProtocolAddress);
var signal = CleanTracePart(SignalOrAddress);
diff --git a/src/ARIEC60870.Desktop/ViewModels/Iec101TraceNarrator.cs b/src/ARIEC60870.Desktop/ViewModels/Iec101TraceNarrator.cs
new file mode 100644
index 0000000..555211e
--- /dev/null
+++ b/src/ARIEC60870.Desktop/ViewModels/Iec101TraceNarrator.cs
@@ -0,0 +1,119 @@
+// Copyright 2026 Ari Sulistiono
+// SPDX-License-Identifier: Apache-2.0
+
+using ARIEC60870.Core.Model;
+using ARIEC60870.Master.Model;
+
+namespace ARIEC60870.Desktop.ViewModels;
+
+///
+/// One cheap, read-only interpretation of the already decoded FT1.2/ASDU evidence.
+/// The line monitor must never infer an ACTTERM, process operation or queue content
+/// from a link ACK, ACD, or an unrelated command.
+///
+public static class Iec101TraceNarrator
+{
+ public static (string Title, string Meaning) Describe(Iec103MasterEvidenceEvent item)
+ {
+ var direction = item.Direction == FrameDirection.MasterToSlave ? "➤ TX" :
+ item.Direction == FrameDirection.SlaveToMaster ? "◀ RX" : "• STATE";
+ var link = item.Frame?.LinkControl;
+ var prefix = string.IsNullOrWhiteSpace(item.PhysicalSource)
+ ? direction : $"{direction} [{item.PhysicalSource}]";
+ var dl = item.LinkAddress ?? item.Frame?.LinkAddress;
+ var dlText = dl.HasValue ? $"DL {dl.Value}" : "DL ?";
+ var kind = string.IsNullOrWhiteSpace(item.DataClass) ? string.Empty : item.DataClass;
+ var className = kind.Contains("Class 1", StringComparison.OrdinalIgnoreCase) ? "CLASS 1" :
+ kind.Contains("Class 2", StringComparison.OrdinalIgnoreCase) ? "CLASS 2" : "REQUESTED CLASS";
+ if (link is not null)
+ {
+ var flags = link.Prm
+ ? $"PRM=1, FCB={Bit(link.Fcb)}, FCV={Bit(link.Fcv)}, FC={link.FunctionCode}"
+ : $"PRM=0, ACD={Bit(link.Acd)}, DFC={Bit(link.Dfc)}, FC={link.FunctionCode}";
+
+ if (link.Prm && link.FunctionCode is 10 or 11)
+ {
+ var requested = link.FunctionCode == 10 ? "CLASS 1" : "CLASS 2";
+ return ($"{prefix} {requested} REQUEST · {dlText}",
+ $"Master requests {requested} queued data from RTU · [{flags}].");
+ }
+
+ if (!link.Prm && link.FunctionCode == 9)
+ {
+ var classText = className == "REQUESTED CLASS" ? "REQUESTED DATA" : $"{className} DATA";
+ var queued = link.Acd == true
+ ? "CLASS 1 AVAILABLE (ACD=1); master should request Class 1"
+ : "No Class 1 availability signaled by ACD";
+ var busy = link.Dfc == true ? " · RTU flow control busy (DFC=1)" : string.Empty;
+ return ($"{prefix} {classText} NOT AVAILABLE · {dlText}",
+ $"No requested data returned · {queued}{busy} · [{flags}]. This is a link response, not a process failure.");
+ }
+
+ if (!link.Prm && link.FunctionCode == 8)
+ {
+ var asdu = item.AsduDetails;
+ var type = asdu?.TypeName ?? item.TypeName;
+ if (string.IsNullOrWhiteSpace(type)) type = "ASDU";
+ var cause = asdu?.CotDisplay ?? (item.CauseOfTransmission.HasValue
+ ? $"{item.CauseOfTransmission.Value} · {item.CauseName}" : "COT unknown");
+ var ca = asdu?.CommonAddress ?? item.CommonAddressNumber;
+ var first = item.InformationObjectDetails ?? asdu?.FirstObject;
+ var objectCount = asdu?.Objects.Count ?? item.ObjectCount ?? 0;
+ var details = $"ASDU Type {asdu?.TypeId ?? item.TypeId}: {type}; COT {cause}; CA={ca?.ToString() ?? "?"}; " +
+ $"objects={objectCount}; [{flags}]";
+ if (first is not null)
+ {
+ details += $"; IOA {first.InformationObjectAddress}={first.ShortValue}";
+ if (first.QualityDetails is not null) details += $"; Quality={first.QualityDetails.DisplayText}";
+ if (!string.IsNullOrWhiteSpace(first.TimestampText)) details += $"; RTU time={first.TimestampText}";
+ }
+ if (link.Acd == true) details += "; additional Class 1 data indicated";
+ return ($"{prefix} RTU USER DATA · {type} · {dlText}", details + ".");
+ }
+
+ if (!link.Prm && link.FunctionCode == 0)
+ return ($"{prefix} LINK ACK · {dlText}",
+ $"RTU acknowledged the link-layer request · [{flags}]. This does not prove application or physical command success.");
+
+ if (!link.Prm && link.FunctionCode == 1)
+ return ($"{prefix} LINK NACK · {dlText}",
+ $"RTU reported negative link confirmation · [{flags}]. Check the preceding request and link state.");
+
+ if (link.Prm && link.FunctionCode == 0)
+ return ($"{prefix} RESET REMOTE LINK · {dlText}",
+ $"Master requests link reset · [{flags}]. Await RTU link response.");
+ if (link.Prm && link.FunctionCode == 9)
+ return ($"{prefix} LINK STATUS REQUEST · {dlText}",
+ $"Master requests link status · [{flags}].");
+ if (!link.Prm && link.FunctionCode == 11)
+ return ($"{prefix} LINK STATUS RESPONSE · {dlText}",
+ $"RTU reports link status · [{flags}].");
+ if (item.AsduDetails is not null)
+ {
+ var asdu = item.AsduDetails;
+ return ($"{prefix} {asdu.TypeName} · {dlText}",
+ $"ASDU Type {asdu.TypeId}, COT {asdu.CotDisplay}, CA={asdu.CommonAddress}, " +
+ $"objects={asdu.Objects.Count} · [{flags}]. {asdu.ShortMeaning}");
+ }
+ return ($"{prefix} {link.FunctionName.ToUpperInvariant()} · {dlText}",
+ $"Link function FC={link.FunctionCode} · [{flags}].");
+ }
+
+ if (item.Frame?.IsSingleCharacterAck == true)
+ return ($"{prefix} E5 SHORT RESPONSE",
+ "IEC-101 E5 short response: meaning depends on the matched link request. " +
+ "It must not be presented as proof that an application command succeeded.");
+
+ if (item.Frame?.IsSingleCharacterNack == true)
+ return ($"{prefix} A2 INVALID IEC-101 RESPONSE",
+ "A2 single-character response is not permitted in IEC-101. Keep original bytes for investigation.");
+
+ if (item.Frame is { IsChecksumValid: false })
+ return ($"{prefix} INVALID FT1.2 FRAME", "Checksum/format invalid or incomplete; consult exact raw bytes.");
+
+ return ($"{prefix} IEC-101 FRAME",
+ string.IsNullOrWhiteSpace(item.ProtocolMeaning) ? item.Summary : item.ProtocolMeaning);
+ }
+
+ private static string Bit(bool? value) => value is null ? "?" : value.Value ? "1" : "0";
+}
diff --git a/tests/ARIEC60870.Desktop.Tests/Iec101TraceNarratorTests.cs b/tests/ARIEC60870.Desktop.Tests/Iec101TraceNarratorTests.cs
new file mode 100644
index 0000000..9388b98
--- /dev/null
+++ b/tests/ARIEC60870.Desktop.Tests/Iec101TraceNarratorTests.cs
@@ -0,0 +1,123 @@
+// Copyright 2026 Ari Sulistiono
+// SPDX-License-Identifier: Apache-2.0
+
+using ARIEC60870.Core.Model;
+using ARIEC60870.Desktop.ViewModels;
+using ARIEC60870.Master.Model;
+using ARIEC60870.Master.Protocol.Iec10x;
+using Xunit;
+
+namespace ARIEC60870.Desktop.Tests;
+
+public sealed class Iec101TraceNarratorTests
+{
+ private static Iec103MasterEvidenceEvent LinkFrame(bool primary, int function, string dataClass = "Class 2",
+ bool acd = false, bool dfc = false) => new()
+ {
+ ProtocolMode = Iec60870ProtocolMode.Iec101,
+ Direction = primary ? FrameDirection.MasterToSlave : FrameDirection.SlaveToMaster,
+ DataClass = dataClass,
+ LinkAddress = 105,
+ RawHex = "10 09 69 00 72 16",
+ Frame = new Ft12FrameDecode
+ {
+ IsChecksumValid = true,
+ LinkAddress = 105,
+ LinkControl = new LinkControlInfo
+ {
+ Prm = primary, FunctionCode = function,
+ Fcb = primary ? true : null, Fcv = primary ? true : null,
+ Acd = primary ? null : acd, Dfc = primary ? null : dfc,
+ FunctionName = "IEC-101 link function"
+ }
+ }
+ };
+
+ [Fact]
+ public void EveryClass2RequestHasDirectionLinkAndRawProtocolFlags()
+ {
+ var presentation = Iec101TraceNarrator.Describe(LinkFrame(true, 11));
+ Assert.Contains("➤ TX", presentation.Title);
+ Assert.Contains("CLASS 2 REQUEST", presentation.Title);
+ Assert.Contains("DL 105", presentation.Title);
+ Assert.Contains("PRM=1", presentation.Meaning);
+ Assert.Contains("FCV=1", presentation.Meaning);
+ }
+
+ [Fact]
+ public void Class2NoDataWithAcdMeansClass1QueueIsIndicatedNotAnError()
+ {
+ var presentation = Iec101TraceNarrator.Describe(LinkFrame(false, 9, "Class 2", acd: true));
+ Assert.Contains("◀ RX", presentation.Title);
+ Assert.Contains("CLASS 2 DATA NOT AVAILABLE", presentation.Title);
+ Assert.Contains("CLASS 1 AVAILABLE", presentation.Meaning);
+ Assert.Contains("ACD=1", presentation.Meaning);
+ Assert.Contains("DFC=0", presentation.Meaning);
+ Assert.Contains("not a process failure", presentation.Meaning);
+ }
+
+ [Fact]
+ public void UnknownPollClassMustNotBeClaimedClass2()
+ {
+ var presentation = Iec101TraceNarrator.Describe(LinkFrame(false, 9, "-"));
+ Assert.Contains("REQUESTED DATA NOT AVAILABLE", presentation.Title);
+ Assert.DoesNotContain("CLASS 2 DATA NOT AVAILABLE", presentation.Title);
+ }
+
+ [Fact]
+ public void RTUAsduKeepsTypeCotCaIoaAndValueWithoutReparsingHex()
+ {
+ var asdu = new Iec10xAsduDecode
+ {
+ TypeId = 30, TypeName = "M_SP_TB_1", CauseOfTransmission = 20,
+ CauseName = "interrogated by station", CommonAddress = 1,
+ Objects = new[] { new Iec10xInformationObject
+ { Index = 1, InformationObjectAddress = 8388715, EngineeringValue = "ON",
+ TimestampText = "2026-10-28 20:39:28.460" } }
+ };
+ var basic = LinkFrame(false, 8, "Class 1", acd: true);
+ var item = new Iec103MasterEvidenceEvent
+ {
+ ProtocolMode = Iec60870ProtocolMode.Iec101,
+ Direction = FrameDirection.SlaveToMaster,
+ DataClass = basic.DataClass,
+ LinkAddress = basic.LinkAddress,
+ Frame = basic.Frame,
+ AsduDetails = asdu
+ };
+ var presentation = Iec101TraceNarrator.Describe(item);
+ Assert.Contains("RTU USER DATA", presentation.Title);
+ Assert.Contains("ASDU Type 30", presentation.Meaning);
+ Assert.Contains("COT 20", presentation.Meaning);
+ Assert.Contains("CA=1", presentation.Meaning);
+ Assert.Contains("IOA 8388715=ON", presentation.Meaning);
+ Assert.Contains("RTU time=2026-10-28", presentation.Meaning);
+ }
+
+ [Fact]
+ public void LinkAckNeverClaimsSuccessfulPhysicalOperation()
+ {
+ var presentation = Iec101TraceNarrator.Describe(LinkFrame(false, 0));
+ Assert.Contains("LINK ACK", presentation.Title);
+ Assert.Contains("does not prove", presentation.Meaning);
+ }
+
+ [Fact]
+ public void E5ResponseIsExplicitlyContextDependent()
+ {
+ var item = new Iec103MasterEvidenceEvent
+ {
+ ProtocolMode = Iec60870ProtocolMode.Iec101,
+ Direction = FrameDirection.SlaveToMaster,
+ Frame = new Ft12FrameDecode
+ {
+ Format = Ft12FrameFormat.SingleCharacter,
+ RawBytes = new byte[] { 0xE5 },
+ IsChecksumValid = true
+ }
+ };
+ var presentation = Iec101TraceNarrator.Describe(item);
+ Assert.Contains("E5 SHORT RESPONSE", presentation.Title);
+ Assert.Contains("matched link request", presentation.Meaning);
+ }
+}