diff --git a/.github/workflows/candidate-package.yml b/.github/workflows/candidate-package.yml
index 18d6ce2c..4a391dde 100644
--- a/.github/workflows/candidate-package.yml
+++ b/.github/workflows/candidate-package.yml
@@ -15,8 +15,17 @@ on:
- "src/**"
- "tests/**"
- "scripts/publish-windows-portable.ps1"
+ - "scripts/publish-windows-installer.ps1"
+ - "scripts/render-offline-manuals.py"
+ - "docs/USER_MANUAL.md"
+ - "docs/QUICK_START.md"
+ - "docs/INSTALLATION_AND_UPDATE.md"
+ - "docs/RELEASE_PACKAGING.md"
+ - "scripts/verify-windows-installer.ps1"
+ - "installer/**"
- "scripts/verify-release-package.ps1"
- "scripts/repository-health.ps1"
+ - "scripts/verify-workspace-spacing.ps1"
- "docs/RELEASE_NOTES_v*.md"
- ".github/workflows/candidate-package.yml"
- ".github/workflows/release-package.yml"
@@ -38,7 +47,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
@@ -66,10 +75,38 @@ jobs:
"value=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
Write-Host "Candidate version: $version"
+ - name: Reject already-published version
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ $tag = "v${{ steps.version.outputs.value }}"
+ # GitHub CLI exits 1 for an expected 404. On modern PowerShell runners
+ # a failed native command can terminate the step BEFORE LASTEXITCODE
+ # is inspected. Query the REST status explicitly; 404 is an unused
+ # version, 200 is already published, and other statuses fail closed.
+ $uri = "https://api.github.com/repos/${{ github.repository }}/releases/tags/$tag"
+ $response = Invoke-WebRequest -Uri $uri -Method Get -SkipHttpErrorCheck -Headers @{
+ Authorization = "Bearer $env:GH_TOKEN"
+ Accept = "application/vnd.github+json"
+ "X-GitHub-Api-Version" = "2022-11-28"
+ }
+ if ($response.StatusCode -eq 200) {
+ throw "Version $tag was already published. Bump VersionPrefix; never replace historical public binaries."
+ }
+ if ($response.StatusCode -ne 404) {
+ throw "Unable to establish release-version availability: HTTP $($response.StatusCode)."
+ }
+ Write-Host "Unused release version verified: $tag"
+
- name: Repository health gate
shell: pwsh
run: .\scripts\repository-health.ps1 -ExpectedVersion '${{ steps.version.outputs.value }}'
+ - name: Workspace spacing geometry gate
+ shell: pwsh
+ run: .\scripts\verify-workspace-spacing.ps1
+
- name: Restore
run: dotnet restore .\ProcessBusSuite.sln
@@ -79,6 +116,14 @@ jobs:
- name: Test
run: dotnet test .\ProcessBusSuite.sln -c Release --no-build --logger "trx;LogFileName=candidate-tests.trx" --results-directory .\TestResults
+ - name: Generate current offline PDF manuals
+ shell: pwsh
+ run: |
+ python -m pip install --disable-pip-version-check reportlab==4.4.9
+ if ($LASTEXITCODE -ne 0) { throw 'Pinned ReportLab PDF build dependency unavailable.' }
+ python .\scripts\render-offline-manuals.py
+ if ($LASTEXITCODE -ne 0) { throw 'Offline manual PDF generation failed.' }
+
- name: Publish portable package
shell: pwsh
run: .\scripts\publish-windows-portable.ps1 -Version '${{ steps.version.outputs.value }}' -Configuration Release -Runtime win-x64
@@ -89,6 +134,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 +163,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 +176,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/**
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 083995d5..3cd98395 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,6 +37,10 @@ jobs:
shell: pwsh
run: .\scripts\repository-health.ps1
+ - name: Workspace spacing geometry gate
+ shell: pwsh
+ run: .\scripts\verify-workspace-spacing.ps1
+
- name: Restore
run: dotnet restore .\ProcessBusSuite.sln
diff --git a/.github/workflows/publish-v1.4.1-on-approval.yml b/.github/workflows/publish-v1.4.1-on-approval.yml
new file mode 100644
index 00000000..424be02c
--- /dev/null
+++ b/.github/workflows/publish-v1.4.1-on-approval.yml
@@ -0,0 +1,48 @@
+name: Publish approved Process Bus Insight v1.4.1 stable
+
+# Single-use, source-controlled publication approval. Not every commit or tag
+# can publish a release. The marker is created after the reviewed PR is merged.
+on:
+ push:
+ branches: [main]
+ paths:
+ - ".github/release/v1.4.1.approved"
+
+permissions:
+ contents: read
+ actions: write
+
+jobs:
+ dispatch_stable:
+ if: github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Check immutable release identity
+ id: existing
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ run: |
+ if gh release view v1.4.1 --repo "$GH_REPO" >/dev/null 2>&1; then
+ echo "exists=true" >> "$GITHUB_OUTPUT"
+ echo "v1.4.1 already exists, refusing to replace assets."
+ else
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Dispatch Windows stable release
+ if: steps.existing.outputs.exists == 'false'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ run: |
+ gh workflow run release-package.yml --repo "$GH_REPO" --ref main \
+ -f version=1.4.1 \
+ -f publish_release=true \
+ -f prerelease=false \
+ -f draft=false \
+ -f release_notes_file=docs/RELEASE_NOTES_v1.4.1.md
+ echo "Dispatched approved public stable v1.4.1 release."
diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml
index 2b0e0523..1246a203 100644
--- a/.github/workflows/release-package.yml
+++ b/.github/workflows/release-package.yml
@@ -1,14 +1,14 @@
-name: Release Windows portable package
+name: Release Windows installer and portable package
on:
workflow_dispatch:
inputs:
version:
- description: Version label, for example 1.4.0
+ description: Version label, for example 1.4.1
required: true
- default: 1.4.0
+ default: 1.4.1
publish_release:
- description: Create or update a GitHub Release
+ description: Publish GitHub Release after successful validation (manual only)
required: true
default: false
type: boolean
@@ -25,7 +25,7 @@ on:
release_notes_file:
description: Markdown file used as release body
required: true
- default: docs/RELEASE_NOTES_v1.4.0.md
+ default: docs/RELEASE_NOTES_v1.4.1.md
push:
tags:
- "v*"
@@ -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
@@ -70,10 +70,28 @@ jobs:
"value=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
Write-Host "Release version: $version"
+ # A stable label identifies a supported community software build,
+ # NOT an IEC 61850 conformance certificate or calibrated bench result.
+ # Keep publication explicit and main-only; field FAT is transparently
+ # described in notes, but is not a gate to open-source distribution.
+ - name: Public release main branch gate
+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_release }}
+ shell: pwsh
+ env:
+ RUN_REF: ${{ github.ref }}
+ run: |
+ if ($env:RUN_REF -ne 'refs/heads/main') {
+ throw 'Public release must be built from validated main.'
+ }
+
- name: Repository health gate
shell: pwsh
run: .\scripts\repository-health.ps1 -ExpectedVersion '${{ steps.version.outputs.value }}'
+ - name: Workspace spacing geometry gate
+ shell: pwsh
+ run: .\scripts\verify-workspace-spacing.ps1
+
- name: Restore
run: dotnet restore .\ProcessBusSuite.sln
@@ -83,6 +101,14 @@ jobs:
- name: Test
run: dotnet test .\ProcessBusSuite.sln -c Release --no-build --logger "trx;LogFileName=release-tests.trx" --results-directory .\TestResults
+ - name: Generate current offline PDF manuals
+ shell: pwsh
+ run: |
+ python -m pip install --disable-pip-version-check reportlab==4.4.9
+ if ($LASTEXITCODE -ne 0) { throw 'Pinned ReportLab PDF build dependency unavailable.' }
+ python .\scripts\render-offline-manuals.py
+ if ($LASTEXITCODE -ne 0) { throw 'Offline manual PDF generation failed.' }
+
- name: Publish portable package
shell: pwsh
run: .\scripts\publish-windows-portable.ps1 -Version '${{ steps.version.outputs.value }}' -Configuration Release -Runtime win-x64
@@ -93,6 +119,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 +146,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 +160,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
@@ -121,7 +171,7 @@ jobs:
retention-days: 30
- name: Publish GitHub Release
- if: ${{ github.event_name == 'push' || github.event.inputs.publish_release == 'true' }}
+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_release }}
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
@@ -129,24 +179,25 @@ 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 ([string]::IsNullOrWhiteSpace($notesFile)) { $notesFile = 'docs/RELEASE_NOTES_v1.4.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 }}')
+ $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') {
- $isPrerelease = if ($version -match '-') { 'true' } else { 'false' }
- }
+ # A pre-release version must always remain a prerelease.
+ if ($version -match '-') { $isPrerelease = 'true' }
if ($isDraft -eq 'true') { $releaseArgs += '--draft' }
if ($isPrerelease -eq 'true') { $releaseArgs += '--prerelease' }
+ # Immutable release identity: never overwrite a published historical
+ # EXE/ZIP/checksum with a new binary under the same semver tag.
gh release view $tag *> $null
if ($LASTEXITCODE -eq 0) {
- gh release upload $tag $zip $sha $manifest --clobber
- } else {
- gh @releaseArgs
+ throw "Release $tag already exists. Bump VersionPrefix; public assets are immutable."
}
+ gh @releaseArgs
diff --git a/.github/workflows/runtime-architecture.yml b/.github/workflows/runtime-architecture.yml
index e705f633..a4135197 100644
--- a/.github/workflows/runtime-architecture.yml
+++ b/.github/workflows/runtime-architecture.yml
@@ -5,6 +5,11 @@ on:
branches: [main]
paths:
- "src/ProcessBus.Iec61850.Raw/Runtime/**"
+ - "src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs"
+ - "src/ProcessBus.Core/Presentation/**"
+ - "src/ProcessBus.App.Wpf/Controls/PhasorDiagramControl.cs"
+ - "tests/ProcessBus.Tests/PhasorPresentationContractTests.cs"
+ - "tests/ProcessBus.Tests/OperatorPresentationReleaseTests.cs"
- "src/ProcessBus.Iec61850.Raw/Replay/**"
- "tests/ProcessBus.Tests/Pcap*Tests.cs"
- "tests/ProcessBus.Tests/AnalyzerRuntimeSnapshotSourceTests.cs"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2147cc19..872e5949 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@ All notable changes are documented here. The project follows Semantic Versioning
## [Unreleased]
+### Implemented in release-candidate branch (public stable acceptance pending)
+
+- Unified selected-stream phasor RMS/angle display without UI-side nominal phase overrides; improved six-vector label collisions, separate measured U/I scales, legible 270° convention, stale data and live-with-warning semantics
+- Removed fabricated Ethernet adapter fallback and pinned native Npcap discovery/capture to trusted Windows x64 system library paths
+- Added deterministic phasor, compact-caption, adapter and status regression tests
+- Made Inno Setup self-contained EXE the primary Windows distribution, with same-version repair, in-place upgrade, offline manuals and install/repair/uninstall CI evidence
+- Generated packaged PDF manuals from current Markdown source, with SHA256 provenance and stale-PDF rejection
+- Hardened stable publication to manual main-only authorization after documented physical acceptance; no automatic public release on tag push
+- Physical target-NIC/injector 60-minute acceptance remains pending for the current candidate; earlier 1.3.0 soak evidence does not substitute
+
### Planned
- Sanitized golden PCAP/PCAPNG replay corpus with broader interoperability scenarios
diff --git a/Directory.Build.props b/Directory.Build.props
index f44804fb..216f8573 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -10,7 +10,7 @@
en
- 1.4.0
+ 1.4.1$(VersionPrefix)$(VersionPrefix)-$(VersionSuffix)
diff --git a/README.md b/README.md
index 96a01ca0..62f8b76f 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.1-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.1-win-x64-setup.exe
+ProcessBusInsight-v1.4.1-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.
@@ -97,8 +100,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.4.0"
-.\scripts\verify-release-package.ps1 -PackageZip ".\artifacts\release\ProcessBusInsight-v1.4.0-win-x64-portable.zip"
+.\scripts\publish-windows-portable.ps1 -Version "1.4.1"
+.\scripts\verify-release-package.ps1 -PackageZip ".\artifacts\release\ProcessBusInsight-v1.4.1-win-x64-portable.zip"
```
Run the repository-quality gate:
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.
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.
diff --git a/docs/PHYSICAL_BENCH_QUICK_START.md b/docs/PHYSICAL_BENCH_QUICK_START.md
index f0440830..2db49055 100644
--- a/docs/PHYSICAL_BENCH_QUICK_START.md
+++ b/docs/PHYSICAL_BENCH_QUICK_START.md
@@ -1,12 +1,12 @@
# Physical test quick start — DigSubAnalyzer / Process Bus Insight
-This folder is included in the **Windows x64 release candidate ZIP** so the bench
+This folder is included in the **installed Windows x64 application and optional portable ZIP** so the bench
does not need a .NET SDK. Run only on an authorized, isolated engineering process
bus. The analyzer is **receive-only** and cannot operate or protect an IED.
## 1. Run the desktop application
-1. Extract the entire ZIP, not just the EXE, to a local folder.
+1. Install the Inno Setup candidate to a local Windows test machine (or extract the entire optional portable ZIP).
2. Install Npcap separately with appropriate raw Ethernet capture rights.
3. Run `ProcessBusInsight.exe` in the package root.
4. Select the verified adapter connected to an independent TAP/SPAN (preserve
@@ -24,7 +24,7 @@ the NIC do **not** establish clock discipline or lock.
## 2. Screen the same wire recording with the bundled CLI
-Open PowerShell in the extracted package directory. Replace file paths with
+Open PowerShell in the installed program directory or extracted package directory. Replace file paths with
files collected on your **own local bench**:
```powershell
@@ -72,5 +72,5 @@ Before any **physical acceptance** claim, independently verify:
The detailed step-by-step sign-off matrix is in
`Bench/M4_ARSTACK_INTEROP_ACCEPTANCE.md`. Record the candidate artifact run
-ID and checksums with your evidence. This candidate is not a stable/certified
+ID and checksums with your evidence. A candidate without documented physical acceptance is not a stable/certified
release and provides no switching, control or protection-action authority.
diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md
index e97b4300..276f82fc 100644
--- a/docs/QUICK_START.md
+++ b/docs/QUICK_START.md
@@ -1,6 +1,6 @@
# Quick Start — Process Bus Insight
-Process Bus Insight is a receive-only IEC 61850 Process Bus analyzer for Windows. This guide helps you run the portable package and start a safe observation workflow.
+Process Bus Insight is a receive-only IEC 61850 Process Bus analyzer for Windows. This guide explains the recommended Inno Setup installation (or optional portable ZIP) and a safe observation workflow.
## 1. Prepare the Windows machine
@@ -18,26 +18,19 @@ Avoid these adapters for serious timing interpretation:
- Hyper-V, VMware, VPN, or virtual bridge adapters.
- Unverified USB Ethernet adapters when timing evidence matters.
-## 2. Download and extract
+## 2. Download and install
-1. Open the GitHub Releases page.
-2. Download the latest `ProcessBusInsight-vX.Y.Z-win-x64-portable.zip`.
-3. Download `SHA256SUMS.txt` if you want to verify file integrity.
-4. Extract the ZIP to a local folder such as:
-
-```text
-C:\Tools\ProcessBusInsight
-```
+1. Open [official GitHub Releases](https://github.com/masarray/DigSubAnalyzer/releases).
+2. Download `ProcessBusInsight-vX.Y.Z-win-x64-setup.exe` and verify its SHA256 against `SHA256SUMS.txt`.
+3. Close any prior running instance and run the Inno Setup installer. It installs into `C:\Program Files\Process Bus Insight` by default, creating a Start Menu shortcut and optional desktop shortcut.
+4. For an upgrade, run the newer installer without uninstalling first; the stable application identity retains the previous install directory. Re-running the same installer can repair files.
+5. The optional portable ZIP can still be extracted manually to `C:\Tools\ProcessBusInsight` when appropriate.
## 3. Run the app
-Open the extracted folder and run:
-
-```text
-ProcessBusInsight.exe
-```
+Launch **Process Bus Insight** from the Start Menu, or run `ProcessBusInsight.exe` from the installed folder. **Quick Start.pdf**, **User Manual.pdf**, and **Install and Update.md** are available offline in the same folder; shortcuts to both PDFs are in the Start Menu.
-The default release package publishes the app as a self-contained single EXE at the package root. The package also includes Quick Start PDF and User Manual PDF documents for offline field use.
+The application is self-contained. Npcap is installed separately for live capture, never silently by this installer. If Npcap cannot load, the application shows `Npcap unavailable — capture disabled` and will not fabricate a capture-capable Ethernet adapter.
## 4. Start a safe capture workflow
diff --git a/docs/RELEASE_NOTES_v1.4.1.md b/docs/RELEASE_NOTES_v1.4.1.md
new file mode 100644
index 00000000..5cefe11b
--- /dev/null
+++ b/docs/RELEASE_NOTES_v1.4.1.md
@@ -0,0 +1,35 @@
+# Process Bus Insight v1.4.1 — Stable
+
+**Process Bus Insight** is a free, open-source Windows analyzer for monitoring IEC 61850 Sampled Values (SV/SMV), GOOSE, and Precision Time Protocol (PTP) traffic. Version 1.4.1 delivers a quieter, faster engineering workflow, multi-stream visibility, and an improved Windows installation experience.
+
+## Highlights
+
+- **Five focused workspaces:** Analyzer, Diagnostics, GOOSE, Timing/PTP, and SCL, with a compact, consistent, low-distraction dark interface.
+- **Live multi-stream SMV monitoring:** stream explorer, waveform plots, phasors, RMS and angle measurements, power estimates, and fast stream switching.
+- **Readable waveform and phasor presentation:** voltage and current share consistent channel colors; phasor arrows, labels, and U/I RMS scaling remain legible.
+- **Per-channel freshness:** invalid/old channel data are clearly marked HELD or MISSING without hiding healthy phases; stale streams retain the last valid evidence in a muted appearance.
+- **Actionable diagnostics:** SV continuity and timing findings, quality flags, capture-driver statistics, and grouped repetitive timing reports with preserved detailed evidence.
+- **GOOSE inspection:** discover publishers, inspect state changes and retransmissions, and review typed DataSet values.
+- **PTP visibility:** monitor message flows, Grandmaster identity, domains and traffic observations.
+- **SCL engineering context:** import SCD, CID, ICD or IID files to compare configured publishers, stream identities and DataSet mappings against received traffic.
+- **Improved capture and packaging:** robust Npcap adapter handling, reliable Start/Stop workflow, reproducible Windows x64 Inno installer with in-place update/repair, portable ZIP, and offline manuals.
+
+## Download and installation
+
+**Recommended:** `ProcessBusInsight-v1.4.1-win-x64-setup.exe` (Windows x64, self-contained .NET 8 application). The optional portable ZIP is intended for users who prefer manual deployment.
+
+1. Download the installer and verify it against `SHA256SUMS.txt`.
+2. Run the Inno Setup installer. The install directory includes `User Manual.pdf`, `Quick Start.pdf`, Markdown manuals, and `Install and Update.md`.
+3. To update, close Process Bus Insight and run the new installer; the existing installation directory and shortcuts are maintained.
+4. Install Npcap separately to capture live Ethernet traffic; classic Ethernet PCAP replay is available offline.
+5. Choose the capture adapter, click **Run**, and select an SV stream.
+
+This is a **receive-only monitoring and analysis application**. PTP timing statistics are based on the available capture timestamps; nominal 4I+4V scaling follows the selected engineering profile. The application does not transmit SV or GOOSE commands.
+
+## Verification and distribution
+
+The stable build is created from `main` by GitHub Actions following Windows Release build, automated regression tests, runtime stability/architecture checks, security analysis, package checksum verification, and Inno install/repair/uninstall smoke tests. Real-world performance also depends on NIC, Npcap, traffic and Windows configuration.
+
+- **License:** GPL-3.0-or-later; source code and license notices are available in this repository.
+- **Assets:** Inno setup EXE, optional portable ZIP, `SHA256SUMS.txt`, and `release-manifest.json`.
+- **Installer:** unsigned; Windows may display its normal unknown-publisher prompt.
diff --git a/docs/RELEASE_PACKAGING.md b/docs/RELEASE_PACKAGING.md
index 8a7da089..41f8f120 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,10 @@ The ZIP contains:
ProcessBusInsight.exe
Quick Start.pdf
User Manual.pdf
+Quick Start.md
+User Manual.md
+ManualSources.json
+Install and Update.md
README.txt
LICENSE.txt
NOTICE.txt
@@ -26,7 +31,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
@@ -42,7 +51,9 @@ The final active Apache-2.0 revision is commit `85d43a0fe58a5888a9e8008c168ab76d
## Required documentation
-The portable package includes `docs/QUICK_START.pdf` and `docs/USER_MANUAL.pdf`. They are intentionally tracked even though generated PDFs are otherwise ignored.
+The packaged **Quick Start.pdf** and **User Manual.pdf** are regenerated from their corresponding Markdown sources at build time, not copied from historically tracked PDF files. `ManualSources.json` records SHA256 for each Markdown source and generated PDF. Both the portable and installer verification reject stale/mismatched pairs.
+
+Older tracked PDFs may remain in the repository for historical website references but are never packaged as the current offline manuals. Build requires Python with pinned `reportlab==4.4.9`; end users do not need Python or ReportLab.
```powershell
git add -f docs/QUICK_START.pdf docs/USER_MANUAL.pdf
@@ -51,16 +62,20 @@ git add -f docs/QUICK_START.pdf docs/USER_MANUAL.pdf
## Local packaging
```powershell
-.\scripts\repository-health.ps1 -ExpectedVersion "1.4.0-beta.2"
-.\scripts\publish-windows-portable.ps1 -Version "1.4.0-beta.2"
-.\scripts\verify-release-package.ps1 -PackageZip ".\artifacts\release\ProcessBusInsight-v1.4.0-beta.2-win-x64-portable.zip"
+.\scripts\repository-health.ps1 -ExpectedVersion "1.4.1"
+python -m pip install reportlab==4.4.9
+python .\scripts\render-offline-manuals.py
+.\scripts\publish-windows-portable.ps1 -Version "1.4.1"
+.\scripts\verify-release-package.ps1 -PackageZip ".\artifacts\release\ProcessBusInsight-v1.4.1-win-x64-portable.zip"
+.\scripts\publish-windows-installer.ps1 -Version "1.4.1"
+.\scripts\verify-windows-installer.ps1 -Installer ".\artifacts\release\ProcessBusInsight-v1.4.1-win-x64-setup.exe" -StageDir ".\artifacts\package\ProcessBusInsight-v1.4.1-win-x64-portable"
```
## GitHub workflows
-The candidate workflow runs for `stabilization/*`, `architecture/*`, and `legal/*` pull requests when relevant files change. It restores, builds, tests, packages, verifies the ZIP and legal documents, creates a candidate manifest, and uploads the candidate without publishing a public release.
+The candidate workflow runs for supported pull-request branches (including `fix/*`, `feat/*`, `stabilization/*`, `architecture/*`, `legal/*`) when relevant files change. It restores, builds, tests, renders current offline PDFs, packages, verifies the ZIP/manual source hashes and legal documents, creates a candidate manifest, and uploads the candidate without publishing a public release.
-The release workflow runs manually or for tags matching `v*`. It restores, builds, tests, packages, verifies the ZIP, produces a checksum and release manifest, uploads workflow evidence, and optionally creates a GitHub Release.
+The release workflow builds on manual dispatch or tags matching `v*`. **A tag push cannot publish a public release.** A public release is an explicit manual `publish_release=true` from `main` requiring `field_acceptance_verified=true`; this must correspond to the current final installer and documented physical soak gate. The workflow restores, builds, tests, packages the Inno EXE and optional ZIP, smoke-tests installation/repair/uninstall, produces checksums and a release manifest, and uploads evidence before publication.
## Packaging design notes
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index 165fb61f..d12c077e 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -1,9 +1,36 @@
# Troubleshooting — Process Bus Insight
-This guide covers common setup and field-use issues for the Windows portable package.
+This guide covers common setup and field-use issues for the Windows Inno installer and optional portable package.
## No adapters appear
+If the adapter selector reads **Npcap unavailable — capture disabled**, the native
+capture interface could not be enumerated. Older builds misleadingly displayed
+`Ethernet (Fallback adapter entry ... index:0)`; that was **not** a usable NIC.
+Run is disabled until a real device is enumerated.
+
+If the diagnostic says **Npcap architecture mismatch**, Windows refused to load
+`wpcap.dll` or one of its dependencies (often `Packet.dll`), potentially because
+a 32-bit or obsolete WinPcap copy was picked up by a 64-bit process.
+The analyzer now resolves those libraries from trusted system locations rather than
+the application folder or the arbitrary PATH search order.
+
+Check the 64-bit system installation (from 64-bit PowerShell):
+
+```powershell
+[Environment]::Is64BitProcess
+Test-Path "$env:WINDIR\System32\Npcap\wpcap.dll"
+Test-Path "$env:WINDIR\System32\Npcap\Packet.dll"
+```
+
+Both the application and native capture library must use matching architecture.
+If either DLL is absent or loading still fails, repair/reinstall the **official
+Npcap x64** installation and restart the application. A reboot may be needed.
+Do not download random individual DLLs, copy x86 DLLs into the app folder, or
+replace protected system DLLs manually. If Wireshark also cannot enumerate Npcap
+devices, fix the host Npcap installation before troubleshooting SV mapping.
+
+
Possible causes:
- Npcap is not installed.
diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md
index c44605ba..9afa743c 100644
--- a/docs/USER_MANUAL.md
+++ b/docs/USER_MANUAL.md
@@ -14,13 +14,15 @@ Process Bus Insight is a receive-only Windows analyzer for IEC 61850 Process Bus
Use a safe capture path such as a TAP, mirror port, or isolated engineering test switch. Avoid connecting an unverified PC directly into a protection-critical path. Install Npcap when raw Ethernet frame capture is required.
-## Running the portable package
+## Install, update, or run the portable option
-1. Download the latest Windows x64 portable ZIP from GitHub Releases.
-2. Extract it to a local folder, for example `C:\Tools\ProcessBusInsight`.
-3. Run `ProcessBusInsight.exe`.
-4. Select the physical Ethernet adapter connected to the Process Bus traffic.
-5. Start capture.
+1. Obtain `ProcessBusInsight-vX.Y.Z-win-x64-setup.exe` from the official GitHub Releases page and compare its SHA256 with the published manifest.
+2. Close the analyzer before installing or upgrading. Run the Inno Setup installer (administrator privileges are required for Program Files). It retains the previous install directory during in-place upgrades.
+3. Start **Process Bus Insight** from the Windows Start Menu. The installed folder includes **User Manual.pdf**, **Quick Start.pdf**, **Install and Update.md**, and separate offline Bench tools; these do not require network access.
+4. Install Npcap separately for live raw Ethernet capture. If its architecture or native driver is incompatible, the selector reports `Npcap unavailable — capture disabled`; no dummy Ethernet entry is usable for capture.
+5. Select the physical Ethernet adapter connected to an authorized TAP/SPAN/test network, then start capture.
+
+A manual portable ZIP remains available for specialist use. If a same-version installation is damaged, rerun that setup EXE for repair; use Windows Installed apps or the Start Menu to uninstall. This is **operator-initiated updating**, not automatic background updating. The current installer is unsigned, so verify source and SHA256 and follow your organization's software policy.
## Main workflow
@@ -34,7 +36,7 @@ Use a safe capture path such as a TAP, mirror port, or isolated engineering test
## Sampled Values view
-Use the SV workspace to check stream identity, APPID, svID, VLAN, source MAC, continuity counters, waveform, phasor, and metering context. Treat missing stream or mismatch indications as investigation leads, not final protection conclusions until the network capture path is confirmed.
+Use the SV workspace to check stream identity, APPID, svID, VLAN, source MAC, continuity counters, waveform, phasor, and metering context. The phasor plot uses independent voltage/current RMS scales, solid U and dashed I arrows, and an explicit held-state indicator when the stream is stale. An amber **LIVE · WARN** stream badge means samples are arriving but continuity/arrival diagnostics require review; a green waveform LIVE badge signals activity, not clean integrity. Treat missing stream or mismatch indications as investigation leads, not final protection conclusions until the network capture path is confirmed.
## GOOSE inspector
@@ -53,7 +55,7 @@ Load SCD, ICD, or CID files when engineering files are available. The app can co
Common causes:
- Wrong adapter selected.
-- Npcap is not installed or capture permission is restricted.
+- Npcap is not installed, its native x64 libraries cannot load, or capture permission is restricted.
- Switch mirror port is not configured correctly.
- VLAN traffic is not forwarded to the capture port.
- PC is connected to the wrong network segment.
diff --git a/docs/development/RELEASE_CHECKLIST.md b/docs/development/RELEASE_CHECKLIST.md
index 29ee5fb5..6488dc39 100644
--- a/docs/development/RELEASE_CHECKLIST.md
+++ b/docs/development/RELEASE_CHECKLIST.md
@@ -24,10 +24,11 @@
- [ ] Truncated, unsupported-link-type, invalid-timestamp, and oversized-record cases are rejected
- [ ] CodeQL completes
- [ ] Dependency review has no unresolved high-severity finding
-- [ ] Portable candidate package verification succeeds
-- [ ] ZIP checksum and candidate/release manifest are generated
+- [ ] Primary Inno Setup candidate and optional portable ZIP package verification succeed
+- [ ] Installer install → repair → offline PDFs/Markdown docs → uninstall smoke test succeeds
+- [ ] EXE and ZIP checksums and candidate/release manifests are generated
-## Runtime smoke test
+## Optional field/hardware smoke test (ongoing post-release QA)
- [ ] Application starts without Npcap crash-handling regressions
- [ ] Physical/known adapter can start and stop cleanly
@@ -39,7 +40,9 @@
- [ ] 4000 and 65536 sample-counter rollover behavior is exercised
- [ ] Duplicate, forward-gap, and publisher-restart behavior is exercised
- [ ] GOOSE, PTP, and SCL workspaces open and update
-- [ ] 30–60 minute soak test shows no material memory growth or UI freeze
+- [ ] At least 60-minute soak of the **exact final candidate** on actual Windows x64/Npcap/Ethernet hardware shows no material memory growth, capture failure or UI freeze; retain sanitized process statistics and driver-drop evidence
+- [ ] Injector U/I RMS/phase changes agree across all six channels in both directions and on non-nominal/zero/missing samples
+- [ ] UI/phasor/status/power is readable at 100%, 125%, 150% DPI, including stale/amber-warning states
## Replay and immutable snapshot checks
@@ -64,7 +67,7 @@
## Package legal content
-- [ ] ZIP contains `LICENSE.txt`, `NOTICE.txt`, `COMMERCIAL-LICENSE.md`, `COPYRIGHT.md`, `TRADEMARK.md`, `THIRD_PARTY_NOTICES.md`, and `Licensing.md`
+- [ ] Installer and ZIP contain `LICENSE.txt`, `NOTICE.txt`, `COMMERCIAL-LICENSE.md`, `COPYRIGHT.md`, `TRADEMARK.md`, `THIRD_PARTY_NOTICES.md`, and `Licensing.md`
- [ ] `LICENSE.txt` contains GNU GPL version 3
- [ ] No historical Apache license file is presented as the current package license
- [ ] Commercial notice non-grant wording is intact
@@ -73,7 +76,9 @@
## Release
- [ ] Create signed/verified tag where available
-- [ ] Publish portable ZIP, `SHA256SUMS.txt`, and `release-manifest.json`
+- [ ] Publish Inno Setup installer (primary), optional portable ZIP, `SHA256SUMS.txt`, and `release-manifest.json`
+- [ ] Record latest CI and release installer checks for the final main commit in `docs/validation/V1.4.1_PUBLIC_RELEASE_ACCEPTANCE.md`; physical soak feedback is optional.
+- [ ] Public stable release is explicitly source-approved on **main** and runs the existing release-package workflow with publish=true, prerelease=false, draft=false. Tag push cannot silently publish.
- [ ] Mark beta releases as prerelease
- [ ] Confirm GitHub Pages and download links
- [ ] Do not replace a historical Apache release asset with a post-transition GPL binary under the same tag; use the new version `v1.4.0-beta.2` or later
\ No newline at end of file
diff --git a/docs/index.html b/docs/index.html
index 0f465fd9..d1aa805b 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -37,7 +37,7 @@
"applicationCategory": "EngineeringApplication",
"applicationSubCategory": "Digital substation and process bus analysis",
"operatingSystem": "Windows 10, Windows 11",
- "softwareVersion": "1.4.0",
+ "softwareVersion": "1.4.1",
"license": "https://spdx.org/licenses/GPL-3.0-or-later.html",
"isAccessibleForFree": true,
"url": "https://masarray.github.io/DigSubAnalyzer/",
diff --git a/docs/validation/CALM_ENGINEERING_POLISH_2026-10-20.md b/docs/validation/CALM_ENGINEERING_POLISH_2026-10-20.md
new file mode 100644
index 00000000..dbfde5b6
--- /dev/null
+++ b/docs/validation/CALM_ENGINEERING_POLISH_2026-10-20.md
@@ -0,0 +1,33 @@
+# Calm Engineering Polish — frozen baseline and acceptance (20 October 2026)
+
+## Immutable pre-polish checkpoint
+
+- Repository: `masarray/DigSubAnalyzer`
+- Preserved branch: `checkpoint/pre-calm-engineering-polish-20261020`
+- Exact pre-polish commit: `03fc3657f2ba21df2bc570d2198c59bb2f2be2dc`
+- At checkpoint time: CI, Runtime Architecture, Runtime Stability, CodeQL, Dependency Security Review and Release Candidate Package **all passed**.
+- Active development PR: #47 (`fix/phasor-ssot-responsive-lightweight-20261019`); do **not** merge to main or publish stable based solely on visual/CI evidence.
+- Must preserve: 8-DIP interpanel geometry, baseline phasor exact-tip callouts, channel-specific HELD/MISSING, selected-stream SSOT, honest Npcap/PTP evidence, Inno upgrade/repair paths.
+
+## Scoped calm polish
+
+1. **Diagnostics:** bounded, exact-message grouping across up to 200 original capture events; grouped count and latest/earliest timestamp as presentation only. Original `AnalyzerShellState.Events` is intact. Incremental ListView updates minimize flicker.
+2. **Timing interpretation:** signed host/Npcap interarrival variation is called `ARRIVAL Δ · HOST`; smpCnt-derived missing sample gaps are explicitly **inferred**, not independently confirmed packet loss.
+3. **Waveforms:** green static `RX` indicator means packet reception, not clean SV integrity. No pulsing storyboard or extra timer. Existing amber `LIVE · WARN` is authoritative for stream health.
+4. **Analyzer controls:** electrical U/I scale controls receive adequate width and full-value tooltips, without enlarging panels or recomputing samples.
+5. **PTP:** fit protocol transport text and preserve event identities, timestamps, clock evidence and source selection.
+6. **GOOSE:** correct no-publisher vs filtered-no-event empty state, preserving state-change-only default and observable bindings.
+7. **SCL:** readable compact monospace engineering references with full-reference hover text.
+
+## Quality gates
+
+- [ ] Exact latest SHA Windows build and entire xUnit suite green
+- [ ] Existing WPF layout geometry regression remains green
+- [ ] Positive/negative event aggregation tests (same messages coalesce; different source, severity or engineering values remain separate; bounded at 200)
+- [ ] Runtime Architecture and Runtime Stability green
+- [ ] CodeQL and Dependency Security Review green
+- [ ] Exact SHA Inno installer built; install/repair/uninstall and executable provenance/hash verification green
+- [ ] User field screenshots at 100%, 125% and 150% DPI, with capture running and empty states
+- [ ] Physical 60-minute target NIC/FAT gate remains separate; no stable publication without it
+
+No visual screenshot or CI can establish measurement calibration, end-device subscriber state, precise PTP clock lock, or actual packet loss attribution from host-only timestamps.
diff --git a/docs/validation/TESTED_CONFIGURATIONS.md b/docs/validation/TESTED_CONFIGURATIONS.md
index 9b53eb31..c64f86d1 100644
--- a/docs/validation/TESTED_CONFIGURATIONS.md
+++ b/docs/validation/TESTED_CONFIGURATIONS.md
@@ -10,15 +10,17 @@ This matrix records explicit evidence. It is not a vendor-certification claim.
| Runtime snapshot | Copied selected-stream identity, waveform, analog, phasor, shape, and diagnostics; atomic generation publication | Repeated `RuntimeArchitecture` xUnit category with TRX artifacts | Automated |
| Classic PCAP replay | PCAP 2.4, Ethernet link type 1, little/big endian, micro/nanosecond timestamps, truncated/oversized rejection | Repeated `RuntimeArchitecture` xUnit category with TRX artifacts | Automated |
| Replay isolation | Three interleaved SV streams replayed through the live raw analyzer entry point | Runtime architecture regression tests | Automated |
-| Packaging | Windows x64 self-contained single-file ZIP | Candidate/release workflow and package verification script | Automated |
+| Packaging | Windows x64 Inno Setup self-contained installer (primary), portable ZIP (optional); install/repair/offline manuals/uninstall | Windows candidate workflow + SHA256 smoke gates | Automated |
| Website | Static GitHub Pages source validation | Pages workflow | Automated |
-| Live capture | Windows + Npcap + selected Ethernet adapter | Maintainer smoke/soak evidence | Maintainer validation |
-| Runtime soak | 60-minute live session, three simultaneous streams, memory 141.6 MB start / 144.5 MB peak / 142.1 MB end, zero freeze/disconnect/cross-stream mixing | `V1.3.0_BETA2_FIELD_EVIDENCE.md` | PASS |
+| Live capture | Windows + Npcap + selected Ethernet adapter | Historical bench and current screenshot show adapter + one SV stream; fresh final candidate acceptance required | **Pending current final SHA** |
+| Runtime soak | Historical v1.3.0-beta.2 60-minute live session, three streams, memory 141.6/144.5/142.1 MB | `V1.3.0_BETA2_FIELD_EVIDENCE.md` | PASS **historical only; pending v1.4.1 final candidate** |
| Timing | Windows/Npcap or recorded PCAP timestamps | Screening only | Not measurement-grade |
| PCAPNG replay | Not implemented in v1.4.0-beta.1 | No evidence | Not claimed |
| Multi-vendor SCL | SCD/ICD/CID examples and parser tests | Partial | Broader validation required |
| Hardware timestamping | Validated NIC/TAP timestamp path | None in public beta | Not claimed |
+The 2026-10-19 same-session injector/analyzer screenshot shows close agreement for one 4I+4V 50 Hz configuration, including Ia +30°, Ib -90° and Ic +150°, but does not prove sustained resource usage or packet-by-packet equivalence. See [current v1.4.1 acceptance gates](V1.4.1_PUBLIC_RELEASE_ACCEPTANCE.md).
+
## Release evidence to record
For each public beta, record:
diff --git a/docs/validation/V1.4.1_PUBLIC_RELEASE_ACCEPTANCE.md b/docs/validation/V1.4.1_PUBLIC_RELEASE_ACCEPTANCE.md
new file mode 100644
index 00000000..5a3f91d5
--- /dev/null
+++ b/docs/validation/V1.4.1_PUBLIC_RELEASE_ACCEPTANCE.md
@@ -0,0 +1,64 @@
+# v1.4.1 public release — automated release evidence and optional field QA
+
+This document tracks automated community-release checks and optional on-hardware engineering feedback separately. The v1.4.1 stable channel refers to software packaging and automated regression readiness. Physical validation can continue after publication as ongoing open-source QA.
+
+## 1. Build and source identity
+
+- Target commit SHA: **PENDING — record the final merged main commit**
+- Public version: 1.4.1
+- Candidate installer: **PENDING — record GitHub Actions run number and SHA256**
+- Native target: Windows x64, self-contained .NET 8 and separately installed Npcap
+- Official license: GPL-3.0-or-later, with all notices and source availability
+
+## 2. Automated release checks
+
+| Gate | Evidence required | Status |
+| --- | --- | --- |
+| Repository health, clean restore/build and full unit suite | CI run and TRX | PENDING current final SHA |
+| Live current vs voltage angle/RMS, invalid sample, rollover, multi-stream isolation | Production analyzer xUnit regressions | PENDING final SHA |
+| Zero phasor invention, label collision and tiny-scale legend | Presentation xUnit regressions | PENDING final SHA |
+| Runtime stability and immutable snapshot/PCAP replay | Repeated Actions jobs | PENDING final SHA |
+| CodeQL and dependency high/critical audit | Completed successful Actions checks | PENDING final SHA |
+| Installer integrity and smoke | EXE SHA256, install → repair → documentation hashes → uninstall | PENDING final SHA |
+| Release notes, GPL notices, local Quick Start/User Manual and update guide | Installer contents + package verification | PENDING final SHA |
+
+An older `.github/release/v1.4.0.approved` file refers to an **older main commit / portable candidate** and does **not** approve this PR's new installer or Npcap/phasor code. The v1.3.0-beta.2 60-minute run is a useful historical baseline, not proof for this SHA.
+
+## 3. Current engineer-visible visual check
+
+The user-supplied same-session 50 Hz injector/analyzer screenshots show:
+
+| Channel | Injector RMS / angle | Analyzer RMS / angle |
+| --- | --- | --- |
+| Ua | 57.740 V / 0.00° | 57.73 V / 0.00° |
+| Ub | 57.740 V / -120.00° | 57.73 V / -120.01° |
+| Uc | 57.740 V / +120.00° | 57.73 V / +120.01° |
+| Ia | 1.000 A / +30.00° | 1.00 A / +30.00° |
+| Ib | 1.000 A / -90.00° | 1.00 A / -90.00° |
+| Ic | 1.200 A / +150.00° | 1.20 A / +149.99° |
+
+These observations show good displayed agreement **for that one setting only**. They are not calibrated accuracy proof, independent PCAP/SCL verification, or sustained-load evidence. Do not publish site/device-specific screenshots or identifiers.
+
+## 4. Optional extended on-hardware QA (non-blocking)
+
+Run on the final installed candidate, not an earlier portable build:
+
+- [ ] Confirm Windows x64 version/build, native Npcap version, NIC driver, adapter name/type (sanitized), receive-only TAP/SPAN topology, installer SHA256, exact application assembly version.
+- [ ] Confirm actual ASIX/other physical adapter enumerates without a fabricated fallback; start/stop/restart capture and uninstall/upgrade/repair on target Windows.
+- [ ] Compare 6 channel U/I RMS + phase against the **actual injector source values** over multiple non-nominal settings; change each one independently. Include zero/missing, distorted/harmonic samples, scale changes and reference fallback.
+- [ ] Confirm phase reference and angle sign conventions on waveform, table, phasor and power; note 0° / +90° / 180° / 270° convention.
+- [ ] Verify true canonical SV publisher strong identity (APPID, svID, MAC, VLAN, confRev, smpCnt, ASDU mapping), not just a diagnostic mirror. Preserve authorized/sanitized PCAP + optional SCL evidence locally.
+- [ ] Test 4000 and 4800 sample-per-second profiles, counter rollovers, duplicate/missing samples, publisher restart, three or more streams selected/interleaved, and 2/4/8 cycle windows as applicable.
+- [ ] Verify GOOSE, PTP and SCL workspaces on actual bench traffic. PTP visibility does not establish hardware timing accuracy.
+- [ ] Record a minimum **60-minute** physical session at real capture rate, plus process CPU, heap/working set, UI responsiveness, native driver/interface drops and consecutive capture-error counts. Attach sanitized summaries, not raw customer captures.
+- [ ] Confirm closing capture, reinstalling/repairing and uninstalling do not hang and do not delete unrelated user data; verify no unexpected data upload/network transmission.
+- [ ] Confirm UI on 100%, 125% and 150% Windows DPI, 1366×768 minimum layout and normal 1920×1080, with readable phasor labels, legends, powers and status warnings.
+
+**Extended physical QA:** Additional test evidence can be attached later without delaying the community stable distribution.
+
+## 5. Publication control
+
+- Merge the reviewed PR to main after automated checks.
+- Source-controlled one-time approval on main dispatches the existing release workflow; publishing is restricted to main and gated by build/test/package verification. A tag push alone cannot publish.
+- Stable GitHub release must include the Inno Setup installer as primary, portable ZIP as optional, SHA256SUMS, manifest and current release notes.
+- Stable is the open-source software distribution channel. Continue optional field feedback independently; avoid unsupported device-specific claims.
diff --git a/installer/ProcessBusInsight.iss b/installer/ProcessBusInsight.iss
new file mode 100644
index 00000000..e982badb
--- /dev/null
+++ b/installer/ProcessBusInsight.iss
@@ -0,0 +1,103 @@
+; 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 FileVersion
+ #error FileVersion is required (/DFileVersion=...)
+#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={#FileVersion}
+; 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}\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
+
+[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;
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"
diff --git a/scripts/publish-windows-portable.ps1 b/scripts/publish-windows-portable.ps1
index b2305baf..1d142ad9 100644
--- a/scripts/publish-windows-portable.ps1
+++ b/scripts/publish-windows-portable.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Version = "1.4.0-beta.2",
+ [string]$Version = "1.4.1",
[string]$Configuration = "Release",
[string]$Runtime = "win-x64",
[string]$AppName = "ProcessBusInsight",
@@ -14,8 +14,14 @@ $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
Set-Location $repoRoot
if ([string]::IsNullOrWhiteSpace($Version)) { throw "Version cannot be empty." }
+# Each Inno candidate needs an unambiguous source revision.
+$sourceCommit = (& git rev-parse HEAD).Trim()
+if ($LASTEXITCODE -ne 0 -or $sourceCommit.Length -ne 40 -or
+ $sourceCommit -cnotmatch "^[a-f0-9]{40}") {
+ throw "Packaging requires a Git checkout and valid source commit SHA."
+}
$requiredReleaseDocs = @(
- "docs/QUICK_START.pdf", "docs/USER_MANUAL.pdf", "LICENSE", "NOTICE",
+ "docs/QUICK_START.md", "docs/USER_MANUAL.md", "docs/INSTALLATION_AND_UPDATE.md", "LICENSE", "NOTICE",
"COMMERCIAL-LICENSE.md", "COPYRIGHT.md", "TRADEMARK.md",
"THIRD_PARTY_NOTICES.md", "docs/LICENSING.md"
)
@@ -31,6 +37,31 @@ if (Test-Path (Join-Path $repoRoot "LICENSE-APACHE-2.0")) {
throw "Historical Apache license must not be staged as a current release license."
}
+# Release PDFs are rendered fresh from the canonical Markdown source and
+# cryptographically tied to the two Markdown files in the same installer.
+# Old tracked PDFs must never silently enter a new public package.
+$manualDir = Join-Path $repoRoot "$OutputRoot/generated-docs"
+$manualManifestPath = Join-Path $manualDir "manual-manifest.json"
+if (-not (Test-Path -LiteralPath $manualManifestPath)) {
+ throw "Current offline PDF manual manifest missing. Run python scripts/render-offline-manuals.py first."
+}
+$manualManifest = Get-Content -LiteralPath $manualManifestPath -Raw | ConvertFrom-Json
+if ($manualManifest.format -ne "ProcessBusInsight-offline-manuals-v1") { throw 'Unsupported manual manifest format.' }
+foreach ($entry in $manualManifest.files) {
+ if ($entry.source -notin @("QUICK_START.md", "USER_MANUAL.md")) {
+ throw "Unexpected manual source: $($entry.source)"
+ }
+ $src = Join-Path $repoRoot ("docs/" + $entry.source)
+ $pdf = Join-Path $manualDir $entry.pdf
+ if (-not (Test-Path -LiteralPath $pdf)) { throw "Missing generated PDF $pdf" }
+ $sourceHash = (Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash
+ $pdfHash = (Get-FileHash -LiteralPath $pdf -Algorithm SHA256).Hash
+ if ($sourceHash -ne $entry.sourceSha256 -or $pdfHash -ne $entry.pdfSha256) {
+ throw "Offline PDF not synchronized with $($entry.source); regenerate manuals."
+ }
+}
+if (@($manualManifest.files).Count -ne 2) { throw "Exactly two offline PDF manuals are required." }
+
$portableName = "$AppName-v$Version-$Runtime-portable"
$publishDir = Join-Path $repoRoot "$OutputRoot/publish/$portableName/publish"
$stageDir = Join-Path $repoRoot "$OutputRoot/package/$portableName"
@@ -110,17 +141,27 @@ Copy-Item (Join-Path $repoRoot "docs/PHYSICAL_BENCH_QUICK_START.md") (Join-Path
$packageReadme = @"
Process Bus Insight / DigSubAnalyzer
-Windows portable package
+Windows self-contained distribution payload
Version: $Version
Community license: GPL-3.0-or-later
+This payload is packaged both by the Inno Setup installer (recommended)
+and as a separate optional portable ZIP.
+
+INSTALLER: Run the official win-x64-setup.exe. It installs this payload,
+Start Menu shortcuts, offline manuals and uninstaller to Program Files.
+UPDATE: Close the analyzer and run the newer installer (no uninstall needed).
+PORTABLE: Extract the ZIP completely and run $AppName.exe.
+
1. Install Npcap separately when live raw Ethernet capture is required.
-2. Extract this ZIP to a local folder.
-3. Run $AppName.exe.
-4. Use an authorized TAP, mirror port, or isolated engineering test network.
-5. For physical SV/PTP test evidence read Bench/START_HERE.md.
-6. Bench/ProcessBus.InteropCli.exe works offline and does not need the .NET SDK.
-7. Bench/measure-m4-bench.ps1 records local process CPU/memory during a bench run.
+ Missing/incompatible native Npcap must not produce a fake usable adapter.
+2. Use an authorized TAP, mirror port, or isolated engineering test network.
+3. Read Quick Start.pdf, User Manual.pdf and Install and Update.md offline.
+ Current Markdown editions are included and cryptographically bound to PDFs
+ through ManualSources.json.
+4. For physical SV/PTP evidence read Bench/START_HERE.md.
+5. Bench/ProcessBus.InteropCli.exe works offline without the .NET SDK.
+6. Bench/measure-m4-bench.ps1 records local process CPU/memory.
A diagnostic broadcast mirror does NOT prove the canonical VLAN SV path.
Included legal documents:
@@ -143,8 +184,12 @@ $copyMap = @(
@{ Source = "TRADEMARK.md"; Destination = "TRADEMARK.md" },
@{ 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 = "$OutputRoot/generated-docs/Quick Start.pdf"; Destination = "Quick Start.pdf" },
+ @{ Source = "$OutputRoot/generated-docs/User Manual.pdf"; Destination = "User Manual.pdf" },
+ @{ Source = "$OutputRoot/generated-docs/manual-manifest.json"; Destination = "ManualSources.json" },
+ @{ Source = "docs/QUICK_START.md"; Destination = "Quick Start.md" },
+ @{ Source = "docs/USER_MANUAL.md"; Destination = "User Manual.md" },
+ @{ Source = "docs/INSTALLATION_AND_UPDATE.md"; Destination = "Install and Update.md" }
)
foreach ($item in $copyMap) {
$source = Join-Path $repoRoot $item.Source
@@ -156,6 +201,17 @@ if (Test-Path (Join-Path $stageDir "LICENSE-APACHE-2.0")) {
throw "Historical Apache license must not appear in a current GPL package."
}
+# Include SHA-linked provenance beside the EXE, both in Inno and portable
+# artifacts, so operators can identify a stale or alternate install path.
+$payloadProvenance = [ordered]@{
+ schema = "processbus-build-v1"
+ version = $Version
+ commit = $sourceCommit
+ executableSha256 = (Get-FileHash -LiteralPath (Join-Path $stageDir "$AppName.exe") -Algorithm SHA256).Hash.ToLowerInvariant()
+ githubRunId = if ($env:GITHUB_RUN_ID) { $env:GITHUB_RUN_ID } else { "" }
+}
+$payloadProvenance | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath (Join-Path $stageDir "BuildInfo.json") -Encoding UTF8
+
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Compress-Archive -Path (Join-Path $stageDir "*") -DestinationPath $zipPath -Force
$hash = Get-FileHash $zipPath -Algorithm SHA256
diff --git a/scripts/render-offline-manuals.py b/scripts/render-offline-manuals.py
new file mode 100644
index 00000000..14d92a3a
--- /dev/null
+++ b/scripts/render-offline-manuals.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""Build offline PDF manuals from the same Markdown shipped with the app.
+
+Requires reportlab 4.4.x at packaging time; no runtime dependency or network calls.
+"""
+import argparse
+import hashlib
+import html
+import json
+import re
+from pathlib import Path
+
+from reportlab.lib import colors
+from reportlab.lib.pagesizes import A4
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.platypus import BaseDocTemplate, Frame, PageTemplate, Paragraph, Preformatted
+
+NAVY = colors.HexColor("#122238")
+BLUE = colors.HexColor("#186DC0")
+INK = colors.HexColor("#1B2836")
+
+
+def inline(source):
+ source = html.escape(source, quote=True)
+ source = re.sub(r"\[([^\]]+)\]\((https?://[^)]+)\)",
+ lambda m: f'{m.group(1)}', source)
+ source = re.sub(r"\*\*(.+?)\*\*", r"\1", source)
+ source = re.sub(r"\x60([^\x60]+)\x60", r'\1', source)
+ return source
+
+
+def paragraphs(markdown, styles):
+ story, pending, code = [], [], []
+ in_code = False
+
+ def flush():
+ if pending:
+ story.append(Paragraph(inline(" ".join(pending)), styles["body"]))
+ pending.clear()
+
+ for line in markdown.splitlines():
+ value = line.strip()
+ if value.startswith(chr(96) * 3):
+ if in_code:
+ story.append(Preformatted("\n".join(code), styles["code"],
+ maxLineLength=85, splitChars=" /\\"))
+ code.clear()
+ else:
+ flush()
+ in_code = not in_code
+ continue
+ if in_code:
+ code.append(line.rstrip())
+ elif not value:
+ flush()
+ elif re.match(r"^#{1,3}\s", value):
+ flush()
+ depth = len(value.split(" ", 1)[0])
+ story.append(Paragraph(inline(value[depth:].strip()), styles[f"h{depth}"]))
+ elif re.match(r"^(?:[-*]\s|\d+\.\s)", value):
+ flush()
+ ordered = re.match(r"^(\d+)\.\s+(.*)$", value)
+ number = f"{ordered.group(1)}." if ordered else "•"
+ entry = ordered.group(2) if ordered else re.sub(r"^[-*]\s+", "", value)
+ story.append(Paragraph(f'{number} {inline(entry)}',
+ styles["list"]))
+ else:
+ pending.append(value)
+ flush()
+ if in_code:
+ raise ValueError("Unclosed Markdown code block")
+ return story
+
+
+def render(source, destination):
+ text = source.read_text(encoding="utf-8")
+ if "Npcap" not in text or "Inno Setup" not in text:
+ raise ValueError(f"Manual is missing current Inno Setup / Npcap guidance: {source}")
+
+ style = {
+ "h1": ParagraphStyle("h1", fontName="Helvetica-Bold", fontSize=18, leading=22,
+ textColor=NAVY, spaceBefore=8, spaceAfter=16),
+ "h2": ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=13, leading=18,
+ textColor=NAVY, spaceBefore=13, spaceAfter=8, keepWithNext=True),
+ "h3": ParagraphStyle("h3", fontName="Helvetica-Bold", fontSize=11, leading=15,
+ textColor=NAVY, spaceBefore=10, spaceAfter=6, keepWithNext=True),
+ "body": ParagraphStyle("body", fontName="Helvetica", fontSize=10.3, leading=15.5,
+ textColor=INK, spaceAfter=8),
+ "list": ParagraphStyle("list", fontName="Helvetica", fontSize=10.2, leading=15,
+ leftIndent=12, firstLineIndent=-10, textColor=INK, spaceAfter=5),
+ "code": ParagraphStyle("code", fontName="Courier", fontSize=8.6, leading=12,
+ leftIndent=8, rightIndent=8, backColor=colors.HexColor("#F0F5F9"),
+ borderPadding=7, textColor=INK, spaceAfter=10),
+ }
+
+ page_width, page_height = A4
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ doc = BaseDocTemplate(str(destination), pagesize=A4, leftMargin=48, rightMargin=48,
+ topMargin=66, bottomMargin=48,
+ title=f"Process Bus Insight - {source.stem.replace('_', ' ').title()}",
+ author="Process Bus Insight Contributors")
+ frame = Frame(48, 48, page_width - 96, page_height - 114,
+ leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0)
+
+ def decorate(canvas, document):
+ canvas.saveState()
+ canvas.setFillColor(NAVY)
+ canvas.rect(0, page_height - 39, page_width, 39, fill=1, stroke=0)
+ canvas.setFillColor(colors.white)
+ canvas.setFont("Helvetica-Bold", 10)
+ canvas.drawString(48, page_height - 25, "PROCESS BUS INSIGHT")
+ canvas.setFont("Helvetica", 8)
+ canvas.drawRightString(page_width - 48, page_height - 25, "OFFLINE OPERATOR GUIDE")
+ canvas.setStrokeColor(BLUE)
+ canvas.setLineWidth(2)
+ canvas.line(48, 38, page_width - 48, 38)
+ canvas.setFont("Helvetica", 8)
+ canvas.setFillColor(colors.HexColor("#5C6D7D"))
+ canvas.drawString(48, 27, "Receive-only | Windows / Npcap timing is screening evidence")
+ canvas.drawRightString(page_width - 48, 27, f"Page {document.page}")
+ canvas.restoreState()
+
+ doc.addPageTemplates([PageTemplate(id="manual", frames=frame, onPage=decorate)])
+ doc.build(paragraphs(text, style))
+ if destination.stat().st_size < 1400:
+ raise ValueError(f"Generated PDF too small: {destination}")
+ return hashlib.sha256(source.read_bytes()).hexdigest()
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--repo-root", default=".")
+ parser.add_argument("--output-dir", default="artifacts/generated-docs")
+ args = parser.parse_args()
+ root = Path(args.repo_root).resolve()
+ output = (root / args.output_dir).resolve()
+ manifest = {"format": "ProcessBusInsight-offline-manuals-v1", "files": []}
+ for stem, name in [("QUICK_START", "Quick Start.pdf"), ("USER_MANUAL", "User Manual.pdf")]:
+ source, pdf = root / "docs" / f"{stem}.md", output / name
+ source_hash = render(source, pdf)
+ manifest["files"].append({
+ "source": source.name, "sourceSha256": source_hash,
+ "pdf": name, "pdfSha256": hashlib.sha256(pdf.read_bytes()).hexdigest()
+ })
+ print(f"{source.name} -> {pdf.name} ({pdf.stat().st_size} bytes)")
+ (output / "manual-manifest.json").write_text(
+ json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/verify-release-package.ps1 b/scripts/verify-release-package.ps1
index 6845680d..e8004551 100644
--- a/scripts/verify-release-package.ps1
+++ b/scripts/verify-release-package.ps1
@@ -17,6 +17,11 @@ try {
"$AppName.exe",
"Quick Start.pdf",
"User Manual.pdf",
+ "Quick Start.md",
+ "User Manual.md",
+ "ManualSources.json",
+ "BuildInfo.json",
+ "Install and Update.md",
"README.txt",
"LICENSE.txt",
"NOTICE.txt",
@@ -36,6 +41,43 @@ try {
}
}
+ # Current source commit must be tied to the exact shipped EXE.
+ $build = Get-Content (Join-Path $temp "BuildInfo.json") -Raw | ConvertFrom-Json
+ if ($build.schema -ne "processbus-build-v1" -or
+ [string]$build.commit -cnotmatch "^[0-9a-f]{40}" -or
+ ([string]$build.commit).Length -ne 40 -or
+ [string]$build.version -notmatch "^\d+\.\d+\.\d+") {
+ throw "Missing or invalid candidate source provenance."
+ }
+ $exeHash = (Get-FileHash (Join-Path $temp "$AppName.exe") -Algorithm SHA256).Hash.ToLowerInvariant()
+ if ($exeHash -cne $build.executableSha256) {
+ throw "Packaged EXE hash differs from BuildInfo.json; refuse ambiguous version."
+ }
+
+ # PDFs must match the exact Markdown editions staged with this build.
+ $manualManifest = Get-Content (Join-Path $temp "ManualSources.json") -Raw | ConvertFrom-Json
+ if ($manualManifest.format -ne "ProcessBusInsight-offline-manuals-v1") {
+ throw "Manual verification failed: missing provenance format."
+ }
+ if (@($manualManifest.files).Count -ne 2) {
+ throw "Manual verification failed: two synchronized PDF editions expected."
+ }
+ foreach ($entry in $manualManifest.files) {
+ $markdownName = switch ($entry.source) {
+ "QUICK_START.md" { "Quick Start.md" }
+ "USER_MANUAL.md" { "User Manual.md" }
+ default { throw "Unexpected PDF manual source: $($entry.source)" }
+ }
+ if ($entry.pdf -notin @("Quick Start.pdf", "User Manual.pdf")) {
+ throw "Unexpected generated PDF filename: $($entry.pdf)"
+ }
+ $markdownSha = (Get-FileHash (Join-Path $temp $markdownName) -Algorithm SHA256).Hash
+ $pdfSha = (Get-FileHash (Join-Path $temp $entry.pdf) -Algorithm SHA256).Hash
+ if ($markdownSha -ne $entry.sourceSha256 -or $pdfSha -ne $entry.pdfSha256) {
+ throw "Manual source/PDF hash mismatch for $markdownName."
+ }
+ }
+
if (Test-Path (Join-Path $temp "LICENSE-APACHE-2.0")) {
throw "Package verification failed. Historical Apache license is presented as a current package license."
}
diff --git a/scripts/verify-windows-installer.ps1 b/scripts/verify-windows-installer.ps1
new file mode 100644
index 00000000..0ce8a271
--- /dev/null
+++ b/scripts/verify-windows-installer.ps1
@@ -0,0 +1,103 @@
+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',
+ 'Quick Start.md',
+ 'User Manual.md',
+ 'BuildInfo.json',
+ 'Install and Update.md',
+ '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', 'BuildInfo.json', '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." }
+ }
+ }
+ $build = Get-Content (Join-Path $installDir 'BuildInfo.json') -Raw | ConvertFrom-Json
+ if ($build.schema -ne 'processbus-build-v1' -or
+ ([string]$build.commit).Length -ne 40 -or
+ [string]$build.commit -cnotmatch "^[0-9a-f]{40}") {
+ throw 'Installed build provenance invalid.'
+ }
+ $exeHash = (Get-FileHash -LiteralPath (Join-Path $installDir 'ProcessBusInsight.exe') -Algorithm SHA256).Hash.ToLowerInvariant()
+ if ($exeHash -cne $build.executableSha256) {
+ throw 'Installed executable hash does not match the build provenance.'
+ }
+ 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
+ }
+}
diff --git a/scripts/verify-workspace-spacing.ps1 b/scripts/verify-workspace-spacing.ps1
new file mode 100644
index 00000000..a5988d1e
--- /dev/null
+++ b/scripts/verify-workspace-spacing.ps1
@@ -0,0 +1,89 @@
+# Fail the Windows release pipeline if primary WPF panel gutters regress.
+# Geometric source contract: 8 DIP between major columns, 8 DIP between
+# primary vertical panels. Inner label/control spacing remains 4/8 DIP.
+[CmdletBinding()]
+param(
+ [string]$Root = (Split-Path -Parent $PSScriptRoot)
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$windowPath = Join-Path $Root 'src/ProcessBus.App.Wpf/MainWindow.xaml'
+$themePath = Join-Path $Root 'src/ProcessBus.App.Wpf/Themes/Controls.xaml'
+[xml]$window = Get-Content -LiteralPath $windowPath -Raw
+[xml]$theme = Get-Content -LiteralPath $themePath -Raw
+$ns = [System.Xml.XmlNamespaceManager]::new($window.NameTable)
+$ns.AddNamespace('w', 'http://schemas.microsoft.com/winfx/2006/xaml/presentation')
+$ns.AddNamespace('x', 'http://schemas.microsoft.com/winfx/2006/xaml')
+
+function Assert-True([bool]$Ok, [string]$Description) {
+ if (-not $Ok) { throw "WPF spacing regression: $Description" }
+}
+
+function Has-Definitions([string]$DefinitionName, [string]$ChildName, [string]$Attribute, [string]$Expected) {
+ $selector = "//w:Grid/w:$DefinitionName"
+ $collections = $window.SelectNodes($selector, $ns)
+ foreach ($collection in $collections) {
+ $parts = @()
+ foreach ($child in $collection.ChildNodes) {
+ if ($child.LocalName -ne $ChildName) { continue }
+ $parts += $child.GetAttribute($Attribute)
+ }
+ if (($parts -join ',') -ceq $Expected) { return $true }
+ }
+ return $false
+}
+
+# Main three-column shell is the operator's visual alignment reference.
+# Before this fix, the left explorer spanned BOTH the workspace row and its
+# footer-gap row; that made its bottom edge 8 DIP lower than the main panel.
+$shells = @($window.SelectNodes("//w:Grid[w:Grid.ColumnDefinitions/w:ColumnDefinition[@Width='280']]", $ns))
+Assert-True ($shells.Count -eq 1) 'exactly one three-column workspace shell'
+$shell = $shells[0]
+$leftExplorer = $shell.SelectSingleNode("w:Border[@Grid.Column='0' and @Grid.Row='0']", $ns)
+Assert-True ($null -ne $leftExplorer) 'left explorer exists in the workspace row'
+Assert-True ($leftExplorer.GetAttribute('Grid.RowSpan') -in @('', '1')) 'left explorer cannot overlap footer-gap row'
+$mainCanvas = $shell.SelectSingleNode("w:Grid[@Grid.Column='2' and @Grid.Row='0']", $ns)
+Assert-True ($null -ne $mainCanvas) 'main workspace canvas shares left explorer row'
+$footer = $shell.SelectSingleNode("w:Border[@Grid.Row='2']", $ns)
+Assert-True ($null -ne $footer) 'workspace footer is separated from the panel row'
+Assert-True (Has-Definitions 'Grid.RowDefinitions' 'RowDefinition' 'Height' '*,8,28') 'uniform 8-DIP footer gutter'
+Assert-True (Has-Definitions 'Grid.ColumnDefinitions' 'ColumnDefinition' 'Width' '280,8,*,8,320') 'shell columns must have 8 DIP gutters'
+Assert-True (Has-Definitions 'Grid.ColumnDefinitions' 'ColumnDefinition' 'Width' '*,8,320') 'Analyzer waveform/inspector gutter'
+Assert-True (Has-Definitions 'Grid.ColumnDefinitions' 'ColumnDefinition' 'Width' '*,8,320') 'overlay aligns with outer explorer gutter'
+Assert-True (Has-Definitions 'Grid.RowDefinitions' 'RowDefinition' 'Height' '0.82*,8,1.18*') 'Analyzer phasor/measurement gap'
+
+# All five customer-facing workspaces share the same horizontal or vertical rhythm.
+Assert-True (Has-Definitions 'Grid.RowDefinitions' 'RowDefinition' 'Height' 'Auto,8,Auto,8,Auto,Auto,*') 'Diagnostics primary vertical gutters'
+Assert-True (Has-Definitions 'Grid.ColumnDefinitions' 'ColumnDefinition' 'Width' '*,8,400') 'GOOSE timeline/inspector gutter'
+Assert-True (Has-Definitions 'Grid.RowDefinitions' 'RowDefinition' 'Height' 'Auto,8,Auto,8,*') 'Timing/PTP major sections'
+Assert-True (Has-Definitions 'Grid.ColumnDefinitions' 'ColumnDefinition' 'Width' '1.15*,8,1.1*,8,1.2*') 'Timing/PTP header-card gutters'
+Assert-True (Has-Definitions 'Grid.ColumnDefinitions' 'ColumnDefinition' 'Width' '1.1*,8,1.4*') 'Timing/PTP evidence gutter'
+Assert-True (Has-Definitions 'Grid.RowDefinitions' 'RowDefinition' 'Height' 'Auto,8,*') 'SCL hero/body vertical gutter'
+Assert-True (Has-Definitions 'Grid.ColumnDefinitions' 'ColumnDefinition' 'Width' '1.18*,8,0.92*') 'SCL catalog/inspector gutter'
+
+# Standardize padding via named tokens, not brittle per-tab copies.
+foreach ($rule in @(
+ @{ Name = 'WorkspacePanelInset'; Value = '12' },
+ @{ Name = 'WorkspaceHeroInset'; Value = '16,12' },
+ @{ Name = 'WorkspacePanelGapBelow'; Value = '0,0,0,8' }
+)) {
+ $nodes = $theme.SelectNodes("//w:Thickness[@x:Key='$($rule.Name)']", $ns)
+ Assert-True ($nodes.Count -eq 1) "one shared token: $($rule.Name)"
+ Assert-True ($nodes[0].InnerText.Trim() -eq $rule.Value) "$($rule.Name) must be $($rule.Value)"
+}
+
+$styles = $theme.SelectNodes("//w:Style[@x:Key='DenseGroupBorderStyle']/w:Setter[@Property='Padding']", $ns)
+Assert-True ($styles.Count -eq 1 -and $styles[0].GetAttribute('Value') -eq '{StaticResource WorkspacePanelInset}') 'shared primary panel padding'
+$source = Get-Content -LiteralPath $windowPath -Raw
+$diagnosticsStart = $source.IndexOf('