From 9bcb95a6179c8b49d04159844ea5b963f7f50ad2 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:20:23 +0700 Subject: [PATCH 01/26] Add immutable SV runtime snapshot boundary --- .../Runtime/SvRuntimeSnapshot.cs | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 src/ProcessBus.Iec61850.Raw/Runtime/SvRuntimeSnapshot.cs diff --git a/src/ProcessBus.Iec61850.Raw/Runtime/SvRuntimeSnapshot.cs b/src/ProcessBus.Iec61850.Raw/Runtime/SvRuntimeSnapshot.cs new file mode 100644 index 0000000..af9d402 --- /dev/null +++ b/src/ProcessBus.Iec61850.Raw/Runtime/SvRuntimeSnapshot.cs @@ -0,0 +1,294 @@ +using ProcessBus.Core.Models; +using System.Collections.ObjectModel; +using System.Threading; + +namespace ProcessBus.Iec61850.Raw.Runtime; + +/// +/// Immutable, per-publication view of one selected SV stream. All mutable analyzer +/// collections are copied before publication so UI, replay, and export consumers can +/// read a coherent generation without holding the analyzer lock. +/// +public sealed class SvRuntimeSnapshot +{ + internal SvRuntimeSnapshot( + long generation, + DateTime createdUtc, + string? streamId, + SvRuntimeIdentitySnapshot? identity, + IReadOnlyList channels, + SvRuntimeDiagnosticsSnapshot diagnostics, + int samplesPerCycle, + double sampleRateHz, + double measuredFrequencyHz, + double windowDurationMilliseconds, + string waveformStatus, + string shapeSeverity, + string shapeStatus, + bool hasShapeWarning) + { + Generation = generation; + CreatedUtc = createdUtc; + StreamId = streamId; + Identity = identity; + Channels = channels; + Diagnostics = diagnostics; + SamplesPerCycle = samplesPerCycle; + SampleRateHz = sampleRateHz; + MeasuredFrequencyHz = measuredFrequencyHz; + WindowDurationMilliseconds = windowDurationMilliseconds; + WaveformStatus = waveformStatus; + ShapeSeverity = shapeSeverity; + ShapeStatus = shapeStatus; + HasShapeWarning = hasShapeWarning; + } + + public long Generation { get; } + public DateTime CreatedUtc { get; } + public string? StreamId { get; } + public SvRuntimeIdentitySnapshot? Identity { get; } + public IReadOnlyList Channels { get; } + public SvRuntimeDiagnosticsSnapshot Diagnostics { get; } + public int SamplesPerCycle { get; } + public double SampleRateHz { get; } + public double MeasuredFrequencyHz { get; } + public double WindowDurationMilliseconds { get; } + public string WaveformStatus { get; } + public string ShapeSeverity { get; } + public string ShapeStatus { get; } + public bool HasShapeWarning { get; } + + public static SvRuntimeSnapshot Empty { get; } = new( + generation: 0, + createdUtc: DateTime.UnixEpoch, + streamId: null, + identity: null, + channels: Array.AsReadOnly(Array.Empty()), + diagnostics: SvRuntimeDiagnosticsSnapshot.Empty, + samplesPerCycle: 0, + sampleRateHz: 0, + measuredFrequencyHz: 0, + windowDurationMilliseconds: 0, + waveformStatus: "No runtime snapshot published.", + shapeSeverity: "Unknown", + shapeStatus: "Shape pending", + hasShapeWarning: false); +} + +public sealed record SvRuntimeIdentitySnapshot( + string StreamName, + string SvId, + string DataSet, + string AppId, + string SourceMac, + string DestinationMac, + string VlanText, + string SmpRateText, + string ConfRevText, + string MappingProfileName); + +public sealed class SvRuntimeChannelSnapshot +{ + internal SvRuntimeChannelSnapshot( + string name, + string unit, + double? instantValue, + double? rmsValue, + double? angleDegrees, + IReadOnlyList samples, + string shapeSeverity, + string shapeStatus, + double shapeResidualPercent, + double crestFactor, + bool hasShapeDistortion) + { + Name = name; + Unit = unit; + InstantValue = instantValue; + RmsValue = rmsValue; + AngleDegrees = angleDegrees; + Samples = samples; + ShapeSeverity = shapeSeverity; + ShapeStatus = shapeStatus; + ShapeResidualPercent = shapeResidualPercent; + CrestFactor = crestFactor; + HasShapeDistortion = hasShapeDistortion; + } + + public string Name { get; } + public string Unit { get; } + public double? InstantValue { get; } + public double? RmsValue { get; } + public double? AngleDegrees { get; } + public IReadOnlyList Samples { get; } + public string ShapeSeverity { get; } + public string ShapeStatus { get; } + public double ShapeResidualPercent { get; } + public double CrestFactor { get; } + public bool HasShapeDistortion { get; } +} + +public sealed record SvRuntimeDiagnosticsSnapshot( + bool IsRunning, + string StreamStatus, + long TotalPackets, + long DecodeErrors, + long SequenceErrors, + long MissingSamples, + int? LastSampleCount, + double? PacketRatePps, + double? CurrentDeltaMicroseconds, + double? AverageDeltaMicroseconds, + double? ExpectedDeltaMicroseconds, + double? CurrentJitterMicroseconds, + double? MaxAbsJitterMicroseconds, + DateTime? LastPacketTimestampUtc) +{ + public static SvRuntimeDiagnosticsSnapshot Empty { get; } = new( + false, + "No stream selected", + 0, + 0, + 0, + 0, + null, + null, + null, + null, + null, + null, + null, + null); +} + +public static class SvRuntimeSnapshotFactory +{ + public static SvRuntimeSnapshot Create(AnalyzerSnapshot source, long generation, DateTime? createdUtc = null) + { + ArgumentNullException.ThrowIfNull(source); + if (generation < 0) + throw new ArgumentOutOfRangeException(nameof(generation)); + + var waveformByName = source.Waveform.VoltageSeries + .Concat(source.Waveform.CurrentSeries) + .GroupBy(series => series.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + var analogChannels = new[] + { + source.AnalogValues.Ia, + source.AnalogValues.Ib, + source.AnalogValues.Ic, + source.AnalogValues.In, + source.AnalogValues.Ua, + source.AnalogValues.Ub, + source.AnalogValues.Uc, + source.AnalogValues.Un + }; + var analogByName = analogChannels.ToDictionary(channel => channel.Name, StringComparer.OrdinalIgnoreCase); + + var orderedNames = source.Waveform.VoltageSeries + .Concat(source.Waveform.CurrentSeries) + .Select(series => series.Name) + .Concat(analogChannels.Select(channel => channel.Name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var channelCopies = orderedNames.Select(name => + { + waveformByName.TryGetValue(name, out var series); + analogByName.TryGetValue(name, out var analog); + + var sampleCopy = series?.Samples.ToArray() ?? Array.Empty(); + var samples = Array.AsReadOnly(sampleCopy); + + return new SvRuntimeChannelSnapshot( + name, + analog?.Unit ?? series?.Unit ?? string.Empty, + analog?.InstantValue, + analog?.RmsValue, + analog?.AngleDegrees, + samples, + series?.ShapeSeverity ?? "Unknown", + series?.ShapeStatusText ?? "Shape pending", + series?.ShapeResidualPercent ?? 0, + series?.CrestFactor ?? 0, + series?.HasShapeDistortion ?? false); + }).ToArray(); + + var details = source.SelectedStreamDetails; + var identity = details is null + ? null + : new SvRuntimeIdentitySnapshot( + details.StreamName, + details.SvId, + details.DataSet, + details.AppId, + details.SourceMac, + details.DestinationMac, + details.VlanText, + details.SmpRateText, + details.ConfRevText, + details.SampleValueMappingText); + + var diagnostics = source.Diagnostics; + var diagnosticCopy = new SvRuntimeDiagnosticsSnapshot( + diagnostics.IsRunning, + diagnostics.StreamStatusText, + diagnostics.TotalPackets, + diagnostics.DecodeErrors, + diagnostics.SequenceErrors, + diagnostics.MissingSamples, + diagnostics.LastSampleCount, + diagnostics.PacketRatePps, + diagnostics.CurrentDeltaMicroseconds, + diagnostics.AverageDeltaMicroseconds, + diagnostics.ExpectedDeltaMicroseconds, + diagnostics.CurrentJitterMicroseconds, + diagnostics.MaxAbsJitterMicroseconds, + diagnostics.LastPacketTimestampUtc); + + return new SvRuntimeSnapshot( + generation, + createdUtc ?? DateTime.UtcNow, + source.SelectedStreamId, + identity, + Array.AsReadOnly(channelCopies), + diagnosticCopy, + source.Waveform.SamplesPerCycle, + source.Waveform.SampleRateHz, + source.Waveform.MeasuredFrequencyHz, + source.Waveform.WindowDurationMilliseconds, + source.Waveform.StatusText, + source.Waveform.ShapeSeverity, + source.Waveform.ShapeStatusText, + source.Waveform.HasShapeWarning); + } +} + +/// +/// Lock-free publication point for immutable runtime generations. A consumer sees +/// either the previous complete generation or the next complete generation, never +/// a partially assembled mix. +/// +public sealed class SvRuntimeSnapshotPublisher +{ + private long _generation; + private SvRuntimeSnapshot _latest = SvRuntimeSnapshot.Empty; + + public SvRuntimeSnapshot Latest => Volatile.Read(ref _latest); + + public SvRuntimeSnapshot Publish(AnalyzerSnapshot source, DateTime? createdUtc = null) + { + var generation = Interlocked.Increment(ref _generation); + var next = SvRuntimeSnapshotFactory.Create(source, generation, createdUtc); + Volatile.Write(ref _latest, next); + return next; + } + + public void Reset() + { + Interlocked.Exchange(ref _generation, 0); + Volatile.Write(ref _latest, SvRuntimeSnapshot.Empty); + } +} From 79b43aedc5d1822cf8fb0c9fcf9757ba808c98e5 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:20:42 +0700 Subject: [PATCH 02/26] Add bounded classic PCAP replay reader --- .../Replay/PcapReplayReader.cs | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/ProcessBus.Iec61850.Raw/Replay/PcapReplayReader.cs diff --git a/src/ProcessBus.Iec61850.Raw/Replay/PcapReplayReader.cs b/src/ProcessBus.Iec61850.Raw/Replay/PcapReplayReader.cs new file mode 100644 index 0000000..a4fcb8d --- /dev/null +++ b/src/ProcessBus.Iec61850.Raw/Replay/PcapReplayReader.cs @@ -0,0 +1,138 @@ +using System.Buffers.Binary; + +namespace ProcessBus.Iec61850.Raw.Replay; + +public sealed class PcapReplayReader +{ + private const uint EthernetLinkType = 1; + private readonly int _maximumFrameBytes; + + public PcapReplayReader(int maximumFrameBytes = 4 * 1024 * 1024) + { + if (maximumFrameBytes < 64) + throw new ArgumentOutOfRangeException(nameof(maximumFrameBytes)); + + _maximumFrameBytes = maximumFrameBytes; + } + + public IEnumerable Read(Stream stream, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + if (!stream.CanRead) + throw new ArgumentException("PCAP stream must be readable.", nameof(stream)); + + var globalHeader = new byte[24]; + ReadExactly(stream, globalHeader, allowCleanEndOfStream: false); + var format = ParseGlobalHeader(globalHeader); + + long sequence = 0; + var recordHeader = new byte[16]; + + while (ReadExactly(stream, recordHeader, allowCleanEndOfStream: true)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var seconds = ReadUInt32(recordHeader.AsSpan(0, 4), format.IsLittleEndian); + var fraction = ReadUInt32(recordHeader.AsSpan(4, 4), format.IsLittleEndian); + var includedLength = ReadUInt32(recordHeader.AsSpan(8, 4), format.IsLittleEndian); + var originalLength = ReadUInt32(recordHeader.AsSpan(12, 4), format.IsLittleEndian); + + var fractionLimit = format.IsNanosecondResolution ? 1_000_000_000u : 1_000_000u; + if (fraction >= fractionLimit) + throw new InvalidDataException($"PCAP timestamp fraction is invalid at record {sequence + 1}."); + if (includedLength == 0) + throw new InvalidDataException($"PCAP record {sequence + 1} has an empty captured frame."); + if (includedLength > format.SnapLength || includedLength > _maximumFrameBytes) + throw new InvalidDataException($"PCAP record {sequence + 1} exceeds the configured frame boundary."); + if (originalLength < includedLength) + throw new InvalidDataException($"PCAP record {sequence + 1} declares original length smaller than captured length."); + + var frameBytes = new byte[checked((int)includedLength)]; + ReadExactly(stream, frameBytes, allowCleanEndOfStream: false); + + var timestamp = DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime; + var ticks = format.IsNanosecondResolution + ? fraction / 100u + : fraction * 10u; + timestamp = timestamp.AddTicks(ticks); + + sequence++; + yield return new PcapReplayFrame(sequence, timestamp, frameBytes, originalLength); + } + } + + private static PcapFormat ParseGlobalHeader(ReadOnlySpan header) + { + var format = header[..4] switch + { + [0xD4, 0xC3, 0xB2, 0xA1] => new PcapFormat(true, false, 0), + [0xA1, 0xB2, 0xC3, 0xD4] => new PcapFormat(false, false, 0), + [0x4D, 0x3C, 0xB2, 0xA1] => new PcapFormat(true, true, 0), + [0xA1, 0xB2, 0x3C, 0x4D] => new PcapFormat(false, true, 0), + _ => throw new InvalidDataException("Unsupported PCAP magic. Only classic microsecond/nanosecond PCAP is accepted by this reader.") + }; + + var major = ReadUInt16(header.Slice(4, 2), format.IsLittleEndian); + var minor = ReadUInt16(header.Slice(6, 2), format.IsLittleEndian); + if (major != 2 || minor != 4) + throw new InvalidDataException($"Unsupported PCAP version {major}.{minor}; expected 2.4."); + + var snapLength = ReadUInt32(header.Slice(16, 4), format.IsLittleEndian); + if (snapLength == 0) + throw new InvalidDataException("PCAP snap length must be greater than zero."); + + var linkType = ReadUInt32(header.Slice(20, 4), format.IsLittleEndian); + if (linkType != EthernetLinkType) + throw new InvalidDataException($"Unsupported PCAP link type {linkType}; Ethernet link type 1 is required."); + + return format with { SnapLength = snapLength }; + } + + private static bool ReadExactly(Stream stream, byte[] buffer, bool allowCleanEndOfStream) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = stream.Read(buffer, offset, buffer.Length - offset); + if (read == 0) + { + if (allowCleanEndOfStream && offset == 0) + return false; + + throw new EndOfStreamException("PCAP ended in the middle of a header or frame."); + } + + offset += read; + } + + return true; + } + + private static ushort ReadUInt16(ReadOnlySpan bytes, bool littleEndian) + => littleEndian + ? BinaryPrimitives.ReadUInt16LittleEndian(bytes) + : BinaryPrimitives.ReadUInt16BigEndian(bytes); + + private static uint ReadUInt32(ReadOnlySpan bytes, bool littleEndian) + => littleEndian + ? BinaryPrimitives.ReadUInt32LittleEndian(bytes) + : BinaryPrimitives.ReadUInt32BigEndian(bytes); + + private sealed record PcapFormat(bool IsLittleEndian, bool IsNanosecondResolution, uint SnapLength); +} + +public sealed class PcapReplayFrame +{ + internal PcapReplayFrame(long sequenceNumber, DateTime captureTimeUtc, byte[] frameBytes, uint originalLength) + { + SequenceNumber = sequenceNumber; + CaptureTimeUtc = captureTimeUtc; + FrameBytes = frameBytes; + OriginalLength = originalLength; + } + + public long SequenceNumber { get; } + public DateTime CaptureTimeUtc { get; } + public ReadOnlyMemory FrameBytes { get; } + public uint OriginalLength { get; } +} From f6f485e6baf4ef7f4e031187cdb7d09e041e6cca Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:21:04 +0700 Subject: [PATCH 03/26] Route PCAP replay through live analyzer runtime --- .../Replay/ProcessBusReplaySession.cs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/ProcessBus.Iec61850.Raw/Replay/ProcessBusReplaySession.cs diff --git a/src/ProcessBus.Iec61850.Raw/Replay/ProcessBusReplaySession.cs b/src/ProcessBus.Iec61850.Raw/Replay/ProcessBusReplaySession.cs new file mode 100644 index 0000000..d1964dc --- /dev/null +++ b/src/ProcessBus.Iec61850.Raw/Replay/ProcessBusReplaySession.cs @@ -0,0 +1,116 @@ +using ProcessBus.Core.Models; +using ProcessBus.Iec61850.Raw.Analysis; +using ProcessBus.Iec61850.Raw.Runtime; +using System.Diagnostics; + +namespace ProcessBus.Iec61850.Raw.Replay; + +/// +/// Deterministic offline replay that uses the same RawProcessBusAnalyzer frame entry +/// point as live Npcap capture. Replay is therefore a reproducibility path, not a +/// second protocol implementation. +/// +public sealed class ProcessBusReplaySession +{ + private readonly PcapReplayReader _reader; + private readonly SvRuntimeSnapshotPublisher _snapshotPublisher = new(); + + public ProcessBusReplaySession( + RawProcessBusAnalyzer? analyzer = null, + PcapReplayReader? reader = null) + { + Analyzer = analyzer ?? new RawProcessBusAnalyzer(); + _reader = reader ?? new PcapReplayReader(); + } + + public RawProcessBusAnalyzer Analyzer { get; } + public SvRuntimeSnapshotPublisher Snapshots => _snapshotPublisher; + + public ProcessBusReplayResult Replay( + Stream pcapStream, + ProcessBusReplayOptions? options = null, + CancellationToken cancellationToken = default) + { + options ??= new ProcessBusReplayOptions(); + if (options.MaximumFrames <= 0) + throw new ArgumentOutOfRangeException(nameof(options.MaximumFrames)); + + if (options.ResetAnalyzer) + { + Analyzer.Reset(); + _snapshotPublisher.Reset(); + } + + var startedUtc = DateTime.UtcNow; + DateTime? firstCaptureUtc = null; + DateTime? lastCaptureUtc = null; + var framesRead = 0L; + + foreach (var frame in _reader.Read(pcapStream, cancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (framesRead >= options.MaximumFrames) + break; + + firstCaptureUtc ??= frame.CaptureTimeUtc; + lastCaptureUtc = frame.CaptureTimeUtc; + + var elapsed = frame.CaptureTimeUtc - firstCaptureUtc.Value; + var replayTicks = checked((long)Math.Round(elapsed.TotalSeconds * Stopwatch.Frequency)); + + Analyzer.ObserveOwnedFrame( + frame.FrameBytes.ToArray(), + frame.CaptureTimeUtc, + replayTicks); + + framesRead++; + } + + var analyzerSnapshot = Analyzer.GetAnalyzerSnapshot(); + var runtimeSnapshot = _snapshotPublisher.Publish(analyzerSnapshot); + var monitor = analyzerSnapshot.ProtocolMonitor; + + return new ProcessBusReplayResult( + framesRead, + startedUtc, + DateTime.UtcNow, + firstCaptureUtc, + lastCaptureUtc, + monitor.TotalFrames, + monitor.SvFrames, + monitor.GooseFrames, + monitor.PtpFrames, + analyzerSnapshot.Diagnostics.DecodeErrors, + analyzerSnapshot, + runtimeSnapshot); + } + + public SvRuntimeSnapshot PublishSelectedStreamSnapshot(DateTime? createdUtc = null) + => _snapshotPublisher.Publish(Analyzer.GetAnalyzerSnapshot(), createdUtc); +} + +public sealed class ProcessBusReplayOptions +{ + public bool ResetAnalyzer { get; init; } = true; + public int MaximumFrames { get; init; } = int.MaxValue; +} + +public sealed record ProcessBusReplayResult( + long FramesRead, + DateTime StartedUtc, + DateTime CompletedUtc, + DateTime? FirstCaptureUtc, + DateTime? LastCaptureUtc, + long TotalDecodedFrames, + long SvFrames, + long GooseFrames, + long PtpFrames, + long DecodeErrors, + AnalyzerSnapshot AnalyzerSnapshot, + SvRuntimeSnapshot RuntimeSnapshot) +{ + public TimeSpan ReplayDuration => CompletedUtc - StartedUtc; + public TimeSpan? CaptureDuration => FirstCaptureUtc.HasValue && LastCaptureUtc.HasValue + ? LastCaptureUtc.Value - FirstCaptureUtc.Value + : null; +} From 83f66d7361513a96a694d097a1519d0ae0a4f539 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:21:55 +0700 Subject: [PATCH 04/26] Add replay and immutable runtime regression coverage --- .../PcapReplayRuntimeTests.cs | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 tests/ProcessBus.Tests/PcapReplayRuntimeTests.cs diff --git a/tests/ProcessBus.Tests/PcapReplayRuntimeTests.cs b/tests/ProcessBus.Tests/PcapReplayRuntimeTests.cs new file mode 100644 index 0000000..9771f97 --- /dev/null +++ b/tests/ProcessBus.Tests/PcapReplayRuntimeTests.cs @@ -0,0 +1,195 @@ +using ProcessBus.Iec61850.Raw.Replay; +using System.Buffers.Binary; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class PcapReplayRuntimeTests +{ + private const int SamplesPerCycle = 80; + + [Fact] + [Trait("Category", "RuntimeArchitecture")] + public void ClassicPcap_ReplaysThroughLiveDecoderAndPublishesCoherentSnapshot() + { + var start = new DateTime(2026, 7, 11, 0, 0, 0, DateTimeKind.Utc); + using var pcap = BuildPcap(Enumerable.Range(0, SamplesPerCycle * 2) + .Select(index => ( + start.AddTicks(index * TimeSpan.TicksPerMillisecond / 4), + GoldenFrames.SvFrameWithChannelSamples( + (ushort)index, + ChannelValuesAt(index, 1200), + "MU_REPLAY", + 0x4000)))); + + var session = new ProcessBusReplaySession(); + var result = session.Replay(pcap); + + Assert.Equal(SamplesPerCycle * 2, result.FramesRead); + Assert.Equal(SamplesPerCycle * 2, result.SvFrames); + Assert.Equal(0, result.DecodeErrors); + Assert.Equal(1, result.RuntimeSnapshot.Generation); + Assert.Equal("MU_REPLAY", result.RuntimeSnapshot.Identity?.SvId); + Assert.Equal(SamplesPerCycle * 2, Channel(result.RuntimeSnapshot, "Ia").Samples.Count); + Assert.Equal(TimeSpan.FromMilliseconds(39.75), result.CaptureDuration); + } + + [Fact] + [Trait("Category", "RuntimeArchitecture")] + public void PublishedGeneration_RemainsImmutableAfterAnalyzerAdvances() + { + var start = new DateTime(2026, 7, 11, 1, 0, 0, DateTimeKind.Utc); + using var pcap = BuildPcap(Enumerable.Range(0, SamplesPerCycle * 2) + .Select(index => ( + start.AddTicks(index * TimeSpan.TicksPerMillisecond / 4), + GoldenFrames.SvFrameWithChannelSamples( + (ushort)index, + ChannelValuesAt(index, 1000), + "MU_IMMUTABLE", + 0x4100)))); + + var session = new ProcessBusReplaySession(); + var first = session.Replay(pcap).RuntimeSnapshot; + var firstMaximum = Channel(first, "Ia").Samples.Max(); + + for (var index = SamplesPerCycle * 2; index < SamplesPerCycle * 4; index++) + { + session.Analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples( + (ushort)index, + ChannelValuesAt(index, 3000), + "MU_IMMUTABLE", + 0x4100)); + } + + var second = session.PublishSelectedStreamSnapshot(); + + Assert.Equal(1, first.Generation); + Assert.Equal(2, second.Generation); + Assert.InRange(firstMaximum, 980, 1020); + Assert.InRange(Channel(first, "Ia").Samples.Max(), 980, 1020); + Assert.InRange(Channel(second, "Ia").Samples.Max(), 2940, 3060); + } + + [Fact] + [Trait("Category", "RuntimeArchitecture")] + public void ThreeInterleavedReplayStreams_RemainIsolatedAcrossPublishedSnapshots() + { + var start = new DateTime(2026, 7, 11, 2, 0, 0, DateTimeKind.Utc); + var frames = new List<(DateTime TimestampUtc, byte[] Frame)>(); + + for (var sample = 0; sample < SamplesPerCycle * 2; sample++) + { + for (var streamIndex = 0; streamIndex < 3; streamIndex++) + { + frames.Add(( + start.AddTicks(frames.Count * TimeSpan.TicksPerMillisecond / 12), + GoldenFrames.SvFrameWithChannelSamples( + (ushort)sample, + ChannelValuesAt(sample, 700 * (streamIndex + 1)), + $"MU_REPLAY_{streamIndex + 1}", + (ushort)(0x4200 + streamIndex)))); + } + } + + using var pcap = BuildPcap(frames); + var session = new ProcessBusReplaySession(); + var result = session.Replay(pcap); + + Assert.Equal(3, result.AnalyzerSnapshot.Streams.Count); + + for (var streamIndex = 0; streamIndex < 3; streamIndex++) + { + var svId = $"MU_REPLAY_{streamIndex + 1}"; + var stream = session.Analyzer.GetAnalyzerSnapshot().Streams.Single(item => item.SvId == svId); + session.Analyzer.SelectStream(stream.StreamId); + var snapshot = session.PublishSelectedStreamSnapshot(); + var expectedPeak = 700.0 * (streamIndex + 1); + + Assert.Equal(svId, snapshot.Identity?.SvId); + Assert.InRange(Channel(snapshot, "Ia").Samples.Max(), expectedPeak * 0.98, expectedPeak * 1.02); + } + } + + [Fact] + [Trait("Category", "RuntimeArchitecture")] + public void Reader_RejectsUnsupportedLinkType() + { + using var pcap = BuildPcap(Array.Empty<(DateTime, byte[])>(), linkType: 101); + var reader = new PcapReplayReader(); + + var error = Assert.Throws(() => reader.Read(pcap).ToArray()); + Assert.Contains("Ethernet link type 1", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + [Trait("Category", "RuntimeArchitecture")] + public void Reader_RejectsTruncatedFrameRecord() + { + var start = new DateTime(2026, 7, 11, 3, 0, 0, DateTimeKind.Utc); + using var complete = BuildPcap(new[] { (start, GoldenFrames.SvFrame()) }); + var bytes = complete.ToArray()[..^3]; + using var truncated = new MemoryStream(bytes, writable: false); + var reader = new PcapReplayReader(); + + Assert.Throws(() => reader.Read(truncated).ToArray()); + } + + private static ProcessBus.Iec61850.Raw.Runtime.SvRuntimeChannelSnapshot Channel( + ProcessBus.Iec61850.Raw.Runtime.SvRuntimeSnapshot snapshot, + string name) + => snapshot.Channels.Single(channel => string.Equals(channel.Name, name, StringComparison.OrdinalIgnoreCase)); + + private static int[] ChannelValuesAt(int sampleIndex, int currentPeak) + { + var phase = 2.0 * Math.PI * (sampleIndex % SamplesPerCycle) / SamplesPerCycle; + var voltagePeak = currentPeak * 8; + + return + [ + (int)Math.Round(Math.Sin(phase) * currentPeak), + (int)Math.Round(Math.Sin(phase - (2.0 * Math.PI / 3.0)) * currentPeak), + (int)Math.Round(Math.Sin(phase + (2.0 * Math.PI / 3.0)) * currentPeak), + 0, + (int)Math.Round(Math.Sin(phase) * voltagePeak), + (int)Math.Round(Math.Sin(phase - (2.0 * Math.PI / 3.0)) * voltagePeak), + (int)Math.Round(Math.Sin(phase + (2.0 * Math.PI / 3.0)) * voltagePeak), + 0 + ]; + } + + private static MemoryStream BuildPcap( + IEnumerable<(DateTime TimestampUtc, byte[] Frame)> frames, + uint linkType = 1) + { + var stream = new MemoryStream(); + var globalHeader = new byte[24]; + globalHeader[0] = 0xD4; + globalHeader[1] = 0xC3; + globalHeader[2] = 0xB2; + globalHeader[3] = 0xA1; + BinaryPrimitives.WriteUInt16LittleEndian(globalHeader.AsSpan(4, 2), 2); + BinaryPrimitives.WriteUInt16LittleEndian(globalHeader.AsSpan(6, 2), 4); + BinaryPrimitives.WriteUInt32LittleEndian(globalHeader.AsSpan(16, 4), 65_535); + BinaryPrimitives.WriteUInt32LittleEndian(globalHeader.AsSpan(20, 4), linkType); + stream.Write(globalHeader); + + foreach (var (timestampUtc, frame) in frames) + { + var timestamp = new DateTimeOffset(DateTime.SpecifyKind(timestampUtc, DateTimeKind.Utc)); + var seconds = timestamp.ToUnixTimeSeconds(); + var secondStart = DateTimeOffset.FromUnixTimeSeconds(seconds); + var microseconds = (timestamp - secondStart).Ticks / 10; + + var recordHeader = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(recordHeader.AsSpan(0, 4), checked((uint)seconds)); + BinaryPrimitives.WriteUInt32LittleEndian(recordHeader.AsSpan(4, 4), checked((uint)microseconds)); + BinaryPrimitives.WriteUInt32LittleEndian(recordHeader.AsSpan(8, 4), checked((uint)frame.Length)); + BinaryPrimitives.WriteUInt32LittleEndian(recordHeader.AsSpan(12, 4), checked((uint)frame.Length)); + stream.Write(recordHeader); + stream.Write(frame); + } + + stream.Position = 0; + return stream; + } +} From 99aab2411dd1f9d26eec904dfe22a3fab797de7a Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:25:03 +0700 Subject: [PATCH 05/26] Set repository version to 1.4.0-beta.1 --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 44bac22..a667847 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,8 +10,8 @@ en - 1.3.0 - beta.2 + 1.4.0 + beta.1 $(VersionPrefix)-$(VersionSuffix) $(Version) $(VersionPrefix).0 From ae0cc4c9d4bbbcec35bd12fc3e5424ce13d4d37c Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:25:22 +0700 Subject: [PATCH 06/26] Build candidates for architecture beta branches --- .github/workflows/candidate-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/candidate-package.yml b/.github/workflows/candidate-package.yml index 4fb9889..7b53bf4 100644 --- a/.github/workflows/candidate-package.yml +++ b/.github/workflows/candidate-package.yml @@ -29,7 +29,7 @@ env: jobs: candidate: name: Build verified Windows x64 candidate - if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'stabilization/') + if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'stabilization/') || startsWith(github.head_ref, 'architecture/') runs-on: windows-latest timeout-minutes: 35 From 3e0d6ff1a413e8d9fd9783e24fb70032d2f245d9 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:26:05 +0700 Subject: [PATCH 07/26] Prepare v1.4.0-beta.1 release workflow defaults --- .github/workflows/release-package.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index c9a552e..ebe60a6 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: version: - description: Version label, for example 1.3.0-beta.2 + description: Version label, for example 1.4.0-beta.1 required: true - default: 1.3.0-beta.2 + default: 1.4.0-beta.1 publish_release: description: Create or update a GitHub Release required: true @@ -25,7 +25,7 @@ on: release_notes_file: description: Markdown file used as release body required: true - default: docs/RELEASE_NOTES_v1.3.0-beta.2.md + default: docs/RELEASE_NOTES_v1.4.0-beta.1.md push: tags: - "v*" @@ -130,7 +130,7 @@ jobs: $sha = "artifacts/release/SHA256SUMS.txt" $manifest = "artifacts/release/release-manifest.json" $notesFile = '${{ github.event.inputs.release_notes_file }}' - if ([string]::IsNullOrWhiteSpace($notesFile)) { $notesFile = 'docs/RELEASE_NOTES_v1.3.0-beta.2.md' } + if ([string]::IsNullOrWhiteSpace($notesFile)) { $notesFile = 'docs/RELEASE_NOTES_v1.4.0-beta.1.md' } if (-not (Test-Path $notesFile)) { throw "Release notes file not found: $notesFile" } $releaseArgs = @('release', 'create', $tag, $zip, $sha, $manifest, '--title', "Process Bus Insight $tag", '--notes-file', $notesFile, '--target', '${{ github.sha }}') From dd2d8bc2cccd1b307037aec06b5f9a67f9436a36 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:26:23 +0700 Subject: [PATCH 08/26] Add v1.4.0-beta.1 release notes --- docs/RELEASE_NOTES_v1.4.0-beta.1.md | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/RELEASE_NOTES_v1.4.0-beta.1.md diff --git a/docs/RELEASE_NOTES_v1.4.0-beta.1.md b/docs/RELEASE_NOTES_v1.4.0-beta.1.md new file mode 100644 index 0000000..27bd678 --- /dev/null +++ b/docs/RELEASE_NOTES_v1.4.0-beta.1.md @@ -0,0 +1,37 @@ +# Process Bus Insight v1.4.0-beta.1 + +This architecture beta introduces a reproducible offline analysis path and a coherent immutable runtime publication boundary. The receive-only safety model is unchanged. + +## Highlights + +- Immutable selected-stream runtime generations with copied waveform samples, analog values, identity, and diagnostics +- Atomic snapshot publication so consumers see either the previous complete generation or the next complete generation +- Classic PCAP Ethernet replay through the same `RawProcessBusAnalyzer.ObserveOwnedFrame` entry point used by live capture +- Little-endian and big-endian PCAP support with microsecond and nanosecond timestamp variants +- Explicit limits and rejection for unsupported link types, oversized records, invalid timestamp fractions, and truncated captures +- Deterministic replay regression coverage for coherent two-cycle windows, three-stream isolation, timing preservation, and snapshot immutability +- Dedicated Runtime Architecture workflow with downloadable TRX evidence +- Verified Windows x64 candidate packaging for `architecture/*` branches + +## Engineering intent + +The replay path is not a second decoder. It feeds captured Ethernet frames into the same raw decoder and analyzer used by Npcap live capture. This makes field defects reproducible without requiring the original merging unit or network to remain connected. + +The immutable runtime snapshot is the first step toward separating per-stream state, calculations, and UI consumption. The internal monolithic analyzer is not yet fully decomposed in this beta; that work continues in later v1.4 builds. + +## Supported replay scope + +- Classic PCAP version 2.4 +- Ethernet link type 1 +- Microsecond and nanosecond timestamp magic variants +- SV, GOOSE, and PTP frames already supported by the raw decoder + +PCAPNG, non-Ethernet link types, capture editing, and traffic transmission are not claimed in this release. + +## Important limitations + +- This remains a public beta, not a certified protection or timing instrument. +- Windows/Npcap and replay timestamps are engineering screening evidence unless the capture path is independently validated. +- Replay preserves recorded ordering and timing context but does not recreate network loading, NIC buffering, or hardware timestamp behavior. +- The runtime snapshot boundary is available to replay and engineering consumers; full live UI migration to the new runtime architecture remains staged work. +- Npcap must be installed separately for live capture. From 0e3037faedf994281ddec86b642a2e017154710f Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:28:19 +0700 Subject: [PATCH 09/26] Document v1.4.0-beta.1 runtime architecture changes --- CHANGELOG.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25dbe15..1988bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,35 @@ All notable changes are documented here. The project follows Semantic Versioning ### Planned -- Golden PCAP replay corpus with sanitized multi-stream scenarios +- Sanitized golden PCAP/PCAPNG replay corpus with broader multi-vendor scenarios - Further decomposition of the analyzer runtime and WPF workspace - Expanded SCL multi-vendor validation - Evidence export for FAT/SAT reports +## [1.4.0-beta.1] - 2026-07-11 + +### Added + +- Immutable selected-stream runtime generations with copied waveform, analog, identity, and diagnostic evidence +- Atomic runtime snapshot publisher for coherent consumer reads +- Classic Ethernet PCAP replay through the same raw decoder/analyzer path used by live Npcap capture +- Microsecond and nanosecond PCAP timestamp variants in little-endian and big-endian formats +- Bounded rejection for unsupported link types, invalid headers, oversized records, and truncated captures +- Deterministic runtime-architecture tests covering replay timing, snapshot immutability, and three-stream isolation +- Dedicated Runtime Architecture GitHub Actions gate with downloadable TRX evidence +- Release-candidate packaging support for `architecture/*` branches + +### Changed + +- Release and documentation versioning use `1.4.0-beta.1` +- Runtime architecture now exposes a coherent publication boundary instead of requiring consumers to retain mutable analyzer display models +- Offline replay is explicitly treated as a reproducibility path, not a separate decoder or a traffic publisher + +### Limitations + +- PCAPNG is not yet supported by the first replay reader +- The internal analyzer remains partly monolithic; complete per-stream runtime extraction and live UI migration continue in later v1.4 builds + ## [1.3.0-beta.2] - 2026-07-10 ### Added @@ -58,6 +82,7 @@ All notable changes are documented here. The project follows Semantic Versioning - Hardened BER parsing, Npcap lifecycle, release version propagation, and public-repository packaging. -[Unreleased]: https://github.com/masarray/DigSubAnalyzer/compare/v1.3.0-beta.2...HEAD +[Unreleased]: https://github.com/masarray/DigSubAnalyzer/compare/v1.4.0-beta.1...HEAD +[1.4.0-beta.1]: https://github.com/masarray/DigSubAnalyzer/compare/v1.3.0-beta.2...v1.4.0-beta.1 [1.3.0-beta.2]: https://github.com/masarray/DigSubAnalyzer/compare/v1.3.0-beta.1...v1.3.0-beta.2 [1.3.0-beta.1]: https://github.com/masarray/DigSubAnalyzer/releases/tag/v1.3.0-beta.1 From 5fb6c3b954136bf45768fc2781a4285c724bf36c Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:28:37 +0700 Subject: [PATCH 10/26] Add runtime architecture regression gate --- .github/workflows/runtime-architecture.yml | 89 ++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .github/workflows/runtime-architecture.yml diff --git a/.github/workflows/runtime-architecture.yml b/.github/workflows/runtime-architecture.yml new file mode 100644 index 0000000..d6ab45f --- /dev/null +++ b/.github/workflows/runtime-architecture.yml @@ -0,0 +1,89 @@ +name: Runtime architecture + +on: + pull_request: + branches: [main] + paths: + - "src/ProcessBus.Iec61850.Raw/Runtime/**" + - "src/ProcessBus.Iec61850.Raw/Replay/**" + - "tests/ProcessBus.Tests/PcapReplayRuntimeTests.cs" + - ".github/workflows/runtime-architecture.yml" + schedule: + - cron: "17 3 * * 4" + workflow_dispatch: + inputs: + iterations: + description: Number of deterministic architecture passes + required: true + default: "3" + +permissions: + contents: read + +concurrency: + group: runtime-architecture-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + architecture: + name: Immutable snapshot and PCAP replay gate + runs-on: windows-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.x + + - name: Restore + run: dotnet restore .\ProcessBusSuite.sln + + - name: Build + run: dotnet build .\ProcessBusSuite.sln -c Release --no-restore /p:ContinuousIntegrationBuild=true + + - name: Repeat runtime architecture suite + shell: pwsh + run: | + $requested = '${{ github.event.inputs.iterations }}' + $iterations = 3 + if (-not [string]::IsNullOrWhiteSpace($requested)) { + $parsed = 0 + if (-not [int]::TryParse($requested, [ref]$parsed)) { + throw "Invalid iterations value: $requested" + } + $iterations = [Math]::Clamp($parsed, 1, 20) + } + + New-Item -ItemType Directory -Path .\TestResults\RuntimeArchitecture -Force | Out-Null + Write-Host "Runtime architecture iterations: $iterations" + + for ($iteration = 1; $iteration -le $iterations; $iteration++) { + Write-Host "=== Runtime architecture pass $iteration/$iterations ===" + dotnet test .\tests\ProcessBus.Tests\ProcessBus.Tests.csproj ` + -c Release ` + --no-build ` + --filter "Category=RuntimeArchitecture" ` + --logger "trx;LogFileName=runtime-architecture-$iteration.trx" ` + --results-directory .\TestResults\RuntimeArchitecture + + if ($LASTEXITCODE -ne 0) { + throw "Runtime architecture pass $iteration failed." + } + } + + - name: Upload runtime architecture evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: runtime-architecture-${{ github.run_id }} + path: TestResults/RuntimeArchitecture/** + if-no-files-found: error + retention-days: 30 From c2125f5d2268f33c9df0066bd53e09f3382c1de7 Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:28:56 +0700 Subject: [PATCH 11/26] Document immutable runtime snapshot and replay architecture --- .../RUNTIME_SNAPSHOT_AND_REPLAY.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/architecture/RUNTIME_SNAPSHOT_AND_REPLAY.md diff --git a/docs/architecture/RUNTIME_SNAPSHOT_AND_REPLAY.md b/docs/architecture/RUNTIME_SNAPSHOT_AND_REPLAY.md new file mode 100644 index 0000000..0964b5b --- /dev/null +++ b/docs/architecture/RUNTIME_SNAPSHOT_AND_REPLAY.md @@ -0,0 +1,83 @@ +# Runtime Snapshot and Replay Architecture + +## Purpose + +The v1.4 architecture introduces two linked boundaries: + +1. a coherent immutable snapshot for consumers such as UI, replay, export, and tests; +2. an offline capture replay path that feeds the same raw analyzer entry point used by live Npcap capture. + +This avoids building a second protocol implementation for offline analysis and prevents consumers from retaining mutable analyzer display objects across refreshes. + +## Data flow + +```text +Live Npcap frame ───────────────┐ + ├─> RawProcessBusAnalyzer.ObserveOwnedFrame +Classic Ethernet PCAP frame ───┘ │ + ▼ + AnalyzerSnapshot under analyzer lock + │ + ▼ copy all mutable collections + SvRuntimeSnapshotPublisher + │ + ▼ atomic reference publication + Immutable SvRuntimeSnapshot generation +``` + +## Snapshot guarantees + +Each `SvRuntimeSnapshot` generation contains copied values for: + +- selected stream identity; +- voltage and current channel samples; +- instant, RMS, and phasor angle values; +- waveform shape evidence; +- sample rate, samples per cycle, measured frequency, and window duration; +- packet, decode, sequence, missing-sample, timing, and jitter diagnostics. + +A consumer sees either the previous complete generation or the next complete generation. Publication does not expose a partially assembled mix. Sample arrays are copied into read-only collections before publication, so advancing the analyzer cannot change an already published generation. + +The snapshot does not claim that the internal analyzer has been fully decomposed. It is the compatibility boundary used while per-stream acquisition, sequence tracking, windowing, and calculations are progressively extracted. + +## Replay guarantees + +`ProcessBusReplaySession` reads bounded classic PCAP records and calls the same `RawProcessBusAnalyzer.ObserveOwnedFrame` method used by live capture. Recorded capture timestamps are converted into deterministic monotonic tick deltas for the analyzer timing path. + +Supported in v1.4.0-beta.1: + +- classic PCAP version 2.4; +- Ethernet link type 1; +- little-endian and big-endian files; +- microsecond and nanosecond timestamp variants; +- SV, GOOSE, and PTP frames understood by the existing raw decoder. + +Rejected explicitly: + +- unsupported magic/version/link type; +- zero or oversized captured frames; +- captured length above snap length; +- original length smaller than captured length; +- invalid timestamp fractions; +- truncated headers or frame payloads. + +## Safety boundary + +Replay is file input only. It does not open a transmit socket, publish SV/GOOSE, send IEC 61850 controls, or emulate a merging unit. Process Bus Insight remains receive-only. + +## Known limitations + +- PCAPNG is not supported by the first reader. +- Replay does not recreate switch loading, NIC buffering, hardware timestamping, or packet loss that was not present in the recorded capture. +- The WPF live workspace still consumes the existing analyzer display snapshot. Migration to the immutable runtime boundary is staged to avoid a risky all-at-once rewrite. +- Full `SvStreamRuntime` extraction remains planned for later v1.4 builds. + +## Regression evidence + +Run the focused suite with: + +```powershell +dotnet test .\tests\ProcessBus.Tests\ProcessBus.Tests.csproj -c Release --filter "Category=RuntimeArchitecture" +``` + +The `Runtime architecture` GitHub Actions workflow repeats the suite and retains TRX evidence. Tests cover decoder-path replay, coherent window publication, capture duration preservation, immutable generations, three-stream isolation, unsupported link types, and truncated records. From 9acf87eb0e213fa04ba3693752b250b29f21f2be Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:29:24 +0700 Subject: [PATCH 12/26] Require runtime architecture and replay evidence --- scripts/repository-health.ps1 | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/repository-health.ps1 b/scripts/repository-health.ps1 index 28a0d22..21c2955 100644 --- a/scripts/repository-health.ps1 +++ b/scripts/repository-health.ps1 @@ -28,16 +28,22 @@ $required = @( ".github/CODEOWNERS", ".github/workflows/ci.yml", ".github/workflows/runtime-stability.yml", + ".github/workflows/runtime-architecture.yml", ".github/workflows/candidate-package.yml", ".github/workflows/codeql.yml", ".github/workflows/dependency-review.yml", ".github/workflows/pages.yml", ".github/workflows/release-package.yml", "docs/architecture/STREAM_RUNTIME.md", + "docs/architecture/RUNTIME_SNAPSHOT_AND_REPLAY.md", "docs/validation/TESTED_CONFIGURATIONS.md", "docs/validation/V1.3.0_BETA2_FIELD_EVIDENCE.md", "docs/development/RELEASE_CHECKLIST.md", - "tests/ProcessBus.Tests/RuntimeStabilityTests.cs" + "src/ProcessBus.Iec61850.Raw/Runtime/SvRuntimeSnapshot.cs", + "src/ProcessBus.Iec61850.Raw/Replay/PcapReplayReader.cs", + "src/ProcessBus.Iec61850.Raw/Replay/ProcessBusReplaySession.cs", + "tests/ProcessBus.Tests/RuntimeStabilityTests.cs", + "tests/ProcessBus.Tests/PcapReplayRuntimeTests.cs" ) $required | ForEach-Object { Assert-Path $_ } @@ -109,6 +115,9 @@ $readme = Get-Content (Join-Path $repoRoot "README.md") -Raw if ($readme -notmatch [regex]::Escape("ProcessBusInsight-v$version-win-x64-portable.zip")) { throw "README package example is not synchronized with version $version." } +if ($readme -notmatch 'Category=RuntimeArchitecture') { + throw "README does not document the RuntimeArchitecture validation filter." +} $releaseNotesPath = "docs/RELEASE_NOTES_v$version.md" Assert-Path $releaseNotesPath From 33ea9d8fd6f2c27ff166e4a751b9db3205de241d Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:29:53 +0700 Subject: [PATCH 13/26] Document v1.4 runtime snapshots and PCAP replay --- README.md | 46 ++++++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 9f7df65..5418475 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![CI](https://github.com/masarray/DigSubAnalyzer/actions/workflows/ci.yml/badge.svg)](https://github.com/masarray/DigSubAnalyzer/actions/workflows/ci.yml) [![Runtime Stability](https://github.com/masarray/DigSubAnalyzer/actions/workflows/runtime-stability.yml/badge.svg)](https://github.com/masarray/DigSubAnalyzer/actions/workflows/runtime-stability.yml) +[![Runtime Architecture](https://github.com/masarray/DigSubAnalyzer/actions/workflows/runtime-architecture.yml/badge.svg)](https://github.com/masarray/DigSubAnalyzer/actions/workflows/runtime-architecture.yml) [![CodeQL](https://github.com/masarray/DigSubAnalyzer/actions/workflows/codeql.yml/badge.svg)](https://github.com/masarray/DigSubAnalyzer/actions/workflows/codeql.yml) [![GitHub Pages](https://github.com/masarray/DigSubAnalyzer/actions/workflows/pages.yml/badge.svg)](https://github.com/masarray/DigSubAnalyzer/actions/workflows/pages.yml) [![Release Package](https://github.com/masarray/DigSubAnalyzer/actions/workflows/release-package.yml/badge.svg)](https://github.com/masarray/DigSubAnalyzer/actions/workflows/release-package.yml) @@ -12,9 +13,9 @@ **Process Bus Insight** is a free, open-source, receive-only **IEC 61850 Process Bus analyzer for Windows**. It provides engineering visibility into **Sampled Values (SV)**, **GOOSE**, **PTPv2 timing context**, and **SCL expected-vs-observed validation** for FAT, SAT, commissioning, interoperability checks, and troubleshooting. -The project is currently released as a **public beta**. Its design goal is field clarity without overstating measurement confidence: identify live publishers, inspect protocol evidence, isolate unhealthy streams, compare observed traffic against SCL, and capture defensible findings. +The project is currently released as a **public beta**. Its design goal is field clarity without overstating measurement confidence: identify live publishers, inspect protocol evidence, isolate unhealthy streams, reproduce captured traffic offline, compare observed traffic against SCL, and capture defensible findings. -> **Timing confidence:** normal Windows/Npcap timestamps are software based. Arrival timing is useful for screening and troubleshooting, but is not certification-grade jitter evidence unless the capture path is validated with appropriate hardware timestamping, TAP, or trusted timing equipment. +> **Timing confidence:** normal Windows/Npcap timestamps and replayed capture timestamps are software evidence. Arrival timing is useful for screening and troubleshooting, but is not certification-grade jitter evidence unless the capture path is validated with appropriate hardware timestamping, TAP, or trusted timing equipment. ![Process Bus Insight analyzer overview](docs/screenshot/analyzer-overview.webp) @@ -27,6 +28,8 @@ Current capabilities include: | Area | Capability | | --- | --- | | SV analyzer | Multi-stream discovery, APPID/svID/MAC/VLAN/confRev evidence, selected-stream workspace, raw decoded sample waveform, RMS, phasor, sequence diagnostics, shape/distortion indication, and selectable scope windows. | +| Runtime snapshots | Immutable selected-stream generations containing copied identity, waveform, analog, phasor, shape, and diagnostic evidence for coherent consumer reads. | +| PCAP replay | Bounded classic Ethernet PCAP replay through the same raw decoder/analyzer entry point used by live Npcap capture. | | GOOSE inspector | Publisher discovery, stNum/sqNum tracking, event timeline, typed `allData` decoding, change summaries, and SCL-assisted semantic context. | | PTP visibility | Passive PTPv2 message context, grandmaster/domain evidence where available, freshness wording, and timestamp-confidence boundaries. | | SCL validation | Load SCD/ICD/CID files and compare expected publishers/streams against observed APPID, destination MAC, VLAN, svID, confRev, and related evidence. | @@ -40,6 +43,7 @@ Wireshark remains an essential packet-analysis tool. Process Bus Insight focuses - Is the observed APPID, MAC, VLAN, svID, or confRev consistent with the SCL design? - Is the problem owned by the stream, publisher, timing source, adapter, or capture path? - Are waveform, RMS, and phasor values coming from the same selected stream? +- Can a sanitized capture reproduce the same decoder and runtime behavior away from site? - What evidence can be copied into a FAT/SAT finding without overclaiming timing accuracy? ## Download and run @@ -54,7 +58,7 @@ Wireshark remains an essential packet-analysis tool. Process Bus Insight focuses Current public-beta package naming: ```text -ProcessBusInsight-v1.3.0-beta.2-win-x64-portable.zip +ProcessBusInsight-v1.4.0-beta.1-win-x64-portable.zip SHA256SUMS.txt release-manifest.json ``` @@ -66,19 +70,18 @@ See [`docs/QUICK_START.md`](docs/QUICK_START.md) for the field checklist. ## Architecture ```text -Process Bus / TAP / Mirror Port - ↓ -Npcap raw Ethernet capture - ↓ -ProcessBus.Iec61850.Raw - SV / GOOSE / PTP decode and per-stream runtime - ↓ -ProcessBus.Core immutable display models - ↓ -ProcessBus.App.Wpf engineering workspaces +Process Bus / TAP / Mirror Port ──> Npcap raw Ethernet capture ─┐ + ├─> ProcessBus.Iec61850.Raw +Classic Ethernet PCAP ───────────> bounded replay reader ───────┘ SV / GOOSE / PTP decode + ↓ + immutable runtime generation + ↓ + WPF / tests / future export ``` -The selected SV stream is the source of truth for waveform, RMS, phasor, and stream details. Per-stream state must remain isolated; UI refresh must never combine values from different publishers. See [`docs/architecture/STREAM_RUNTIME.md`](docs/architecture/STREAM_RUNTIME.md). +The selected SV stream is the source of truth for waveform, RMS, phasor, and stream details. Per-stream state must remain isolated; consumers must never combine values from different publishers. The v1.4 runtime snapshot boundary copies mutable analyzer collections before atomic publication. See [`docs/architecture/STREAM_RUNTIME.md`](docs/architecture/STREAM_RUNTIME.md) and [`docs/architecture/RUNTIME_SNAPSHOT_AND_REPLAY.md`](docs/architecture/RUNTIME_SNAPSHOT_AND_REPLAY.md). + +The first replay reader supports classic PCAP 2.4 with Ethernet link type 1 in little/big-endian microsecond or nanosecond variants. PCAPNG is not yet claimed. ## Build from source @@ -106,8 +109,8 @@ dotnet run --project .\src\ProcessBus.App.Wpf\ProcessBus.App.Wpf.csproj -c Relea Create and verify a portable package: ```powershell -.\scripts\publish-windows-portable.ps1 -Version "1.3.0-beta.2" -.\scripts\verify-release-package.ps1 -PackageZip ".\artifacts\release\ProcessBusInsight-v1.3.0-beta.2-win-x64-portable.zip" +.\scripts\publish-windows-portable.ps1 -Version "1.4.0-beta.1" +.\scripts\verify-release-package.ps1 -PackageZip ".\artifacts\release\ProcessBusInsight-v1.4.0-beta.1-win-x64-portable.zip" ``` Run the repository-quality gate: @@ -122,9 +125,15 @@ Run only the deterministic runtime-stability suite: dotnet test .\tests\ProcessBus.Tests\ProcessBus.Tests.csproj -c Release --filter "Category=RuntimeStability" ``` +Run only the immutable snapshot and PCAP replay suite: + +```powershell +dotnet test .\tests\ProcessBus.Tests\ProcessBus.Tests.csproj -c Release --filter "Category=RuntimeArchitecture" +``` + ## Validation status -The repository includes parser and regression tests, repeated deterministic multi-stream stability evidence, CI build/test evidence, CodeQL analysis, dependency review, release-package verification, and explicit repository-hygiene checks. Public-beta status does **not** imply vendor certification or measurement-grade timing validation. +The repository includes parser and regression tests, repeated deterministic multi-stream stability evidence, immutable runtime/replay evidence, CI build/test evidence, CodeQL analysis, dependency review, release-package verification, and explicit repository-hygiene checks. Public-beta status does **not** imply vendor certification or measurement-grade timing validation. Before interpreting results, review: @@ -140,8 +149,9 @@ Before interpreting results, review: - [`docs/USER_MANUAL.md`](docs/USER_MANUAL.md) — user workflow - [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) — capture and interpretation issues - [`docs/architecture/STREAM_RUNTIME.md`](docs/architecture/STREAM_RUNTIME.md) — selected-stream and snapshot invariants +- [`docs/architecture/RUNTIME_SNAPSHOT_AND_REPLAY.md`](docs/architecture/RUNTIME_SNAPSHOT_AND_REPLAY.md) — immutable generation and PCAP replay design - [`docs/validation/TESTED_CONFIGURATIONS.md`](docs/validation/TESTED_CONFIGURATIONS.md) — explicitly tested environments -- [`docs/validation/V1.3.0_BETA2_FIELD_EVIDENCE.md`](docs/validation/V1.3.0_BETA2_FIELD_EVIDENCE.md) — maintained live/replay evidence form +- [`docs/validation/V1.3.0_BETA2_FIELD_EVIDENCE.md`](docs/validation/V1.3.0_BETA2_FIELD_EVIDENCE.md) — maintained 60-minute live stability evidence - [`docs/RELEASE_PACKAGING.md`](docs/RELEASE_PACKAGING.md) — portable packaging design - [`ROADMAP.md`](ROADMAP.md) — product direction - [`SECURITY.md`](SECURITY.md) — vulnerability reporting and data-handling policy From 4bf683786c08dfc59b91ade482135306fad103ef Mon Sep 17 00:00:00 2001 From: masarray Date: Sat, 11 Jul 2026 03:31:12 +0700 Subject: [PATCH 14/26] Update landing metadata for v1.4.0-beta.1 --- docs/index.html | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/index.html b/docs/index.html index 44573ad..69003ae 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,8 +4,8 @@ Process Bus Insight - IEC 61850 SV, GOOSE, PTP and SCL Analyzer for Windows - - + + @@ -19,14 +19,14 @@ - + - + @@ -42,7 +42,7 @@ "alternateName": "DigSubAnalyzer", "applicationCategory": "EngineeringApplication", "operatingSystem": "Windows 10, Windows 11", - "softwareVersion": "1.3.0-beta.2", + "softwareVersion": "1.4.0-beta.1", "license": "https://www.apache.org/licenses/LICENSE-2.0", "isAccessibleForFree": true, "url": "https://masarray.github.io/DigSubAnalyzer/", @@ -54,9 +54,10 @@ "GOOSE publisher inspection", "PTP timing context", "SCL expected-vs-observed validation", + "Classic Ethernet PCAP replay", "Windows portable EXE package" ], - "description": "Free open-source Windows analyzer for IEC 61850 Process Bus SV, GOOSE, PTP timing context, and SCL validation during FAT, SAT, commissioning, and troubleshooting." + "description": "Free open-source Windows analyzer for IEC 61850 Process Bus SV, GOOSE, PTP timing context, SCL validation, and reproducible PCAP replay during FAT, SAT, commissioning, and troubleshooting." }