diff --git a/.claude/skills/run-socktuner/SKILL.md b/.claude/skills/run-socktuner/SKILL.md
index be03eb1..b329739 100644
--- a/.claude/skills/run-socktuner/SKILL.md
+++ b/.claude/skills/run-socktuner/SKILL.md
@@ -1,6 +1,6 @@
---
name: run-socktuner
-description: Build, launch, drive, screenshot and probe the SockTuner WPF desktop app on Windows. Use when asked to run, start, open, screenshot, inspect the UI of, or capture an inventory from SockTuner, or to verify a change in the real app rather than in tests.
+description: Build, launch, drive and screenshot the SockTuner WPF desktop app on Windows. Use when asked to run, start, open, screenshot, inspect the UI of, or verify a change in the real app rather than in tests.
---
# Run SockTuner
@@ -33,7 +33,6 @@ powershell.exe -NoProfile -File .claude/skills/run-socktuner/driver.ps1 "` | Selects a tab by name (substring match) |
| `shot [path]` | PNG of the window → `.claude/skills/run-socktuner/shots/` |
-| `probe` | Read-only inventory dump to JSON on the Desktop |
| `stop` | Kills the process |
A full verified cycle:
@@ -82,19 +81,12 @@ the saved position no longer lands on an attached monitor.
`Tuning plan` used to be last; it now sits with the other surfaces that act. The reference
links moved out of `Preferences` into `Tools & references`.
-## Inventory without the GUI
+## Compatibility reports
-`probe` runs the app's own `--probe` mode: a read-only capture of the whole inventory,
-redacted (`machineName` masked, no addresses), written as JSON to the Desktop. It changes
-nothing on the machine. This is the fastest way to inspect what the app *sees* without
-touching the UI:
-
-```bash
-powershell.exe -NoProfile -File .claude/skills/run-socktuner/driver.ps1 probe
-# OK probe -> C:\Users\\Desktop\socktuner-probe-20260828-022443.json (410.4 KB)
-```
-
-Committed probe corpora live in `alpha-tester-output/`.
+Compatibility reports are intentionally created only through the visible UI flow under
+**Preferences → Help improve SockTuner**. The user reviews the complete JSON before saving
+and explicitly chooses whether to open the GitHub issue form. The agent driver does not
+bypass that consent flow.
## Tests
@@ -102,21 +94,19 @@ Committed probe corpora live in `alpha-tester-output/`.
dotnet test SockTuner.sln
```
-659 pass, 12 skipped. The 12 skipped are read-only live-inventory checks against the real
-adapters and the two device-level settings; they are safe on a normal desktop and mutate
+The default suite reports its current pass/skip counts. Skipped tests are read-only
+live-inventory checks against real adapters; they are safe on a normal desktop and mutate
nothing:
```bash
SOCKTUNER_LIVE_INVENTORY=1 dotnet test SockTuner.sln
```
-671 pass, 0 skipped.
-
## Never run these on a real machine
`--verify-tcp-writes` **writes to the live TCP stack** and `--verify-device-writes`
**enables and disables a real adapter, writes PnPCapabilities and creates a QoS policy**.
-Both are gated behind `SOCKTUNER_VM_WRITE_TEST=1` so a mistyped `--probe` cannot trigger
+Both are gated behind `SOCKTUNER_VM_WRITE_TEST=1`, so an argument alone cannot trigger
them, and both belong in a disposable VM only. The driver deliberately exposes no command
for either.
@@ -161,13 +151,6 @@ path is skipped — the run refuses to disable the adapter carrying the default
- **Screenshot tooling downscales.** A desktop-capture MCP may return an image scaled from
3640x2144 to ~1833x1080, so coordinates read off that image are wrong by a ~1.99 factor.
`driver.ps1 shot` captures the window rect at native size and sidesteps this.
-- **`--probe` used to end on a modal.** It now attaches to the calling console, prints the
- report path there and exits on its own; the message box is kept only for someone who
- double-clicked the exe and has no console to read. The driver still waits for the file
- before returning, which is what makes it reliable, but it no longer has to kill a process
- left holding a dialog nobody could dismiss.
-- **Probe takes longer than you expect.** A full inventory capture exceeded 6 s here, so a
- fixed sleep is not enough; the driver polls for up to 120 s.
- **`pwsh` 7 cannot load `UIAutomationClient`.** Use `powershell.exe`.
- **A stale VM registration breaks every `-Name` Hyper-V cmdlet on this host.** `Get-VM`,
`Checkpoint-VM -Name`, `Copy-VMFile -Name` and friends enumerate all VMs first, hit the
@@ -176,10 +159,9 @@ path is skipped — the run refuses to disable the adapter carrying the default
- **PowerShell Direct runs over VMBus, not the network.** That is what makes it safe to
disable an adapter in the guest from the host: the session survives losing all guest
networking. It does need guest credentials; there is no passwordless path.
-- **A console-mode run has no console over PowerShell Direct.** `--probe` and the verify
+- **A verification-mode run has no console over PowerShell Direct.** The verification
modes attach to the caller's console when there is one and fall back to a message box when
- there is not — and over PSDirect there is not, so the modal blocks forever. Start the
- process detached and poll for the JSON, which is written before the message appears.
+ there is not. Start them detached in the disposable VM and poll for their JSON result.
## Troubleshooting
@@ -188,6 +170,4 @@ path is skipped — the run refuses to disable the adapter carrying the default
| `SockTuner window not found. Run "launch" first.` | The app is not running, or it was started as a shell child and already died. Re-run `launch`. |
| `Not built. Run: driver.ps1 build` | The Release exe is missing. Run `build`. |
| `No tab matching ''` | Run `tabs` for the exact names; matching is substring, case-insensitive. |
-| `Probe produced no JSON on the Desktop within 120s.` | Check for a stuck `SockTuner probe` process: `Get-Process SockTuner`. Kill it and retry. |
| `Could not raise the SockTuner window; another window is on top…` | Something is covering the app and Windows refused the foreground handover. Minimise it, or click the SockTuner window once, then re-run `shot`. |
-| A leftover window titled `SockTuner probe` | An earlier probe attempt was interrupted. `powershell.exe -NoProfile -File .claude/skills/run-socktuner/driver.ps1 stop` clears all SockTuner processes. |
diff --git a/.claude/skills/run-socktuner/driver.ps1 b/.claude/skills/run-socktuner/driver.ps1
index ece3f42..2d2f515 100644
--- a/.claude/skills/run-socktuner/driver.ps1
+++ b/.claude/skills/run-socktuner/driver.ps1
@@ -152,33 +152,6 @@ switch ($Command) {
"OK shot ${w}x${ht} -> $path"
}
- # Read-only inventory dump to JSON. Ends on a modal this driver cannot click, so the
- # process is stopped once the file lands. Nothing on the machine is changed.
- 'probe' {
- if (-not (Test-Path $Exe)) { throw "Not built. Run: driver.ps1 build" }
- $before = Get-Date
- $desktop = [Environment]::GetFolderPath('Desktop')
- $proc = Start-Process -FilePath $Exe -ArgumentList '--probe' -WorkingDirectory (Split-Path $Exe) -PassThru
- # The JSON is written BEFORE the modal appears, so wait for the file and then close
- # that exact process. Do not try to click the dialog: it is a Win32 #32770 whose
- # buttons this build does not expose to UI Automation, so a UIA click never lands
- # and the process is left alive holding a modal on the user's desktop.
- $json = $null
- $deadline = (Get-Date).AddSeconds(120)
- while ((Get-Date) -lt $deadline) {
- Start-Sleep -Milliseconds 750
- $json = Get-ChildItem $desktop -Filter 'socktuner-probe-*.json' -ErrorAction SilentlyContinue |
- Where-Object { $_.LastWriteTime -ge $before } |
- Sort-Object LastWriteTime -Descending | Select-Object -First 1
- if ($json) { break }
- if ($proc.HasExited) { break }
- }
- if (-not $json) { throw 'Probe produced no JSON on the Desktop within 120s.' }
- Start-Sleep -Milliseconds 500
- if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue }
- "OK probe -> $($json.FullName) ($([math]::Round($json.Length/1KB,1)) KB)"
- }
-
'stop' {
Get-Process SockTuner -ErrorAction SilentlyContinue | Stop-Process -Force
'OK stopped'
@@ -193,7 +166,6 @@ SockTuner driver — commands:
tabs list navigation tabs, * marks the selected one
select "" select a tab by name (substring match)
shot [path] PNG of the window -> .claude/skills/run-socktuner/shots/
- probe --probe read-only inventory dump to JSON on the Desktop
stop kill the process
'@
}
diff --git a/.github/ISSUE_TEMPLATE/compatibility-report.yml b/.github/ISSUE_TEMPLATE/compatibility-report.yml
new file mode 100644
index 0000000..612fc13
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/compatibility-report.yml
@@ -0,0 +1,25 @@
+name: Hardware compatibility report
+description: Share a privacy-safe SockTuner capability report from real hardware.
+title: "Compatibility: "
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thank you for helping validate SockTuner on real hardware.
+
+ In SockTuner, open **Preferences → Help improve SockTuner → Create compatibility report**. Review the plain-text JSON file, then drag it into the field below. The report excludes all current setting values, persistent device identifiers, network addresses, paths, machine name, and exact timestamps.
+ - type: textarea
+ id: report
+ attributes:
+ label: Compatibility report
+ description: Drag the generated SockTuner-compatibility.json file here.
+ placeholder: Drag and drop SockTuner-compatibility.json here.
+ validations:
+ required: true
+ - type: checkboxes
+ id: consent
+ attributes:
+ label: Consent
+ options:
+ - label: I reviewed the generated JSON and consent to publishing it in this GitHub issue.
+ required: true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 165780f..1540dcf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,6 +36,11 @@ jobs:
- name: Verify formatting
run: dotnet format SockTuner.sln --no-restore --verify-no-changes
+ - name: Verify capability archive
+ run: |
+ python -m unittest tools/test_build_probe_archive.py
+ python tools/build-probe-archive.py --check
+
- name: Build
run: dotnet build SockTuner.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true
diff --git a/.gitignore b/.gitignore
index 66aae27..248ca80 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@
**/obj/
artifacts/
TestResults/
+__pycache__/
+*.py[cod]
# IDE and user state
.vs/
diff --git a/README.md b/README.md
index 19cb86b..71ca1f2 100644
--- a/README.md
+++ b/README.md
@@ -18,46 +18,22 @@
> [!WARNING]
-> **SockTuner is in active development (alpha) and is NOT ready for production use.**
-> The project is at **Step 7b of 10: NIC and driver controls**. This build **can change live network settings**. Applying a NIC property restarts that adapter and briefly drops its link — never apply over a remote session on the adapter you are changing, and be able to recover the machine's network without remote access. Every change is snapshotted, verified by read-back, and reversible from the audit history, but treat it as alpha software.
+> **SockTuner is a community diagnostic alpha, not production software.** Read-only inventory and diagnostics are the primary public surface. Tuning is experimental and can change live network settings. Applying a NIC property restarts that adapter and briefly drops its link — never do this through the connection being changed. Every write requires consent and elevation, is snapshotted, verified by read-back, audited, and reversible.
SockTuner is intended for tweakers, technicians, competitive gamers, system integrators, and power users who need one place to inspect and control the Windows networking stack, network adapters, and NIC driver settings.
It is not a generic “make my ping lower” button. SockTuner will show the current value, proposed value, scope, expected trade-off, restart requirement, and rollback data for every change.
-## Help wanted: hardware capability probe
+## Help improve hardware compatibility
-SockTuner exposes only settings that the NIC driver actually advertises. Virtual machines do not expose real driver properties, so we are collecting capability reports from real hardware — especially **Intel I219/I225/I226** and **Realtek RTL8111/RTL8125** adapters.
+SockTuner exposes only settings that the installed NIC driver actually advertises. We need reports from real hardware — especially **Intel I219/I225/I226**, **Realtek RTL8111/RTL8125**, Killer/Qualcomm, Broadcom, and Marvell adapters.
-If you have one of these NICs, you can contribute in two minutes with the built-in read-only probe. **The probe changes nothing on your PC** — it only reads the same inventory the app already displays.
+1. Download and extract the latest Windows x64 build from [Releases](https://github.com/PrimeBuild-pc/SockTuner/releases).
+2. Refresh the inventory, then open **Preferences → Help improve SockTuner**.
+3. Choose **Create compatibility report**, review the plain-text JSON, and let SockTuner open the dedicated GitHub issue form.
+4. Attach the JSON file to the issue.
-### How to run the probe
-
-1. Download the latest `SockTuner-*-win-x64.zip` from [Releases](https://github.com/PrimeBuild-pc/SockTuner/releases) and extract it.
-2. The build is currently **unsigned**, so Windows SmartScreen will warn you: click **More info → Run anyway**. You can inspect the source and build it yourself with `dotnet publish` if you prefer.
-3. Open a terminal in the extracted folder (File Explorer address bar → type `cmd` → Enter) and run:
- ```
- SockTuner.exe --probe
- ```
-4. A dialog confirms the report location: `socktuner-probe-.json` on your **Desktop**.
-
-### What the probe collects
-
-| Included (hardware identity) | Masked (personal data) |
-| --- | --- |
-| NIC description, driver provider/version/date, INF name | Machine name |
-| PCI vendor/device ID, reported as the driver's component ID (`PCI\VEN_xxxx&DEV_xxxx`) | IP addresses, gateways, DNS servers, routes |
-| NDIS advanced properties: keyword, current value, default, type, valid ranges/enums | MAC address (only the vendor OUI prefix is kept) |
-| OS version, admin state, CPU count | User-assigned values (e.g. a custom `NetworkAddress` MAC) |
-
-The report is a plain-text JSON file — open it in any editor and check it yourself before sending.
-
-### How to send your report
-
-- **GitHub:** open an [issue](https://github.com/PrimeBuild-pc/SockTuner/issues/new) titled `Probe report: ` and attach the JSON file.
-- **Discord:** open a ticket on our Discord server (invite link in the repository sidebar) and attach the JSON file.
-
-Include your Windows version if you know it. Thank you!
+The report contains the adapter model, driver version, OS version, and driver-advertised keywords and constraints. It excludes all current setting values, persistent adapter/PNP identifiers, MAC and IP addresses, routes, DNS servers, INF and user paths, machine name, and exact timestamps. Creating it is read-only and changes nothing on the PC.
## Product goals
@@ -104,16 +80,11 @@ The reference scripts in this private workspace are research inputs, not product
## Current stage
-- Architecture and product scope approved.
-- Step 1 is complete: the dark Metro shell, application icon, OS/adapter inventory, driver-advertised NDIS discovery, filtering/copy, and DPI validation pass.
-- Native IPv4/IPv6 routes and metrics, interface indexes/MTU/DNS, network profiles and bindings, NIC counters, TCP templates, QoS policies, global/RSS/RSC/LSO/checksum/USO/URO offload state, Winsock catalog, bounded structured logs, full/redacted snapshot export, retention preferences, and log export provide the Step 2 inventory.
-- Steps 1–2 now pass inventory, export, search/copy, dark-theme, and 100–200% WPF DPI validation.
-- Step 3 adds explicit quick/standard/extended diagnostics, concurrent boundary probes, full statistics/timelines, route/MTU/counter evidence, and bounded continuous monitoring.
-- Step 4 includes versioned JSON, self-contained offline HTML, bounded local history, redacted history export, identical-parameter before/after comparison, and multi-run trends.
-- Step 5 is complete with an allowlisted dry-run cart, exact rollback preview, bounded audit history, deterministic transactions, failure injection, and a strict typed elevated-worker protocol.
-- Step 6 is complete: the first two low-disruption MMCSS settings passed direct and published-worker apply/read/rollback/read gates on disposable Windows 10 22H2 and Windows 11 VMs. NIC/driver writes and the normal UI remain locked while Step 7 starts.
-- Step 7a added a read-only `--probe` mode: it writes a redacted capability report (`socktuner-probe-.json` on the Desktop) that collaborators with real Intel/Realtek NICs can share. Personal data is masked; driver identity, NDIS keywords, defaults, and valid ranges/enums are preserved. The probe changes nothing on the machine.
-- Step 7b unlocks live writes. The **Tuning plan** tab lists the properties the selected driver actually advertises, filtered by intent preset — latency, bandwidth, power and wake, Wi-Fi radio, VLAN and identity — with preview, apply, and exact rollback from the audit history. Capability reports so far cover Intel I226-V and Wireless-AC 3168, Realtek RTL8125, 8852CE and 8922AE, and MediaTek MT7925; the generated [capability archive index](alpha-tester-output/INDEX.md) is the authoritative list.
+- Native read-only inventory covers adapters, routes, interfaces, DNS, profiles, bindings, NIC counters, TCP templates, QoS, offloads, interrupts, Winsock, driver identity, and driver-advertised properties.
+- The complete network check measures each path boundary, latency/loss/jitter, DNS and TCP timing, path MTU, route quality, counter deltas, and game-specific playability; loaded-latency and throughput traffic remain separate opt-in tests.
+- Versioned reports, bounded local history, redacted exports, before/after comparison, trends, monitoring, and imported capture reports are available.
+- Experimental writes use one allowlisted tuning plan with dry run, consent, elevation, stale-state refusal, read-back verification, audit, visible recovery, and exact rollback.
+- Real-hardware coverage is crowdsourced through the in-app compatibility report. The generated [capability archive index](alpha-tester-output/INDEX.md) lists current Intel, Realtek, and MediaTek coverage.
## How a change is applied
@@ -127,4 +98,4 @@ The reference scripts in this private workspace are research inputs, not product
Changing network, registry, adapter, or driver settings can interrupt connectivity or reduce stability and throughput. Applying a NIC property restarts that adapter, which briefly drops the link — do not apply over a remote session on the adapter being changed. Test on a machine you can recover without remote access. SockTuner never invents a value or a range: it exposes only what the installed driver advertises, and treats any keyword it has not characterised as high risk.
-SockTuner is released under the [MIT License](LICENSE). The public contribution policy is still being defined; probe reports via issues or Discord are the best way to help right now.
+SockTuner is released under the [MIT License](LICENSE). Compatibility reports and bug reports are welcome through the dedicated GitHub issue forms.
diff --git a/SECURITY.md b/SECURITY.md
index 24f6c6c..3ee9eae 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -32,7 +32,7 @@ The privilege boundary is the interesting part, and it is the part worth attacki
or driver advertises.
- Anything that turns imported, untrusted data — a capture report, an exported diagnostic, a probe
report — into code execution, a file path, or a write.
-- Anything that causes an exported report or a probe report to contain data the redaction claims to
+- Anything that causes an exported report or compatibility report to contain data the redaction claims to
have removed.
- Anything that leaves the machine unable to reach the network with no path back to the captured
state.
diff --git a/alpha-tester-output/INDEX.md b/alpha-tester-output/INDEX.md
index 586a625..458bf3e 100644
--- a/alpha-tester-output/INDEX.md
+++ b/alpha-tester-output/INDEX.md
@@ -1,6 +1,6 @@
# Capability archive
-Redacted `--probe` reports, split one file per adapter model. This is the reference for
+Privacy-safe compatibility reports, split one file per adapter model. This is the reference for
which hardware SockTuner has real capability data for, and therefore which keywords its
catalog is characterised against. Regenerate with `python tools/build-probe-archive.py`.
@@ -9,8 +9,8 @@ collapses to a single entry; the richer record wins when a model appears in seve
Raw reports are kept under `reports/` for provenance.
`capabilities` carries the structured driver constraints (valid values, min/max/step,
-default) that the tuning surface uses. Reports captured before schema 12 have only
-`ndisProperties`; re-running the probe on that hardware upgrades the entry.
+default) that the tuning surface uses. Older probe reports may have only
+`ndisProperties`; creating a new compatibility report on that hardware upgrades the entry.
## Physical adapters
@@ -52,10 +52,10 @@ Hardware with no report yet, roughly in order of how common it is among the targ
### Adding a report
-1. Run `SockTuner.exe --probe` on the machine; it writes a redacted report to the Desktop.
-2. Drop the file into this folder, or pass its folder to the build script.
+1. In SockTuner, open Preferences → Help improve SockTuner → Create compatibility report.
+2. Review the JSON, then drop it into this folder or pass its folder to the build script.
3. Run `python tools/build-probe-archive.py` to file it, split it per model and refresh this index.
4. Run `python tools/build-probe-archive.py --check` to verify regeneration produces no diff.
-Reports contain no machine name, IP addresses, routes or full MAC addresses; the vendor OUI
-prefix and driver identity are kept deliberately, since they are the point of the archive.
+Current reports contain no persistent device identifiers, network addresses, user paths,
+machine name or exact timestamps. Hardware model and driver identity are kept deliberately.
diff --git a/docs/JACKPOTS_ZENIT_NDIS_CANDIDATES.md b/docs/JACKPOTS_ZENIT_NDIS_CANDIDATES.md
index 9dd59ba..69214eb 100644
--- a/docs/JACKPOTS_ZENIT_NDIS_CANDIDATES.md
+++ b/docs/JACKPOTS_ZENIT_NDIS_CANDIDATES.md
@@ -20,7 +20,7 @@
>
> The ~90 properties below are the *superset the Zenit tool writes blindly by
> name*. They are kept here only as a
-> **watch-list**: if a future probe report shows one on real hardware, promote it into the
+> **watch-list**: if a future compatibility report shows one on real hardware, promote it into the
> corpus with a real range/enum; until then it is a name to recognise, never a value to apply.
> **The Zenit "forced value" columns are the tool's opinion, unverified — do not import them.**
@@ -42,7 +42,7 @@ SockTuner's existing risk model:
## A. Standardized (`*`) keywords — legitimate candidates when the driver advertises them
-Cross-check each against `NicKeywordCatalog`; add the ones a probe report confirms.
+Cross-check each against `NicKeywordCatalog`; add the ones a compatibility report confirms.
### Offloads (checksum / LSO / USO / RSC / IPsec / encap)
| Keyword | Zenit value | Note |
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index cbbf63e..916355a 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -149,7 +149,7 @@ Exit criteria:
Step 7 is split into two gates:
-- **7a — capability collection (no disposable hardware required):** the read-only `--probe` mode captures a redacted inventory from collaborator PCs with real Intel/Realtek NICs. Personal data (machine name, IPs, MAC device octets, user-assigned values) is masked; hardware identity (driver, PNP ID, NDIS keywords, defaults, ranges/enums) is preserved. Probe reports seed the capability matrix and fake-platform fixtures.
+- **7a — capability collection (no disposable hardware required):** the in-app compatibility-report flow captures a privacy-safe inventory from collaborator PCs with real NICs and opens a dedicated GitHub issue form. All current setting values, persistent adapter/PNP identifiers, addresses, paths, machine name, and exact timestamps are omitted; adapter model, driver version, NDIS keywords, defaults, ranges and enums seed the capability matrix and fake-platform fixtures.
- **7b — write unlock (alpha):** NIC/driver writes are enabled behind versioned in-app consent, UAC elevation, driver-advertised validation re-read inside the elevated worker, and a typed confirmation for high-risk or experimental changes. The static per-setting allowlist is retained for registry-backed catalog entries only; for NIC properties the driver's own advertised constraints are the allowlist, so an unsupported keyword or value cannot be planned or written. Capability coverage is currently Intel I226-V and Wireless-AC 3168, Realtek RTL8125 and 8852CE, and MediaTek MT7925 — keywords outside that corpus are exposed but reported as high risk and uncharacterised.
Deliverables:
@@ -220,7 +220,7 @@ Exit criteria:
The queue defines completion gates. A read-only prerequisite from the next item may land in the same reviewed increment, but no step is marked complete and no writable scope unlocks until every earlier exit criterion passes.
-1. **P1 / Step 7b:** broaden real-hardware validation of capability-advertised NIC and driver controls, and grow the characterised-keyword corpus as more probe reports arrive.
+1. **P1 / Step 7b:** broaden real-hardware validation of capability-advertised NIC and driver controls, and grow the characterised-keyword corpus as more compatibility reports arrive.
2. **P2 / Step 10:** the gates that need what this project does not yet have — a code-signing certificate, and real machines across the Windows and locale matrix. Listed below rather than left implicit, because none of them can be closed by writing code.
Steps 8 and 9 are complete. Step 8 closed with three things rather than a large surface. The interface metric joined MTU as a per-interface override whose restore is the *removal* of the value, since absent means Windows derives the metric from link speed — writing a number back would be a different setting that happened to match, not a rollback. A remote-session guard now stands in front of every change that drops the link: it does not work out which adapter carries the session, so it assumes this one might and forces the typed confirmation, because being wrong in that direction costs a sentence and being wrong in the other direction costs the machine. An unrecognised restart requirement is treated as disruptive, and a test fails if one appears that nobody classified.
diff --git a/src/SockTuner/App.xaml.cs b/src/SockTuner/App.xaml.cs
index e2a06bd..7f2b94f 100644
--- a/src/SockTuner/App.xaml.cs
+++ b/src/SockTuner/App.xaml.cs
@@ -18,12 +18,6 @@ protected override void OnStartup(StartupEventArgs e)
return;
}
- if (e.Args.Length == 1 && string.Equals(e.Args[0], "--probe", StringComparison.Ordinal))
- {
- Shutdown(RunProbe());
- return;
- }
-
if (e.Args.Length == 1 && string.Equals(e.Args[0], VerifyTcpWritesArgument, StringComparison.Ordinal))
{
Shutdown(RunTcpWriteVerification());
@@ -55,8 +49,7 @@ protected override void OnStartup(StartupEventArgs e)
///
/// Set to 1 in the guest to arm the write verification. The argument alone is not enough: this
- /// mode writes to the live TCP stack, so it must be impossible to trigger by mistyping --probe
- /// on a real desktop.
+ /// mode writes to the live TCP stack, so an argument alone must not arm it on a real desktop.
///
internal const string VerifyTcpWritesGate = "SOCKTUNER_VM_WRITE_TEST";
@@ -171,42 +164,13 @@ private static int RunDeviceWriteVerification()
}
}
- // Read-only hardware capability probe for collaborators: captures the inventory,
- // redacts personal data, and writes a shareable JSON report. Mutates nothing.
- private static int RunProbe()
- {
- try
- {
- var snapshot = new SystemInventoryService().Capture();
- var path = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.Desktop),
- $"socktuner-probe-{DateTime.Now:yyyyMMdd-HHmmss}.json");
- File.WriteAllText(path, SnapshotExporter.Serialize(snapshot, probe: true));
- Report(
- "SockTuner probe",
- $"Probe report saved to:{Environment.NewLine}{path}{Environment.NewLine}{Environment.NewLine}"
- + "Nothing on this PC was changed. Share this file with the SockTuner team.",
- MessageBoxImage.Information);
- return 0;
- }
- catch (Exception exception)
- {
- Report("SockTuner probe", $"Probe failed: {exception.Message}", MessageBoxImage.Error);
- return 1;
- }
- }
-
///
- /// Reports the outcome of a console-invoked mode. The README tells a contributor to run the
- /// probe from a terminal, so the terminal is where the answer belongs: this writes there when
- /// there is one to write to. The message box is kept for the person who double-clicked the exe
- /// and has nowhere else to read it — that case has no console to attach to, which is exactly
- /// the condition tested here.
+ /// Reports the outcome of a console-invoked verification mode. This writes to the parent
+ /// terminal when one exists; otherwise a message box is the only visible channel.
///
///
/// A WPF process is built without a console, so it has to borrow the parent's. When it cannot,
- /// nothing has been written and the modal is the only remaining channel. This is also what
- /// stopped an automated probe run from leaving a modal nobody could dismiss.
+ /// nothing has been written and the modal is the only remaining channel.
///
private static void Report(string title, string message, MessageBoxImage severity)
{
diff --git a/src/SockTuner/Assets/i18n/translations.json b/src/SockTuner/Assets/i18n/translations.json
index 73a5cb8..22bbf7b 100644
--- a/src/SockTuner/Assets/i18n/translations.json
+++ b/src/SockTuner/Assets/i18n/translations.json
@@ -3173,5 +3173,170 @@
"es": "{0}{1}{2}{3}Enlace {4}, direcciones {5}, puertas de enlace {6}.",
"ru": "{0}{1}{2}{3}Канал {4}, адреса {5}, шлюзы {6}.",
"zh-Hans": "{0}{1}{2}{3}链路 {4},地址 {5},网关 {6}。"
+ },
+ "Create compatibility report…": {
+ "es": "Crear informe de compatibilidad…",
+ "ru": "Создать отчёт о совместимости…",
+ "zh-Hans": "创建兼容性报告…"
+ },
+ "Create compatibility report": {
+ "es": "Crear informe de compatibilidad",
+ "ru": "Создать отчёт о совместимости",
+ "zh-Hans": "创建兼容性报告"
+ },
+ "Help improve SockTuner": {
+ "es": "Ayuda a mejorar SockTuner",
+ "ru": "Помогите улучшить SockTuner",
+ "zh-Hans": "帮助改进 SockTuner"
+ },
+ "Create a privacy-safe hardware capability report. It includes adapter model, driver version and supported settings; it excludes device IDs, addresses, paths, machine name and exact timestamps.": {
+ "es": "Crea un informe de capacidades de hardware que protege la privacidad. Incluye el modelo del adaptador, la versión del controlador y los ajustes compatibles; excluye identificadores de dispositivo, direcciones, rutas, el nombre del equipo y marcas de tiempo exactas.",
+ "ru": "Создайте безопасный для конфиденциальности отчёт о возможностях оборудования. Он включает модель адаптера, версию драйвера и поддерживаемые параметры, но исключает идентификаторы устройств, адреса, пути, имя компьютера и точные метки времени.",
+ "zh-Hans": "创建保护隐私的硬件功能报告。报告包含适配器型号、驱动程序版本和支持的设置;不包含设备 ID、地址、路径、计算机名称和精确时间戳。"
+ },
+ "Refresh inventory before creating a compatibility report.": {
+ "es": "Actualiza el inventario antes de crear un informe de compatibilidad.",
+ "ru": "Обновите инвентаризацию перед созданием отчёта о совместимости.",
+ "zh-Hans": "请先刷新清单,再创建兼容性报告。"
+ },
+ "The report includes adapter model, driver version and supported settings. It excludes persistent device IDs, addresses, paths, machine name and exact timestamps. Save it for review?": {
+ "es": "El informe incluye el modelo del adaptador, la versión del controlador y los ajustes compatibles. Excluye identificadores persistentes de dispositivo, direcciones, rutas, el nombre del equipo y marcas de tiempo exactas. ¿Guardarlo para revisarlo?",
+ "ru": "Отчёт включает модель адаптера, версию драйвера и поддерживаемые параметры. Он исключает постоянные идентификаторы устройств, адреса, пути, имя компьютера и точные метки времени. Сохранить его для проверки?",
+ "zh-Hans": "报告包含适配器型号、驱动程序版本和支持的设置,不包含持久设备 ID、地址、路径、计算机名称和精确时间戳。是否保存以供检查?"
+ },
+ "SockTuner compatibility report (*.json)|*.json": {
+ "es": "Informe de compatibilidad de SockTuner (*.json)|*.json",
+ "ru": "Отчёт о совместимости SockTuner (*.json)|*.json",
+ "zh-Hans": "SockTuner 兼容性报告 (*.json)|*.json"
+ },
+ "Compatibility report saved to {0}.": {
+ "es": "Informe de compatibilidad guardado en {0}.",
+ "ru": "Отчёт о совместимости сохранён в {0}.",
+ "zh-Hans": "兼容性报告已保存到 {0}。"
+ },
+ "Compatibility report saved.": {
+ "es": "Informe de compatibilidad guardado.",
+ "ru": "Отчёт о совместимости сохранён.",
+ "zh-Hans": "兼容性报告已保存。"
+ },
+ "The JSON file is plain text. Inspect it if you wish, then attach it to the GitHub issue. Open the issue form now?": {
+ "es": "El archivo JSON es texto sin formato. Revísalo si quieres y adjúntalo a la incidencia de GitHub. ¿Abrir ahora el formulario?",
+ "ru": "Файл JSON является обычным текстом. При желании проверьте его и прикрепите к задаче GitHub. Открыть форму задачи сейчас?",
+ "zh-Hans": "JSON 文件是纯文本。你可以先检查内容,然后将其附加到 GitHub 议题。现在打开议题表单吗?"
+ },
+ "Compatibility report saved": {
+ "es": "Informe de compatibilidad guardado",
+ "ru": "Отчёт о совместимости сохранён",
+ "zh-Hans": "兼容性报告已保存"
+ },
+ "GitHub issue form opened. Attach the saved JSON file before submitting.": {
+ "es": "Se abrió el formulario de incidencias de GitHub. Adjunta el archivo JSON guardado antes de enviarlo.",
+ "ru": "Форма задачи GitHub открыта. Перед отправкой прикрепите сохранённый файл JSON.",
+ "zh-Hans": "GitHub 议题表单已打开。提交前请附加已保存的 JSON 文件。"
+ },
+ "Compatibility report failed: {0}": {
+ "es": "Error al crear el informe de compatibilidad: {0}",
+ "ru": "Не удалось создать отчёт о совместимости: {0}",
+ "zh-Hans": "兼容性报告失败:{0}"
+ },
+ "Running application": {
+ "es": "Aplicación en ejecución",
+ "ru": "Запущенное приложение",
+ "zh-Hans": "正在运行的应用程序"
+ },
+ "Choose a running application": {
+ "es": "Elegir una aplicación en ejecución",
+ "ru": "Выбрать запущенное приложение",
+ "zh-Hans": "选择正在运行的应用程序"
+ },
+ "Recovery": {
+ "es": "Recuperación",
+ "ru": "Восстановление",
+ "zh-Hans": "恢复"
+ },
+ "No successful change is waiting to be undone.": {
+ "es": "No hay ningún cambio correcto pendiente de deshacer.",
+ "ru": "Нет успешных изменений, ожидающих отмены.",
+ "zh-Hans": "没有等待撤销的成功更改。"
+ },
+ "Undo last successful change": {
+ "es": "Deshacer el último cambio correcto",
+ "ru": "Отменить последнее успешное изменение",
+ "zh-Hans": "撤销上次成功更改"
+ },
+ "Open recovery history": {
+ "es": "Abrir historial de recuperación",
+ "ru": "Открыть журнал восстановления",
+ "zh-Hans": "打开恢复历史记录"
+ },
+ "Last reversible change: {0:g} · {1} setting(s).": {
+ "es": "Último cambio reversible: {0:g} · {1} ajuste(s).",
+ "ru": "Последнее обратимое изменение: {0:g} · параметров: {1}.",
+ "zh-Hans": "上次可撤销更改:{0:g} · {1} 个设置。"
+ },
+ "Undo the successful change from {0:g} ({1} setting(s))?": {
+ "es": "¿Deshacer el cambio correcto de {0:g} ({1} ajuste(s))?",
+ "ru": "Отменить успешное изменение от {0:g} (параметров: {1})?",
+ "zh-Hans": "撤销 {0:g} 的成功更改({1} 个设置)?"
+ },
+ "Run complete check": {
+ "es": "Ejecutar comprobación completa",
+ "ru": "Запустить полную проверку",
+ "zh-Hans": "运行完整检查"
+ },
+ "Runs the read-only inventory and path checks together. Loaded-latency and throughput tests stay separate because they generate traffic.": {
+ "es": "Ejecuta conjuntamente el inventario y las comprobaciones de ruta de solo lectura. Las pruebas de latencia con carga y rendimiento permanecen separadas porque generan tráfico.",
+ "ru": "Вместе запускает инвентаризацию и проверку пути только для чтения. Тесты задержки под нагрузкой и пропускной способности остаются отдельными, поскольку создают трафик.",
+ "zh-Hans": "同时运行只读清单和路径检查。负载延迟与吞吐量测试会产生流量,因此保持独立。"
+ },
+ "Review compatibility report": {
+ "es": "Revisar informe de compatibilidad",
+ "ru": "Проверка отчёта о совместимости",
+ "zh-Hans": "检查兼容性报告"
+ },
+ "Review the complete JSON before saving": {
+ "es": "Revisa el JSON completo antes de guardarlo",
+ "ru": "Проверьте полный JSON перед сохранением",
+ "zh-Hans": "保存前检查完整 JSON"
+ },
+ "Only attach this file if you are comfortable publishing the contents in a GitHub issue.": {
+ "es": "Adjunta este archivo solo si aceptas publicar su contenido en una incidencia de GitHub.",
+ "ru": "Прикрепляйте этот файл, только если согласны опубликовать его содержимое в задаче GitHub.",
+ "zh-Hans": "仅当你愿意在 GitHub 议题中公开其内容时,才附加此文件。"
+ },
+ "Save report": {
+ "es": "Guardar informe",
+ "ru": "Сохранить отчёт",
+ "zh-Hans": "保存报告"
+ },
+ "Report saved, but the GitHub issue form could not be opened: {0}": {
+ "es": "El informe se guardó, pero no se pudo abrir el formulario de incidencias de GitHub: {0}",
+ "ru": "Отчёт сохранён, но форму задачи GitHub открыть не удалось: {0}",
+ "zh-Hans": "报告已保存,但无法打开 GitHub 议题表单:{0}"
+ },
+ "Create a privacy-safe hardware capability report. It includes adapter model, driver version and supported setting constraints; it excludes current values, device IDs, addresses, paths, machine name and exact timestamps.": {
+ "es": "Crea un informe de capacidades de hardware que protege la privacidad. Incluye el modelo del adaptador, la versión del controlador y las restricciones admitidas; excluye valores actuales, identificadores de dispositivo, direcciones, rutas, el nombre del equipo y marcas de tiempo exactas.",
+ "ru": "Создайте безопасный для конфиденциальности отчёт о возможностях оборудования. Он включает модель адаптера, версию драйвера и ограничения поддерживаемых параметров, но исключает текущие значения, идентификаторы устройств, адреса, пути, имя компьютера и точные метки времени.",
+ "zh-Hans": "创建保护隐私的硬件功能报告。报告包含适配器型号、驱动程序版本和支持的设置约束;不包含当前值、设备 ID、地址、路径、计算机名称和精确时间戳。"
+ },
+ "The report includes adapter model, driver version and supported setting constraints. It excludes all current values, persistent device IDs, addresses, paths, machine name and exact timestamps. Save it for review?": {
+ "es": "El informe incluye el modelo del adaptador, la versión del controlador y las restricciones admitidas. Excluye todos los valores actuales, identificadores persistentes de dispositivo, direcciones, rutas, el nombre del equipo y marcas de tiempo exactas. ¿Guardarlo para revisarlo?",
+ "ru": "Отчёт включает модель адаптера, версию драйвера и ограничения поддерживаемых параметров. Он исключает все текущие значения, постоянные идентификаторы устройств, адреса, пути, имя компьютера и точные метки времени. Сохранить его для проверки?",
+ "zh-Hans": "报告包含适配器型号、驱动程序版本和支持的设置约束,不包含任何当前值、持久设备 ID、地址、路径、计算机名称和精确时间戳。是否保存以供检查?"
+ },
+ "Undo the successful change from {0:g} ({1} setting(s))? Settings: {2}. Recovery can restart affected adapters and briefly interrupt connectivity.": {
+ "es": "¿Deshacer el cambio correcto de {0:g} ({1} ajuste(s))? Ajustes: {2}. La recuperación puede reiniciar los adaptadores afectados e interrumpir brevemente la conectividad.",
+ "ru": "Отменить успешное изменение от {0:g} (параметров: {1})? Параметры: {2}. Восстановление может перезапустить затронутые адаптеры и кратковременно прервать подключение.",
+ "zh-Hans": "撤销 {0:g} 的成功更改({1} 个设置)?设置:{2}。恢复可能会重启受影响的适配器并短暂中断连接。"
+ },
+ "Running applications could not be listed: {0}": {
+ "es": "No se pudieron enumerar las aplicaciones en ejecución: {0}",
+ "ru": "Не удалось получить список запущенных приложений: {0}",
+ "zh-Hans": "无法列出正在运行的应用程序:{0}"
+ },
+ "Confirm rollback": {
+ "es": "Confirmar reversión",
+ "ru": "Подтвердить откат",
+ "zh-Hans": "确认回滚"
}
}
diff --git a/src/SockTuner/MainWindow.xaml b/src/SockTuner/MainWindow.xaml
index 19be013..ed7783c 100644
--- a/src/SockTuner/MainWindow.xaml
+++ b/src/SockTuner/MainWindow.xaml
@@ -39,6 +39,7 @@
+
@@ -161,6 +162,16 @@
+
+
+
+
+
+
+
+
@@ -404,6 +415,13 @@
+
+
+
+
@@ -538,14 +556,14 @@
Tag="e.g. 7777" ToolTip="Only verifies TCP connection setup"
AutomationProperties.Name="Optional TCP port"/>
-
-
+
@@ -1315,6 +1333,17 @@
+
+
+
+
+
+
+
+
@@ -1333,7 +1362,7 @@
-
+
diff --git a/src/SockTuner/MainWindow.xaml.cs b/src/SockTuner/MainWindow.xaml.cs
index 07e056f..0f9d526 100644
--- a/src/SockTuner/MainWindow.xaml.cs
+++ b/src/SockTuner/MainWindow.xaml.cs
@@ -12,6 +12,7 @@
using SockTuner.Services.Collection;
using SockTuner.Services.Diagnosis;
using SockTuner.Services.Remediation;
+using SockTuner.Views;
namespace SockTuner;
@@ -20,6 +21,8 @@ public partial class MainWindow : Window
private const int UseImmersiveDarkMode = 20;
private const int UseImmersiveDarkModeBefore20H1 = 19;
private const int MonitorMaximumSamples = 1000;
+ private const string CompatibilityIssueUrl =
+ "https://github.com/PrimeBuild-pc/SockTuner/issues/new?template=compatibility-report.yml";
/// The catalogue, plus whatever tick rate an imported capture brought with it.
private readonly ObservableCollection _gameProfiles = new(GameProfiles.All);
@@ -112,6 +115,7 @@ public MainWindow()
ShowWriteState();
CaptureVerificationBaseline();
await RefreshInventoryAsync();
+ ShowRecoveryState();
};
SourceInitialized += (_, _) => ApplyDarkTitleBar();
RestoreWindowGeometry();
@@ -122,6 +126,7 @@ public MainWindow()
LoadInterruptAffinity();
};
ShowWriteState();
+ ShowRecoveryState();
WriteLog("app.started", "SockTuner UI started.");
}
@@ -397,6 +402,7 @@ private void ShowSnapshot(NetworkSnapshot snapshot)
? Loc.F($"{snapshot.WinsockProviders?.Count ?? 0} native protocol provider(s). Inspection only; repair remains separately gated.")
: Loc.F($"Winsock inventory partial: {snapshot.WinsockInventoryError}");
ApplyInventoryFilter();
+ ShowRecoveryState();
}
private async void RunDiagnostic_Click(object sender, RoutedEventArgs e) => await RunDiagnosticAsync();
@@ -586,6 +592,74 @@ private void SaveSnapshot(bool redact)
}
}
+ private void CreateCompatibilityReport_Click(object sender, RoutedEventArgs e)
+ {
+ if (_snapshot is null)
+ {
+ StatusText.Text = Loc.T("Refresh inventory before creating a compatibility report.");
+ return;
+ }
+
+ if (MessageBox.Show(
+ Loc.T("The report includes adapter model, driver version and supported setting constraints. It excludes all current values, persistent device IDs, addresses, paths, machine name and exact timestamps. Save it for review?"),
+ Loc.T("Create compatibility report"),
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Information) != MessageBoxResult.Yes)
+ {
+ return;
+ }
+
+ var report = CompatibilityReportExporter.Serialize(_snapshot);
+ var preview = new CompatibilityReportPreviewWindow(report) { Owner = this };
+ if (preview.ShowDialog() != true) return;
+
+ var dialog = new SaveFileDialog
+ {
+ Filter = Loc.T("SockTuner compatibility report (*.json)|*.json"),
+ DefaultExt = ".json",
+ AddExtension = true,
+ FileName = $"SockTuner-compatibility-{Guid.NewGuid():N}.json"
+ };
+ if (dialog.ShowDialog(this) != true) return;
+
+ try
+ {
+ File.WriteAllText(dialog.FileName, report);
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ ContributionStatusText.Text = Loc.F($"Compatibility report failed: {exception.Message}");
+ WriteLog("compatibility_report.failed", exception.Message);
+ return;
+ }
+
+ ContributionStatusText.Text = Loc.F($"Compatibility report saved to {dialog.FileName}.");
+ StatusText.Text = Loc.T("Compatibility report saved.");
+ WriteLog("compatibility_report.saved", "A privacy-safe compatibility report was saved.");
+
+ if (MessageBox.Show(
+ Loc.T("The JSON file is plain text. Inspect it if you wish, then attach it to the GitHub issue. Open the issue form now?"),
+ Loc.T("Compatibility report saved"),
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question) != MessageBoxResult.Yes)
+ {
+ return;
+ }
+
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(CompatibilityIssueUrl)
+ {
+ UseShellExecute = true
+ });
+ ContributionStatusText.Text = Loc.T("GitHub issue form opened. Attach the saved JSON file before submitting.");
+ }
+ catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or InvalidOperationException)
+ {
+ ContributionStatusText.Text = Loc.F($"Report saved, but the GitHub issue form could not be opened: {exception.Message}");
+ }
+ }
+
private void ExportReportJson_Click(object sender, RoutedEventArgs e) => SaveReport(html: false, redact: false);
private void ExportReportHtml_Click(object sender, RoutedEventArgs e) => SaveReport(html: true, redact: false);
private void ExportRedactedReportHtml_Click(object sender, RoutedEventArgs e) => SaveReport(html: true, redact: true);
@@ -2067,6 +2141,50 @@ private void ShowVerification(ApplyVerification verification)
/// goes to the tuning plan rather than being applied here, so it is previewed, confirmed,
/// verified by read-back and recorded where it can be rolled back.
///
+ private void QosRunningApplication_DropDownOpened(object sender, EventArgs e)
+ {
+ var names = new List();
+ try
+ {
+ foreach (var process in System.Diagnostics.Process.GetProcesses())
+ {
+ using (process)
+ {
+ try
+ {
+ names.Add(process.ProcessName);
+ }
+ catch (InvalidOperationException)
+ {
+ // The process exited between enumeration and reading its name.
+ }
+ }
+ }
+ }
+ catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or InvalidOperationException)
+ {
+ QosActionStatusText.Text = Loc.F($"Running applications could not be listed: {exception.Message}");
+ return;
+ }
+
+ QosRunningApplicationComboBox.ItemsSource = NormalizeApplicationNames(names);
+ }
+
+ private void QosRunningApplication_SelectionChanged(
+ object sender,
+ System.Windows.Controls.SelectionChangedEventArgs e)
+ {
+ if (QosRunningApplicationComboBox.SelectedItem is string application)
+ QosApplicationText.Text = application;
+ }
+
+ internal static IReadOnlyList NormalizeApplicationNames(IEnumerable names) => names
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .Select(name => name!.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? name : $"{name}.exe")
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Order(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
private void QueueQosPolicy_Click(object sender, RoutedEventArgs e)
{
var application = QosApplicationText.Text.Trim();
@@ -2124,6 +2242,24 @@ private void QueueQos(string name, string? value, string description)
private void OpenTuningPlan_Click(object sender, RoutedEventArgs e) => SelectTab("Tuning plan");
+ private async void UndoLastChange_Click(object sender, RoutedEventArgs e)
+ {
+ await TuningPlan.UndoLatestSuccessfulChangeAsync();
+ ShowRecoveryState();
+ }
+
+ private void DashboardCheckDrift_Click(object sender, RoutedEventArgs e)
+ {
+ TuningPlan.CheckForDrift();
+ SelectTab("Tuning plan");
+ }
+
+ private void ShowRecoveryState()
+ {
+ RecoverySummaryText.Text = TuningPlan.RecoverySummary;
+ UndoLastChangeButton.IsEnabled = TuningPlan.CanUndoLastChange;
+ }
+
///
/// Opens a Windows management console. Both targets are fixed strings in this method — nothing
/// the user or a report supplies reaches a process start.
@@ -2151,7 +2287,8 @@ private void SelectTab(string header)
{
var tab = InventoryTabs.Items
.OfType()
- .FirstOrDefault(item => string.Equals(item.Header?.ToString(), header, StringComparison.Ordinal));
+ .FirstOrDefault(item => string.Equals(
+ item.Tag as string ?? item.Header?.ToString(), header, StringComparison.Ordinal));
if (tab is not null) InventoryTabs.SelectedItem = tab;
}
diff --git a/src/SockTuner/Persistence/CompatibilityReportExporter.cs b/src/SockTuner/Persistence/CompatibilityReportExporter.cs
new file mode 100644
index 0000000..5f485ef
--- /dev/null
+++ b/src/SockTuner/Persistence/CompatibilityReportExporter.cs
@@ -0,0 +1,135 @@
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
+using SockTuner.Models;
+
+namespace SockTuner.Persistence;
+
+///
+/// A deliberately small, public hardware-capability report. Adapter identifiers are synthetic for
+/// this file, and network state, addresses, MACs, paths, timestamps and machine names never enter
+/// the serialized object.
+///
+public static class CompatibilityReportExporter
+{
+ public const int SchemaVersion = 1;
+
+ private sealed record CommunityDriver(
+ string Provider,
+ string Version,
+ string Date,
+ string ComponentId,
+ string NdisVersion,
+ uint Characteristics);
+
+ private sealed record CommunityAdapter(
+ Guid Id,
+ string Name,
+ string Description,
+ string InterfaceType,
+ CommunityDriver Driver,
+ IReadOnlyList NdisProperties,
+ IReadOnlyList Capabilities);
+
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ public static string Serialize(NetworkSnapshot snapshot)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ var capabilities = snapshot.AdapterCapabilities ?? [];
+ var adapters = snapshot.Adapters
+ .Where(adapter => adapter.Driver is not null
+ && adapter.Kind is AdapterKind.Physical or AdapterKind.DriverBacked)
+ .Select((adapter, index) => BuildAdapter(adapter, capabilities, index + 1))
+ .Where(adapter => adapter.NdisProperties.Count > 0 || adapter.Capabilities.Count > 0)
+ .ToArray();
+
+ return JsonSerializer.Serialize(new
+ {
+ schemaVersion = SchemaVersion,
+ reportType = "socktuner.compatibility",
+ toolVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown",
+ redacted = true,
+ privacy = new
+ {
+ persistentDeviceIdentifiers = false,
+ networkAddresses = false,
+ exactTimestamps = false,
+ userPaths = false,
+ currentSettingValues = false
+ },
+ system = new
+ {
+ snapshot.System.OperatingSystem,
+ snapshot.System.Version
+ },
+ adapters
+ }, Options);
+ }
+
+ private static CommunityAdapter BuildAdapter(
+ AdapterInfo adapter,
+ IReadOnlyList allCapabilities,
+ int ordinal)
+ {
+ var syntheticId = new Guid($"00000000-0000-0000-0000-{ordinal:000000000000}");
+ var capabilities = Guid.TryParse(adapter.Id, out var realId)
+ ? allCapabilities.Where(capability => capability.AdapterId == realId)
+ : allCapabilities.Where(capability => string.Equals(
+ capability.InterfaceDescription, adapter.Description, StringComparison.OrdinalIgnoreCase));
+
+ return new CommunityAdapter(
+ syntheticId,
+ $"Adapter {ordinal}",
+ adapter.Description,
+ adapter.InterfaceType.ToString(),
+ new CommunityDriver(
+ adapter.Driver!.Provider,
+ adapter.Driver.Version,
+ adapter.Driver.Date,
+ SanitizeComponentId(adapter.Driver.ComponentId),
+ adapter.Driver.NdisVersion,
+ adapter.Driver.Characteristics),
+ adapter.NdisProperties.Select(property => property with
+ {
+ CurrentValue = "[redacted]"
+ }).ToArray(),
+ capabilities.Select(capability => capability with
+ {
+ AdapterId = syntheticId,
+ AdapterName = $"Adapter {ordinal}",
+ CurrentValue = "[redacted]"
+ }).ToArray());
+ }
+
+ internal static string SanitizeComponentId(string componentId)
+ {
+ var normalized = componentId.Trim().ToUpperInvariant();
+ var bus = normalized.StartsWith("PCI\\", StringComparison.Ordinal) ? "PCI"
+ : normalized.StartsWith("USB\\", StringComparison.Ordinal) ? "USB"
+ : null;
+ if (bus is null) return "Unavailable";
+
+ var fields = normalized[(normalized.IndexOf('\\') + 1)..];
+ var vendor = Token(fields, bus == "PCI" ? "VEN" : "VID", 4);
+ var device = Token(fields, bus == "PCI" ? "DEV" : "PID", 4);
+ if (vendor is null || device is null) return "Unavailable";
+ var revision = Token(fields, "REV", bus == "PCI" ? 2 : 4);
+ return $"{bus}\\{(bus == "PCI" ? "VEN" : "VID")}_{vendor}"
+ + $"&{(bus == "PCI" ? "DEV" : "PID")}_{device}"
+ + (revision is null ? string.Empty : $"&REV_{revision}");
+ }
+
+ private static string? Token(string value, string name, int digits)
+ {
+ var match = Regex.Match(value, $@"(?:^|&){name}_([0-9A-F]{{{digits}}})(?:&|$)",
+ RegexOptions.CultureInvariant);
+ return match.Success ? match.Groups[1].Value : null;
+ }
+}
diff --git a/src/SockTuner/Persistence/SnapshotExporter.cs b/src/SockTuner/Persistence/SnapshotExporter.cs
index 7389f66..567fee2 100644
--- a/src/SockTuner/Persistence/SnapshotExporter.cs
+++ b/src/SockTuner/Persistence/SnapshotExporter.cs
@@ -16,14 +16,13 @@ public static class SnapshotExporter
Converters = { new JsonStringEnumConverter() }
};
- public static string Serialize(NetworkSnapshot snapshot, bool redact = false, bool probe = false) => JsonSerializer.Serialize(new
+ public static string Serialize(NetworkSnapshot snapshot, bool redact = false) => JsonSerializer.Serialize(new
{
schemaVersion = 12,
toolVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown",
exportedAt = DateTimeOffset.Now,
- redacted = redact || probe,
- probe,
- snapshot = redact || probe ? Redact(snapshot, probe) : snapshot
+ redacted = redact,
+ snapshot = redact ? Redact(snapshot) : snapshot
}, Options);
///
@@ -53,7 +52,7 @@ public static string SerializeDeviceWriteVerification(DeviceWriteVerificationRep
report
}, Options);
- internal static NetworkSnapshot Redact(NetworkSnapshot snapshot, bool probe = false)
+ internal static NetworkSnapshot Redact(NetworkSnapshot snapshot)
{
var adapterNames = snapshot.Adapters
.Select((adapter, index) => (adapter.Name, Replacement: $"Adapter {index + 1}"))
@@ -66,22 +65,20 @@ internal static NetworkSnapshot Redact(NetworkSnapshot snapshot, bool probe = fa
System = snapshot.System with { MachineName = Redacted },
Adapters = snapshot.Adapters.Select((adapter, index) => adapter with
{
- Id = probe ? adapter.Id : Redacted,
+ Id = Redacted,
Name = $"Adapter {index + 1}",
- MacAddress = probe ? MaskMac(adapter.MacAddress) : Redacted,
+ MacAddress = Redacted,
Addresses = adapter.Addresses.Select(Address).ToArray(),
Gateways = adapter.Gateways.Select(Address).ToArray(),
DnsServers = adapter.DnsServers.Select(Address).ToArray(),
InventoryError = Error(adapter.InventoryError),
Driver = adapter.Driver is null ? null : adapter.Driver with
{
- InfPath = probe ? adapter.Driver.InfPath : Redacted,
- PnpInstanceId = probe ? adapter.Driver.PnpInstanceId : Redacted
+ InfPath = Redacted,
+ PnpInstanceId = Redacted
},
NdisProperties = adapter.NdisProperties.Select(property =>
- probe && !IsUserAssignedValue(property.Keyword)
- ? property
- : property with { CurrentValue = Redacted }).ToArray(),
+ property with { CurrentValue = Redacted }).ToArray(),
NdisInventoryError = Error(adapter.NdisInventoryError)
}).ToArray(),
Routes = snapshot.Routes.Select(route => route with
@@ -129,17 +126,12 @@ internal static NetworkSnapshot Redact(NetworkSnapshot snapshot, bool probe = fa
JobObject = RedactedValue(policy.JobObject)
}).ToArray(),
QosPolicyInventoryError = Error(snapshot.QosPolicyInventoryError),
- // Driver-advertised constraints are the point of a probe report, so they survive
- // intact. Only the current value of a user-assigned keyword is masked, exactly as the
- // NDIS property list above is.
AdapterCapabilities = snapshot.AdapterCapabilities?.Select(capability => capability with
{
- AdapterId = probe ? capability.AdapterId : Guid.Empty,
+ AdapterId = Guid.Empty,
AdapterName = AdapterName(capability.AdapterName),
- InterfaceDescription = probe ? capability.InterfaceDescription : Redacted,
- CurrentValue = probe && !IsUserAssignedValue(capability.Keyword)
- ? capability.CurrentValue
- : Redacted
+ InterfaceDescription = Redacted,
+ CurrentValue = Redacted
}).ToArray(),
AdapterCapabilityInventoryError = Error(snapshot.AdapterCapabilityInventoryError)
};
@@ -147,15 +139,5 @@ internal static NetworkSnapshot Redact(NetworkSnapshot snapshot, bool probe = fa
private static string Address(string address) => address.Contains(':') ? "[IPv6 address redacted]" : "[IPv4 address redacted]";
- // Keeps the vendor OUI, masks the device-specific octets.
- private static string MaskMac(string macAddress)
- {
- var octets = macAddress.Split('-');
- return octets.Length == 6 ? $"{octets[0]}-{octets[1]}-{octets[2]}-00-00-00" : Redacted;
- }
-
- // Keywords whose value is assigned by the user (for example a locally administered MAC).
- private static bool IsUserAssignedValue(string keyword) =>
- string.Equals(keyword, "NetworkAddress", StringComparison.OrdinalIgnoreCase);
private static string RedactedValue(string value) => string.IsNullOrEmpty(value) ? value : Redacted;
}
diff --git a/src/SockTuner/Persistence/TransactionAuditStore.cs b/src/SockTuner/Persistence/TransactionAuditStore.cs
index 3d795c4..5c69e6d 100644
--- a/src/SockTuner/Persistence/TransactionAuditStore.cs
+++ b/src/SockTuner/Persistence/TransactionAuditStore.cs
@@ -70,6 +70,17 @@ public TransactionAuditEntry SaveRollback(
errors.Count == 0 ? null : string.Join("; ", errors),
maximumEntries);
+ public static TransactionAuditEntry? FindLatestReversibleApply(
+ IEnumerable entries)
+ {
+ ArgumentNullException.ThrowIfNull(entries);
+ var ordered = entries.OrderByDescending(entry => entry.RecordedAt).ToArray();
+ return ordered.FirstOrDefault(apply => apply.Outcome == TransactionAuditOutcome.ApplySucceeded
+ && !ordered.Any(rollback => rollback.RecordedAt > apply.RecordedAt
+ && rollback.Outcome == TransactionAuditOutcome.RollbackSucceeded
+ && Inverts(apply, rollback)));
+ }
+
public IReadOnlyList Load()
{
if (!Directory.Exists(_directory)) return [];
@@ -87,6 +98,14 @@ public IReadOnlyList Load()
}
}
+ private static bool Inverts(TransactionAuditEntry apply, TransactionAuditEntry rollback) =>
+ apply.Changes.Count == rollback.Changes.Count
+ && apply.Changes.All(change => rollback.Changes.Any(candidate =>
+ string.Equals(candidate.SettingId, change.SettingId, StringComparison.Ordinal)
+ && string.Equals(candidate.TargetId, change.TargetId, StringComparison.OrdinalIgnoreCase)
+ && candidate.Before == change.After
+ && candidate.After == change.Before));
+
private TransactionAuditEntry Save(
TransactionAuditOutcome outcome,
SettingSnapshot snapshot,
diff --git a/src/SockTuner/Views/CompatibilityReportPreviewWindow.xaml b/src/SockTuner/Views/CompatibilityReportPreviewWindow.xaml
new file mode 100644
index 0000000..1a7f61e
--- /dev/null
+++ b/src/SockTuner/Views/CompatibilityReportPreviewWindow.xaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SockTuner/Views/CompatibilityReportPreviewWindow.xaml.cs b/src/SockTuner/Views/CompatibilityReportPreviewWindow.xaml.cs
new file mode 100644
index 0000000..2b3f141
--- /dev/null
+++ b/src/SockTuner/Views/CompatibilityReportPreviewWindow.xaml.cs
@@ -0,0 +1,16 @@
+using System.Windows;
+using SockTuner.Services;
+
+namespace SockTuner.Views;
+
+public partial class CompatibilityReportPreviewWindow : Window
+{
+ public CompatibilityReportPreviewWindow(string report)
+ {
+ InitializeComponent();
+ UiTranslator.Apply(this);
+ ReportTextBox.Text = report;
+ }
+
+ private void Save_Click(object sender, RoutedEventArgs e) => DialogResult = true;
+}
diff --git a/src/SockTuner/Views/TuningPlanView.xaml.cs b/src/SockTuner/Views/TuningPlanView.xaml.cs
index a6f829a..38b101a 100644
--- a/src/SockTuner/Views/TuningPlanView.xaml.cs
+++ b/src/SockTuner/Views/TuningPlanView.xaml.cs
@@ -94,6 +94,12 @@ public TuningPlanView()
/// Raised when a change was applied or rolled back, so the shell can re-inventory.
public event EventHandler? Applied;
+ public bool CanUndoLastChange => LatestReversibleApply() is not null;
+
+ public string RecoverySummary => LatestReversibleApply() is { } entry
+ ? Loc.F($"Last reversible change: {entry.RecordedAt:g} · {entry.Changes.Count} setting(s).")
+ : Loc.T("No successful change is waiting to be undone.");
+
public void SetAdapters(IReadOnlyList adapters)
{
var selectedId = (AdapterComboBox.SelectedItem as AdapterInfo)?.Id;
@@ -197,7 +203,9 @@ public int Propose(IReadOnlyList changes)
/// Reads back every setting this app has written and reports the ones something else has since
/// changed. Read-only: it opens the same stores the preview uses, in their read-only form.
///
- private void CheckDrift_Click(object sender, RoutedEventArgs e)
+ private void CheckDrift_Click(object sender, RoutedEventArgs e) => CheckForDrift();
+
+ public void CheckForDrift()
{
try
{
@@ -388,7 +396,38 @@ private async void Rollback_Click(object sender, RoutedEventArgs e)
return;
}
- if (!EnsureWriteConsent()) return;
+ await RollbackAsync(entry);
+ }
+
+ public async Task UndoLatestSuccessfulChangeAsync()
+ {
+ var entry = LatestReversibleApply();
+ if (entry is null)
+ {
+ SetStatus(Loc.T("No successful change is waiting to be undone."));
+ return false;
+ }
+
+ return await RollbackAsync(entry);
+ }
+
+ private TransactionAuditEntry? LatestReversibleApply() =>
+ TransactionAuditStore.FindLatestReversibleApply(_auditStore.Load());
+
+ private async Task RollbackAsync(TransactionAuditEntry entry)
+ {
+ var settingIds = entry.Changes.Select(change => change.SettingId).Distinct().ToArray();
+ var settings = string.Join(", ", settingIds.Take(5)) + (settingIds.Length > 5 ? ", …" : string.Empty);
+ if (MessageBox.Show(
+ Loc.F($"Undo the successful change from {entry.RecordedAt:g} ({entry.Changes.Count} setting(s))? Settings: {settings}. Recovery can restart affected adapters and briefly interrupt connectivity."),
+ Loc.T("Confirm rollback"),
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning,
+ MessageBoxResult.No) != MessageBoxResult.Yes
+ || !EnsureWriteConsent())
+ {
+ return false;
+ }
// Rollback inverts the recorded change: the value it wrote becomes what we expect to
// find, and the value it captured beforehand becomes what we restore.
@@ -404,6 +443,7 @@ private async void Rollback_Click(object sender, RoutedEventArgs e)
ChangeSource.Recovery)).ToArray());
await RunWorkerAsync(request, "Rollback");
+ return true;
}
private async Task RunWorkerAsync(ElevatedWorkerRequest request, string operation)
diff --git a/tests/SockTuner.Tests/CapabilityArchiveTests.cs b/tests/SockTuner.Tests/CapabilityArchiveTests.cs
index 71ca251..80f8fd5 100644
--- a/tests/SockTuner.Tests/CapabilityArchiveTests.cs
+++ b/tests/SockTuner.Tests/CapabilityArchiveTests.cs
@@ -50,7 +50,8 @@ public void GeneratedEntriesKeepDriverConstraintsUsableAndUnique()
continue;
}
- capability.Validate(capability.CurrentValue);
+ if (!string.Equals(capability.CurrentValue, "[redacted]", StringComparison.Ordinal))
+ capability.Validate(capability.CurrentValue);
if (!string.IsNullOrEmpty(capability.DefaultValue)) capability.Validate(capability.DefaultValue);
}
}
diff --git a/tests/SockTuner.Tests/CompatibilityReportExporterTests.cs b/tests/SockTuner.Tests/CompatibilityReportExporterTests.cs
new file mode 100644
index 0000000..e937c4b
--- /dev/null
+++ b/tests/SockTuner.Tests/CompatibilityReportExporterTests.cs
@@ -0,0 +1,70 @@
+using System.Net.NetworkInformation;
+using System.Text.Json;
+using SockTuner.Models;
+using SockTuner.Persistence;
+
+namespace SockTuner.Tests;
+
+public sealed class CompatibilityReportExporterTests
+{
+ [Fact]
+ public void Serialize_KeepsCapabilityConstraintsWithoutPersistentMachineIdentifiers()
+ {
+ var realId = Guid.Parse("DBE23C40-A216-4351-BC0F-CBF9519BC5CE");
+ var adapter = new AdapterInfo(
+ realId.ToString(), "Secret NIC", "Intel(R) Ethernet Controller I226-V",
+ NetworkInterfaceType.Ethernet, OperationalStatus.Up, 2_500_000_000,
+ "AA-BB-CC-DD-EE-FF", ["10.0.0.2"], ["10.0.0.1"], ["1.1.1.1"],
+ 7, 1500, 7, 1500, true, true, null,
+ new DriverInfo("Intel", "2.1.5.7", "2025-01-01", "oem42.inf",
+ "PCI\\VEN_8086&DEV_125C&SUBSYS_DEADBEEF&REV_06", "6.85",
+ "PCI\\VEN_8086&DEV_125C\\SECRET-INSTANCE", 0x84),
+ [new("*RSS", "Receive Side Scaling", "1", "1", "enum", "0: Disabled, 1: Enabled"),
+ new("NetworkAddress", "Network Address", "DEADBEEF0001", "", "edit", "")],
+ true, null);
+ var capability = new AdapterSettingCapability(
+ realId, "Secret NIC", adapter.Description, "*RSS", "Receive Side Scaling", "1", "1",
+ [new("0", "Disabled"), new("1", "Enabled")], null, null, null,
+ AdapterSettingCapability.RegistrySz, false, TuningArea.Throughput, ChangeRisk.Medium, "trade-off");
+ var snapshot = new NetworkSnapshot(
+ new("Windows", "10.0.26100", "SECRET-PC", 16, false,
+ DateTimeOffset.Parse("2026-09-26T20:00:00+02:00")),
+ [adapter], [], null, AdapterCapabilities: [capability]);
+
+ var json = CompatibilityReportExporter.Serialize(snapshot);
+ using var document = JsonDocument.Parse(json);
+ var root = document.RootElement;
+ var exportedAdapter = root.GetProperty("adapters")[0];
+
+ Assert.Equal("socktuner.compatibility", root.GetProperty("reportType").GetString());
+ Assert.True(root.GetProperty("redacted").GetBoolean());
+ Assert.Equal("00000000-0000-0000-0000-000000000001", exportedAdapter.GetProperty("id").GetString());
+ Assert.Equal("PCI\\VEN_8086&DEV_125C&REV_06",
+ exportedAdapter.GetProperty("driver").GetProperty("componentId").GetString());
+ Assert.Equal(2, exportedAdapter.GetProperty("capabilities")[0].GetProperty("choices").GetArrayLength());
+ Assert.All(exportedAdapter.GetProperty("ndisProperties").EnumerateArray(), property =>
+ Assert.Equal("[redacted]", property.GetProperty("currentValue").GetString()));
+ Assert.All(exportedAdapter.GetProperty("capabilities").EnumerateArray(), capabilityRow =>
+ Assert.Equal("[redacted]", capabilityRow.GetProperty("currentValue").GetString()));
+ Assert.False(root.GetProperty("system").TryGetProperty("logicalProcessors", out _));
+
+ foreach (var secret in new[]
+ {
+ realId.ToString(), "SECRET-PC", "Secret NIC", "AA-BB-CC", "10.0.0.2", "1.1.1.1",
+ "oem42.inf", "SUBSYS", "SECRET-INSTANCE", "DEADBEEF0001", "2026-09-26T20:00:00"
+ })
+ {
+ Assert.DoesNotContain(secret, json, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ [Theory]
+ [InlineData("PCI\\VEN_10EC&DEV_8125&SUBSYS_7C751462&REV_04", "PCI\\VEN_10EC&DEV_8125&REV_04")]
+ [InlineData("USB\\VID_1234&PID_5678&MI_00", "USB\\VID_1234&PID_5678")]
+ [InlineData("ROOT\\VEN_8086&DEV_125C", "Unavailable")]
+ [InlineData("PCI\\VEN_8086&DEV_12ZZ", "Unavailable")]
+ [InlineData("PCI\\VEN_8086EVIL&DEV_125C", "Unavailable")]
+ [InlineData("", "Unavailable")]
+ public void SanitizeComponentId_KeepsOnlyHardwareClassFields(string input, string expected) =>
+ Assert.Equal(expected, CompatibilityReportExporter.SanitizeComponentId(input));
+}
diff --git a/tests/SockTuner.Tests/LocalizationTests.cs b/tests/SockTuner.Tests/LocalizationTests.cs
index 6ebf596..98739e0 100644
--- a/tests/SockTuner.Tests/LocalizationTests.cs
+++ b/tests/SockTuner.Tests/LocalizationTests.cs
@@ -18,7 +18,7 @@ public class LocalizationTests
private static readonly HashSet Untranslated =
[
"DSCP", "ECN", "Export ▾", "IPv4", "IPv6", "MSI", "MTU", "NDIS",
- "PRE-ALPHA 0.1", "RTT ms", "SockTuner", "game.exe", "ms"
+ "COMMUNITY ALPHA 0.9", "RTT ms", "SockTuner", "game.exe", "ms"
];
private static readonly Regex XamlText = new(
@@ -40,7 +40,8 @@ private static Dictionary> Table()
}
public static TheoryData XamlFiles() =>
- ["MainWindow.xaml", Path.Combine("Views", "TuningPlanView.xaml")];
+ ["MainWindow.xaml", Path.Combine("Views", "TuningPlanView.xaml"),
+ Path.Combine("Views", "CompatibilityReportPreviewWindow.xaml")];
[Theory]
[MemberData(nameof(XamlFiles))]
diff --git a/tests/SockTuner.Tests/RunningApplicationPickerTests.cs b/tests/SockTuner.Tests/RunningApplicationPickerTests.cs
new file mode 100644
index 0000000..1e5849b
--- /dev/null
+++ b/tests/SockTuner.Tests/RunningApplicationPickerTests.cs
@@ -0,0 +1,12 @@
+namespace SockTuner.Tests;
+
+public sealed class RunningApplicationPickerTests
+{
+ [Fact]
+ public void NormalizeApplicationNames_AddsExtensionSortsAndDeduplicates()
+ {
+ var names = MainWindow.NormalizeApplicationNames(["Game", "game.exe", null, "", "Browser"]);
+
+ Assert.Equal(["Browser.exe", "Game.exe"], names);
+ }
+}
diff --git a/tests/SockTuner.Tests/SnapshotExporterTests.cs b/tests/SockTuner.Tests/SnapshotExporterTests.cs
index a6ea225..a9b65f5 100644
--- a/tests/SockTuner.Tests/SnapshotExporterTests.cs
+++ b/tests/SockTuner.Tests/SnapshotExporterTests.cs
@@ -59,71 +59,6 @@ [new RouteInfo("IPv4", "203.0.113.0/24", "192.0.2.1", 1, "Secret NIC", 10, "NetM
}, redact: true), StringComparison.Ordinal);
}
- [Fact]
- public void Serialize_ProbeSnapshot_KeepsHardwareIdentityAndMasksPersonalValues()
- {
- var adapter = new AdapterInfo(
- "GUID-1", "Secret NIC", "Intel(R) I226-V", NetworkInterfaceType.Ethernet,
- OperationalStatus.Up, 2_500_000_000, "AA-BB-CC-DD-EE-FF",
- ["10.0.0.2"], ["10.0.0.1"], ["1.1.1.1"], 1, 1500, 1, 1500,
- true, true, null,
- new DriverInfo("Intel", "1.2.3.4", "01/01/2025", "oem5.inf", "PCI\\VEN_8086", "6.85", "PCI\\VEN_8086&DEV_125C", 0x84),
- [new NdisAdvancedProperty("*RSS", "Receive Side Scaling", "1", "1", "enum", "0: Disabled, 1: Enabled"),
- new NdisAdvancedProperty("NetworkAddress", "Network Address", "DEADBEEF0001", "", "edit", "")],
- true, null);
- var snapshot = new NetworkSnapshot(
- new SystemOverview("Windows", "10", "SECRET-PC", 8, false, DateTimeOffset.UnixEpoch),
- [adapter],
- [],
- null);
-
- var json = SnapshotExporter.Serialize(snapshot, probe: true);
- using var document = JsonDocument.Parse(json);
- var probeAdapter = document.RootElement.GetProperty("snapshot").GetProperty("adapters")[0];
-
- Assert.True(document.RootElement.GetProperty("probe").GetBoolean());
- Assert.True(document.RootElement.GetProperty("redacted").GetBoolean());
- Assert.Equal("PCI\\VEN_8086&DEV_125C", probeAdapter.GetProperty("driver").GetProperty("pnpInstanceId").GetString());
- Assert.Equal("oem5.inf", probeAdapter.GetProperty("driver").GetProperty("infPath").GetString());
- Assert.Equal("AA-BB-CC-00-00-00", probeAdapter.GetProperty("macAddress").GetString());
- Assert.Equal("GUID-1", probeAdapter.GetProperty("id").GetString());
- Assert.Equal("1", probeAdapter.GetProperty("ndisProperties")[0].GetProperty("currentValue").GetString());
- Assert.Equal("[redacted]", probeAdapter.GetProperty("ndisProperties")[1].GetProperty("currentValue").GetString());
- Assert.DoesNotContain("SECRET-PC", json, StringComparison.Ordinal);
- Assert.DoesNotContain("Secret", json, StringComparison.OrdinalIgnoreCase);
- Assert.DoesNotContain("DD-EE-FF", json, StringComparison.Ordinal);
- Assert.DoesNotContain("DEADBEEF0001", json, StringComparison.Ordinal);
- Assert.DoesNotContain("10.0.0.2", json, StringComparison.Ordinal);
- }
-
- [Fact]
- public void Serialize_ProbeSnapshot_KeepsDriverAdvertisedConstraintsAndMasksUserAssignedValues()
- {
- var capabilities = new[]
- {
- Capability("*InterruptModeration", "0", [new CapabilityChoice("0", "Disabled"), new CapabilityChoice("1", "Enabled")]),
- Capability("*JumboPacket", "1514", []) with { Minimum = 1514, Maximum = 9014, Step = 1 },
- Capability("NetworkAddress", "DEADBEEF0002", [])
- };
- var snapshot = new NetworkSnapshot(
- new SystemOverview("Windows", "10", "SECRET-PC", 8, false, DateTimeOffset.UnixEpoch),
- [], [], null, AdapterCapabilities: capabilities);
-
- var json = SnapshotExporter.Serialize(snapshot, probe: true);
- using var document = JsonDocument.Parse(json);
- var exported = document.RootElement.GetProperty("snapshot").GetProperty("adapterCapabilities");
-
- // The constraints are the whole point of a capability report and must survive redaction.
- Assert.Equal("0", exported[0].GetProperty("currentValue").GetString());
- Assert.Equal(2, exported[0].GetProperty("choices").GetArrayLength());
- Assert.Equal(1514, exported[1].GetProperty("minimum").GetInt64());
- Assert.Equal(9014, exported[1].GetProperty("maximum").GetInt64());
-
- // A user-assigned MAC override is personal data even inside a capability row.
- Assert.Equal("[redacted]", exported[2].GetProperty("currentValue").GetString());
- Assert.DoesNotContain("DEADBEEF0002", json, StringComparison.Ordinal);
- }
-
[Fact]
public void Serialize_RedactedSupportSnapshot_StripsCapabilityIdentityToo()
{
diff --git a/tests/SockTuner.Tests/TransactionAuditStoreTests.cs b/tests/SockTuner.Tests/TransactionAuditStoreTests.cs
index 0c96d5a..398d517 100644
--- a/tests/SockTuner.Tests/TransactionAuditStoreTests.cs
+++ b/tests/SockTuner.Tests/TransactionAuditStoreTests.cs
@@ -89,6 +89,25 @@ public void Load_RejectsOmittedExactValue()
Assert.False(File.Exists(path));
}
+ [Fact]
+ public void FindLatestReversibleApply_SkipsAnApplyAlreadyUndone()
+ {
+ var start = DateTimeOffset.Parse("2026-09-26T10:00:00Z");
+ var oldApply = Entry(TransactionAuditOutcome.ApplySucceeded, start, "20", "10");
+ var rollback = Entry(TransactionAuditOutcome.RollbackSucceeded, start.AddMinutes(1), "10", "20");
+ var latestApply = Entry(TransactionAuditOutcome.ApplySucceeded, start.AddMinutes(2), "20", "15");
+
+ Assert.Equal(latestApply.Id,
+ TransactionAuditStore.FindLatestReversibleApply([oldApply, rollback, latestApply])?.Id);
+ Assert.Null(TransactionAuditStore.FindLatestReversibleApply([oldApply, rollback]));
+ }
+
+ private static TransactionAuditEntry Entry(
+ TransactionAuditOutcome outcome, DateTimeOffset time, string before, string after) => new(
+ 2, Guid.NewGuid(), time, outcome, Guid.NewGuid(),
+ [new("mmcss.system-responsiveness", null,
+ new(true, before), new(true, after), ChangeSource.Manual)], null);
+
private static ApplyResult Result(bool success)
{
var definition = SettingCatalog.Get("mmcss.system-responsiveness");
diff --git a/tools/build-probe-archive.py b/tools/build-probe-archive.py
index 352c510..3cfd397 100644
--- a/tools/build-probe-archive.py
+++ b/tools/build-probe-archive.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Rebuild alpha-tester-output/ as a per-model capability archive from --probe reports.
+"""Rebuild alpha-tester-output/ as a per-model archive from compatibility reports.
Development-time maintenance script; not part of the application or its build. Reports are read
from the archive itself plus any extra folders given on the command line, filed under reports/
@@ -10,6 +10,7 @@
"""
import collections
import glob
+import hashlib
import json
import os
import re
@@ -46,13 +47,25 @@ def slug(desc, vendor):
return re.sub(r"-+", "-", text) or "unknown"
+def report_archive_name(path, report):
+ name = os.path.basename(path)
+ if not name.lower().startswith("socktuner-compatibility"):
+ return name
+ stem, extension = os.path.splitext(name)
+ stem = re.sub(r"-[0-9a-f]{12}$", "", stem, flags=re.I)
+ content = json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
+ return f"{stem}-{hashlib.sha256(content).hexdigest()[:12]}{extension}"
+
+
def load_reports(extra_sources):
reports = []
for source in [ARCHIVE, REPORTS, *extra_sources]:
- for path in sorted(glob.glob(os.path.join(source, "socktuner-probe-*.json"))):
+ patterns = ("socktuner-probe-*.json", "SockTuner-compatibility*.json")
+ for path in sorted({path for pattern in patterns for path in glob.glob(os.path.join(source, pattern))}):
try:
with open(path, encoding="utf-8") as handle:
- reports.append((path, json.load(handle)))
+ report = json.load(handle)
+ reports.append((path, report_archive_name(path, report), report))
except (OSError, ValueError) as error:
print(" skipped", os.path.basename(path), error)
return reports
@@ -96,8 +109,8 @@ def build(extra_sources, write=True):
reports = load_reports(extra_sources)
previous_entries = load_archive_entries()
entries = {}
- for path, report in reports:
- snapshot = report["snapshot"]
+ for path, archive_name, report in reports:
+ snapshot = report.get("snapshot", report)
system = snapshot["system"]
caps_by_desc = collections.defaultdict(list)
for capability in (snapshot.get("adapterCapabilities") or []):
@@ -107,7 +120,8 @@ def build(extra_sources, write=True):
ndis = adapter.get("ndisProperties") or []
caps = []
seen_keywords = set()
- for capability in caps_by_desc.get(adapter["description"], []):
+ reported_capabilities = adapter.get("capabilities") or caps_by_desc.get(adapter["description"], [])
+ for capability in reported_capabilities:
if capability["keyword"] not in seen_keywords:
caps.append(capability)
seen_keywords.add(capability["keyword"])
@@ -132,7 +146,7 @@ def build(extra_sources, write=True):
"schemaVersion": report.get("schemaVersion"),
"operatingSystem": system.get("operatingSystem"),
"osVersion": system.get("version"),
- "sourceReport": os.path.basename(path),
+ "sourceReport": archive_name,
},
"adapter": {k: v for k, v in adapter.items() if k != "ndisProperties"},
"ndisProperties": ndis,
@@ -143,8 +157,8 @@ def build(extra_sources, write=True):
if not write:
return entries
- for path, _ in reports:
- target = os.path.join(REPORTS, os.path.basename(path))
+ for path, archive_name, _ in reports:
+ target = os.path.join(REPORTS, archive_name)
if os.path.abspath(path) != os.path.abspath(target):
shutil.copy2(path, target)
if os.path.dirname(os.path.abspath(path)) == os.path.abspath(ARCHIVE):
@@ -168,7 +182,7 @@ def render_index(entries):
lines = [
"# Capability archive",
"",
- "Redacted `--probe` reports, split one file per adapter model. This is the reference for",
+ "Privacy-safe compatibility reports, split one file per adapter model. This is the reference for",
"which hardware SockTuner has real capability data for, and therefore which keywords its",
"catalog is characterised against. Regenerate with `python tools/build-probe-archive.py`.",
"",
@@ -177,8 +191,8 @@ def render_index(entries):
"Raw reports are kept under `reports/` for provenance.",
"",
"`capabilities` carries the structured driver constraints (valid values, min/max/step,",
- "default) that the tuning surface uses. Reports captured before schema 12 have only",
- "`ndisProperties`; re-running the probe on that hardware upgrades the entry.",
+ "default) that the tuning surface uses. Older probe reports may have only",
+ "`ndisProperties`; creating a new compatibility report on that hardware upgrades the entry.",
"",
"## Physical adapters",
"",
@@ -226,13 +240,13 @@ def render_index(entries):
"",
"### Adding a report",
"",
- "1. Run `SockTuner.exe --probe` on the machine; it writes a redacted report to the Desktop.",
- "2. Drop the file into this folder, or pass its folder to the build script.",
+ "1. In SockTuner, open Preferences → Help improve SockTuner → Create compatibility report.",
+ "2. Review the JSON, then drop it into this folder or pass its folder to the build script.",
"3. Run `python tools/build-probe-archive.py` to file it, split it per model and refresh this index.",
"4. Run `python tools/build-probe-archive.py --check` to verify regeneration produces no diff.",
"",
- "Reports contain no machine name, IP addresses, routes or full MAC addresses; the vendor OUI",
- "prefix and driver identity are kept deliberately, since they are the point of the archive.",
+ "Current reports contain no persistent device identifiers, network addresses, user paths,",
+ "machine name or exact timestamps. Hardware model and driver identity are kept deliberately.",
]
return "\n".join(lines) + "\n"
diff --git a/tools/test_build_probe_archive.py b/tools/test_build_probe_archive.py
new file mode 100644
index 0000000..224447a
--- /dev/null
+++ b/tools/test_build_probe_archive.py
@@ -0,0 +1,40 @@
+import importlib.util
+import json
+import os
+import tempfile
+import unittest
+from pathlib import Path
+
+
+SCRIPT = Path(__file__).with_name("build-probe-archive.py")
+SPEC = importlib.util.spec_from_file_location("build_probe_archive", SCRIPT)
+archive = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+SPEC.loader.exec_module(archive)
+
+
+class CompatibilityReportIngestionTests(unittest.TestCase):
+ def test_same_default_filename_from_two_sources_gets_distinct_stable_archive_names(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ archive.ARCHIVE = str(root / "archive")
+ archive.REPORTS = str(root / "archive" / "reports")
+ Path(archive.REPORTS).mkdir(parents=True)
+ sources = [root / "one", root / "two"]
+ for index, source in enumerate(sources):
+ source.mkdir()
+ (source / "SockTuner-compatibility.json").write_text(
+ json.dumps({"reportType": "socktuner.compatibility", "variant": index}),
+ encoding="utf-8")
+
+ loaded = archive.load_reports([str(source) for source in sources])
+ names = [name for path, name, _ in loaded if os.path.dirname(path) in map(str, sources)]
+
+ self.assertEqual(2, len(set(names)))
+ self.assertTrue(all(name.startswith("SockTuner-compatibility-") for name in names))
+ path, name, report = loaded[0]
+ self.assertEqual(name, archive.report_archive_name(name, report))
+
+
+if __name__ == "__main__":
+ unittest.main()