Reactive CPU core-group isolation for heterogeneous processors.
While a game is running, Affinity Sentinel gives it uncontended use of your CPU's preferred core group (the AMD 3D V‑Cache CCD today; Intel P‑cores in a planned future release) by evicting every other reassignable process onto the remaining cores. When the game exits, normal scheduling resumes — every process is restored to the full CPU mask.
This is the reactive exclusivity that Process Lasso can't express on its own. Process Lasso (or you) sets the per‑game pin to the preferred group; Affinity Sentinel enforces the eviction of everything else and releases it on exit. It complements an existing Process Lasso setup — it does not replace it.
Developed and validated on an AMD Ryzen 9 9950X3D (dual‑CCD X3D). The engine, topology detection and config model are deliberately CPU‑architecture‑agnostic: AMD dual‑CCD is the first concrete implementation behind a clean interface, and an Intel P+E (hybrid) provider drops in behind the same interface with no engine changes.
-
Topology detection (runtime, never assumed). At startup the service detects the machine's core groups and produces a generic model: a preferred mask, an eviction mask, and the full mask (plus the matching CPU Set IDs and architecture‑appropriate labels). The engine only ever sees this generic model.
- AMD: the preferred group is the CCD whose cores report the largest L3 cache (the stacked 3D
V‑Cache die), read via
GetLogicalProcessorInformationEx. - Intel (future): the preferred group is the cores at the highest
EfficiencyClass(the P‑cores) — exposed by the same Win32 call.
Why detection is mandatory: on the test machine the BIOS option "preferred CCD = Frequency" changes how Windows enumerates logical processors, so the V‑Cache cores are not reliably CPUs 0–15. Affinity Sentinel never hardcodes core IDs — it finds the V‑Cache CCD by cache size wherever it actually lands. (CPU Set IDs are likewise not processor numbers — on the 9950X3D they start at 256 — so they're resolved from
GetSystemCpuSetInformation.) - AMD: the preferred group is the CCD whose cores report the largest L3 cache (the stacked 3D
V‑Cache die), read via
-
Trigger detection (checks BOTH affinity and CPU Set). A process counts as a present "game" when it is pinned exclusively to the preferred group — either its
ProcessorAffinityequals the preferred mask or its default CPU Set equals the preferred group's CPU Set. Process Lasso can pin via either primitive, so checking only one would miss the other. The trigger strategy is pluggable (a process‑name allowlist could be added later). -
Eviction + restoration. With ≥1 trigger present, every other reassignable process is confined to the eviction cores. The engine records how each process was placed so restoration reverses the correct primitive. When no trigger remains, everything is restored to the full mask.
-
Safety. Protected/system processes that reject placement (Access Denied) are logged and skipped. The service restores all placements on graceful stop, and runs a startup safety net that frees anything left stuck on the eviction cores after an unclean shutdown.
A single solution in the new XML .slnx format (AffinitySentinel.slnx), all targeting .NET 10:
| Project | Type | Role |
|---|---|---|
src/AffinitySentinel.Core |
class library | Topology detection (ICpuTopologyProvider + AMD/Intel impls), the architecture‑agnostic monitoring engine, placement strategies, trigger detection, config, update checker, realtime (SignalR) contracts, file logger. |
src/AffinitySentinel.Service |
.NET 10 Worker Service | Hosts the engine as a BackgroundService (LocalSystem) plus a loopback SignalR hub, watches config, restore‑on‑shutdown + startup safety net. --dump-topology prints detected groups; runs identically when launched manually for debugging. |
src/AffinitySentinel.App |
WPF + Blazor Hybrid | Dashboard, service control, config editor, log viewer, updates — plus a system‑tray icon. Runs un‑elevated; works even when the service is stopped. |
src/AffinitySentinel.Installer |
WiX 7 MSI (.wixproj) |
Service install + recovery, ProgramData + ACLs, Event Log source, Start Menu shortcut, optional tray autostart, MajorUpgrade, graceful uninstall. |
tests/AffinitySentinel.IntegrationTests |
xUnit | Validates real placement/restoration against actual spawned processes (not mocks). |
- Architecture‑agnostic engine. The engine references only
CpuTopology(preferred/eviction/full masks). All AMD/Intel knowledge lives behindICpuTopologyProvider, selected at runtime byTopologyProviderFactory. - Mechanism‑aware restoration. Each confined process carries a
PlacementRecord(which primitive, original value). Restoration is driven by the record, not the current mechanism — so it stays correct across fallback‑mode mixed state and live mechanism changes. - Realtime channel = SignalR over loopback. The service self‑hosts a SignalR hub on Kestrel bound to
127.0.0.1only (port from configHubPort, default48710). State is pushed to the GUI live (no polling), with automatic reconnection. Durable settings still flow through the service‑watchedconfig.json. The GUI degrades gracefully when the hub is unreachable. Loopback works across the LocalSystem‑service ↔ user‑GUI boundary without pipe ACLs and is exempt from the Windows Firewall, so a manually launched service (e.g.dotnet runfor debugging) is recognised the moment it appears — "is the service running?" is simply "is the hub connected?", independent of Service Control Manager registration. Trade‑off vs the previous named pipe: any local process can reach the loopback hub (no per‑user ACL), and the elevated service now carries the ASP.NET Core surface — accepted deliberately for a robust, push‑based, debuggable channel. The exposed commands are limited to enable/disable and restore. - GUI un‑elevated. It runs
asInvokerso you keep full all‑core headroom; service start/stop is elevated on demand via a single UAC prompt.
Affinity Sentinel uses Windows' native placement APIs directly — the same primitives Process Lasso uses — and does not route through Process Lasso (which has no reliable programmatic API for live per‑process control). Choose the mechanism in the config editor:
| Mechanism | API | Trade‑off |
|---|---|---|
| Affinity | SetProcessAffinityMask |
Strongest isolation (process truly cannot run on preferred cores). Some protected/system processes reject it. |
| CPU Sets | SetProcessDefaultCpuSets |
Best compatibility (almost nothing rejects it). A scheduler hint the OS strongly honours but may override under load — softer guarantee. |
| Affinity + CPU Set fallback (recommended default) | both | Hard affinity first; if a process refuses it, fall back to a CPU Set for that process so it's still steered off the preferred cores rather than skipped. |
The dashboard shows, per confined process, which primitive is actually in effect — so you can see when the fallback path engaged.
Stored at %ProgramData%\AffinitySentinel\config.json (created with defaults on first run; the service
watches it and applies changes live). Edit it in the GUI's Configuration view or by hand.
{
"Enabled": true,
"Mechanism": "AffinityWithCpuSetFallback", // Affinity | CpuSets | AffinityWithCpuSetFallback
"PollIntervalSeconds": 3,
"Exclusions": [], // process names (no .exe) the engine must never touch
"ProactiveGlobalConfine": false, // also confine NEW processes when no game is present (default OFF)
"LogLevel": "Information",
"GitHubOwner": "adds39939",
"GitHubRepo": "AffinitySentinel",
"AutoCheckUpdates": true, // passive check (GUI launch + daily in service)
"AutoInstallUpdates": false // opt-in; the service never silently installs
}
ProactiveGlobalConfineis OFF by default on purpose. When you're not gaming you want the full 32‑thread machine for all‑core workloads; a persistent global mask would cripple that. Leave it off unless you specifically want background processes kept off the preferred cores at all times.
The service also always protects: itself, the GUI, system‑critical processes (System/Idle/Registry/ csrss/wininit/winlogon/services/lsass/…), and any triggering game.
- .NET 10 SDK (this repo was built with
10.0.100;global.jsonpins it withlatestFeatureroll‑forward). - A toolchain recent enough to understand the
.slnxsolution format — the .NET 10 SDK CLI does, as does a current Visual Studio 2022. Check current requirements rather than pinning a version. - WiX 7 for the MSI — it ships as the
WixToolset.SdkNuGet package (SDK‑style.wixproj), so a plaindotnet restorepulls the toolset in. No separate WiX install is required.
WiX Open Source Maintenance Fee (OSMF). WiX v7 carries an Open Source Maintenance Fee: organisations generating more than US$10,000/year from projects that use WiX must sponsor the WiX project, and WiX v7 enforces EULA acceptance at build time. Builds therefore pass
-p:AcceptEula=wix7. Review the terms at https://wixtoolset.org/osmf/ and ensure you're entitled to accept before building/redistributing.
dotnet build AffinitySentinel.slnx -c ReleaseThe .slnx builds the four .NET projects. The MSI is built separately (the .wixproj is x64‑only and
isn't part of the AnyCPU solution build):
dotnet build src/AffinitySentinel.Installer/AffinitySentinel.Installer.wixproj -c Release -p:AcceptEula=wix7
# -> src/AffinitySentinel.Installer/bin/x64/Release/AffinitySentinel.msiThe installer project automatically dotnet publishes the Service and GUI into a staging folder and
harvests it, so building the .wixproj is all you need.
dotnet run --project src/AffinitySentinel.Service -c Release -- --dump-topologyOn the 9950X3D this reports the V‑Cache CCD, the Frequency CCD, their masks and CPU Set IDs.
The integration tests make real SetProcessAffinityMask / CPU Set calls against processes they spawn
and own, then read them back via the same P/Invoke layer the app uses. They are tagged
[Trait("Category","Integration")].
# Unit tests only (what CI runs) — integration excluded:
dotnet test AffinitySentinel.slnx -c Release --filter "Category!=Integration"
# Integration tests — RUN ON THE TARGET HARDWARE (real CCD/hybrid topology), Windows, with privilege:
dotnet test tests/AffinitySentinel.IntegrationTests -c Release --filter "Category=Integration"They only ever touch their own spawned helper processes (the engine is driven with a scoped process enumerator), and clean them up even on failure — they do not perturb your real running processes.
Run AffinitySentinel.msi (elevation requested). It:
- deploys to
%ProgramFiles%\AffinitySentinel\; - registers the AffinitySentinel service (LocalSystem, auto‑start, restart‑on‑crash) and starts it;
- creates
%ProgramData%\AffinitySentinel\{logs}with ACLs so the elevated service can read/write and the un‑elevated GUI can read/write config + read logs; - ships a default
config.jsonthat is never overwritten on upgrade; - registers an Application Event Log source (removed on uninstall);
- adds a Start Menu shortcut and an optional "start tray app at logon" feature.
Uninstall/upgrade are graceful: the MSI stops the service and waits, so the service's own shutdown
restoration runs (everything back on the full mask) before files are touched. No managed custom action
is involved — the service owns restoration. MajorUpgrade preserves config.json and restarts the service.
Keep using Process Lasso to apply your per‑game pin to the preferred group (via affinity or CPU Set). That pin is the signal Affinity Sentinel watches for. Affinity Sentinel then performs the reactive eviction of everything else and releases it when the game exits. The two are complementary.
- The checker (in Core) queries
…/releases/latest, compares thevX.Y.Ztag to the running assembly version, and reports availability + notes + the MSI asset URL. It respects the unauthenticated rate limit (cached; GUI‑launch + daily in service) and fails quietly when offline/rate‑limited. No token is embedded or needed for public‑repo reads. - The GUI shows an "update available" banner with release notes and Update now. The service only logs availability — it never silently installs unless you opt in.
- Applying an update: the app downloads the MSI to temp, verifies it (size + the published
.sha256checksum), launchesmsiexec /i, and immediately exits so the upgrade can replace its own binaries (the classic self‑replace pitfall — handled). The MSI'sMajorUpgradeowns the transaction and restarts the service.
The git tag is the single source of truth for the version — nothing is bumped manually:
- Push a SemVer tag:
git tag v1.2.3 && git push origin v1.2.3. release.yamlderives1.2.3from the tag, reusesbuild_test.yaml(onwindows-latest) to build- test + produce the MSI with
-p:Version=1.2.3stamped into the assemblies and the MSI ProductVersion, then a separateubuntu-latestjob downloads the MSI, generates its.sha256, and publishes a GitHub Release with both attached.
- test + produce the MSI with
- Deployed apps discover the release via the update checker.
Because the version is stamped from the tag, assembly version ≡ MSI ProductVersion ≡ release tag by construction.
build_test.yaml— push tomain/ PRs tomain, andworkflow_call. Onwindows-latest: restore, build the.slnx, run unit tests (integration excluded — they need the target hardware), build the MSI, upload it as an artifact. Fails on build/test failure.release.yaml— onv*tags. Reusesbuild_test.yaml, then publishes the Release fromubuntu-latest(contents: write, built‑inGITHUB_TOKEN).
- Single processor group (≤ 64 logical CPUs). True for the 9950X3D (32 threads) and essentially all
consumer desktop CPUs; multi‑group servers (>64 logical processors) are out of scope, matching the limits
of
ProcessorAffinityand single‑groupKAFFINITYmasks. - AMD detection keys on asymmetric L3 (two CCDs with different L3 sizes = X3D). A symmetric dual‑CCD part has no "preferred" group and the engine stays idle.
- Intel hybrid provider is implemented against the same interface but has not been validated on real
hybrid silicon (the project's hardware is AMD). It only activates on a
GenuineIntelmachine reporting ≥2 efficiency classes. - ASP.NET Core runtime required on the target. Because the service self‑hosts a SignalR hub, it is framework‑dependent on ASP.NET Core 10 (in addition to the Windows Desktop runtime needed by the GUI). Dev machines with the .NET 10 SDK already have it. For end‑user installs, either ensure the ASP.NET Core 10 runtime is present or publish the service self‑contained.
- WiX builds require
-p:AcceptEula=wix7(see the OSMF note above). - The
.slnxAnyCPU build skips the x64‑only.wixproj; build the MSI with the explicit command shown.