From 3f662fb247e396a78dae43b17f50b42fd85c67d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 07:29:36 +0700 Subject: [PATCH 001/148] Add deterministic viewport-aware phasor label layout --- .../Presentation/PhasorLabelLayout.cs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/ProcessBus.Core/Presentation/PhasorLabelLayout.cs diff --git a/src/ProcessBus.Core/Presentation/PhasorLabelLayout.cs b/src/ProcessBus.Core/Presentation/PhasorLabelLayout.cs new file mode 100644 index 00000000..ed11de1f --- /dev/null +++ b/src/ProcessBus.Core/Presentation/PhasorLabelLayout.cs @@ -0,0 +1,69 @@ +namespace ProcessBus.Core.Presentation; + +/// +/// A six-vector plot has little room for text. Keep label placement deterministic, +/// inside the viewport, and independent of WPF so the engineering readout can be tested. +/// This is presentation geometry only: it never changes measured phasor angles. +/// +public readonly record struct PhasorLabelAnchor( + double TipX, double TipY, double Width, double Height, bool Right); + +public readonly record struct PhasorLabelPosition(double X, double Y); + +public static class PhasorLabelLayout +{ + public static PhasorLabelPosition[] Arrange( + IReadOnlyList anchors, + double width, + double height, + double topPadding = 20, + double bottomPadding = 22, + double gap = 2) + { + var positions = new PhasorLabelPosition[anchors.Count]; + for (var side = 0; side < 2; side++) + { + var right = side == 1; + var indices = new List(anchors.Count); + for (var i = 0; i < anchors.Count; i++) + if (anchors[i].Right == right) + indices.Add(i); + + // Stable tie-breaker prevents jitter when two vectors share an angle. + indices.Sort((a, b) => + { + var order = anchors[a].TipY.CompareTo(anchors[b].TipY); + return order != 0 ? order : a.CompareTo(b); + }); + + var top = Math.Max(0, topPadding); + var bottom = Math.Max(top, height - Math.Max(0, bottomPadding)); + var cursor = top; + foreach (var index in indices) + { + var label = anchors[index]; + var desiredY = label.TipY - label.Height / 2; + var y = Math.Max(cursor, Math.Max(top, desiredY)); + positions[index] = new PhasorLabelPosition( + Math.Clamp(right ? width - label.Width - 4 : 4, 0, Math.Max(0, width - label.Width)), + y); + cursor = y + label.Height + gap; + } + + // Resolve bottom overflow backwards while preserving sorted order. + // The six phasor labels comfortably fit in the normal 210px WPF panel. + var ceiling = bottom; + for (var i = indices.Count - 1; i >= 0; i--) + { + var index = indices[i]; + var label = anchors[index]; + var position = positions[index]; + var y = Math.Max(top, Math.Min(position.Y, ceiling - label.Height)); + positions[index] = position with { Y = y }; + ceiling = y - gap; + } + } + + return positions; + } +} From d2e11cc31d08709935380b6aedaeaa934c20c165 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 07:31:36 +0700 Subject: [PATCH 002/148] Redraw phasors from SSOT with collision-safe labels and low-allocation rendering --- .../Controls/PhasorDiagramControl.cs | 459 ++++++++---------- 1 file changed, 215 insertions(+), 244 deletions(-) diff --git a/src/ProcessBus.App.Wpf/Controls/PhasorDiagramControl.cs b/src/ProcessBus.App.Wpf/Controls/PhasorDiagramControl.cs index 453c68d0..bc38a935 100644 --- a/src/ProcessBus.App.Wpf/Controls/PhasorDiagramControl.cs +++ b/src/ProcessBus.App.Wpf/Controls/PhasorDiagramControl.cs @@ -2,15 +2,22 @@ using System.Windows; using System.Windows.Media; using ProcessBus.Core.Models; +using ProcessBus.Core.Presentation; namespace ProcessBus.App.Wpf.Controls; +/// +/// A strictly presentational six-vector plot. Angles and magnitudes come ONLY from +/// the selected stream's engineering snapshot; no nominal templates, tweening, +/// phase correction, or packet-rate redraws are performed by this control. +/// public sealed class PhasorDiagramControl : FrameworkElement { private const double VoltageRadiusFactor = 0.96; - private const double CurrentRadiusFactor = 0.78; - private const double VoltageLineThickness = 3.2; - private const double CurrentLineThickness = 2.8; + private const double CurrentRadiusFactor = 0.73; + private const double VoltageLineThickness = 2.7; + private const double CurrentLineThickness = 2.4; + private IReadOnlyList _visualItems = Array.Empty(); public static readonly DependencyProperty ItemsSourceProperty = @@ -23,26 +30,54 @@ public sealed class PhasorDiagramControl : FrameworkElement FrameworkPropertyMetadataOptions.AffectsRender, OnItemsSourceChanged)); - private static readonly double[] RingFractions = [0.25, 0.5, 0.75, 1.0]; + public static readonly DependencyProperty IsStreamLiveProperty = + DependencyProperty.Register( + nameof(IsStreamLive), + typeof(bool), + typeof(PhasorDiagramControl), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender)); private static readonly Brush PanelBrush = CreateBrush("#0C1B2B"); - private static readonly Brush TextPrimaryBrush = CreateBrush("#EAF3FF"); private static readonly Brush TextSecondaryBrush = CreateBrush("#A7B8CB"); private static readonly Brush TextMutedBrush = CreateBrush("#7E93AA"); + private static readonly Brush StaleBrush = CreateBrush("#F0B533"); private static readonly Brush OriginBrush = CreateBrush("#EAF3FF"); + private static readonly Brush OriginInnerBrush = CreateBrush("#1D8FFF"); - private static readonly Brush PhaseRBrush = CreateBrush("#E3263A"); + private static readonly Brush PhaseRBrush = CreateBrush("#F02D4A"); private static readonly Brush PhaseSBrush = CreateBrush("#F5B400"); - private static readonly Brush PhaseTBrush = CreateBrush("#1E7BD6"); - - private static readonly Pen OuterBorderPen = CreatePen("#1D8FFF", 1.55); - private static readonly Pen RingPen = CreatePen("#28445E", 1.0, [4.0, 6.0]); - private static readonly Pen AxisPen = CreatePen("#45627E", 1.35); - private static readonly Pen DiagonalPen = CreatePen("#1D344E", 0.9, [3.0, 5.0]); - - private static readonly Typeface TitleTypeface = new(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal); - private static readonly Typeface LabelTypeface = new(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal); - private static readonly Typeface BodyTypeface = new(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal); + private static readonly Brush PhaseTBrush = CreateBrush("#258DF4"); + + private static readonly Pen OuterBorderPen = CreatePen(CreateBrush("#1D8FFF"), 1.4); + private static readonly Pen RingPen = CreatePen(CreateBrush("#28445E"), 1.0, [3.0, 5.0]); + private static readonly Pen AxisPen = CreatePen(CreateBrush("#45627E"), 1.2); + private static readonly Pen DiagonalPen = CreatePen(CreateBrush("#1D344E"), 0.85, [2.5, 5.0]); + private static readonly Pen LeaderPen = CreatePen(CreateBrush("#58748E"), 0.75); + private static readonly Pen[] VoltagePens = + [ + CreatePen(PhaseRBrush, VoltageLineThickness), + CreatePen(PhaseSBrush, VoltageLineThickness), + CreatePen(PhaseTBrush, VoltageLineThickness) + ]; + private static readonly Pen[] CurrentPens = + [ + CreatePen(PhaseRBrush, CurrentLineThickness, [3.0, 1.8]), + CreatePen(PhaseSBrush, CurrentLineThickness, [3.0, 1.8]), + CreatePen(PhaseTBrush, CurrentLineThickness, [3.0, 1.8]) + ]; + private static readonly Pen[] CurrentArrowPens = + [ + CreatePen(PhaseRBrush, CurrentLineThickness), + CreatePen(PhaseSBrush, CurrentLineThickness), + CreatePen(PhaseTBrush, CurrentLineThickness) + ]; + + private static readonly Typeface LabelTypeface = + new(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal); + private static readonly Typeface BodyTypeface = + new(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal); + + private readonly record struct RenderedVector(PhasorDisplayItem Item, Point Tip, Brush Brush, FormattedText Label); public IEnumerable? ItemsSource { @@ -50,271 +85,218 @@ public IEnumerable? ItemsSource set => SetValue(ItemsSourceProperty, value); } - public PhasorDiagramControl() + /// + /// False means the arrows are a historical HOLD, not evidence of live traffic. + /// This flag is bound to the selected SV stream's existing IsActive state. + /// + public bool IsStreamLive { - // Deliberately no vector tweening here. Protection/substation phasors must show - // the latest computed state immediately; animation hides real state changes. + get => (bool)GetValue(IsStreamLiveProperty); + set => SetValue(IsStreamLiveProperty, value); } - private static void OnItemsSourceChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs args) + private static void OnItemsSourceChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) { - if (dependencyObject is not PhasorDiagramControl control) + if (obj is not PhasorDiagramControl control) return; - var next = ((IEnumerable?)args.NewValue)?.ToArray() ?? []; - // ARSVIN-style receiver behavior: phasor vectors are not animated/tweened. - // The view renders the latest computed RMS/angle snapshot directly, so a publisher - // phase/state change is visible on the next UI tick instead of taking seconds to settle. - control._visualItems = next; - control.InvalidateVisual(); + // Copy just the small selected-stream presentation list when it changes. + // Do not re-enumerate, allocate brushes/pens, or transform packets on repaint. + control._visualItems = ((IEnumerable?)args.NewValue)?.ToArray() ?? []; } protected override Size MeasureOverride(Size availableSize) { - var width = double.IsInfinity(availableSize.Width) ? 360 : availableSize.Width; - var height = double.IsInfinity(availableSize.Height) ? 360 : availableSize.Height; - // Do not impose 280px into a 210px high WPF row: the previous desired - // size overflowed its clip and cut off vectors / phase labels. - return new Size(Math.Max(120, width), Math.Max(140, height)); + // Respect constrained WPF rows; requesting a larger plot causes label clipping. + var width = double.IsInfinity(availableSize.Width) ? 290 : Math.Max(0, availableSize.Width); + var height = double.IsInfinity(availableSize.Height) ? 245 : Math.Max(0, availableSize.Height); + return new Size(width, height); } protected override void OnRender(DrawingContext dc) { base.OnRender(dc); - var bounds = new Rect(new Point(0, 0), RenderSize); - dc.DrawRectangle(PanelBrush, null, bounds); - - var items = _visualItems.Count > 0 - ? _visualItems.ToArray() - : ItemsSource?.ToArray() ?? []; - var validItems = items.Where(static item => item.HasValue).ToArray(); - - var voltageMax = validItems.Where(static item => item.Family == PhasorFamily.Voltage) - .Select(static item => item.Magnitude!.Value) - .DefaultIfEmpty() - .Max(); - - var currentMax = validItems.Where(static item => item.Family == PhasorFamily.Current) - .Select(static item => item.Magnitude!.Value) - .DefaultIfEmpty() - .Max(); - - var plotRect = new Rect( - bounds.Left + 10, - bounds.Top + 10, - Math.Max(120, bounds.Width - 20), - Math.Max(150, bounds.Height - 20)); - - var center = new Point( - plotRect.Left + plotRect.Width / 2.0, - plotRect.Top + plotRect.Height / 2.0); + var width = RenderSize.Width; + var height = RenderSize.Height; + dc.DrawRectangle(PanelBrush, null, new Rect(0, 0, width, height)); + if (width < 130 || height < 130) + return; - var radius = Math.Max(0, Math.Min(plotRect.Width, plotRect.Height) / 2.0 - 24.0); + // Reserve side rails for collision-free labels, rather than letting text + // overlap the arrows or escape beyond the narrow analyzer workspace. + var center = new Point(width / 2.0, height / 2.0 - 2); + var radius = Math.Max(0, Math.Min(width / 2.0 - 69, height / 2.0 - 35)); + if (radius < 12) + return; DrawGrid(dc, center, radius); - if (validItems.Length == 0) + var voltageMax = 0.0; + var currentMax = 0.0; + foreach (var item in _visualItems) + { + if (!IsRenderable(item)) + continue; + if (item.Family == PhasorFamily.Voltage) + voltageMax = Math.Max(voltageMax, item.Magnitude!.Value); + else + currentMax = Math.Max(currentMax, item.Magnitude!.Value); + } + + if (voltageMax <= 0 && currentMax <= 0) { DrawCenteredMessage(dc, center, "Phasor data pending"); return; } - DrawSoftOriginGlow(dc, center); - - foreach (var item in validItems) - DrawPhasor(dc, bounds, center, radius, item, voltageMax, currentMax); - - } - - private static void DrawGrid(DrawingContext dc, Point center, double radius) - { - if (radius <= 10) - return; + var vectors = new List(6); + dc.PushOpacity(IsStreamLive ? 1.0 : 0.42); + foreach (var item in _visualItems) + { + if (!IsRenderable(item)) + continue; + + var maximum = item.Family == PhasorFamily.Voltage ? voltageMax : currentMax; + if (maximum <= 0) + continue; + + // U and I use independent normalized radii. Never compare V with A + // numerically or infer absolute magnitudes from the same circle. + var fraction = Math.Clamp(item.Magnitude!.Value / maximum, 0.0, 1.0); + var range = item.Family == PhasorFamily.Voltage + ? VoltageRadiusFactor : CurrentRadiusFactor; + var radians = item.AngleDegrees!.Value * Math.PI / 180.0; + var length = radius * range * fraction; + var tip = new Point( + center.X + length * Math.Cos(radians), + center.Y - length * Math.Sin(radians)); + var phase = PhaseIndex(item.Name); + var brush = PhaseBrush(phase); + var pen = item.Family == PhasorFamily.Voltage ? VoltagePens[phase] : CurrentPens[phase]; + var arrowPen = item.Family == PhasorFamily.Voltage ? pen : CurrentArrowPens[phase]; + + dc.DrawLine(pen, center, tip); + DrawArrow(dc, center, tip, arrowPen, item.Family == PhasorFamily.Voltage ? 9.5 : 8.0); + vectors.Add(new RenderedVector(item, tip, brush, + CreateText(ToPhaseLabel(item.Name), 11.0, brush, LabelTypeface))); + } - // soft outer glow - dc.DrawEllipse(null, CreatePen("#0D3357", 5.0), center, radius + 1.0, radius + 1.0); + dc.DrawEllipse(OriginInnerBrush, null, center, 4.2, 4.2); + dc.DrawEllipse(OriginBrush, null, center, 2.25, 2.25); - foreach (var fraction in RingFractions) + var anchors = new PhasorLabelAnchor[vectors.Count]; + for (var i = 0; i < vectors.Count; i++) { - var pen = Math.Abs(fraction - 1.0) < 0.001 ? OuterBorderPen : RingPen; - dc.DrawEllipse(null, pen, center, radius * fraction, radius * fraction); + var vector = vectors[i]; + anchors[i] = new PhasorLabelAnchor( + vector.Tip.X, vector.Tip.Y, + vector.Label.Width, vector.Label.Height, + vector.Tip.X >= center.X); } - dc.DrawLine(AxisPen, new Point(center.X - radius, center.Y), new Point(center.X + radius, center.Y)); - dc.DrawLine(AxisPen, new Point(center.X, center.Y - radius), new Point(center.X, center.Y + radius)); + var positions = PhasorLabelLayout.Arrange(anchors, width, height, + topPadding: IsStreamLive ? 18 : 30, + bottomPadding: 25); - var diagonalOffset = radius / Math.Sqrt(2.0); - dc.DrawLine(DiagonalPen, new Point(center.X - diagonalOffset, center.Y - diagonalOffset), new Point(center.X + diagonalOffset, center.Y + diagonalOffset)); - dc.DrawLine(DiagonalPen, new Point(center.X + diagonalOffset, center.Y - diagonalOffset), new Point(center.X - diagonalOffset, center.Y + diagonalOffset)); + for (var i = 0; i < vectors.Count; i++) + { + var vector = vectors[i]; + var position = positions[i]; + var onRight = anchors[i].Right; + var endpoint = new Point( + onRight ? position.X - 3 : position.X + vector.Label.Width + 3, + position.Y + vector.Label.Height / 2.0); + dc.DrawLine(LeaderPen, vector.Tip, endpoint); + dc.DrawText(vector.Label, new Point(position.X, position.Y)); + } + dc.Pop(); - DrawSmallText(dc, "0°", new Point(center.X + radius + 6, center.Y - 10), TextMutedBrush); - DrawSmallText(dc, "+90°", new Point(center.X - 16, center.Y - radius - 20), TextMutedBrush); - DrawSmallText(dc, "180°", new Point(center.X - radius - 38, center.Y - 10), TextMutedBrush); - DrawSmallText(dc, "-90°", new Point(center.X - 16, center.Y + radius + 4), TextMutedBrush); + DrawText(dc, "U solid · I dashed · separate scales", + 9.0, new Point(6, height - 16), TextMutedBrush, BodyTypeface); + if (!IsStreamLive) + DrawText(dc, "STALE · last valid samples", 11, new Point(7, 5), StaleBrush, LabelTypeface); } - private static void DrawSoftOriginGlow(DrawingContext dc, Point center) - { - dc.DrawEllipse(CreateBrush("#123F68"), null, center, 10.0, 10.0); - dc.DrawEllipse(CreateBrush("#1D8FFF"), null, center, 6.2, 6.2); - dc.DrawEllipse(OriginBrush, null, center, 4.0, 4.0); - } + private static bool IsRenderable(PhasorDisplayItem item) => + item.HasValue && + double.IsFinite(item.Magnitude!.Value) && + double.IsFinite(item.AngleDegrees!.Value) && + item.Magnitude.Value > 0; - private static void DrawPhasor(DrawingContext dc, Rect bounds, Point center, double radius, PhasorDisplayItem item, double voltageMax, double currentMax) + private static int PhaseIndex(string name) => name switch { - var maxMagnitude = item.Family == PhasorFamily.Voltage ? voltageMax : currentMax; - if (maxMagnitude <= 0) - return; - - var normalized = Math.Clamp(item.Magnitude!.Value / maxMagnitude, 0.0, 1.0); - - var familyRadius = item.Family == PhasorFamily.Voltage - ? radius * VoltageRadiusFactor - : radius * CurrentRadiusFactor; - - var visualLength = familyRadius * normalized; - var radians = item.AngleDegrees!.Value * Math.PI / 180.0; - - var end = new Point( - center.X + visualLength * Math.Cos(radians), - center.Y - visualLength * Math.Sin(radians)); - - var brush = GetBrush(item); - var glowPen = new Pen(CloneWithOpacity(brush, 0.20), item.Family == PhasorFamily.Voltage ? 8.0 : 6.5) - { - StartLineCap = PenLineCap.Round, - EndLineCap = PenLineCap.Round - }; - glowPen.Freeze(); - - var pen = CreateVectorPen(item, brush); + "Ub" or "Ib" => 1, + "Uc" or "Ic" => 2, + _ => 0 + }; - dc.DrawLine(glowPen, center, end); - dc.DrawLine(pen, center, end); - - DrawArrowHead(dc, center, end, brush, item.Family == PhasorFamily.Voltage ? 17.0 : 15.0); - DrawVectorLabel(dc, bounds, item, center, end, brush); - } + private static Brush PhaseBrush(int index) => index switch + { + 1 => PhaseSBrush, + 2 => PhaseTBrush, + _ => PhaseRBrush + }; - private static void DrawArrowHead(DrawingContext dc, Point start, Point end, Brush brush, double arrowLength) + private static string ToPhaseLabel(string name) => name switch { - var direction = end - start; - if (direction.Length <= 0.001) + "Ua" => "Ua (R)", + "Ub" => "Ub (S)", + "Uc" => "Uc (T)", + "Ia" => "Ia (R)", + "Ib" => "Ib (S)", + "Ic" => "Ic (T)", + _ => name + }; + + private static void DrawArrow(DrawingContext dc, Point origin, Point tip, Pen pen, double size) + { + var direction = tip - origin; + if (direction.LengthSquared < 1) return; direction.Normalize(); var normal = new Vector(-direction.Y, direction.X); - var arrowWidth = arrowLength * 0.46; - - var p1 = end; - var p2 = end - direction * arrowLength + normal * arrowWidth; - var p3 = end - direction * arrowLength - normal * arrowWidth; - - var geometry = new StreamGeometry(); - using var ctx = geometry.Open(); - ctx.BeginFigure(p1, true, true); - ctx.LineTo(p2, true, false); - ctx.LineTo(p3, true, false); - geometry.Freeze(); - - dc.DrawGeometry(brush, null, geometry); - } - - private static void DrawVectorLabel(DrawingContext dc, Rect bounds, PhasorDisplayItem item, Point center, Point end, Brush brush) - { - var direction = end - center; - if (direction.Length <= 0.001) - direction = new Vector(1, 0); - else - direction.Normalize(); - - var label = ToPhaseLabel(item.Name); - var labelPoint = end + direction * 11.0; - - if (direction.X >= 0) - labelPoint.X += 5; - else - labelPoint.X -= 48; - - if (direction.Y >= 0) - labelPoint.Y += 2; - else - labelPoint.Y -= 17; - - var text = CreateText(label, 11.5, brush, LabelTypeface); - labelPoint.X = Math.Clamp(labelPoint.X, bounds.Left + 4, Math.Max(bounds.Left + 4, bounds.Right - text.Width - 4)); - labelPoint.Y = Math.Clamp(labelPoint.Y, bounds.Top + 4, Math.Max(bounds.Top + 4, bounds.Bottom - text.Height - 4)); - dc.DrawText(text, labelPoint); + dc.DrawLine(pen, tip, tip - direction * size + normal * (size * 0.42)); + dc.DrawLine(pen, tip, tip - direction * size - normal * (size * 0.42)); } - private static void DrawCenteredMessage(DrawingContext dc, Point center, string text) + private void DrawGrid(DrawingContext dc, Point center, double radius) { - var formatted = CreateText(text, 17, TextSecondaryBrush, LabelTypeface); - dc.DrawText(formatted, new Point(center.X - formatted.Width / 2.0, center.Y - formatted.Height / 2.0)); + foreach (var fraction in new[] { 0.25, 0.5, 0.75, 1.0 }) + dc.DrawEllipse(null, fraction == 1.0 ? OuterBorderPen : RingPen, + center, radius * fraction, radius * fraction); + + dc.DrawLine(AxisPen, new Point(center.X - radius, center.Y), + new Point(center.X + radius, center.Y)); + dc.DrawLine(AxisPen, new Point(center.X, center.Y - radius), + new Point(center.X, center.Y + radius)); + + var offset = radius / Math.Sqrt(2); + dc.DrawLine(DiagonalPen, new Point(center.X - offset, center.Y - offset), + new Point(center.X + offset, center.Y + offset)); + dc.DrawLine(DiagonalPen, new Point(center.X + offset, center.Y - offset), + new Point(center.X - offset, center.Y + offset)); + + // Tick labels stay inside the ring so they cannot collide with side labels. + DrawText(dc, "0°", 9, new Point(center.X + radius - 19, center.Y + 4), TextMutedBrush, BodyTypeface); + DrawText(dc, "180°", 9, new Point(center.X - radius + 4, center.Y + 4), TextMutedBrush, BodyTypeface); + DrawText(dc, "+90°", 9, new Point(center.X + 5, center.Y - radius + 3), TextMutedBrush, BodyTypeface); + DrawText(dc, "-90°", 9, new Point(center.X + 5, center.Y + radius - 14), TextMutedBrush, BodyTypeface); } - private static Brush GetBrush(PhasorDisplayItem item) + private void DrawCenteredMessage(DrawingContext dc, Point center, string message) { - return item.Name switch - { - "Ua" => PhaseRBrush, - "Ub" => PhaseSBrush, - "Uc" => PhaseTBrush, - "Ia" => PhaseRBrush, - "Ib" => PhaseSBrush, - "Ic" => PhaseTBrush, - _ => item.Family == PhasorFamily.Voltage ? PhaseRBrush : PhaseRBrush - }; + var text = CreateText(message, 12, TextSecondaryBrush, BodyTypeface); + dc.DrawText(text, new Point(center.X - text.Width / 2, center.Y - text.Height / 2)); } - private static string ToPhaseLabel(string name) - { - return name switch - { - "Ua" => "Ua (R)", - "Ub" => "Ub (S)", - "Uc" => "Uc (T)", - "Ia" => "Ia (R)", - "Ib" => "Ib (S)", - "Ic" => "Ic (T)", - _ => name - }; - } + private void DrawText(DrawingContext dc, string value, double size, Point point, Brush brush, Typeface typeface) => + dc.DrawText(CreateText(value, size, brush, typeface), point); - private static Pen CreateVectorPen(PhasorDisplayItem item, Brush brush) - { - var pen = new Pen(brush, item.Family == PhasorFamily.Voltage ? VoltageLineThickness : CurrentLineThickness) - { - StartLineCap = PenLineCap.Round, - EndLineCap = PenLineCap.Round - }; - - pen.Freeze(); - return pen; - } - - private static void DrawSmallText(DrawingContext dc, string text, Point point, Brush brush) - { - DrawText(dc, text, 11, point, brush, BodyTypeface); - } - - private static void DrawText(DrawingContext dc, string text, double size, Point point, Brush brush, Typeface typeface) - { - dc.DrawText(CreateText(text, size, brush, typeface), point); - } - - private static FormattedText CreateText(string text, double size, Brush brush, Typeface typeface) - { - return new FormattedText( - text, - CultureInfo.InvariantCulture, - FlowDirection.LeftToRight, - typeface, - size, - brush, - VisualTreeHelper.GetDpi(Application.Current.MainWindow).PixelsPerDip); - } + private FormattedText CreateText(string value, double size, Brush brush, Typeface typeface) => + new(value, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, + typeface, size, brush, VisualTreeHelper.GetDpi(this).PixelsPerDip); private static Brush CreateBrush(string hex) { @@ -323,23 +305,12 @@ private static Brush CreateBrush(string hex) return brush; } - private static SolidColorBrush CloneWithOpacity(Brush brush, double opacity) - { - var color = brush is SolidColorBrush solid ? solid.Color : Colors.Black; - var clone = new SolidColorBrush(color) { Opacity = opacity }; - clone.Freeze(); - return clone; - } - - private static Pen CreatePen(string hex, double thickness, double[]? dashArray = null) + private static Pen CreatePen(Brush brush, double thickness, double[]? dash = null) { - var pen = new Pen(CreateBrush(hex), thickness); - if (dashArray is not null) - pen.DashStyle = new DashStyle(dashArray, 0); - + var pen = new Pen(brush, thickness); + if (dash is not null) + pen.DashStyle = new DashStyle(dash, 0); pen.Freeze(); return pen; } - } - From c0319014b627664f18394998c446c43889bc0db2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 07:32:32 +0700 Subject: [PATCH 003/148] Coalesce phasor redraws by RMS and angle, not instantaneous sample --- .../Presentation/LiveRenderCoalescer.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/ProcessBus.Core/Presentation/LiveRenderCoalescer.cs b/src/ProcessBus.Core/Presentation/LiveRenderCoalescer.cs index c255e86d..48e9310c 100644 --- a/src/ProcessBus.Core/Presentation/LiveRenderCoalescer.cs +++ b/src/ProcessBus.Core/Presentation/LiveRenderCoalescer.cs @@ -51,6 +51,25 @@ public static bool SameAnalog(AnalogValuesSnapshot left, AnalogValuesSnapshot ri SameChannel(left.Ua, right.Ua) && SameChannel(left.Ub, right.Ub) && SameChannel(left.Uc, right.Uc) && SameChannel(left.Un, right.Un); + /// + /// Only RMS magnitude, electrical angle and engineering unit define a displayed + /// phasor. A new instantaneous SV sample must update the digital readout without + /// forcing six arrow/label recalculations when the phasors themselves are unchanged. + /// No quantization: genuine small angle or magnitude changes still redraw. + /// + public static bool SamePhasors(AnalogValuesSnapshot left, AnalogValuesSnapshot right) => + SamePhasorChannel(left.Ua, right.Ua) && + SamePhasorChannel(left.Ub, right.Ub) && + SamePhasorChannel(left.Uc, right.Uc) && + SamePhasorChannel(left.Ia, right.Ia) && + SamePhasorChannel(left.Ib, right.Ib) && + SamePhasorChannel(left.Ic, right.Ic); + + private static bool SamePhasorChannel(ChannelValueModel a, ChannelValueModel b) => + a.RmsValue == b.RmsValue && + a.AngleDegrees == b.AngleDegrees && + string.Equals(a.Unit, b.Unit, StringComparison.Ordinal); + private static bool SameChannel(ChannelValueModel a, ChannelValueModel b) => a.InstantValue == b.InstantValue && a.RmsValue == b.RmsValue && a.AngleDegrees == b.AngleDegrees && From 8e90603a16658070e6b4255680192e5d29b53c06 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 07:33:06 +0700 Subject: [PATCH 004/148] Remove UI phase-reference overrides and avoid redundant phasor repaints --- .../ViewModels/MainWindowViewModel.cs | 61 +++++++------------ 1 file changed, 22 insertions(+), 39 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 2b3841bb..79187082 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -48,6 +48,7 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private IReadOnlyList _gooseMessages = Array.Empty(); private GooseMonitorSnapshot _gooseSnapshot = new(); private IReadOnlyList _phasors = Array.Empty(); + private IReadOnlyList? _lastRaisedPhasors; private AnalogValuesSnapshot? _lastPhasorSourceSnapshot; private StreamDetailsModel? _displayedSelectedStreamDetails; private AnalogValuesSnapshot _displayedAnalogValues = new(); @@ -2289,16 +2290,12 @@ private void UpdateSelectedStreamWorkspaceFromSnapshot(string? selectedStreamId, OnPropertyChanged(nameof(SelectedEngineeringScale)); OnPropertyChanged(nameof(EngineeringScaleExplanation)); - // ARSVIN-style display contract: phasor vectors are derived from the latest - // engineering snapshot on every UI refresh. Do not use object-reference caching - // here. The raw analyzer may legitimately hold/reuse an AnalogValuesSnapshot while - // the SV window is settling, and previous reference caching made the phasor panel - // appear frozen until a user click rebuilt the SV workspace. Six vectors are cheap; - // correctness and responsiveness are more important than suppressing this tiny - // allocation. + // One SSOT: derive vectors from the selected stream's engineering snapshot. + // Do not recalculate arrows when only the instantaneous sample has changed. + // Exact RMS/angle comparisons retain immediate response to genuine transients. _displayedAnalogValues = analogValues; if (_lastPhasorSourceSnapshot is null || - !LiveRenderCoalescer.SameAnalog(_lastPhasorSourceSnapshot, _displayedAnalogValues) || + !LiveRenderCoalescer.SamePhasors(_lastPhasorSourceSnapshot, _displayedAnalogValues) || !string.Equals(_lastPhasorSourceStreamId, selectedStreamId, StringComparison.Ordinal)) { _phasors = BuildPhasorItems(_displayedAnalogValues); @@ -2666,8 +2663,11 @@ private void RaiseAnalyzerRenderProperties(bool force = false) OnPropertyChanged(nameof(WaveformHeaderWindowText)); OnPropertyChanged(nameof(WaveformHeaderStatusText)); OnPropertyChanged(nameof(WaveformShapeStatusText)); - if (force || analogChanged) + if (force || !ReferenceEquals(_lastRaisedPhasors, _phasors)) + { + _lastRaisedPhasors = _phasors; OnPropertyChanged(nameof(Phasors)); + } OnPropertyChanged(nameof(WaveformVoltageScale)); OnPropertyChanged(nameof(WaveformCurrentScale)); } @@ -4296,20 +4296,15 @@ private void UpdateRefreshCadence() }; } - private static IReadOnlyList BuildPhasorItems(AnalogValuesSnapshot analogValues) - { - var referenceName = ResolvePhasorReferenceName(analogValues); - - return - [ - CreatePhasor("Ua", PhasorFamily.Voltage, analogValues.Ua, forceAngleZero: string.Equals(referenceName, "Ua", StringComparison.OrdinalIgnoreCase)), - CreatePhasor("Ub", PhasorFamily.Voltage, analogValues.Ub), - CreatePhasor("Uc", PhasorFamily.Voltage, analogValues.Uc), - CreatePhasor("Ia", PhasorFamily.Current, analogValues.Ia, forceAngleZero: string.Equals(referenceName, "Ia", StringComparison.OrdinalIgnoreCase)), - CreatePhasor("Ib", PhasorFamily.Current, analogValues.Ib), - CreatePhasor("Ic", PhasorFamily.Current, analogValues.Ic) - ]; - } + private static IReadOnlyList BuildPhasorItems(AnalogValuesSnapshot analogValues) => + [ + CreatePhasor("Ua", PhasorFamily.Voltage, analogValues.Ua), + CreatePhasor("Ub", PhasorFamily.Voltage, analogValues.Ub), + CreatePhasor("Uc", PhasorFamily.Voltage, analogValues.Uc), + CreatePhasor("Ia", PhasorFamily.Current, analogValues.Ia), + CreatePhasor("Ib", PhasorFamily.Current, analogValues.Ib), + CreatePhasor("Ic", PhasorFamily.Current, analogValues.Ic) + ]; private (double P, double Q, double S) ComputeThreePhasePower() { @@ -4329,28 +4324,16 @@ private static string FormatPower(double value, string unit) return $"{value:0.00} {unit}"; } - private static string? ResolvePhasorReferenceName(AnalogValuesSnapshot analogValues) - { - if (analogValues.Ua.RmsValue.HasValue) - return "Ua"; - - if (analogValues.Ia.RmsValue.HasValue) - return "Ia"; - - return null; - } - - private static PhasorDisplayItem CreatePhasor(string name, PhasorFamily family, ChannelValueModel channel, bool forceAngleZero = false) - { - return new PhasorDisplayItem + private static PhasorDisplayItem CreatePhasor(string name, PhasorFamily family, ChannelValueModel channel) => + new() { Name = name, Family = family, Magnitude = channel.RmsValue, - AngleDegrees = forceAngleZero && channel.AngleDegrees.HasValue ? 0.0 : channel.AngleDegrees, + // The engine alone owns relative-angle/reference interpretation. + AngleDegrees = channel.AngleDegrees, Unit = channel.Unit }; - } private bool FilterGooseHistory(object item) { From 86db9a64b37809648cbede82e244db81699be2f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 07:33:21 +0700 Subject: [PATCH 005/148] Bind phasor freshness directly to selected stream live state --- src/ProcessBus.App.Wpf/MainWindow.xaml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 5097587f..b179340b 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -2144,6 +2144,7 @@ From 8b36a0d378b6310dfb8822267d51775b1c856fb4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 07:34:29 +0700 Subject: [PATCH 006/148] Regression-test fast phasor updates and collision-free compact label placement --- .../PhasorPresentationContractTests.cs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/ProcessBus.Tests/PhasorPresentationContractTests.cs diff --git a/tests/ProcessBus.Tests/PhasorPresentationContractTests.cs b/tests/ProcessBus.Tests/PhasorPresentationContractTests.cs new file mode 100644 index 00000000..0fe6e5e5 --- /dev/null +++ b/tests/ProcessBus.Tests/PhasorPresentationContractTests.cs @@ -0,0 +1,108 @@ +using ProcessBus.Core.Models; +using ProcessBus.Core.Presentation; +using Xunit; + +namespace ProcessBus.Tests; + +/// +/// Deterministic layout / live-render contracts: no UI-side electrical assumptions +/// and no dependence on Windows/Npcap to verify the narrow phasor panel. +/// +public sealed class PhasorPresentationContractTests +{ + private static ChannelValueModel Channel(string name, double rms, double angle, double instant = 0) => + new(name) { Unit = name.StartsWith("U") ? "V" : "A", RmsValue = rms, + AngleDegrees = angle, InstantValue = instant }; + + private static AnalogValuesSnapshot Values( + double iaAngle = -30, double iaRms = 0.5, double iaInstant = 0) => + new() + { + Ua = Channel("Ua", 57.73, 0), + Ub = Channel("Ub", 57.73, -120), + Uc = Channel("Uc", 57.73, 120), + Ia = Channel("Ia", iaRms, iaAngle, iaInstant), + Ib = Channel("Ib", 1, -150), + Ic = Channel("Ic", 1, 90) + }; + + [Fact] + public void InstantaneousSampleChangesDoNotForceFullPhasorRepaint() + { + var first = Values(iaInstant: 0.1); + var next = Values(iaInstant: 0.2); + Assert.False(LiveRenderCoalescer.SameAnalog(first, next)); + Assert.True(LiveRenderCoalescer.SamePhasors(first, next)); + } + + [Fact] + public void AnyMeasuredPhaseOrRmsChangeInvalidatesPhasorImmediately() + { + var original = Values(); + Assert.False(LiveRenderCoalescer.SamePhasors(original, Values(iaAngle: -29.999))); + Assert.False(LiveRenderCoalescer.SamePhasors(original, Values(iaRms: 0.500001))); + // Current C is independently measured; it must never borrow voltage C. + Assert.NotEqual(original.Ic.AngleDegrees, original.Uc.AngleDegrees); + } + + [Fact] + public void MissingPhasorNeverBecomesAnInventedNominalAngle() + { + var original = Values(); + var missing = new AnalogValuesSnapshot + { + Ua = original.Ua, Ub = original.Ub, Uc = original.Uc, + Ia = original.Ia, Ib = original.Ib, + Ic = new ChannelValueModel("Ic") { RmsValue = 0, Unit = "A" } + }; + Assert.False(LiveRenderCoalescer.SamePhasors(original, missing)); + Assert.Null(missing.Ic.AngleDegrees); + } + + [Theory] + [InlineData(298, 220)] + [InlineData(320, 280)] + [InlineData(215, 210)] + public void SixCrowdedLabelsStayInsideNarrowViewportAndNeverOverlap(double width, double height) + { + // Two co-linear Ua/Ia vectors and clustered Uc/Ic and Ub/Ib labels. + var anchors = new[] + { + new PhasorLabelAnchor(210, 112, 47, 14, true), + new PhasorLabelAnchor(85, 166, 47, 14, false), + new PhasorLabelAnchor(85, 65, 47, 14, false), + new PhasorLabelAnchor(190, 112, 47, 14, true), + new PhasorLabelAnchor(95, 162, 47, 14, false), + new PhasorLabelAnchor(95, 69, 47, 14, false) + }; + var labels = PhasorLabelLayout.Arrange(anchors, width, height); + Assert.Equal(anchors.Length, labels.Length); + + for (var i = 0; i < labels.Length; i++) + { + Assert.InRange(labels[i].X, 0, width - anchors[i].Width); + Assert.InRange(labels[i].Y, 18, height - anchors[i].Height); + for (var j = i + 1; j < labels.Length; j++) + { + if (anchors[i].Right != anchors[j].Right) + continue; + var firstBottom = labels[i].Y + anchors[i].Height; + var secondBottom = labels[j].Y + anchors[j].Height; + Assert.True(firstBottom <= labels[j].Y || secondBottom <= labels[i].Y, + $"Overlapping labels {i}/{j} at height {height}."); + } + } + } + + [Fact] + public void SixVectorsSharingOneAngleStillReceiveDistinctStableLabelSlots() + { + var anchors = Enumerable.Range(0, 6) + .Select(_ => new PhasorLabelAnchor(210, 110, 45, 15, true)) + .ToArray(); + var labels = PhasorLabelLayout.Arrange(anchors, 290, 210); + Assert.Equal(6, labels.Select(x => x.Y).Distinct().Count()); + Assert.Equal(labels.OrderBy(x => x.Y).ToArray(), labels); + Assert.True(labels[^1].Y + 15 <= 188); + } +} From 35447c6a6734ce4fb5cc071e1c589198359e1bd5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 07:36:35 +0700 Subject: [PATCH 007/148] Document phasor SSOT invariants and required physical-bench validation --- docs/PHASOR_SSOT_ACCEPTANCE_2026-10-19.md | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/PHASOR_SSOT_ACCEPTANCE_2026-10-19.md diff --git a/docs/PHASOR_SSOT_ACCEPTANCE_2026-10-19.md b/docs/PHASOR_SSOT_ACCEPTANCE_2026-10-19.md new file mode 100644 index 00000000..26f8bcd7 --- /dev/null +++ b/docs/PHASOR_SSOT_ACCEPTANCE_2026-10-19.md @@ -0,0 +1,34 @@ +# Phasor SSOT and rendering acceptance — 2026-10-19 + +## Problem and evidence boundary + +The analyzer previously corrected a phase-reference angle a second time in its WPF ViewModel, although the selected-stream raw engine had already resolved relative electrical angles. The plot also placed six text labels independently, so coincident or clustered voltage/current angles collided in a narrow layout. Instantaneous-value changes could cause needless phasor item rebuilds, and a stale publisher could leave its last valid arrows looking live. + +This change **does not claim that an injector and receiver have been proven equivalent on the wire**. That requires a synchronized captured packet / injector settings comparison for the same svID, APPID, MAC, mapping, quality, smpCnt, RMS scale, and timestamp. + +## New invariants + +1. **Single authority**: the raw selected-stream snapshot is the only source of RMS magnitude and relative angle. The ViewModel and WPF control do not synthesize, quantize, hold, or rotate electrical angles. +2. **One selected stream**: measurement, waveform and vectors come from the existing locked selected-stream analyzer snapshot. No new packet decoder, stream cache, worker or UI stream owner is introduced. +3. **Independent channel truth**: current and voltage phases cannot borrow an angle merely because their R/S/T suffix matches. A missing or zero fundamental has no drawable vector. +4. **Responsive without churn**: an RMS or angle change (even a tiny one) invalidates the phasor list. An instantaneous sample changing while RMS/angles remain identical does not create a new six-vector plot list. All pens and grid brushes are frozen and reused, no animation or packet-rate WPF callback is added. +5. **Readable small panels**: voltage vectors use solid strokes, currents use dashed strokes and a separate normalized radius. Labels are packed per side with deterministic ordering and stay in the viewport at supported panel sizes. Voltage and current radii are not directly comparable physical scales. +6. **Freshness visible**: a non-live selected stream is rendered as a dimmed **STALE · last valid samples** hold; it is not a current live phasor estimate. + +## Deterministic regression tests + +- Existing `LiveCurrentPhasorRegressionTests` checks individually driven Ia/Ib/Ic versus Ua/Ub/Uc, live phase changes and zero-magnitude suppression. +- `PhasorPresentationContractTests` checks exact phasor change detection (no angular rounding), no repaints for instantaneous-only changes, independent Ic/Uc, missing-angle behavior and collision avoidance at multiple narrow panel sizes. + +## Physical-bench acceptance (required before claiming injector equivalence) + +1. Configure a known synthetic 4I+4V stream on the injector and record the source configuration in the test report. +2. Capture an authorized SV PCAP and note its independent smpCnt/APPID/svID/quality/DataSet mapping; verify the analyzer selected that exact publisher. +3. Compare each of six decoded channel RMS and relative angle values with injector setpoints, allowing only justified rounding and injection/capture uncertainties. Do not infer channel correctness from waveform color or nominal three-phase templates. +4. Change **one** current angle, for example Ic +90° to +45°, without changing Uc +120°. Confirm the analyzer's table and arrows follow the changed stream without an explorer click or an intermediate fabricated phase. +5. Change a voltage magnitude and a current magnitude independently, then stop publishing for more than two seconds. Verify stale status and last-valid-hold labeling. +6. Run a 15-minute live stream on the target Windows machine; record capture packets/s, UI update latency, CPU utilization and heap growth. This change's lightness is architectural; sustained performance remains **unmeasured** until bench results are attached. + +## Build gates + +On a Windows .NET 8 runner: `scripts/repository-health.ps1`, solution restore/build and all tests. This feature must not be described as wire-validated or performance-certified solely because unit tests pass. From 31fee580ed886637c5c73f0f3cb179998bef8463 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:29:04 +0700 Subject: [PATCH 008/148] Add stable Inno Setup 6 Windows installer with offline manuals and upgrade identity --- installer/ProcessBusInsight.iss | 99 +++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 installer/ProcessBusInsight.iss diff --git a/installer/ProcessBusInsight.iss b/installer/ProcessBusInsight.iss new file mode 100644 index 00000000..2c5abec5 --- /dev/null +++ b/installer/ProcessBusInsight.iss @@ -0,0 +1,99 @@ +; Process Bus Insight – stable, offline, per-machine Inno Setup 6 installer. +; Build by scripts/publish-windows-installer.ps1 after staging a verified portable package. +; This script does NOT redistribute or attempt to install Npcap. + +#ifndef AppVersion + #error AppVersion is required (/DAppVersion=...) +#endif +#ifndef StageDir + #error StageDir is required (/DStageDir=...) +#endif +#ifndef OutputDir + #error OutputDir is required (/DOutputDir=...) +#endif + +#define AppName "Process Bus Insight" +#define AppExe "ProcessBusInsight.exe" +#define RepoUrl "https://github.com/masarray/DigSubAnalyzer" + +[Setup] +; Stable AppId: never change between releases or upgrades will create duplicates. +AppId={{EAF21D82-164A-4EEA-9DAE-D4358769D10A} +AppName={#AppName} +AppVersion={#AppVersion} +AppVerName={#AppName} {#AppVersion} +AppPublisher=Process Bus Insight Contributors +AppPublisherURL={#RepoUrl} +AppSupportURL={#RepoUrl}/issues +AppUpdatesURL={#RepoUrl}/releases/latest +DefaultDirName={autopf}\Process Bus Insight +DefaultGroupName=Process Bus Insight +DisableProgramGroupPage=yes +UsePreviousAppDir=yes +UsePreviousGroup=yes +UsePreviousTasks=yes +PrivilegesRequired=admin +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +OutputDir={#OutputDir} +OutputBaseFilename=ProcessBusInsight-v{#AppVersion}-win-x64-setup +SetupIconFile={#SourcePath}\..\src\ProcessBus.App.Wpf\IEC61850.ico +UninstallDisplayIcon={app}\{#AppExe} +Compression=lzma2/ultra64 +SolidCompression=yes +WizardStyle=modern +WizardSizePercent=100 +ShowLanguageDialog=no +CloseApplications=yes +RestartApplications=no +SetupLogging=yes +ChangesAssociations=no +DisableWelcomePage=no +MinVersion=10.0 +LicenseFile={#StageDir}\LICENSE.txt +InfoAfterFile={#StageDir}\README.txt +VersionInfoDescription=Process Bus Insight Setup +VersionInfoProductName=Process Bus Insight +VersionInfoCompany=Process Bus Insight Contributors +VersionInfoVersion={#AppVersion} +; Installer remains unsigned until a legitimate code-signing certificate is configured. +; Never imply that an unsigned build bypasses Windows SmartScreen. + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked + +[Files] +; Exact files verified by the release-packaging gate. No customer configuration or +; capture data is copied into Program Files by this installer. +Source: "{#StageDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{autoprograms}\Process Bus Insight"; Filename: "{app}\{#AppExe}"; WorkingDir: "{app}" +Name: "{autoprograms}\User Manual (PDF)"; Filename: "{app}\User Manual.pdf" +Name: "{autoprograms}\Quick Start (PDF)"; Filename: "{app}\Quick Start.pdf" +Name: "{autoprograms}\Uninstall Process Bus Insight"; Filename: "{uninstallexe}" +Name: "{autodesktop}\Process Bus Insight"; Filename: "{app}\{#AppExe}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#AppExe}"; Description: "Launch Process Bus Insight"; Flags: nowait postinstall skipifsilent + +[Code] +function IsNpcapPresent(): Boolean; +begin + // Npcap has both native 64-bit and compatibility DLL deployments. Only give + // guidance, since offline PCAP replay works without a live capture driver. + Result := + FileExists(ExpandConstant('{sys}\Npcap\wpcap.dll')) or + FileExists(ExpandConstant('{sys}\wpcap.dll')); +end; + +procedure CurPageChanged(CurPageID: Integer); +begin + if (CurPageID = wpReady) and (not IsNpcapPresent()) then + WizardForm.ReadyMemo.Lines.Add( + 'Note: Npcap was not detected. Live Ethernet capture requires Npcap, ' + + 'installed separately. Offline analysis can still be used.'); +end; From 22a5cb1d14f8e6f6376e0b2e1d99dd0b034b1873 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:29:58 +0700 Subject: [PATCH 009/148] Compile verified portable stage into reproducible Inno installer artifact --- scripts/publish-windows-installer.ps1 | 95 +++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 scripts/publish-windows-installer.ps1 diff --git a/scripts/publish-windows-installer.ps1 b/scripts/publish-windows-installer.ps1 new file mode 100644 index 00000000..3eedc640 --- /dev/null +++ b/scripts/publish-windows-installer.ps1 @@ -0,0 +1,95 @@ +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+\.\d+\.\d+(?:\.\d+)?(?:-[A-Za-z0-9.-]+)?$')] + [string]$Version, + [string]$Runtime = 'win-x64', + [string]$AppName = 'ProcessBusInsight', + [string]$OutputRoot = 'artifacts', + [string]$IsccPath = '' +) + +$ErrorActionPreference = 'Stop' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if ($Runtime -ne 'win-x64') { throw 'Inno installer currently supports win-x64 only.' } + +$stageDir = Join-Path $repoRoot "$OutputRoot/package/$AppName-v$Version-$Runtime-portable" +$releaseDir = Join-Path $repoRoot "$OutputRoot/release" +$issPath = Join-Path $repoRoot 'installer/ProcessBusInsight.iss' +$installerName = "$AppName-v$Version-$Runtime-setup.exe" +$installerPath = Join-Path $releaseDir $installerName +$shaFile = Join-Path $releaseDir 'SHA256SUMS.txt' + +# Fail closed before producing an installer. This is the identical verified +# portable payload, including legal docs, PDFs and the separate offline bench CLI. +$mandatory = @( + "$AppName.exe", 'Quick Start.pdf', 'User Manual.pdf', + 'LICENSE.txt', 'NOTICE.txt', 'COMMERCIAL-LICENSE.md', + 'COPYRIGHT.md', 'TRADEMARK.md', 'THIRD_PARTY_NOTICES.md', + 'Licensing.md', 'README.txt', 'Bench/ProcessBus.InteropCli.exe', + 'Bench/START_HERE.md' +) +foreach ($file in $mandatory) { + $item = Join-Path $stageDir $file + if (-not (Test-Path -LiteralPath $item -PathType Leaf)) { + throw "Installer staging is missing $file. Run publish-windows-portable.ps1 first." + } +} +if (-not (Test-Path -LiteralPath $issPath -PathType Leaf)) { + throw "Missing Inno setup definition: $issPath" +} + +if ([string]::IsNullOrWhiteSpace($IsccPath)) { + $command = Get-Command 'ISCC.exe' -ErrorAction SilentlyContinue + if ($command) { $IsccPath = $command.Source } + if ([string]::IsNullOrWhiteSpace($IsccPath)) { + foreach ($candidate in @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "$env:ProgramFiles\Inno Setup 6\ISCC.exe" + )) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + $IsccPath = $candidate + break + } + } + } +} +if (-not (Test-Path -LiteralPath $IsccPath -PathType Leaf)) { + throw 'Inno Setup 6 compiler (ISCC.exe) is unavailable. Install Inno Setup 6 first.' +} + +New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null +Remove-Item -LiteralPath $installerPath -Force -ErrorAction SilentlyContinue +# Avoid prerelease labels in Win32 VERSIONINFO numeric resource. +$numericVersion = $Version.Split('-', 2)[0] +$versionNumbers = $numericVersion.Split('.') +if ($versionNumbers.Length -eq 3) { $numericVersion += '.0' } + +Write-Host "Compiling Inno Setup installer: $installerName" +Write-Host "Payload: $stageDir" +Write-Host "Inno compiler: $IsccPath" +$compilerArgs = @( + "/DAppVersion=$Version", "/DFileVersion=$numericVersion", + "/DStageDir=$stageDir", "/DOutputDir=$releaseDir", $issPath +) +& $IsccPath @compilerArgs +if ($LASTEXITCODE -ne 0) { throw "ISCC failed with exit code $LASTEXITCODE." } +if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { + throw "ISCC succeeded without producing $installerPath." +} +if ((Get-Item -LiteralPath $installerPath).Length -lt 1024 * 1024) { + throw 'Installer is unexpectedly small; refusing to publish.' +} + +$hash = (Get-FileHash -LiteralPath $installerPath -Algorithm SHA256).Hash.ToLowerInvariant() +$checksumLine = "$hash $installerName" +if (Test-Path $shaFile) { + # Running this step twice must not duplicate its checksum line. + $others = @(Get-Content $shaFile | Where-Object { + $_ -and $_ -notmatch ('\s+' + [regex]::Escape($installerName) + '$') + }) + @($others + $checksumLine) | Set-Content $shaFile -Encoding ASCII +} else { + Set-Content $shaFile $checksumLine -Encoding ASCII +} +Write-Host "Created $installerPath" +Write-Host "SHA256 $hash" From 0082e7ae73e35b7bbaf3a2bceaceafefaa70bfb6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:30:33 +0700 Subject: [PATCH 010/148] Separate numeric Windows version resource from friendly prerelease version --- installer/ProcessBusInsight.iss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/installer/ProcessBusInsight.iss b/installer/ProcessBusInsight.iss index 2c5abec5..f61a08d8 100644 --- a/installer/ProcessBusInsight.iss +++ b/installer/ProcessBusInsight.iss @@ -5,6 +5,9 @@ #ifndef AppVersion #error AppVersion is required (/DAppVersion=...) #endif +#ifndef FileVersion + #error FileVersion is required (/DFileVersion=...) +#endif #ifndef StageDir #error StageDir is required (/DStageDir=...) #endif @@ -55,7 +58,7 @@ InfoAfterFile={#StageDir}\README.txt VersionInfoDescription=Process Bus Insight Setup VersionInfoProductName=Process Bus Insight VersionInfoCompany=Process Bus Insight Contributors -VersionInfoVersion={#AppVersion} +VersionInfoVersion={#FileVersion} ; Installer remains unsigned until a legitimate code-signing certificate is configured. ; Never imply that an unsigned build bypasses Windows SmartScreen. From f52c62407629e32fb763e07856f265b5fde69e5c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:31:07 +0700 Subject: [PATCH 011/148] Add Windows installer install-repair-uninstall smoke test and PDF hash checks --- scripts/verify-windows-installer.ps1 | 89 ++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 scripts/verify-windows-installer.ps1 diff --git a/scripts/verify-windows-installer.ps1 b/scripts/verify-windows-installer.ps1 new file mode 100644 index 00000000..4973a280 --- /dev/null +++ b/scripts/verify-windows-installer.ps1 @@ -0,0 +1,89 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Installer, + [string]$StageDir = '', + [switch]$SkipInstallSmoke +) + +$ErrorActionPreference = 'Stop' +$installerPath = (Resolve-Path -LiteralPath $Installer).Path +if ((Get-Item -LiteralPath $installerPath).Length -lt 1024 * 1024) { + throw "Inno installer is too small: $installerPath" +} +if ([System.IO.Path]::GetExtension($installerPath) -ne '.exe' -or + (Split-Path -Leaf $installerPath) -notmatch '^ProcessBusInsight-v.+-win-x64-setup\.exe$') { + throw 'Unexpected installer name or extension.' +} +$installerDirectory = Split-Path $installerPath -Parent +$sumPath = Join-Path $installerDirectory 'SHA256SUMS.txt' +$sha = (Get-FileHash -LiteralPath $installerPath -Algorithm SHA256).Hash.ToLowerInvariant() +if (-not (Test-Path -LiteralPath $sumPath)) { throw "Missing installer checksums: $sumPath" } +$expectedLine = "$sha $(Split-Path $installerPath -Leaf)" +if (-not (@(Get-Content $sumPath) -ccontains $expectedLine)) { + throw "Installer checksum mismatch in SHA256SUMS.txt" +} + +Write-Host "Verified installer checksum: $sha" +if ($SkipInstallSmoke) { return } + +# Full install/repair/uninstall smoke test on an ephemeral GitHub Windows runner: +# no production apps, user settings or real capture devices are touched. +$root = Join-Path $env:TEMP ("ProcessBusInnoSmoke_" + [Guid]::NewGuid().ToString('N')) +$installDir = Join-Path $root 'installed' +$logDir = Join-Path $root 'logs' +New-Item -ItemType Directory -Path $logDir -Force | Out-Null +$uninstallPath = Join-Path $installDir 'unins000.exe' +try { + for ($attempt = 1; $attempt -le 2; $attempt++) { + $log = Join-Path $logDir "setup-$attempt.log" + $arguments = @( + '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-', + ('/DIR="' + $installDir + '"'), ('/LOG="' + $log + '"') + ) + $process = Start-Process -FilePath $installerPath -ArgumentList $arguments -PassThru -Wait + if ($process.ExitCode -ne 0) { + throw "Silent install/repair attempt $attempt failed: exit $($process.ExitCode). Log: $log" + } + $required = @( + 'ProcessBusInsight.exe', + 'Quick Start.pdf', + 'User Manual.pdf', + 'LICENSE.txt', + 'README.txt', + 'Bench/ProcessBus.InteropCli.exe', + 'unins000.exe' + ) + foreach ($relative in $required) { + if (-not (Test-Path -LiteralPath (Join-Path $installDir $relative) -PathType Leaf)) { + throw "Installer smoke test: missing installed $relative." + } + } + if (-not [string]::IsNullOrWhiteSpace($StageDir)) { + foreach ($relative in @('ProcessBusInsight.exe', 'User Manual.pdf', 'Quick Start.pdf')) { + $source = Join-Path $StageDir $relative + if (-not (Test-Path -LiteralPath $source)) { + throw "Smoke test source missing: $source" + } + $sourceHash = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash + $destHash = (Get-FileHash -LiteralPath (Join-Path $installDir $relative) -Algorithm SHA256).Hash + if ($sourceHash -ne $destHash) { throw "Installed $relative does not match verified stage." } + } + } + Write-Host "Installed payload and offline manuals verified (run $attempt / 2)." + } + + $uninstallArguments = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', + ('/LOG="' + (Join-Path $logDir 'uninstall.log') + '"')) + $process = Start-Process -FilePath $uninstallPath -ArgumentList $uninstallArguments -PassThru -Wait + if ($process.ExitCode -ne 0) { throw "Silent uninstall failed: exit $($process.ExitCode)." } + if (Test-Path -LiteralPath (Join-Path $installDir 'ProcessBusInsight.exe')) { + throw 'Uninstaller left the installed main executable behind.' + } + Write-Host 'Installer verification passed: install, same-version repair, offline PDFs, uninstall.' +} +finally { + # Only the uniquely generated ephemeral smoke path is removed. + if (Test-Path -LiteralPath $root) { + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } +} From ff4466e986f98bb86b0b86cbc9841f48aa821b7f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:31:46 +0700 Subject: [PATCH 012/148] Make Inno installer and offline manual smoke testing primary candidate artifact --- .github/workflows/candidate-package.yml | 31 ++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/workflows/candidate-package.yml b/.github/workflows/candidate-package.yml index 18d6ce2c..f3fce863 100644 --- a/.github/workflows/candidate-package.yml +++ b/.github/workflows/candidate-package.yml @@ -15,6 +15,9 @@ on: - "src/**" - "tests/**" - "scripts/publish-windows-portable.ps1" + - "scripts/publish-windows-installer.ps1" + - "scripts/verify-windows-installer.ps1" + - "installer/**" - "scripts/verify-release-package.ps1" - "scripts/repository-health.ps1" - "docs/RELEASE_NOTES_v*.md" @@ -38,7 +41,7 @@ jobs: name: Build verified Windows x64 candidate if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'stabilization/') || startsWith(github.head_ref, 'architecture/') || startsWith(github.head_ref, 'legal/') || startsWith(github.head_ref, 'fix/') || startsWith(github.head_ref, 'feat/') runs-on: windows-latest - timeout-minutes: 35 + timeout-minutes: 50 steps: - name: Checkout @@ -89,6 +92,24 @@ jobs: $zip = ".\artifacts\release\ProcessBusInsight-v${{ steps.version.outputs.value }}-win-x64-portable.zip" .\scripts\verify-release-package.ps1 -PackageZip $zip + - name: Install Inno Setup 6 + shell: pwsh + run: | + choco install innosetup --yes --no-progress + if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compiler installation failed.' } + + - name: Compile Inno installer + shell: pwsh + run: .\scripts\publish-windows-installer.ps1 -Version '${{ steps.version.outputs.value }}' + + - name: Smoke test Inno installation and repair + shell: pwsh + run: | + $version = '${{ steps.version.outputs.value }}' + $installer = ".\artifacts\release\ProcessBusInsight-v$version-win-x64-setup.exe" + $stage = ".\artifacts\package\ProcessBusInsight-v$version-win-x64-portable" + .\scripts\verify-windows-installer.ps1 -Installer $installer -StageDir $stage + - name: Create candidate manifest shell: pwsh run: | @@ -100,6 +121,10 @@ jobs: sourceRef = '${{ github.head_ref || github.ref_name }}' runtime = 'win-x64' framework = 'net8.0-windows' + primaryDistribution = 'inno-setup-exe' + offlineManuals = @('User Manual.pdf', 'Quick Start.pdf') + upgradeMode = 'stable-inno-appid-in-place-repair' + installerSigned = $false communityLicense = 'GPL-3.0-or-later' historicalBoundary = '85d43a0fe58a5888a9e8008c168ab76d2333ea87' timestampUtc = (Get-Date).ToUniversalTime().ToString('o') @@ -109,9 +134,9 @@ jobs: - name: Upload verified candidate uses: actions/upload-artifact@v7 with: - name: ProcessBusInsight-v${{ steps.version.outputs.value }}-win-x64-candidate-${{ github.run_number }} + name: ProcessBusInsight-v${{ steps.version.outputs.value }}-win-x64-inno-candidate-${{ github.run_number }} path: | - artifacts/release/ProcessBusInsight-v${{ steps.version.outputs.value }}-win-x64-portable.zip + artifacts/release/ProcessBusInsight-v${{ steps.version.outputs.value }}-win-x64-setup.exe artifacts/release/SHA256SUMS.txt artifacts/release/candidate-manifest.json TestResults/** From 16459fff5821ab30dd04e7db1ed4dd5d58071bca Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:32:12 +0700 Subject: [PATCH 013/148] Ship signed-ready Inno EXE as primary release download alongside optional ZIP --- .github/workflows/release-package.yml | 35 +++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index 2b0e0523..db55da22 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -1,4 +1,4 @@ -name: Release Windows portable package +name: Release Windows installer and portable package on: workflow_dispatch: @@ -45,7 +45,7 @@ jobs: package: name: Validate and package Windows x64 runs-on: windows-latest - timeout-minutes: 35 + timeout-minutes: 55 steps: - name: Checkout @@ -93,6 +93,24 @@ jobs: $zip = ".\artifacts\release\ProcessBusInsight-v${{ steps.version.outputs.value }}-win-x64-portable.zip" .\scripts\verify-release-package.ps1 -PackageZip $zip + - name: Install Inno Setup 6 + shell: pwsh + run: | + choco install innosetup --yes --no-progress + if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compiler installation failed.' } + + - name: Compile Inno installer + shell: pwsh + run: .\scripts\publish-windows-installer.ps1 -Version '${{ steps.version.outputs.value }}' + + - name: Smoke test Inno installation and repair + shell: pwsh + run: | + $version = '${{ steps.version.outputs.value }}' + $installer = ".\artifacts\release\ProcessBusInsight-v$version-win-x64-setup.exe" + $stage = ".\artifacts\package\ProcessBusInsight-v$version-win-x64-portable" + .\scripts\verify-windows-installer.ps1 -Installer $installer -StageDir $stage + - name: Create release manifest shell: pwsh run: | @@ -102,6 +120,11 @@ jobs: commit = '${{ github.sha }}' runtime = 'win-x64' framework = 'net8.0-windows' + primaryDistribution = 'inno-setup-exe' + secondaryDistribution = 'portable-zip' + offlineManuals = @('User Manual.pdf', 'Quick Start.pdf') + upgradeMode = 'stable-inno-appid-in-place-repair' + installerSigned = $false communityLicense = 'GPL-3.0-or-later' historicalBoundary = '85d43a0fe58a5888a9e8008c168ab76d2333ea87' timestampUtc = (Get-Date).ToUniversalTime().ToString('o') @@ -111,8 +134,9 @@ jobs: - name: Upload workflow artifact uses: actions/upload-artifact@v7 with: - name: ProcessBusInsight-v${{ steps.version.outputs.value }}-win-x64-portable + name: ProcessBusInsight-v${{ steps.version.outputs.value }}-win-x64-installer-and-portable path: | + artifacts/release/ProcessBusInsight-v${{ steps.version.outputs.value }}-win-x64-setup.exe artifacts/release/ProcessBusInsight-v${{ steps.version.outputs.value }}-win-x64-portable.zip artifacts/release/SHA256SUMS.txt artifacts/release/release-manifest.json @@ -129,13 +153,14 @@ jobs: $version = '${{ steps.version.outputs.value }}' $tag = "v$version" $zip = "artifacts/release/ProcessBusInsight-v$version-win-x64-portable.zip" + $installer = "artifacts/release/ProcessBusInsight-v$version-win-x64-setup.exe" $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.4.0.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 }}') + $releaseArgs = @('release', 'create', $tag, $installer, $zip, $sha, $manifest, '--title', "Process Bus Insight $tag", '--notes-file', $notesFile, '--target', '${{ github.sha }}') $isDraft = '${{ github.event.inputs.draft }}' $isPrerelease = '${{ github.event.inputs.prerelease }}' if ('${{ github.event_name }}' -eq 'push') { @@ -146,7 +171,7 @@ jobs: gh release view $tag *> $null if ($LASTEXITCODE -eq 0) { - gh release upload $tag $zip $sha $manifest --clobber + gh release upload $tag $installer $zip $sha $manifest --clobber } else { gh @releaseArgs } From 633d36e8076d13808e797f84511e9e5c9f0cb8ac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:32:55 +0700 Subject: [PATCH 014/148] Document safe offline installation, manual updates and repair/uninstall guarantees --- docs/INSTALLATION_AND_UPDATE.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/INSTALLATION_AND_UPDATE.md diff --git a/docs/INSTALLATION_AND_UPDATE.md b/docs/INSTALLATION_AND_UPDATE.md new file mode 100644 index 00000000..bbc83203 --- /dev/null +++ b/docs/INSTALLATION_AND_UPDATE.md @@ -0,0 +1,29 @@ +# Installation and updates — Process Bus Insight + +## Standard Windows installation (recommended) + +Download the latest **ProcessBusInsight-v...-win-x64-setup.exe** from the official project's GitHub Releases. Run the Inno Setup installer as an administrator. Choose the destination (default: `C:\Program Files\Process Bus Insight`). A Start Menu entry is created; the desktop shortcut is optional. + +The installer places **User Manual.pdf**, **Quick Start.pdf**, this guide, the executable, license notices and read-only Bench tools in that same program folder. You can open either PDF without an internet connection. The Start Menu also contains a direct **User Manual (PDF)** shortcut. + +**Npcap is a separate prerequisite for live Ethernet capture**, not part of this installer. Offline PCAP analysis can still be used without Npcap. If Npcap is not detected the setup shows guidance rather than installing an unknown driver or blocking offline operation. + +## Update or repair an installed copy + +1. Stop any live capture and close Process Bus Insight (allow the application to finish its normal shutdown). +2. Download a newer official setup EXE, verify the published SHA256, and run it. +3. Inno Setup uses the same application ID and remembers the previous installation directory and shortcut options. No prior uninstall is normally necessary. +4. The installer detects files in use using the Windows Restart Manager; do not forcibly replace files while the analyzer is running. +5. Re-running the same version can repair damaged or missing installed files. + +**There is no background or in-app automatic update claim**. Updating is deliberately operator-initiated; no capture is started automatically and no live traffic or customer information is sent to the update service. AppUpdatesURL points at the official releases page for manually obtaining newer versions. + +## Uninstall, user data and troubleshooting + +Use Windows Settings > Installed apps > Process Bus Insight > Uninstall, or the Start Menu uninstall shortcut. The installer removes its deployed binaries/docs/shortcuts, but intentionally has no broad deletion rule for unrelated user captures, SCL files, logs or profiles. Back up any data you previously placed inside the program directory before uninstalling; the recommended location for user data is outside `Program Files`. + +The official community build is currently **unsigned**. Windows SmartScreen may show a publisher warning; do not treat that as proof of malware, and do not bypass security policy. Verify origin and SHA256 before deciding to run it. A future signed build would require a real code-signing certificate and verified CI provenance. + +If installation fails, locate the Inno installer log (setup logging is enabled) and provide sanitized error details without sharing customer identifiers or raw captures. An installation smoke test on the CI runner is useful engineering evidence, not proof for all Windows versions, antivirus products and Npcap configurations. + +For source builds and portable use, see the repository's release-packaging documentation. From 817de5af0109246cdd55cf2ea889204c30da5bb6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:33:05 +0700 Subject: [PATCH 015/148] Bundle offline installation and update guide beside user manuals in installed payload --- scripts/publish-windows-portable.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/publish-windows-portable.ps1 b/scripts/publish-windows-portable.ps1 index b2305baf..5041434a 100644 --- a/scripts/publish-windows-portable.ps1 +++ b/scripts/publish-windows-portable.ps1 @@ -15,7 +15,7 @@ Set-Location $repoRoot if ([string]::IsNullOrWhiteSpace($Version)) { throw "Version cannot be empty." } $requiredReleaseDocs = @( - "docs/QUICK_START.pdf", "docs/USER_MANUAL.pdf", "LICENSE", "NOTICE", + "docs/QUICK_START.pdf", "docs/USER_MANUAL.pdf", "docs/INSTALLATION_AND_UPDATE.md", "LICENSE", "NOTICE", "COMMERCIAL-LICENSE.md", "COPYRIGHT.md", "TRADEMARK.md", "THIRD_PARTY_NOTICES.md", "docs/LICENSING.md" ) @@ -144,7 +144,8 @@ $copyMap = @( @{ Source = "THIRD_PARTY_NOTICES.md"; Destination = "THIRD_PARTY_NOTICES.md" }, @{ Source = "docs/LICENSING.md"; Destination = "Licensing.md" }, @{ Source = "docs/QUICK_START.pdf"; Destination = "Quick Start.pdf" }, - @{ Source = "docs/USER_MANUAL.pdf"; Destination = "User Manual.pdf" } + @{ Source = "docs/USER_MANUAL.pdf"; Destination = "User Manual.pdf" }, + @{ Source = "docs/INSTALLATION_AND_UPDATE.md"; Destination = "Install and Update.md" } ) foreach ($item in $copyMap) { $source = Join-Path $repoRoot $item.Source From e357d2b8a6d37749a82e5b683d5cf801465c6187 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:34:07 +0700 Subject: [PATCH 016/148] Bundle offline install/update guide and validate installer documentation --- scripts/verify-release-package.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/verify-release-package.ps1 b/scripts/verify-release-package.ps1 index 6845680d..d3a9f702 100644 --- a/scripts/verify-release-package.ps1 +++ b/scripts/verify-release-package.ps1 @@ -17,6 +17,7 @@ try { "$AppName.exe", "Quick Start.pdf", "User Manual.pdf", + "Install and Update.md", "README.txt", "LICENSE.txt", "NOTICE.txt", From 3983c60bcdddc8dc36a2ef424d024930b2ead4e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:34:09 +0700 Subject: [PATCH 017/148] Bundle offline install/update guide and validate installer documentation --- scripts/verify-windows-installer.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/verify-windows-installer.ps1 b/scripts/verify-windows-installer.ps1 index 4973a280..01c4b6aa 100644 --- a/scripts/verify-windows-installer.ps1 +++ b/scripts/verify-windows-installer.ps1 @@ -48,6 +48,7 @@ try { 'ProcessBusInsight.exe', 'Quick Start.pdf', 'User Manual.pdf', + 'Install and Update.md', 'LICENSE.txt', 'README.txt', 'Bench/ProcessBus.InteropCli.exe', From 658fe732f07b73b0ee61b7518f22f44cf4f7540c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:34:12 +0700 Subject: [PATCH 018/148] Bundle offline install/update guide and validate installer documentation --- docs/RELEASE_PACKAGING.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/RELEASE_PACKAGING.md b/docs/RELEASE_PACKAGING.md index 8a7da089..7d7b6c7d 100644 --- a/docs/RELEASE_PACKAGING.md +++ b/docs/RELEASE_PACKAGING.md @@ -1,10 +1,11 @@ # Release Packaging — Process Bus Insight -The release workflow creates a Windows x64 self-contained portable package for users who do not have Visual Studio or a separate .NET runtime. +The release workflow produces a Windows x64 self-contained **Inno Setup 6 EXE installer (primary)**, plus an optional portable ZIP. The installer supports in-place upgrades and repairs via a stable AppId and includes both offline PDF manuals inside the installed program folder. It is currently unsigned and does not automatically download updates. ## Release files ```text +ProcessBusInsight-v-win-x64-setup.exe ProcessBusInsight-v-win-x64-portable.zip SHA256SUMS.txt release-manifest.json @@ -16,6 +17,7 @@ The ZIP contains: ProcessBusInsight.exe Quick Start.pdf User Manual.pdf +Install and Update.md README.txt LICENSE.txt NOTICE.txt @@ -26,7 +28,11 @@ THIRD_PARTY_NOTICES.md Licensing.md ``` -Npcap is required on the target Windows machine for live raw Ethernet capture and is not bundled. +Npcap is required on the target Windows machine for live raw Ethernet capture and is not bundled. Setup provides nonblocking guidance if the driver is not detected because offline analysis remains available. + +The Inno Setup default location is `C:\Program Files\Process Bus Insight`. `User Manual.pdf`, `Quick Start.pdf`, `Install and Update.md`, and the read-only `Bench/` tools are physically installed there. Start Menu shortcuts link to the application and both PDFs. An optional desktop shortcut is available. To update, close the analyzer and rerun the newer installer; Inno remembers the install directory and updates in place. Reinstalling the same version can repair missing files. The uninstaller does not use broad rules to delete user captures or SCL data. + +GitHub Actions verifies checksums and performs a real silent install, repair, PDF hash comparison, and uninstall. Bench performance and third-party antivirus compatibility are not implied by that test. ## License boundary From b005b3389dcd4cdd3921e7423b34cbdfc2e8c3b1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:34:14 +0700 Subject: [PATCH 019/148] Bundle offline install/update guide and validate installer documentation --- installer/ProcessBusInsight.iss | 1 + 1 file changed, 1 insertion(+) diff --git a/installer/ProcessBusInsight.iss b/installer/ProcessBusInsight.iss index f61a08d8..e982badb 100644 --- a/installer/ProcessBusInsight.iss +++ b/installer/ProcessBusInsight.iss @@ -77,6 +77,7 @@ Source: "{#StageDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs c Name: "{autoprograms}\Process Bus Insight"; Filename: "{app}\{#AppExe}"; WorkingDir: "{app}" Name: "{autoprograms}\User Manual (PDF)"; Filename: "{app}\User Manual.pdf" Name: "{autoprograms}\Quick Start (PDF)"; Filename: "{app}\Quick Start.pdf" +Name: "{autoprograms}\Install and Update Guide"; Filename: "{app}\Install and Update.md" Name: "{autoprograms}\Uninstall Process Bus Insight"; Filename: "{uninstallexe}" Name: "{autodesktop}\Process Bus Insight"; Filename: "{app}\{#AppExe}"; Tasks: desktopicon From ae0b0628c7bdca1a1989cb0c73f36708e08fcb96 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 08:34:42 +0700 Subject: [PATCH 020/148] Make verified Inno installer recommended Windows download with offline manuals and repair instructions --- README.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 96a01ca0..0e152517 100644 --- a/README.md +++ b/README.md @@ -46,22 +46,25 @@ The application can record what was configured in SCL, what the selected capture ## Download and run -1. Install **Npcap** on the Windows capture machine when live raw Ethernet capture is required. -2. Download the latest portable ZIP from [GitHub Releases](https://github.com/masarray/DigSubAnalyzer/releases). -3. Extract it to a local folder such as `C:\Tools\ProcessBusInsight`. -4. Run `ProcessBusInsight.exe`. -5. Select a physical Ethernet adapter connected through an authorized TAP, mirror port, or isolated engineering test network. -6. Start capture and select the SV/GOOSE/PTP target to inspect. +1. Download the latest **Windows Inno Setup installer** from [GitHub Releases](https://github.com/masarray/DigSubAnalyzer/releases): `ProcessBusInsight-v1.4.0-win-x64-setup.exe`. +2. Run the installer. Its stable app identity supports in-place updates, same-version repair, Windows uninstall, and optional desktop shortcut. +3. **Offline manuals are installed directly inside the app folder** (`User Manual.pdf`, `Quick Start.pdf`, and `Install and Update.md`), with Start Menu shortcuts. +4. Install **Npcap separately** when live raw Ethernet capture is required; it is not bundled. Offline PCAP analysis remains available without it. +5. Launch Process Bus Insight, select an authorized TAP or mirror capture adapter, and start analysis. +6. To update, close the app and run the newer installer; no uninstall is needed for standard in-place upgrades. -Current post-transition package naming: +An optional **portable ZIP** is also available for manual extraction. The community installer is currently unsigned, so verify its official source and SHA256; do not treat it as a background auto-updater. See [installation and update guidance](docs/INSTALLATION_AND_UPDATE.md). + +Current Windows distribution naming: ```text -ProcessBusInsight-v1.4.0-win-x64-portable.zip +ProcessBusInsight-v1.4.0-win-x64-setup.exe +ProcessBusInsight-v1.4.0-win-x64-portable.zip (optional) SHA256SUMS.txt release-manifest.json ``` -A separate .NET runtime is not required for the self-contained package. Npcap remains separately installed and is not redistributed by this repository. +A separate .NET runtime is not required for these self-contained packages. Npcap remains separately installed and is not redistributed by this repository. See [`docs/QUICK_START.md`](docs/QUICK_START.md) for the field checklist. From ccb6f8c70f49f2ebf4ada69d432d7226fdbc63d6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 11:38:01 +0700 Subject: [PATCH 021/148] Resolve 64-bit Npcap and Packet DLL from trusted system location for both capture consumers --- .../Services/NpcapNativeLibrary.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/ProcessBus.Core/Services/NpcapNativeLibrary.cs diff --git a/src/ProcessBus.Core/Services/NpcapNativeLibrary.cs b/src/ProcessBus.Core/Services/NpcapNativeLibrary.cs new file mode 100644 index 00000000..7b77f26f --- /dev/null +++ b/src/ProcessBus.Core/Services/NpcapNativeLibrary.cs @@ -0,0 +1,97 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +namespace ProcessBus.Core.Services; + +/// +/// Resolve Npcap from the native Windows system directory, never from the app +/// working directory, an arbitrary PATH entry, or a stale WinPcap DLL. The same +/// native module is shared between catalog enumeration and packet capture. +/// No driver installation occurs here; a missing/broken Npcap stays a clear error. +/// +public static class NpcapNativeLibrary +{ + private static readonly object RegistrationGate = new(); + private static readonly HashSet RegisteredAssemblies = new(); + private static readonly Lazy PcapHandle = new(LoadPcap); + + /// + /// Register once for each assembly that contains wpcap P/Invokes. + /// Called from their static constructors, before the first native call. + /// + public static void RegisterFor(Assembly assembly) + { + if (!OperatingSystem.IsWindows()) + return; + + lock (RegistrationGate) + { + if (RegisteredAssemblies.Add(assembly)) + NativeLibrary.SetDllImportResolver(assembly, Resolve); + } + } + + // Also testable on non-Windows CI without loading a native Windows library. + public static string? SelectLibraryPath(string systemDirectory, Func fileExists) + { + if (string.IsNullOrWhiteSpace(systemDirectory)) + return null; + + // Prefer the modern Npcap installation, including its matching Packet.dll. + // Legacy system32 WinPcap-compatible deployments are a second choice only. + var npcap = Path.Combine(systemDirectory, "Npcap", "wpcap.dll"); + if (fileExists(npcap)) + return npcap; + + var compatible = Path.Combine(systemDirectory, "wpcap.dll"); + return fileExists(compatible) ? compatible : null; + } + + private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (!string.Equals(libraryName, "wpcap", StringComparison.OrdinalIgnoreCase) && + !string.Equals(libraryName, "wpcap.dll", StringComparison.OrdinalIgnoreCase)) + return IntPtr.Zero; + + return PcapHandle.Value; + } + + private static IntPtr LoadPcap() + { + if (!Environment.Is64BitProcess) + throw new BadImageFormatException( + "Process Bus Insight requires the Windows x64 build for the supported Npcap capture path."); + + var system = Environment.SystemDirectory; + var dll = SelectLibraryPath(system, File.Exists); + if (dll is null) + throw new DllNotFoundException( + "Npcap wpcap.dll is not installed in the 64-bit Windows system directory. " + + "Install or repair the official Npcap x64 driver, then restart Process Bus Insight."); + + // LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR + SYSTEM32 keeps Packet.dll/dependencies + // away from unrelated PATH directories. Loading Packet.dll from the same + // approved installation first prevents old x86 Packet.dll precedence. + var options = DllImportSearchPath.UseDllDirectoryForDependencies | DllImportSearchPath.System32; + var packet = Path.Combine(Path.GetDirectoryName(dll)!, "Packet.dll"); + if (File.Exists(packet)) + NativeLibrary.Load(packet, typeof(NpcapNativeLibrary).Assembly, options); + + try + { + return NativeLibrary.Load(dll, typeof(NpcapNativeLibrary).Assembly, options); + } + catch (BadImageFormatException ex) + { + throw new BadImageFormatException( + $"Npcap native DLL architecture is incompatible with the x64 process ({dll}). " + + "Repair/reinstall Npcap x64 and remove conflicting legacy WinPcap DLLs.", ex); + } + catch (DllNotFoundException ex) + { + throw new DllNotFoundException( + $"Npcap library or a native dependency could not be loaded from {dll}. " + + "Repair Npcap x64, then restart the analyzer.", ex); + } + } +} From 9af84709997b0eccf42c621ebebe816147586bc3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 11:38:23 +0700 Subject: [PATCH 022/148] Stop fabricating Ethernet index:0; show non-capturable Npcap diagnostic row --- .../Services/PcapAdapterCatalog.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/ProcessBus.Core/Services/PcapAdapterCatalog.cs b/src/ProcessBus.Core/Services/PcapAdapterCatalog.cs index 6f404716..94552fb8 100644 --- a/src/ProcessBus.Core/Services/PcapAdapterCatalog.cs +++ b/src/ProcessBus.Core/Services/PcapAdapterCatalog.cs @@ -4,6 +4,11 @@ namespace ProcessBus.Core.Services; public static class PcapAdapterCatalog { + static PcapAdapterCatalog() + { + NpcapNativeLibrary.RegisterFor(typeof(PcapAdapterCatalog).Assembly); + } + public static List GetAdapters() { try @@ -12,7 +17,7 @@ public static List GetAdapters() } catch (DllNotFoundException ex) { - return BuildFallback($"Npcap/wpcap.dll not found: {ex.Message}"); + return BuildUnavailable($"Npcap/wpcap.dll not found: {ex.Message}"); } catch (BadImageFormatException ex) { @@ -82,11 +87,11 @@ private static List BuildFallback(string reason) { new() { - Id = "0", - Index = 0, - Name = "Ethernet", - Description = $"Fallback adapter entry ({reason})", - RawDeviceName = "index:0" + Id = "npcap-unavailable", + Index = -1, + Name = "Npcap unavailable — capture disabled", + Description = reason, + RawDeviceName = string.Empty } }; } From 22127ae8fcfbf33c014fab626984026349bd5ec0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 11:38:34 +0700 Subject: [PATCH 023/148] Pin packet capture P/Invokes to same trusted Npcap DLL as adapter enumeration --- src/ProcessBus.Iec61850.Raw/Capture/NpcapRawFrameSource.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ProcessBus.Iec61850.Raw/Capture/NpcapRawFrameSource.cs b/src/ProcessBus.Iec61850.Raw/Capture/NpcapRawFrameSource.cs index 54799127..55defa6f 100644 --- a/src/ProcessBus.Iec61850.Raw/Capture/NpcapRawFrameSource.cs +++ b/src/ProcessBus.Iec61850.Raw/Capture/NpcapRawFrameSource.cs @@ -1,10 +1,17 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using ProcessBus.Core.Services; namespace ProcessBus.Iec61850.Raw.Capture; public sealed class NpcapRawFrameSource : IRawFrameSource { + static NpcapRawFrameSource() + { + // Capture and catalog resolve exactly the same trusted Npcap binary. + NpcapNativeLibrary.RegisterFor(typeof(NpcapRawFrameSource).Assembly); + } + private const int SnapshotLength = 65536; private const int ReadTimeoutMilliseconds = 100; private const int DataLinkEthernet = 1; From 834ff69ee0c39ff4deb730e16ad851efd1821659 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 11:39:13 +0700 Subject: [PATCH 024/148] Disable Run without a real Npcap device and retain adapter diagnostics --- src/ProcessBus.Core/Services/NetworkAdapterInfo.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ProcessBus.Core/Services/NetworkAdapterInfo.cs b/src/ProcessBus.Core/Services/NetworkAdapterInfo.cs index 7eaad2cf..b5bf3847 100644 --- a/src/ProcessBus.Core/Services/NetworkAdapterInfo.cs +++ b/src/ProcessBus.Core/Services/NetworkAdapterInfo.cs @@ -8,6 +8,10 @@ public sealed class NetworkAdapterInfo public string Description { get; set; } = string.Empty; public string RawDeviceName { get; set; } = string.Empty; + public bool IsCaptureCapable => + !string.IsNullOrWhiteSpace(RawDeviceName) && + !RawDeviceName.StartsWith("index:", StringComparison.OrdinalIgnoreCase); + public override string ToString() { var label = string.IsNullOrWhiteSpace(Description) ? Name : $"{Name} ({Description})"; From b91b49b951ac503645422f94f3410458e6e436b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 11:39:17 +0700 Subject: [PATCH 025/148] Disable Run without a real Npcap device and retain adapter diagnostics --- .../ViewModels/MainWindowViewModel.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 79187082..e03673b4 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -111,12 +111,16 @@ private set _isRunning = value; OnPropertyChanged(); OnPropertyChanged(nameof(CanSwitchCaptureMode)); + OnPropertyChanged(nameof(CanStartCapture)); OnPropertyChanged(nameof(IsSelectedSvReceiving)); } } public bool IsRawMode => true; public bool CanSwitchCaptureMode => !IsRunning; + public bool CanStartCapture => !IsRunning && GetSelectedAdapter()?.IsCaptureCapable == true; + public string SelectedAdapterDiagnostic => GetSelectedAdapter() is { IsCaptureCapable: false } invalid + ? invalid.Description : "Select a real Npcap capture adapter."; private readonly ObservableCollection _gooseHistory = new(); private readonly Dictionary _lastGooseState = new(); @@ -1976,8 +1980,11 @@ public string SelectedAdapterId _selectedAdapterId = value; var selected = Adapters.FirstOrDefault(adapter => adapter.Id == value); _rawDataSource.SelectAdapter(value, selected?.RawDeviceName ?? string.Empty); - SaveLastAdapterId(value); + if (selected?.IsCaptureCapable == true) + SaveLastAdapterId(value); OnPropertyChanged(); + OnPropertyChanged(nameof(CanStartCapture)); + OnPropertyChanged(nameof(SelectedAdapterDiagnostic)); OnPropertyChanged(nameof(AdapterTimingRiskText)); OnPropertyChanged(nameof(TimingConfidenceText)); OnPropertyChanged(nameof(TimingConfidenceBadgeText)); @@ -4204,6 +4211,9 @@ private async Task SwitchToRawAsync() private async Task StartAsync() { + if (!CanStartCapture) + return; + await _rawDataSource.StartAsync(); IsRunning = _rawDataSource.IsRunning; From 17026d8b7cc50eb71721e06986a82c6785e342d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 11:39:22 +0700 Subject: [PATCH 026/148] Disable Run without a real Npcap device and retain adapter diagnostics --- src/ProcessBus.App.Wpf/MainWindow.xaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index b179340b..aa9dd26d 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -335,10 +335,11 @@ ItemsSource="{Binding Adapters}" SelectedValuePath="Id" SelectedValue="{Binding SelectedAdapterId, UpdateSourceTrigger=PropertyChanged}" - ToolTip="Select capture adapter"/> + ToolTip="{Binding SelectedAdapterDiagnostic}"/>