Skip to content

How it Works

NightHammer1000 edited this page Sep 14, 2026 · 18 revisions

How it Works

WSGM rebuilds the SteamOS Game Mode experience on Windows 11. You sign in, you land in Steam Big Picture, you drive everything with the pad and the touchscreen, and you only see the desktop when you ask for it.

This page explains what WSGM 2.0 does and why. Almost every decision here was forced by something Windows or Steam actually did on real hardware. My reference handheld is an MSI Claw 8 AI+ A2VM, and my reference Desktop-first machine is a desktop PC behind an HDMI switch. Where I say the obvious approach failed, it failed on one of those machines and the shipped approach is what worked instead.

Source paths point into the repository. Core/..., Shell/... and so on live under src/WSGM; external/... and native/... are the sibling libraries. The in-repo docs carry the exact log lines, budgets and dates this page summarizes.

Order of the page: starting at sign-in, Game Mode boot, Desktop Mode, entering and leaving Game Mode, restoring Explorer, the Steam autostart takeover, elevation, machine settings, install and update, recovery. Then the Steam Input Lease, the quick access sheet and its input stack, RTSS performance, display, power, memory, the Steam CEF bridge and everything built on it, device integration and the Claw package, Device Lab, common plugins, the IR endpoint, and Windows Device Control.


Starting at sign-in: the logon service

Explorer stays your registered Windows shell. Earlier versions replaced the per-user Winlogon\Shell value, and it turns out a session where Explorer never initializes has broken touch features, most obviously the touch keyboard. Letting Explorer start normally and then ending it is what works. 2.0 deleted the registration path entirely; Core/ShellRegistration.cs now only restores a snapshotted value for recovery and uninstall.

A SYSTEM service, WSGMLogonService (src/WSGM.LogonService/, raw SCM API), listens for WTS_SESSION_LOGON. It ignores console connect, so a fast-user-switch reconnect leaves whatever is running alone. A startup sweep catches autologons that beat the service: a session logged on less than 60 seconds ago counts as fresh.

On logon the service reads %LOCALAPPDATA%\WSGM\boot.json, which WSGM writes out from config.json on --setup, on every Settings save and on every shell start (Core/BootManifestWriter.cs). Two settings feed it, StartAtSignIn and StartMode (Desktop or Game). Starting with Windows and taking the screen over are separate choices, so a desktop PC can have the first without the second. They become GameModeBoot and DesktopResident, and both are false when the sign-in start is off. The old GameModeBootEnabled switch is migrated on load by Core/ConfigMigrations.cs.

The manifest is untrusted. The service does exactly one thing with it: launch WSGM.exe --boot (or --shell --desktop-resident) as that user through CreateProcessAsUserW, using the user's linked elevated token when asked. That is legal because SYSTEM holds SeTcbPrivilege, and it raises no UAC prompt. A user-writable manifest is not an escalation, because it can only start WSGM as the user who wrote it.

Two timing traps, both found on hardware:

  • The logon notification fires before Winlogon has started Explorer. Gating the takeover on "is Explorer running" left Explorer alive behind Big Picture, so --boot now runs the takeover unconditionally and waiting for Explorer is part of it.
  • It also fires while LogonUI still owns the screen, and WTS_SESSION_DESKTOP_READY never arrives at all on the Claw. Core/InputDesktop.cs polls OpenInputDesktop until the input desktop is winsta0\Default. Without that wait, Steam audio leaks out behind the Welcome screen.

The service keeps the launched pid as a watchdog. If WSGM exits dirty in an active session with no Explorer, it gives the session's shell anchor (below) five seconds to restore a proper Explorer, then starts Explorer itself with the unlinked token, once per logon. It never relaunches WSGM.

The crash-loop breaker counts shell starts: three inside two minutes disarms the sign-in start. boot.json gets both flags off, StartAtSignIn is cleared, the shell snapshot is restored and Explorer is started if none is running. StartMode is left alone, so re-enabling in Settings brings back the mode you chose. A clean exit resets the counter, otherwise two update restarts plus a sign-in would look like a loop. --restore-shell disarms the same way by hand.

Program.DecideMode picks one mode from the command line: --boot, --shell, --shell --desktop-resident, --settings (or nothing) and --overlay-test. Double-clicking the exe always lands on Settings. The service logs to %ProgramData%\WSGM\wsgm-service.log, because SYSTEM must not write into user profiles, and WSGM's own Run mode: Shell (service boot, elevated=…, session N) line keeps wsgm.log the main surface.

Game Mode boot

ShellSession.StartBootTakeover (Shell/ShellSession.cs) runs in this order: show the splash (it re-covers on display change, since posture is applied later), wait for the input desktop, wait for Explorer to be ready, exit Explorer with a 30 second budget, apply posture, create the tray host and start the startup apps while skipping anything Explorer already autostarted, then start Steam, strictly after Explorer is gone.

Explorer readiness (Shell/ExplorerReadiness.cs) means GetShellWindow() plus Shell_TrayWnd, then a settle period (ExplorerLogonSettleMs, 5 seconds by default) for Run-key and Startup-folder processing, with a 60 second hard cap. If a Big Picture window shows up under the cover, the wait ends immediately.

Ending Explorer

Explorer is the registered shell, so Winlogon's AutoRestartShell brings it back when it dies. ExplorerControl.ExitExplorerAndWait (Core/ExplorerControl.cs) posts 0x5B4 (WM_USER+436, the Ctrl+Shift taskbar "Exit Explorer" command) to a pid-verified Shell_TrayWnd. That deliberate shutdown is the only exit Winlogon does not respawn. Two other approaches failed on the device: Process.Kill respawned within a second, and Restart Manager's RmShutdown wedged a fresh Explorer for about 30 seconds with error 351 and then respawned it anyway.

Explorer pids get snapshotted first. A pid outside the snapshot is a Winlogon replacement and is never killed, because fighting AutoRestartShell loops forever. The orderly exit is retried once against the fresh shell under the same deadline. If the replacement persists, the exit fails open: desktop mode is kept and you see Couldn't exit Windows Explorer safely.

A shell extension can hold the process open after the taskbar is gone. Lingering snapshotted pids are terminated only after the taskbar was destroyed and only after LingerGrace (8 seconds), because killing a remnant mid-shutdown is itself what Winlogon respawns. On the device that showed up as "game mode needs two tries". A clean run has the remnant leave about 830 ms after the taskbar. The grace is never shortened to fit the budget: a remnant that did not get its full window is left alone and the exit fails open. Success needs 500 ms of stable absence.

What the splash taught me

Big Picture's Chromium UI stops rendering while it is fully occluded, so an intro video that initializes under an opaque cover just stays black. Shell/BootSplash.cs polls for the Big Picture window every 250 ms and starts fading the moment it appears, and the first tick drops the layered alpha below 255, which lifts the occlusion. A no-activate splash did not help. After the splash closes, leave Steam alone: a steam://open/bigpicture re-activation during the intro kills the video.

The detection poll must not throw. Narrowing the blanket catch in WindowFinder.FindProcessIds let an exception escape, so Big Picture was never detected and the cover sat over a black intro on every single boot.

Shell/SplashPolicy.cs holds two rules. The splash always shows on Game Mode entry, because it is the cancel surface. Its 120 second Big Picture timeout starts when Steam is actually asked, not when the cover goes up, because my reference machine waits for a TV behind an HDMI switch and a timeout measured from the cover would fire in the middle of exactly the wait the cover exists for.

The "Switch to desktop" button cancels the waits while Explorer is still up. Once Explorer's exit has been requested it cannot be undone, so the button skips every game-mode side effect and completes an ordinary desktop transition without letting Big Picture start.

The splash is yours

The cover is rendered from the Splash section of config.json, and WSGM puts no branding on it. Text, spinner, background, logo and placement are all configurable, and a look exports as a .wsgmsplash zip.

That archive arrives from strangers, so Core/SplashTheme.cs treats it as hostile. Entry names must equal their own file name: a drive-relative D:logo.png has no separator yet is rooted, and Path.Combine throws the directory away in front of it. Decompression is bounded by a counted copy, not by the sizes the archive claims. Image paths in the JSON are replaced by whatever the import actually extracted, so a theme cannot point the Settings thumbnailer at a remote share. Core/ImageHeader.cs checks declared pixel dimensions before anything decodes, because a few kilobytes of PNG can declare a multi-gigabyte bitmap.

Saving picked images happens in two phases (Core/SplashAssets.cs): stage GUID-named sidecars, write the config pointing at them, then promote with same-directory moves. A failed config write can never leave the config naming an image that was already overwritten.

Desktop Mode

Desktop Mode is a full resident session, not a cut-down agent. Plugins, overlay, hotkey, chord, application monitor, performance services, permitted Steam integration and config watching all run. Explorer stays the shell, so there is no takeover, no replacement tray host, no Game display posture, no startup-app sequence and no Big Picture request.

WSGM starts the windowed Steam client itself after the input-desktop barrier, so Steam inherits WSGM's integrity rather than the user's own autostart. Shell/SteamExitPolicy.cs decides what an observed Steam exit means. Game mode needs Steam on screen, so it relaunches Big Picture or shows the overlay. A desktop session has Explorer, so it restarts the windowed client or does nothing, and it never interrupts you with the overlay. A deliberate "Close Steam" sets SteamClosedByUser, otherwise the session would immediately start Steam back up.

A notification icon (Shell/DesktopTray.cs) offers Open WSGM, Enter Game Mode, Settings and Exit WSGM. It is hidden in Game Mode and is separate from Game Mode's TrayHost. Exit leaves the logon service installed. The Start Menu shortcut runs WSGM.exe --shell --activate: with Explorer running that starts the Desktop session, and running it again tells the existing instance to open the overlay.

This mode exists because gaming PCs and DIY Steam Machines want WSGM's overlay, plugins and Steam ownership without giving up the desktop at sign-in. Game Mode is launched from it.

Entering and leaving Game Mode from the desktop

Entry is one cancellable transaction, Shell/GameModeEntryTransaction.cs. The order: record what to return to, run the entry actions, wait for every required display, persist the pending return layout, prepare the Explorer anchor, re-check the displays, exit Explorer, apply the layout, request Big Picture, commit.

Everything before Explorer leaves is undoable, so the splash offers Cancel and a failure puts the desktop back exactly as it was. The Explorer exit is the boundary. After it there is no cheap desktop to go back to, the button becomes "Switch to desktop", and later failures compensate forwards. If Explorer refuses to exit, desktop mode is kept.

Big Picture is requested after the exit and after the layout, which reverses the old order. Requesting it first was a latency optimisation for a Steam that was not running yet. In a resident session Steam is already up, the splash covers the whole transaction, and a Big Picture window created before the layout lands on the wrong display at the wrong scaling.

The display wait has no deadline, only cancellation. My reference setup puts a TV behind an HDMI switch, and how long that takes is up to a person and some consumer hardware, so a timeout would only ever fire on the honest case. Shell/DisplayArrivalWaiter.cs requires two identical observations a settle apart, because a monitor behind a switch enumerates, disappears and re-enumerates while the sink negotiates. Interop/DisplayChangeWindow.cs is a hidden top-level window that hears WM_DISPLAYCHANGE, which a message-only window never does. It only shortens the wait, and a five-second backstop poll keeps things correct when no broadcast arrives.

Leaving: pause the Steam monitor, close Big Picture, restore the layout the session owes the desktop, start Explorer through the anchor, run the leave actions, resume monitoring and supply the windowed client. The layout goes back before Explorer, because Explorer sizes its taskbar and icons to whatever the displays say when it starts. Leave actions run only after Explorer recovery succeeds.

AppConfig.GameModeLaunch holds four ordered lists of plugin action steps, run on entering Game Mode, leaving it, desktop startup and desktop wake (Shell/PluginActionSequence.cs). Entry stops at the first step that did not succeed; the other three run every step and report failures. Nothing is ever retried, because a Dispatched or Unconfirmed outcome means the command may already be on the wire. Compensation before the boundary runs the leave actions whenever an entry step was dispatched or uncertain, but a Rejected step changed nothing and earns none. An IR burst there would move a switch nobody asked to move. Desktop startup and wake are coalesced by Shell/DesktopActionAdmission.cs: one run at a time, a five-second cooldown, and never while Game Mode is active or a transition is in flight, because the notifications around a sleep overlap.

Restoring Explorer

An Explorer started by the de-elevating scheduled task inherits the Task Scheduler's job, and desktop launchers like Mod Organizer 2 then fail CREATE_BREAKAWAY_FROM_JOB with error 5. So the task is recovery, not the normal path.

Immediately before each orderly exit, WSGM resolves the Shell_TrayWnd owner and accepts it only if GetShellWindow names the same process, its image is %WINDIR%\explorer.exe, it is in the current session, at medium integrity and not in a job. Under that process it starts one medium, jobless anchor (Core/ExplorerShellAnchor.cs, installed as WSGM.ShellAnchor.exe) before the old shell exits. The anchor accepts one authenticated per-session start command for the fixed Explorer path and nothing else.

Owner loss is judged strictly. Pipe EOF alone is not loss; the anchor keeps its role until the retained owner process exits or the stop event fires. On abnormal WSGM loss it waits briefly for another recovery actor, checks the session is still active, and only then restores Explorer.

Success is the observed taskbar owner, not the created pid. The transition completes when GetShellWindow and Shell_TrayWnd share one owner for a stable 500 ms and that owner passes the same checks again. A medium Explorer in a job is a degraded desktop, and a wrong-image, elevated or unsettled taskbar is a failure. Once an anchor request may have crossed the pipe, WSGM never dispatches the scheduled task as a second creator and never recreates TrayHost while a late shell may still appear.

The scheduled task is the last resort when no anchor request was dispatched, and its result is always reported as degraded even when the Explorer it produced happens to be jobless. Shutdown keeps the anchor alive until the desktop is verified.

Steam autostart takeover

A Steam that Windows started first does not have WSGM's integrity, and Steam Input then silently loses its reach over elevated windows. Owning how Steam starts is WSGM's own behaviour, so this is the recorded exception to "Settings configures WSGM itself only".

Core/SteamAutostart.cs scans Run values in HKCU and HKLM including the 32-bit view, shortcuts in either Startup folder, and scheduled tasks with a logon trigger. Tasks are read as XML from schtasks /Query /XML, because the table output is localized. Matching compares the resolved executable against Steam's own path, and an unquoted command is resolved the way Windows does it, by successive prefixes rather than at the first space.

Core/SteamAutostartTakeover.cs disables an entry the same way Task Manager's Startup tab does, by writing Windows' own StartupApproved bytes or disabling the task. Nothing is deleted. The previous state is recorded in SteamAutostartDisabled before the write, so an interrupted takeover is still undoable, the write is confirmed by readback, and an unconfirmed one stays pending. Restore only undoes an entry that still carries WSGM's own value, so a decision you made afterwards wins.

HKLM and task changes need elevation and go through the --disable-steam-autostart one-shot, which rescans and takes no name from its command line. A sign-in never prompts: an unelevated re-check disables user-scope entries and warns about the rest. The elevated route is only taken from Quick Setup or Settings > System "Take over again". --restore-steam-autostart runs from the elevated uninstall restore.

Elevation and de-elevation

UIPI is the reason. A medium-integrity process cannot send input to, inject into, or take foreground from a high-integrity one, so an unelevated Steam goes dead against elevated games. Steam has no "run elevated" switch, but children inherit elevation, so an elevated WSGM gives you an elevated Steam. WSGM's own overlay and edge swipes ride the same chain.

Core/ElevationPolicy.cs is one rule for both callers. WSGM wants elevation when Steam is already elevated (including a RUNASADMIN compat flag), when an enabled startup app is marked elevated, when device integration is on, or when WSGM starts Steam at its own integrity, which is the default. SteamLaunchUnelevated removes that last reason and sends the cold start through the de-elevating task. Core/SelfElevation.cs relaunches on the rule and BootManifestWriter projects it, so a shortcut start and a sign-in start land at the same integrity. The logon service elevates through the linked token silently; otherwise SelfElevation does a runas relaunch, which is one UAC prompt.

Getting back down is harder. The textbook TokenLinkedToken route fails with error 1346 from user land, because it needs SeTcbPrivilege. What works is a one-shot scheduled task with InteractiveToken logon and no RunLevel (Core/UnelevatedLauncher.cs). The task XML must be UTF-16, and /NoUACCheck is never shipped because EDRs flag it. Windows 11 Explorer usually de-elevates itself; ExplorerControl verifies 5 seconds after a start and repairs through the task only on blocking recovery paths.

The per-game wrapper

WSGM.Launch.exe is the single wrapper for games that reject elevation or need a Steam Input lease:

"...\WSGM.Launch.exe" [--deelevate] [--input-lease | --input-lease-inject] -- %command%

--input-lease holds the lease through the resident shim and never injects; --input-lease-inject is the only route that injects. The wrapper stays alive for the target's lifetime, preserves arguments and working directory, and stops the target tree if Steam terminates it. It waits on a job object, never on the process, because a game behind a launcher exits its root process seconds in and waiting on that released the lease mid-session.

Four things have to hold when Steam is elevated, each one a separate failure I hit:

  • The wrapper is a console executable. Steam treats a windowless WinExe as a game and hooks Steam Input into it, and the wrapper died before it could even log.
  • The handshake pipe grants WindowsIdentity.User explicitly. PipeOptions.CurrentUserOnly on an elevated server grants BUILTIN\Administrators, which is deny-only in the child's filtered token, so the connect fails with "Access is denied".
  • The medium child launches with __COMPAT_LAYER=RunAsInvoker. Without it, a RUNASADMIN target fails a medium CreateProcess with error 740.
  • Non-Steam shortcuts take the wrapper as Target and the real program as Launch Arguments, because Steam ignores an exe-replacing launch option there. Core/SteamLaunchConfig.cs writes that layout into the running client.

Every controlled child environment removes SDL_GAMECONTROLLER_IGNORE_DEVICES. Steam sets it to hide direct controllers from SDL, and that also suppresses WSGM's VIIPER virtual pad regardless of whether the lease worked.

The lease is the outer layer. The elevated parent acquires it before the de-elevation hand-off and releases it after the medium child reports exit, because a medium process cannot inject into elevated Steam. A lease failure logs, tells you, and launches anyway. An impossible de-elevation (UAC off, no limited token) also fails open, but only when the parent's own token says so; a forged failure tag from another same-user process is refused. launch.log records the variable's disposition, the target filename and the lease outcome under the wrapper pid, never the environment block.

Machine settings: change exactly, restore exactly

Snapshot before the first write, restore the snapshot exactly, survive crashes. Snapshots live in config.json so a recovery process can undo what a crashed session changed.

  • UAC prompts (optional): ConsentPromptBehaviorAdmin and PromptOnSecureDesktop set to 0 through the elevated --set-uac-silent and --restore-uac one-shots (Core/UacSettings.cs). EnableLUA is read for display and never written, so UAC stays on.
  • Lock-on-wake (optional): Core/LockScreenSettings.cs snapshots every power scheme's console-lock AC and DC values, the policy override and NoLockScreen, persists them, then writes, through the --disable-lock-on-wake and --restore-lock-on-wake one-shots.
  • Display scaling (Core/DisplayScale.cs): the Default posture captures every per-display scale, persists it, and only then drops to 100%. Capture-then-set means a crash between the two cannot strand you. A Custom layout is a separate subject.
  • StartupToGamingHome = 0 (Core/ShellRegistration.cs, HKCU): with Explorer as the boot shell, the Xbox Full Screen Experience would otherwise compete at sign-in. Snapshotted and restored like everything else.
  • Device posture: hands off. Nothing in 2.0 reads or writes ConvertibleSlateMode or TouchKeyboardTapInvoke. Windows owns posture.

Install, update, uninstall

The installer is PrivilegesRequired=admin because the service lives in Program Files and needs the SCM. The app itself stays per-user in {localappdata} and HKCU. Run setup from the account that uses the device.

Setup asks which machine you have, not which components you want: Minimal (core, Game Mode start), MSI Claw 8 AI+ A2VM (core, device, controller, integration on), Desktop first (core, Desktop start), plus Custom, which names no mode. Which bytes get installed is Inno's job; what the first run does is --profile=, applied by Core/InstallProfile.cs only when the machine has no config.json. Re-running setup is how people repair and upgrade, and a mode that rewrote the start mode each time would undo their Settings behind their back.

Update order:

  1. Record whether the shell is running (mutex Local\WSGM.Shell).
  2. sc stop WSGMLogonService. A live watchdog would see the killed WSGM and start Explorer mid-update.
  3. Signal the manual-reset event Local\WSGM.ExitForUpdate. One SetEvent releases every instance, elevated ones included. WSGM asks Steam and the wrappers to exit under a 10 second pre-stop, then runs its own 10 second cleanup so the mapped Steam Input payload becomes replaceable. Stragglers get taskkill, WSGM.exe only, in the installer's session.
  4. Retire the shell anchor only after it publishes Local\WSGM.ShellAnchor.RecoverySettled. Without that, setup defers the file rather than killing the only remaining desktop-recovery owner.
  5. Refuse replacement while Steam or a launch wrapper is still running. Setup never terminates either tree.
  6. [Run]: WSGM.exe --setup, WSGM.LogonService.exe --install, the USB/IP driver if selected, then WSGM in its previous mode.

The event names, their access grant and the stale-signal reset are a cross-version contract (Core/UpdateExitWatcher.cs), because a newer installer still has to release an older build. NeedRestart is true only when the USB/IP driver task was selected and the driver reported a reboot or reported nothing at all. Silent setup never reboots.

Uninstall signals Local\WSGM.ExitForUninstall, which does not stop Steam. [UninstallRun] is service --uninstall, --unregister-shell, then --uninstall-restore (display scaling, UAC, Steam autostart, lock-on-wake), all before files go.

Quick Setup (revision 2) asks about the sign-in start and its mode, lists the Steam autostart entries it found and refuses Continue until the takeover is allowed, and carries revision 1's two consents: the Steam Input shim file in Steam's directory and the CEF integration. Nothing is written until Continue. An install mode seeds the answers, but the panel still appears.

A device package can land in the protected slot long after a Minimal install. Core/DevicePrerequisites.cs checks the machine (package present, integration on, libviiper.dll beside WSGM, HidHide answering) and the overlay's Device page shows a banner naming what is missing. It offers Enable Device Integration and nothing else, because the USB/IP driver install restarts every USB 3.0 hub and drops the built-in controller, touch and keyboard, so it only happens while setup is on screen.

Recovery: every way out restores everything

You must always be able to get the desktop back, and no crash may leave a machine-level change behind. From the outside in:

  • The service watchdog: a dirty exit with no Explorer gets the anchor's five-second grace, then Explorer from the unlinked token.
  • The shell anchor: restores a medium, jobless Explorer on abnormal WSGM loss.
  • Fail-open transitions: Explorer refusing to exit never yields half a game mode.
  • The crash-loop breaker: three shell starts in two minutes disarms the sign-in start.
  • Panic() on any unhandled exception in shell mode: restore the shell snapshot, destroy the tray host, delegate to the anchor when one owns recovery (otherwise start Explorer), restore display scales, release the lease.
  • --restore-shell: runs before logging and Avalonia, so a corrupt profile cannot block it. Disarms the sign-in start, restores the shell snapshot, starts and verifies Explorer, releases the lease, restores display scales.
  • The Steam Input lease is pipe-backed and dies with the process.
  • A corrupt config.json is preserved as config.bad.json, because it holds the snapshots recovery depends on.

Underneath all of it, the log file is the test harness. Real testing happens through pasted wsgm.log and wsgm-service.log. Every decision path logs a distinctive line, poll loops go through Log.Change so a steady state does not drown the file, and Warn means behaviour changed, not "something did not happen". If a behaviour cannot be diagnosed from a pasted log, it cannot be maintained.

A note on the runtime

WSGM 2.0 is a self-contained CoreCLR application on .NET 10, not NativeAOT. The runtime ships beside the exe, so nobody needs a .NET install. COM interop and WinRT projections work in-process now: volume goes through Core Audio in external/windows-device-control, radios use Windows.Devices.Radios and Windows.Devices.Bluetooth directly, and plugins load dynamically against the shared SDK. The native helpers WSGM.VolumeControl.dll, WSGM.Radio.dll and WSGM.RadioProbe.exe are gone, and the installer deletes them on update. What is still native is the Steam Input lease payload and VIIPER. Memory is covered further down.


The Steam Input Lease

When Steam Input's desktop profile activates, Steam takes the controller away from every API on the system, in every process, the moment a non-game window has focus.

The things that do not work have not changed. Reading the APIs directly returns the profile's output. HidHide hides the pad from Steam too. And steam://forceinputappid/480, which 1.x shipped, could leave Big Picture deaf, survived WSGM crashes, and only ever emitted a plain Xbox pad.

What ships instead is the Steam Input Lease (native/SteamInput, Rust with a C ABI, now version 4). While a lease is held, a gate inside steam.exe denies HID and XInput access in that one process. SDL in WSGM reads the pad directly and Steam's layout is untouched.

Getting the gate into Steam

The gate is a search-order proxy DLL that Steam loads by itself. Core/SteamInputShim.cs copies steam_input_gate.dll into Steam's directory as XInput1_4.dll, or as dinput8.dll when that name is taken, and Steam maps it on its next cold start. WSGM never writes into a running process: allow_injection defaults to false and Core/SteamInputBlocker.cs sets it explicitly. I checked against the live client that Steam does not harden its search order and that nothing in its directory statically imports XInput or DirectInput.

Three deployer rules carry weight. Ownership is proven by finding the WsgmSteamInputGateProxy export in the file's own bytes, because ValvePlug and Special K claim the same file names. Disabling parks the file as .dlld, because a rename succeeds while Steam has the DLL mapped and a replace does not. And inside the gate, the real module is resolved by its full System32 path, because a bare-name load would just return the gate's own image.

Doing nothing at load

MinHook's MH_ApplyQueued suspends every thread in the process. Doing that while the proxy was mapped during Steam's own startup hung Steam on the first cold boot after an install. So hook installation now waits for the first lease.

The second hang was a livelock. The gate's self-identity guard fails closed while its own module handle is unknown, and that handle was being recorded on a worker thread that could not run until the loader lock was released. In that window every XInput call reloaded the real DLL and cached nothing, while SDL probed four controller slots and retried. The fix was one line: record the handle in DllMain from the HINSTANCE the loader already hands you. Ten clean boots in a row followed. Every proxy export now also starts blocked until the worker has cached the forwarding table.

A later hang got traced and cleared the proxy: it finished in 2 ms, identical to a good boot. That one turned out to be CEF touching Steam's front end before a Big Picture window existed (docs/steam-cef-system.md).

The rules

Core/SteamInputBlocker.cs keeps four rules. The lease is scoped to a focused WSGM surface, acquired before the overlay or Settings opens and released after the last one closes. It is an open named-pipe connection, so a WSGM crash releases it. A normal release asks Steam to rediscover its controllers. Failures fail open: no resident payload means the surface opens unblocked.

The per-game sibling is still there. WSGM.Launch.exe --input-lease -- %command% holds a lease for as long as a game's process tree lives, and --input-lease-inject is the only shipped route that injects. The wrapper also strips Steam's inherited SDL_GAMECONTROLLER_IGNORE_DEVICES from the child, which was otherwise hiding the direct controller from the game's own SDL.

Temporary Steam controller ownership

2.0 adds the opposite operation. An OEM button assigned to Steam's Quick Access or Overlay, and the on-screen keyboard in Game Mode, need Steam to see the pad for a moment while WSGM owns it. The gate gained a pass-through claim that overrides block leases without consuming them.

The order matters. WSGM neutralizes its virtual target, verifies the plugin released the physical pad, removes its own HidHide entries, then grants Steam access and invokes Steam's native button handler on the exact observed window and CEF generation. One registered game overlay wins over the main window; two are refused. Restoration takes a temporary block claim, reacquires the physical pad, restores HidHide, then drops the claim. Surface observations govern it, and unknown state never expires into assumed closure. A confirmed Steam exit permits restoration without a reply from the dead pipe. Main-window replay has live CEF evidence; the end-to-end hardware pass is still to come.

Tools > Controller ownership exposes the same path by hand, as Release to Steam and Reacquire for WSGM. A manual release stays in force until an explicit reacquire. A failed transition offers a recovery request, never an automatic retry.

Owner claims

Several surfaces can need the one process-wide lease at the same time, so each registers a named owner claim in SteamInputBlocker, and the lease is released when the last owner lets go. AcquireFor registers the owner before the native acquire, so every close path must call ReleaseFor even when Steam was unavailable. In the overlay-to-Settings handoff, Settings registers first and the overlay's deferred close removes its own name. Abandoning either name leaves the controller blocked after the surface is gone; releasing during the overlay's 150 ms deferred close drops and re-revokes the controller. I hit both on the device.

What it costs

Measured with steam-input-lease.exe and a real Steam Controller: acquire took the pad from Steam, and Steam rediscovered it within 700 ms of release. A warm acquire plus release costs 41 to 42 ms and one pipe reply 12 to 16 ms. The old injection-era cold path cost 492 ms.


The quick access sheet

The 1.x side panel and bottom taskbar are gone. WSGM 2.0 has one surface, Overlay/OverlayWindow, docked to the top of the display. It covers 81% of the screen height and leaves the game visible below. That strip is outside the window rectangle, so the raw-input tap-outside rule dismisses the sheet with no extra code.

The header carries the wordmark, an eyebrow naming the active destination, and the status pills: tray icons, eject, audio, Wi-Fi, Bluetooth, battery and clock, bound to a per-open Shell/SystemStatus. Below it a TabStrip selects one of five always-alive roots: Quick access, Steam, Device, Tools and Power. Roots are toggled by visibility and never rebuilt, because a rebuild destroys the button under the gamepad cursor.

Every root except Quick access is a menu of category tiles rather than the controls themselves. Steam has Library and Per-game launch fixes. Tools has System, Performance, Storage, Display, Plugins and Controller ownership. Power has Wake, Idle timeouts, Power and Session, which was its own root until four buttons stopped justifying one. Device leads with the plugin's declared sections, then the shared Power, RGB, Controller and Info pages. A new control belongs on the category page that names its group, never on a root.

Quick access is the home root and the Back target of every other. It holds pinned rows (AppConfig.QuickAccessPins; X, touch-hold or right-click toggles a pin) and plugin widgets. Pins are live mirrors of the source row and press through to its Click handler, so a row that rewrites its own title keeps working when pinned.

LB and RB cycle the roots with wrap, as two optional callbacks on Input/GamepadNavigation.cs. The sheet reopens on its last destination, and switching roots lands focus on the new root's first row.

Focus

The sheet takes focus deliberately, Game Bar style: the game stops receiving input while it is open. That is only possible because of the Steam Input Lease, which keeps the pad readable while a non-game window has focus.

On close, the sheet refocuses whatever window was foreground when it opened (_restoreFocusTo in OverlayController), because an exclusive-fullscreen game sits minimized after losing focus. That fires only in game mode and only when no overlay action redirected focus. Next-app cycling, an Open apps chip and a tray icon all set _suppressFocusRestore, since the chosen app has to stay foreground. A close cancelled by a re-show inside the 150 ms deferral clears both fields, because a latched suppression would disable refocus for the rest of the sheet's life.

Ghost clicks

I traced this into Avalonia's source. Avalonia never marks touch raw events handled, so WM_POINTER reaches DefWindowProc, which synthesizes a mouse click delivered after the tap. If the tap closed the sheet, that click lands in the game. Two defenses, both needed: OverlayWindow's WndProc hook eats mouse messages tagged MI_WP_SIGNATURE, and OverlayController.CloseOverlay defers the real Close() by 150 ms so the window still exists to eat it.

The swallowed click still carries WM_MOUSEACTIVATE, which on the reference device re-activated the sheet over a status panel the tap had just opened, since two topmost windows order by activation. While a panel is open the sheet now answers MA_NOACTIVATE. Window ownership does not work as an alternative: Avalonia re-points every ShowInTaskbar=false window's owner at its hidden helper on Show().

DPI and accent

Game mode forces 100% scaling, so the sheet upscales itself to the desktop scale recorded in the pre-game-mode snapshot (Core/DisplayScale.GetUiScalePercent). The docked panels read their own HWND's DPI rather than Avalonia's screens cache, which only refreshes while an Avalonia window is alive to receive the display-change message.

The accent (Themes/AccentPalette.cs) sets FluentAvalonia's CustomAccentColor and shadows WSGM's Hc* tokens so every DynamicResource consumer re-resolves live. It is forced opaque. The foreground over it is chosen by WCAG contrast, which reduces to a relative-luminance threshold of 0.1791: black above, white below, so pale accents stay readable.

Settings, sub-views and text entry

Settings uses the same idiom: always-alive pages behind a bumper-cyclable tab strip. The pages under Settings/Pages are System, Steam, Integration, Device setup, Startup, Quick access, Display, Appearance and, last because its content belongs to the installed plugin, Plugin settings.

Nested sheet pages are hosted in place over Overlay/OverlaySubView.cs, never as a Popup or Flyout, which GamepadNavigation cannot reach. Each page names its parent, so one Back press moves one level. A BackButton in the fixed header does what B does, because a long Device section pushes any in-page control off the bottom.

Text entry is a press-to-edit row, never a bare TextBox. GamepadNavigation skips TextBoxes so the Windows touch keyboard cannot pop up mid-navigation, which means focus never lands on one. Pressing the row opens Overlay/KeyboardWindow as a peer window over the sheet's lower edge. D-pad Down off the sheet's last row crosses into it, and Up off its top row crosses back. Its bounds count as inside for tap-outside, so the first key tap does not dismiss everything under your finger.

Tools > Controller ownership offers Release to Steam and Reacquire, plus an On-Screen Keyboard action that dismisses the sheet and invokes Steam's keyboard in Game Mode or the Windows touch keyboard in Desktop Mode. A focused CurveEditor consumes directions before window navigation, with identical semantics on the Steam and SDL paths so whichever duplicate arrives first cannot change the result.

Open apps and the tray host

The old taskbar's tile strip is now a chip row along the sheet's bottom (Overlay/AppSwitcherViewModel.cs), scrolling once the chips no longer fit. It is reconciled in place every second, Y cycles to the next window, and X on a tray pill opens its context menu. The process and EnumWindows snapshot runs off the UI thread so it cannot compete with the 16 ms gamepad poll.

Tray icons render as pills in the header. Their count is not WSGM's to control, so the tray alone is budgeted (OverlayWindow.ComputeTrayMaxWidth) and scrolls inside that width; everything after it is fixed-size and always visible.

Two pieces make a tray work without Explorer, both in Shell/TrayHost.cs. WSGM registers a window class literally named Shell_TrayWnd (plus the TrayNotifyWnd child), because Shell_NotifyIcon finds the tray by that name and delivers WM_COPYDATA. The host parses the wire format (Core/TrayProtocol.cs) and broadcasts TaskbarCreated so apps re-register. Then there is the UIPI gate: WSGM runs elevated, and an unelevated app's WM_COPYDATA is silently dropped unless ChangeWindowMessageFilterEx(WM_COPYDATA, MSGFLT_ALLOW) is applied. No shipped replacement shell runs elevated, so that one took its own round on the device to find. Icon callbacks are relayed only when the app's callback message lies in WM_USER..0xFFFF, the application-defined range.

Hard rule: the host never coexists with Explorer's taskbar, because two Shell_TrayWnd windows fight over FindWindow. TrayHost.Create refuses while Explorer is in the session, the host is destroyed before Explorer starts, and it is recreated only after Explorer has verifiably exited.

Shell/SystemStatus.cs feeds the pills from a 1 second timer. The battery pill hides when GetSystemPowerStatus reports no system battery or its unknown markers, so a desktop shows no battery rather than a wrong one.

Touch, controller and hardware keys

Edge swipes and tap-outside are raw input observation only. Overlay/TouchSwipeMonitor.cs registers the HID digitizer with RIDEV_INPUTSINK, which delivers without focus, and parses contacts with HidP_*. Nothing is consumed. The only low-level hook in the codebase is Input/KeyRecorder.cs, alive only while you are recording a shortcut. Tap-outside hit-tests contacts against the sheet rectangle rather than dismissing on deactivate, because Next-app cycling deactivates the sheet while it has to stay open.

Four edges are configurable. Top opens the sheet. Bottom is off by default and can open Open apps. Left sends Ctrl+1 (Steam menu) and right Ctrl+2 (Quick Access Menu), including over a game. On the desktop Explorer owns the bottom edge, and a sheet fallback there read as a regression.

Input/SdlGamepads.cs is the process-wide SDL3 owner with one event pump. Two GamepadService instances exist while Settings is open, and per-instance pumps would steal each other's hotplug events. A 16 ms UI-thread poll produces edge-triggered ButtonPressed with directional auto-repeat, and full-state StateChanged for chords, which are per physical pad with an optional hold. GamepadNavigation moves focus through tab order and mirrors arrow keys with a 250 ms dedupe.

A visible WSGM surface holds a named capture claim that neutralizes the virtual controller target, and the last close resumes game forwarding only after every control the UI used is released. Navigation switches from SDL to the managed canonical path after its first complete sample, keeping SDL as fallback.

Explorer handles physical volume buttons on the desktop. In game mode Shell/VolumeButtonService.cs registers a shell-hook window and applies HSHELL_APPCOMMAND volume commands, and it deregisters before Explorer starts so there is never a double step. The change is always applied. The OSD is topmost, non-activating and click-through, shown for two seconds, and suppressed only by a confirmed QUNS_RUNNING_D3D_FULL_SCREEN from SHQueryUserNotificationState (Shell/VolumeOsdVisibility.cs). QUNS_BUSY must not suppress it, because Big Picture and borderless fullscreen both report it.

Radios, audio and Safe Eject

All three are panels docked below the header. The Windows mechanisms moved into external/windows-device-control, a managed library, since WSGM is self-contained CoreCLR now and the 1.x native helper DLLs are gone. Radios are covered further down.

Shell/AudioManager.cs owns default render and capture endpoint state over the library's CoreAudio. Switching the default device still goes through a hand-declared IPolicyConfig, the undocumented interface the Windows sound UI uses, now living in external/windows-device-control/src/WindowsDeviceControl/CoreAudio.cs. The manager polls volume every second and enumerates endpoints every fifth tick, and revision tokens stop a stale snapshot overwriting a slider you just moved. A default-output change made outside WSGM reopens the feedback stream, because a waveOut stream stays glued to the endpoint it opened against.

Safe Eject (Shell/RemovableDriveManager.cs, Interop/NativeStorage.cs, cfgmgr32 and kernel32 only) exists because Explorer's "Safely Remove Hardware" does not, and card switchers pull game libraries. The classification comes from IOCTL_STORAGE_GET_HOTPLUG_INFO:

  • A hot-pluggable device (USB stick or drive) gets a PnP eject via CM_Request_Device_EjectW, aimed at the first ancestor with CM_DEVCAP_REMOVABLE. For USB storage that is the USB device above the USBSTOR disk, since ejecting the disk node itself commonly fails. One row per device, because the eject takes every partition.
  • Removable media in a non-removable device (a microSD in a built-in reader) gets FSCTL_LOCK_VOLUME, FSCTL_DISMOUNT_VOLUME, then a best-effort eject-media IOCTL, one row per volume. The lock is the open-files check. A device-level eject here would disable the reader until reboot, which is the whole reason the classification exists.

Vetoes are retried three times at 500 ms for transient holders like the indexer or Defender, and a persistent veto is named in the row. Steam holds no idle handles on library volumes, so vetoes only show up during an active download or install. WSGM's own card watcher would veto itself, so it stands down before every eject and format. A 2 second signature poll (letter, type, readiness) decides whether the expensive enumeration runs at all, and catches a card slipped into an already-present slot. The disks Windows and WSGM run from are excluded by disk number before classification, because a USB-attached boot drive would pass the hotplug test.

Performance through RTSS

RTSS is an optional external application. WSGM does not download, install, redistribute, repair or remove it, and ships none of its SDK, headers, DLLs or licence text. Using the profile API of your own installation is the boundary.

Core/RtssDiscovery.cs accepts exactly one machine-wide RTSS 7.3-or-newer registration whose publisher, protected Program Files location, RTSS.exe identity, required profile-API exports and running process path all agree. It reads the export table as data rather than loading the DLL, and a process merely named RTSS is never enough. RtssNativeAdapter loads only that signed, architecture-matched RTSSHooks.dll by absolute path. My reference is 7.3.7 on the Claw.

One session-owned Core/PerformanceService.cs feeds both the sheet and the native Steam QAM. RTSS is independent of Device Integration, and its absence disables only the performance controls.

RunningApplicationMonitor is the only detector. A Steam AppID wins when exactly one game is running; more than one is ambiguous and uses global policy. A bare foreground name is never enough, because WindowsTerminal.exe once became HITMAN 3's frame-limit target for an entire run. Two proofs pair a process with an AppID: it runs inside Steam's strInstallFolder, or RTSS lists it as rendering in RTSSSharedMemoryV2, matched on process id. The second exists because Skyrim SE through Mod Organizer reports an empty install folder. Per-application profiles are written only on opt-in or when RTSS already has one, because writing on every transition sprayed profiles onto every executable that took focus.

Every poll compares verified readback against what WSGM asked for (PerformanceService.DriftNeedsRepair) and re-applies once per disagreement. A second consecutive disagreement is reported and left alone, because another writer owns the profile. Outcomes name their origin (overlay, native-qam, drift-repair), because an unattributed 12 FPS cap once cost me an evening.

The frame limit is a slider ranged by Core/FrameLimitPairing.cs: 30 FPS up to the highest rate the display accepted, capped at 280 so it stays crossable on a thumbstick, with values under the floor treated as Off. The sheet and the QAM row share those bookends. When they did not, a stray thumbstick set a 12 FPS cap the QAM row could not represent, and the row deleted itself.

The OSD control offers Steam's five notches. Levels 1 to 3 are fixed presets in Core/RtssOsd.cs, 4 is the custom layout from Settings > Integration, and 0 renders nothing. Text goes into one claimed RTSS OSD slot, with offsets checked against RTSS 2.21 on the Claw. Every nonzero apply sets EnableOSD=1, and level 0 never writes EnableOSD=0, because a build that did turned off every overlay on the device.

Core/AutoTdp.cs is a pure controller. A window is a miss above 1.05x its deadline and headroom at or below 0.92x; three misses raise one step, eight comfortable windows probe one down, and a probe that misses restores the previous limit and records it as that context's floor, which is what stops oscillation. The deadline comes only from a verified, active RTSS frame limit; without one the controls read Requires frame-rate limit. A manual power change pauses control until AutoTDP is toggled. Core/RtssFrametimeReader.cs reads the shared-memory application table, layout confirmed against RTSS 2.21; a read from a rendering game is still an attended test.

Display

AppConfig.GameModeLaunch has two kinds, not the four modes of 1.x. Default is the scaling posture in Core/DisplayScale.cs: capture every display's scaling, drop to 100% so DPI-unaware games render 1:1, restore on the way back. Off, DPI-only and automatic all collapse into it, because Off left desktop scaling inside Big Picture and automatic capture could learn an exclusive-fullscreen game's temporary mode as the saved preference.

Custom applies a saved DisplayLayout (displays on, primary, position, resolution, refresh, scaling, advanced colour), captured with Snapshot from a desktop you already arranged. Layouts are keyed by DisplayTargetIdentity (device-interface path with EDID fallback), so they survive GDI renumbering and hotplug. WindowsDeviceControl.DisplayLayouts validates, captures a rollback set, applies once with readback, and attempts one rollback on a mismatch, never a retry. A failed rollback is reported as unknown, not as a restored desktop.

GameModeLaunch.KnownDisplays remembers every display WSGM has ever seen, which is what lets a TV behind an HDMI switch be configured while unplugged: my reference desktop's TV exposes no EDID until the switch selects the PC. Choosing a primary normalizes the arrangement so it sits at 0,0. Layouts migrated from the retired per-monitor profiles keep their values but have no resolvable identity, so Settings marks them as needing confirmation and entry refuses them. HDR uses DisplayConfig advanced-colour packets against the path target, and a persisted flag is neither shown nor applied when the target reports no support. The layout a running session owes the desktop lives in AppConfig.GameModeLaunchRecovery, separate from the scaling snapshot.

What a panel advertises is not what a driver accepts. On the Claw, EnumDisplaySettings reports 30/48/60/75/100/120 Hz and CDS_TEST accepts all six, but the EDID advertises only 60 and 120; the rest are synthesized inside the panel's adaptive-sync range. Applying 48 Hz moved DWM's refresh to 47.997 while Windows Settings kept showing 120, because the change is made without CDS_UPDATEREGISTRY. That is exactly what makes a game-scoped change safe: exit, crash or reboot all restore your own configuration. Core/RefreshRatePairingService.cs prefers the lowest mode at least twice the cap under frame doubling, the lowest exact multiple otherwise, and leaves the rate alone when no multiple exists. Cap-only is the default wherever variable refresh covers the range, since a mode change can hitch an exclusive-fullscreen title.

Variable refresh goes over IGCL, unelevated, and the transport belongs to the Claw plugin. Four facts that cost me real time: enumerations are two-call; the panel is chosen by which output answers (twelve enumerate, one is real); IGCL's bool is one byte; and every struct passes its own size, where a mismatch is indistinguishable from "no variable refresh". Driver VSync is a presentation mode rather than a toggle: IGCL names the offered modes, but the value lives in the adapter's Global_AsyncFlipMode registry value and needs elevation to write. The GPU memory share is a registry percentage under the adapter's GMM key, default 57, effective after restart, and it is never journalled because it is your choice.

Tools > Display offers driver-validated resolution and refresh pickers, where Apply is the only write, plus a brightness slider that shares the session's brightness service with Steam QAM, with serialized writes and confirmed readback.

Power

Windows power schemes live on the sheet's Device > Power page, even with Device Integration off. Core/PowerSchemes.cs reads them through the library, keyed by GUID. Choosing stages the change; Apply calls PowerSetActiveScheme once and verifies with PowerGetActiveScheme. An unconfirmed write needs a Refresh before another attempt, never a retry or a rollback.

Device power presets and AC/battery assignments appear when the plugin declares them. The Claw supplies Super Battery, Balanced, Extreme Performance and Full Power, each one a PL1/PL2 pair, a Windows power mode and an EC scenario. Both UIs derive the current preset from observed values and show Custom on any mismatch; changing a value inside an applied preset saves the observed profile as Custom for that source, and per-game overrides change only that game. Nothing is retried. The TDP selector offers Unified, where the boost row is read-only and the plugin coordinates both limits, or Advanced with independent edits.

Core/HybridCores.cs exposes the Intel P-core/E-core preference beside the energy plan and on Steam's Performance tab, and both drive one policy. WSGM writes only the thread scheduling policies Windows names, and never chooses a heterogeneous-policy value, because powercfg /qh publishes no meaning for it.

Core/GameplayDisplayHold.cs holds a DISPLAY request while a game runs, because Windows feeds its idle timer from keyboard and mouse only and a controller-only session looks idle. With Big Picture in the foreground, powercfg /requests listed a DISPLAY request from RustDesk and nothing else, while the display timeout read 60 seconds. The hold is scoped to the running application, and it is DISPLAY rather than SYSTEM, because stopping standby is your decision.

Shell/ModernStandbyGuard.cs (off by default, Settings > System > Power) puts the machine back to sleep after an unexplained wake. It changes no power settings; ModernStandby.WasLastResumeUnattended is the whole basis, and it refuses on a lit display, on any input, inside a settle period, or after three attempts. Anything it cannot read leaves the machine awake. The report under the toggle states whether the machine does S0 idle, how long the last standby was, and which devices may wake it. It never names what woke it, because Windows exposes no call that says. I have not measured a drain comparison.

Keep-awake

Downloads during real Modern Standby sleep are impossible for a Win32 application, because DAM suspends every desktop process. So the feature is "don't fall asleep", which is SteamOS's Display-Off Downloads model. Core/WakeLock.cs holds a system request; the display still times out, and Wi-Fi and Steam keep running. Windows limits apply: indefinite on AC, force-terminated about five minutes after the sleep timeout on battery, and the power button always wins. Checked on the Claw.

There are two independent holds, each its own request so powercfg /requests attributes them. The manual toggle cycles Off, Standby lock, Standby+Display lock, acquiring before releasing so there is never a gap. The download hold (Shell/KeepAwakeService.cs) polls Steam's download overview over CEF every 30 seconds. The Windows client's active string is Downloading, not the Updating that Linux documentation describes. Release is debounced over two inactive polls, and an unreachable poll counts as inactive so a dead Steam cannot pin the device awake.

The row's dot uses WakeWatch's colours: green free, yellow standby-blocked, red display-pinned, grey unknown. "What's keeping this awake" (Overlay/WakeLockHoldersView.cs) lists every requester, collapsed so thirty Steam requests read as steam.exe ×30, and it does not hide WSGM's own. It reads NtPowerInformation(GetPowerRequestList) on ntdll directly because the documented wrapper rejects the class. It needs elevation, and any structural surprise yields grey, never a false all-clear.

Four idle-timeout rows cycle presets through the library's policy-value API, never powercfg /q, whose output is localized. The screen-off rows never go below Steam's Big Picture screensaver timeout (Shell/DisplayTimeouts.cs).

Mute during screen-off downloads

Steam plays a sound for every finished download into a dark room. Shell/DisplayOffMuteService.cs (off by default) mutes when the setting is on, this session's display is off, and Steam is actively downloading. Screen-off on its own never mutes.

The signal is RegisterPowerSettingNotification for GUID_SESSION_DISPLAY_STATUS, the one Microsoft documents for interactive applications. GUID_CONSOLE_DISPLAY_STATE is for services and GUID_MONITOR_POWER_ON is legacy. Dimmed is not off. It does fire when the Claw's screen times out under Modern Standby. Because no user-mode API reports display power, WSGM registers all three settings plus session unlock, with one asymmetry: only the session setting may report dark, since console state would mute the wrong session after a fast user switch, while every source may report the screen coming back. Only state 0 mutes, and every other value restores, including anything Windows adds later.

Three rules, each one a bug that actually happened. The "we muted this" claim clears only after a confirmed unmute, because clearing it first let one transient endpoint failure strand the mute forever. A failed unmute retries on a 2 second timer that runs only while the claim is outstanding. And that timer watches GetLastInputInfo against a mute-time baseline, so keyboard, mouse or touch lifts the mute even without the display-on notification. It does not see gamepads or the power button. Only a mute WSGM applied is undone, the service restores on process exit, and a hard kill can still leave the device muted, which is why the setting defaults off.

Memory behavior

A resident shell gets judged by its Task Manager number while a game runs. WSGM 2.0 is self-contained CoreCLR rather than NativeAOT, but Avalonia still has no supported in-process teardown, so the trim stays: after boot settles, and a few seconds after each sheet close, Core/MemoryTrim.cs runs an aggressive compacting GC and then K32EmptyWorkingSet. Trimmed pages soft-fault back on the next open. The GC is configured for small heaps (System.GC.ConserveMemory=9, concurrent GC off). The 1.x figure of 166 MB down to 1 MB was measured on the NativeAOT build, so I am not restating it here.


The Steam CEF bridge, 2.0 edition

Steam's UI is Chromium. When <SteamDir>\.cef-enable-remote-debugging exists at a cold start, the client opens a CDP endpoint on 127.0.0.1:8080, and Runtime.evaluate against the right target lets WSGM call Steam's own client API.

In 2.0 the reusable half lives in external/steam-ui-toolkit (SteamUiToolkit, MIT): transport, patch lifecycle, in-page bridge, ownership primitives and every revived Valve surface. WSGM keeps the data behind them, the policy about which patches are on when, and its own features: library tabs, download sorting, glyph delivery. Shell/SteamUiSessionHost.cs owns the bridge, patch manager, modules and runtime.

Two targets, as before. SharedJSContext is the headless brain: stores, webpack modules, React, empty DOM. The visible Big Picture window is where the DOM lives, matched by its creation URL (about:blank? with createflags and minwidth, without browserviewpopup or openerid), never by its localized title.

The port is a trust boundary. The toolkit reads the TCP listener table through GetExtendedTcpTable and refuses port 8080 unless the owner is steam or steamwebhelper, loopback rows first so a squatter cannot hide behind Steam's wildcard row. The 1.x netstat parser is gone for the reason it failed: LISTENING never matched on a German Windows. The webSocketDebuggerUrl must be ws or wss on loopback port 8080. The flag file is never deleted, because CSSLoader-Desktop and Millennium depend on it too.

Values are JSON-encoded into JavaScript, always (SteamCef.JsString), because a raw Windows path loses its backslashes and Steam rejects it as NotWritableFolder. Results keep unreachable, Steam threw, and a value apart (CefEvalResult.Reachable), so a renamed API is never diagnosed as a closed client.

The transport gate is the big 2.0 rule. Two cold boots on the Claw never produced a Big Picture window, and in both, WSGM had touched CEF seconds before the window existed; the one boot that succeeded had connected 80 ms after it appeared. A running Steam process and a reachable SharedJSContext are not proof the UI may be touched. So ShellSession closes the transport whenever game mode has no Big Picture window, and in both modes discovery must find exactly one shaped main window before attaching to anything (requireMainWindow: true); a login popup does not count. Every patch, probe and one-shot evaluator shares that choke point. Before a Big Picture request the session host retracts everything and closes the transport under a 5 second budget, because Steam rebuilds its front end against whatever SteamClient.System.* says exists. Turning Cef.Enabled off retracts first and closes second, since a closed transport fails even WSGM's own removal calls.

Patches declare what they own, probe read-only, apply, verify, and get removed when they cannot verify. An unverified mutation is never left in the client. A Steam restart is detected by the socket closing and a new browser id advancing every generation. The injected asset (Core/SteamUiAssets/NativeQamBootstrap.js) is compiled by eng/build-steam-assets.mjs from the toolkit's TypeScript fragments. Its SHA-256 lives in Core/SteamUiAssetCatalog.cs, is re-hashed at runtime so a hand edit cannot ship, and travels into the page, because otherwise a new WSGM build kept running the old script until Steam restarted.

The Steam Client Beta of 2026-09-09 renumbered the whole webpack registry. Four gates that had named module ids refused on first start, and every Quick Access row refused because the localizer had been chosen by parameter names the new minifier changed. Nothing names a module id or a minified export any more: a module is found by a source fingerprint that matches it alone, an export by its shape. eng/check-steam-fingerprints.mjs parses an installed client's bundle without executing it and reports each fingerprint's match count. The registry is never swept, because a probe that called every factory once restarted the machine and signed Steam out.

Reviving Steam's own Quick Access Menu

Valve's performance, audio, Bluetooth and network surfaces ship in the Windows client and are inert only because nothing answers behind them. SystemPerfStore's constructor calls SteamClient.System.Perf?.RegisterForStateChanges; the namespace does not exist on Windows, the optional chaining no-ops, and every control renders null.

Decision D16 allows four responses and forbids one. An absent namespace is supplied (Perf, Audio). An absent RPC response is supplied. A stub with no backend has its methods replaced (BluetoothManagerService). A Deck-only store getter is overridden narrowly (networkManagementAvailable, literally return TS.IS_STEAMOS). The global platform constant is never set, because it gives the same Wi-Fi row and changes unrelated client behaviour everywhere. force_deck_perf_tab, Valve's persisted gate override, is never touched either, since it force-shows rows WSGM cannot back.

Filling a store is not enough. The audio store computes m_bAvailable = null != SteamClient.System.Audio once, in its constructor, before the namespace existed, so the running store is written directly. Rows behind constants that no data reaches (night mode is IN_GAMESCOPE) become WSGM-owned controls or nothing.

A row calls request(patchId, command, payload); the host authorizes the envelope (schema, allowlisted command, payload under 16 KiB, monotonic sequences, current generations) and the handler reads the payload strictly. State flows the other way: every service raises StateChanged, the host coalesces one publication, and the bridge replays the latest state to new subscribers. A failed or uncertain command is shown without a retry. A perf delta equal to the desired value is dropped as an echo, which is what ended a 4/0 overlay-level ping-pong.

Without a device coordinator, TDP, AutoTDP, device controls and controller target publish unavailable and refuse writes with the reason. A control is hidden by omitting its field. When a row vanishes, look for state received but rejected by validation: three frame-limit disappearances had that as the only evidence, the last because a Deferred progress term was missing from the injected row's vocabulary. A test now reads that vocabulary out of the built asset.

Performance state mirrors Valve's CMsgSystemPerfState (Core/NativeQamPerfProjection.cs). Valve's "no game" is pseudo-app 769, not 0, and publishing 0 made the header look up a blank name while a game was running. The header is driven by Steam's AppID as soon as Steam names a game, not by RTSS discovery. TDP is two sliders, Sustained (PL1) and Boost (PL2), bound to observed device values and writing only on a completed edit; Steam's saved TDP setting is never replayed.

Brightness (Shell/NativeQamBrightnessService.cs) polls every 2 seconds and stamps each read with a revision so stale readback is rejected. Matching readback is applied even when you asked for that same percent, because dropping it once left Steam's initial 100% in place.

Rows are mounted through the component host in the toolkit's components.ts. It resolves Valve's primitives by localization token and source shape, wraps React.useMemo once, and when the Quick Access tab array passes through, replaces two panels with wrappers built from Valve's own PanelSection, slider, dropdown and toggle. Nothing enters the DOM and no CSS is injected. No glyph is used twice, because the panel is navigated by shape before the label is read.

Steam's Storage and Screensaver pages

Big Picture ships a complete storage UI that never appears on Windows. It hangs off one question, StorageDeviceManager.IsServiceAvailable, asked over the WebUI service transport. Nothing answers, so the pages stay inert. SteamStorageSurface claims SendMsg on the transport and answers StorageDeviceManager.* from WSGM's state; every other message passes through untouched.

Shell/SteamStorageBridge.cs feeds it and reimplements nothing: eject goes to RemovableDriveManager, format and registration to SdFormatManager. The two managers know a card differently, by disk number versus device instance path, so the bridge joins them through what Windows reports about mounted volumes. Before that, Steam saw a reader with no volume and offered to format a card holding a library. has_steam_library is read from the card's own marker rather than Steam's registration, because a blank card showed true on the strength of the previous card's entry. Library paths travel in mount_paths, because Steam's eject looks up the block device whose mounts contain the folder path.

Steam's page never sends Format. Its Format Drive modal sends Adopt, because on SteamOS adopting a drive with no filesystem is what erases it. The bridge decides by the disk: no mountable filesystem means erase and register, behind a switch that defaults off; a filesystem present means register what is there. Trim is offered whenever a volume is mounted and refused with its reason when the reader does not pass it. An empty state after the first scan is still published, because "no removable drives" is a truthful answer and refusing to answer leaves Steam's spinner up. Both eject surfaces reach Shell/LibraryPolicy.cs.

The September 2026 beta brought Big Picture's screensaver to Windows, on its own idle timeout and holding no power request. WSGM leaves it Steam's and adds two rows, "Turn display off after (on battery)" and "(plugged in)", to the Screensaver section of Customization through SteamScreensaverSurface. They edit the active scheme's display timeouts through Shell/DisplayTimeouts.cs, the same owner behind the overlay's Power page; nothing caches, and both read Windows each time. The gate reports Steam's screensaver timeouts, and Core/DisplayTimeoutPolicy.cs bounds each display timeout by the one Steam pairs with it, so the display cannot go dark before the screensaver starts. A timeout under its bound is raised once, and a refused write is logged rather than retried. On the beta, the screensaver reported 5 minutes plugged in and the display timeout was raised from 1 to 5. The report is dropped whenever the patch does not hold, because the bound once outlived a switch to the Stable client, which has no screensaver.

Libraries

Adding a library to the running client is SteamClient.InstallFolder.AddInstallFolder(path) in SharedJSContext (Core/SteamCdp.cs), and Steam adopts, persists, mounts and scans on its own thread. The in-process route through CApplicationManager::AddLibraryFolder destroyed the library list and is abandoned. With Steam closed, Shell/SteamLibraryVdf.cs splices config\libraryfolders.vdf byte-exactly in Steam's own dialect.

The identity rule stands: a library is its contentid, never its path or letter. Steam keys install folders by path and never dedupes. Pull a card and its registration stays behind, unmounted, with its app list and capacity; add the new card at the same path and Steam appends a second entry. That is what "the new card shows the previous card's games at the right capacity" actually is. RefreshFolders() does not dedupe. A second add at a mounted path is refused with NotWritableFolder, which here means "already registered". A registration stays mounted with zero capacity when its folder is deleted, so mounted proves nothing. WSGM purges same-path registrations before adding, removes every match, and prefers the mounted entry when relabelling. nFolderIndex is a stable id, not an array position, so removals iterate one snapshot in order.

Two rules are new since 1.x. A card is named by its own libraryfolder.vdf marker, never by Steam's config label, which belongs to a path registration and survives a swap onto the next card's id; two cards flipped names on every swap, and the old two-way LastSteamLabel sync is gone. And a drive letter is a mount point Windows re-points on its own, so a rename resolves the letter to a volume GUID path once, validates the marker through it, and addresses every write to the volume. AddInstallFolder is the one call that needs a letter path, and its marker re-read sits immediately before it. An unmounted library cannot be renamed.

Shell/LibraryPolicy.cs owns every transition, because a media-level eject remounts the card within seconds and the monitor used to put back the registration the user had just ejected. An eject is now an intent recorded against the volume and its identity, cleared only by the media leaving, a different card in the slot, or an explicit adopt.

Formatting (Shell/SdFormatManager.cs) is three destructive stages: clean and create the partition; wait for the volume to appear, then format with up to three reverified attempts; assign a letter only when one is still needed. They are separate because format straight after create partition fails with "no volume selected" whenever the volume manager is slower than diskpart. A 512 GB card lost that race every time while a 256 GB card in the same reader won it. Before every destructive step the disk is reopened and its identity compared. The script is written under a GUID name, raised to high integrity, and consumed by an elevated diskpart. The card's existing registration is removed by contentid first, a fresh contentid goes into marker and config alike, and TRIM follows, best-effort. The card keeps its drive letter, because emulator front-ends store absolute paths. Any writable folder or network share can be registered through the same add flow.

Library tabs

User-defined tabs render inside Steam's tab strip, indistinguishable from Steam's own. Collections were the wrong model, since they render under the Collections tab, and are removed; Core/SteamCollections.cs survives as a read-only filter bridge and a one-time cleanup of ids older builds created.

Core/SteamLibraryTabs.cs is the one remaining legacy resident script, outside the patch manager until an attended migration. It finds React through the toolkit's resolver by a unique source fingerprint and installs a getter on React's dispatcher slot (__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H) so every useMemo result passes through patchTabs. The library's tab array is recognized by a tab with id === 'AllGames', and WSGM's tabs are appended as fake in-memory collections that Steam's own grid renders. Nothing is persisted in Steam. WSGM supplies only window.__wsgm.tabs, tabOrder and hiddenTabs; hiding is omission, and a hidden native tab reappears untouched.

Membership is computed by WSGM: each filter tree in Core/LibraryFilter.cs compiles to a pure JavaScript predicate over appStore. User regexes pass a gate first: at most 64 characters, no (? groups or backreferences, no nested quantifiers, then a 25 ms timeout probe across several alphabets plus one built from the pattern's own literals, because ([0-9]+)+x passes an all-a probe instantly and would hang V8 on a real title.

The audit removed the old walk of the require cache, which could poison exports for the session; the script now matches source without executing anything. The accepted fragility is the dispatcher slot name and the Library_FilteredByHeader marker. Kill switch is window.__wsgm.disableTabs(), and a Steam restart also recovers. The boot sync waits for the Big Picture window plus webpackChunksteamui, collectionStore and appStore.

Memory cards

A card is its contentid. Shell/LibraryTabManager.cs scans external volumes for <X>:\SteamLibrary\libraryfolder.vdf and reconciles a tracked-card list in config.json. Games are remembered while the card is ejected, so its tab survives unplugging. Fixed and Removable drives both count, classified by hotplug info, and the physical disk is opened with query access only, because GENERIC_READ needs elevation.

Rename sync is one-way now: the marker is written first, and only when the medium reads back the new name do the cache, Steam's label and the volume label follow.

The badge is a toolkit surface, SteamLibraryBadgeSurface, fed by Shell/LibraryBadges.cs. The beta broke the old in-page script, which anchored under a game page's hero art by pixel measurement. The beta's tile is one exported React.memo, so the toolkit claims its type and finds Valve's Steam Input badge in the rendered tree by identity; the badge sits immediately left of it and shows exactly when Valve shows that row. It is tile-relative, so Big Art Mode (library_home_big_art) changes nothing and is only logged. The text is the library name alone; green means installed and grey not, which is what a disconnected card amounts to. A game on no listed card is Internal while installed, and one installed nowhere gets no badge.

The game page names its library too, as a stat after Last Played and Play Time. The play bar is mobx observer classes whose instances pin a non-writable render, so no claim reaches the row; the stat is a transform on the toolkit's shared JSX-runtime claim. Offline checks pass on Stable and the beta, and I confirmed the page on Stable.

Home's carousel lists the attached libraries (SteamHomeCarouselSurface, fed by Shell/HomeCarousel.cs). Steam builds one array of app ids from four collections, capped at 20, and hands it to the carousel and its background; the gate replaces that array, so Steam's own components draw the result. Games on a pulled card leave, uninstalled games are greyed when you want them, and when nothing qualifies Steam's own list stands in. Home passes overscan: games.length, which mounts every tile, harmless at 20 and a flood at a whole library, so the gate resets it to the default of 3.

Shell/CardAcfWatcher.cs watches each card's steamapps for appmanifest_*.acf name events only: an install creates the file, an uninstall deletes it, and a download merely rewrites contents. Its directory handles would veto WSGM's own Safe Eject (FSCTL_LOCK_VOLUME needs the volume otherwise unopened), so eject suspends it.

Shell/CardVolumeMonitor.cs reconciles Steam's registrations on GUID_DEVINTERFACE_VOLUME notifications, which reach a message-only window where the broadcast DBT_DEVTYP_VOLUME never would. The notification arrives before the volume is lettered, so it settles 3 seconds and rescans. It considers Removable volumes only, purges only paths it saw as card libraries while mounted, and re-reads the marker immediately before acting because a pass can be seconds old. It runs in both modes now, after Big Picture on the desktop kept showing a pulled card's library. It detects immediately while the live add waits for the window. It reports; LibraryPolicy decides.

Header Wi-Fi indicator

Big Picture's header Wi-Fi icon is empty on Windows because Steam's backend sends device reports with an empty wireless.aps list, so SystemNetworkStore never sees a connected access point. WSGM's network gate merges the connected network from WindowsRadio.GetWifiStatus into the store through its own SetDeviceInfo ingestion. Wrapping OnNetworkDevicesChanged does not work, because the backend holds the callback it bound at init. The synthetic entry gets a no-op MarkAsNotPresent so periodic reports do not clear it, and removal deletes it and calls ForceRefresh. The indicator follows Cef.WifiIndicator in both modes, since game-mode-only left the header empty next to Explorer until Steam's network page started a scan. Any access point seen in a live probe may be WSGM's own.

Artwork

ArtworkSearch (Core/ArtworkProviders.cs) asks every ready provider at once rather than falling back in order, so a slow or empty primary cannot hide a good secondary; declaration order decides ties, so SteamGridDB leads. Screenscraper.fr issues developer credentials per application, so none ship and you supply your own. It indexes emulated systems by ROM, so it answers title searches only, and its media names (box-2D, wheel, fanart) are mapped onto Steam's slots inside the provider. HTTP 429 is the thread or minute quota, 430 the daily one. A source you never asked and a source that had nothing both produce an empty grid, so every refusal is named on screen.

Applying is unchanged (Core/SteamArtwork.cs): ClearCustomArtworkForApp, about 500 ms, then SetCustomArtworkForApp(appid, base64, ext, type) on SharedJSContext, because the clear's promise resolves before the clear finishes. Icons need filesystem writes and are refused. A non-Steam shortcut's generated id means nothing to SteamGridDB, so the changer searches by name once, asks you to pick, and persists the link in SgdbLinks. Shortcuts appear because the list reads collectionStore.allAppsCollection and flags BIsShortcut(); the type-games collection excludes them. Current art is previewed from userdata\<account>\config\grid\ (<id>p, <id>_hero, <id>_logo, bare <id>), by plain file reading.

Applying per-game launch fixes

Core/SteamLaunchConfig.cs writes the de-elevation and Input Block wrappers into the running client. A real title takes SetAppLaunchOptions. A non-Steam shortcut ignores an exe-replacement option, so its wrapper goes into the Target with SetShortcutExe and the real program into SetShortcutLaunchOptions. With Cef.Enabled off, every button falls back to the clipboard.

Four facts hold. Steam stores values verbatim, so WSGM supplies the quotes and never uses JSON.stringify(path), which doubles backslashes. The change is live and durable within a second, written by Steam; shortcuts.vdf and localconfig.vdf are never hand-edited. Reading back is RegisterForAppDetails, a subscription with a 3 second timeout and unregister on both paths. StartDir is never written.

Existing launch options are composed, not replaced, so %command% keeps expanding. A user prefix ahead of it is preserved byte for byte and only logged. A shortcut's original values are snapshotted into LaunchWrappers before the write, unwrapping an already-wrapped command so WSGM's own wrapper is never recorded as the original.

Download-queue sorting

Name, Size and Type buttons in the "Up Next" header reorder the queue through SetQueueIndex (Core/SteamDownloadSort.cs). The buttons are built from Steam's Focusable, because a plain DOM injection is invisible to the gamepad focus tree. The lookup is tight: a plain function under 1500 characters destructuring the quoted "flow-children" key with onActivate:, focusClassName and focusWithinClassName. A looser match once rendered a text area into the header.

The sorter no longer wraps jsx/jsxs itself. The library stat on the game page needs the same runtime, and two wrappers hand each other's originals back on removal, so the toolkit owns one claim and the sorter registers its header transform through the bridge's elements gate. It therefore needs the bridge and keeps it up on its own. The resident went to version 3 and unwinds a version 2 wrapper if it finds one.

Size means bytes left to download. A restarted client reports zero total for unplanned apps, which is unknown rather than smallest, so those are parked at the end in both directions. The scope is the entire pending list renumbered from zero, which empties "Scheduled" by design and resumes a paused queue, the same as dragging to the top does. One SetQueueIndex per item at 120 ms. It follows Cef.DownloadQueueSort in both modes.

Coexistence and what the beta brought

Steam's CEF allows concurrent CDP clients, and CSSLoader-Desktop only appends and removes <style> nodes in document.head. WSGM's resident scripts live under window.__wsgm, its glyph stylesheet is one <style class="wsgm-glyph-style"> removed by that class only, nothing named css-loader-style is ever touched, and the flag and port are never disabled. Glyph replacement copies CSSLoader's Handheld Controller Glyphs theme: content: rules on Valve's glyph images, with the plugin supplying artwork and WSGM the selectors. The probe reads the parsed stylesheets rather than the DOM, because the glyph nodes only exist while a controller screen is open.

The beta also brought per-source idle settings, which are not revived because dim and sleep there are SteamOS backends, and Big Art Mode, which is read and logged with nothing to revive.


Device integration

WSGM knows nothing about hardware. A device plugin teaches it one machine: power limits, fans, lighting, the controller, motion, OEM buttons. Device Integration is optional, and with it off the shell, overlay, lease, RTSS and recovery all still work.

One slot, no ambiguity

Exactly one installed package may exist, under %ProgramFiles%\WSGM\DevicePlugins\installed\<id>. Startup counts package roots before anything else runs, and two or more refuse normal startup with a message listing every root. WSGM never ranks one package over another, because there is nothing to rank with: a package is administrator-installed hardware code running with WSGM's authority. There are no trust tiers, so ambiguity is refused rather than resolved.

Replacement goes through the fixed .staging and .previous siblings while the Global\WSGM.DevicePackageSlot mutex is held, published as two moves, so discovery never sees a half-published package. The manifest carries six fields; identity and capabilities come from plugin code, so the manifest cannot disagree with what the plugin does.

In-process, deliberately

ShellSession creates at most one DeviceCoordinator. It reserves Global\WSGM.DeviceOwner for the process lifetime, serializes every transition through one gate, and loads the entry type into a collectible AssemblyLoadContext. That context isolates dependency resolution, not crashes: a process-fatal plugin failure terminates WSGM with it. Resolution is host-first, and the SDK and the WinRT runtime pair always come from the host. CsWinRT registers a process-global ComWrappers once, so a second WinRT.Runtime.dll from the package broke whichever side initialized second. On the Claw that turned out to be WSGM's own Wi-Fi and Bluetooth queries dying.

The resident common PluginHost admits this runtime through DevicePluginCompatibilityAdapter. The coordinator keeps machine policy and the controller cleanup ordering.

Lifecycle

Detect is side-effect free. Start receives a cycle generation and a private state directory. The plugin then publishes a descriptor set (at most 128 capabilities in 16 sections), capability states, physical devices, controller samples, OEM controls and a settings manifest. Every command funnels through DeviceCoordinator.ExecuteCapabilityAsync with a 5 second timeout. Resume advances the cycle generation, and anything carrying a stale generation is refused. Stop closes admission, quiesces in-flight commands, runs the controller handoff, stops the plugin, and unloads the context only when cleanup was verified. A background fault gets two restarts, then Device Integration faults for the run with a manual retry.

A command outcome is AppliedVerified, AppliedUnverified, TimedOut, Indeterminate or Rejected. The middle two show as Uncertain and are never retried automatically, by AutoTDP or by anything else; a new user action or a new cycle supplies recovery. The plugin keeps a bounded recovery journal of temporary state it changed and could not restore, and replays it on the next start when the firmware identity matches.

Declared settings are rendered by WSGM and re-resolved against the declaration on every apply. Authored profiles, like a fan curve or a colour, are built in Settings, selected in the overlay, referenced by id rather than copied, and validated against the live descriptor at apply time. Glyphs are static, hash-locked plugin data, and any mismatch leaves Valve's glyphs in place.

Controller management

ControllerManager is the one owner of the virtual target, WSGM's HidHide delta and UI capture. The target is a VIIPER virtual USB device over usbip-win2's signed driver. The default is a Steam Deck, which carries the whole Neptune frame including all four rear controls and stick touch; Xbox 360 and DualShock 4 are selectable per running application. Motion is encoded as raw Deck counts, because normalized axes gave Steam a motion source with no usable gyro. HidHide hides the physical pad from games. WSGM allowlists itself before the plugin's cycle starts, because Handheld Companion's leftover allowlist had hidden the pad from SDL and from the plugin, and nothing said so.

Make-safe keeps two orderings: the target is removed only after the physical release has concluded either way, and HidHide entries only after the target is gone. Anything earlier exposes a device the plugin still holds, which is the duplicate-input state the single target exists to prevent.

The driver is installed only by setup's explicit task. INV-020 forbids the runtime from installing a driver, and the concrete reason is that installing usbip-win2 re-enumerates every USB 3.0 hub, which on a handheld drops the built-in controller, the touch digitiser and the keyboard all at once. A missing prerequisite makes controller management unavailable and nothing else. The virtual Deck costs under 1% of the machine.

Motion from the legacy sensor API

The Claw's pad HID never streams the IMU. Intel's sensor stack classifies both LSM6DSO sensors as SENSOR_TYPE_CUSTOM, so WinRT does not expose the accelerometer and suppresses unchanged gyroscope readings. LegacyPhysicalMotionSensors.cs reads both physical collections through the legacy sensorsapi COM interfaces and uses the hardware report counter to tell a fresh sample from a repeated poll.

The gyro carries a real zero-rate offset. Two eight-minute stationary captures hours apart measured +0.75, -0.37 and -0.14 degrees per second, identical flat and tilted. Nothing downstream removes it, so a Deck target that reports it drifts the view forever. StationaryGyroBiasCalibrator measures it from 200-report rest windows and subtracts it. Subtraction only: a deadband would trade drift for a dead zone around rest.

OEM buttons

The plugin publishes its controls, and DeviceOemActionRouter maps a press to one action from a closed vocabulary. Assignments are authored in plugin code and there is no rebinding UI, because every handheld on the market maps onto a Steam Deck controller with nothing left over. WSGM is not a remapper.

The MSI Claw 8 AI+ A2VM package

src/WSGM.Device.Msi.Claw8A2Vm is the reference plugin. Detection matches SMBIOS manufacturer MICRO-STAR INTERNATIONAL CO., LTD., baseboard MS-1T52 and SKU 1T52.1, exactly. Start re-reads that identity and separately gates the EC firmware prefix 1T52EMS1.109 and MCU revision 0229; a mismatch leaves those services FirmwareNotVerified. A different Claw is a different device.

ClawCapabilities.cs publishes power.primary-limit and power.boost-limit (8 to 37 W), battery.charge-limit, power.scenario, fan.mode, fan.curve (six points, both fans under one snapshot because they share a heatsink), fan.measured-rpm, telemetry.temperature, lighting.brightness, lighting.zone-color, controller.source, motion.source, haptic.rumble, and, only when the driver answers, display.variable-refresh, display.endurance-gaming, display.endurance-gaming-mode, display.shader-download, display.shared-gpu-memory and display.driver-vsync.

Presets and paired limits

Four presets: Super Battery (8/9 W), Balanced (17/18 W), Extreme Performance (30/31 W) and Full Power (37/37 W, a WSGM addition at the device's maximum). The pair is PL1/PL2. Their EC scenarios on AC are Eco, Green, Sport and Sport, and all four use Comfort on battery. WSGM selects the scenario before the watt limits, because firmware can reset limits, then derives Custom whenever any observed target stops matching. The mapping follows Handheld Companion's ClawA1M handler, which is source evidence rather than an attended check of the scenario's firmware effects.

The sustained descriptor names its boost companion through PairedPowerLimitId. AutoTDP sends one watt target with ApplyPowerPair, and the plugin writes both limits in the order that keeps PL1 <= PL2, then verifies or rolls back the pair.

Intel features

Arc Sync (ArcSyncTransport.cs) drives the panel's variable refresh through Intel's Graphics Control Library. ControlLib.dll ships with the driver in System32 and is loaded by name; its absence means unsupported. On the reference unit the panel reports 30 to 120 Hz, and a write to OFF and a restore both read back. Endurance Gaming and prebuilt shader download (Intel3dFeatureTransport.cs) use the same library's 3D-feature API. Driver VSync is Intel's gaming flip mode: the adapter offers application default, VSync on, Smooth Sync and capped FPS, and deliberately not VSync off. Shared GPU memory is not in IGCL at all (IntelGraphicsMemoryTransport.cs): it is GpuSystemMemoryPinninglimit under the adapter's GMM registry key, 13 to 87%, default 57, effective after a restart. The value read 57 and the adapter reported 57.07% of installed memory, which is what ties the key to the feature.

Recovery

ClawRecoveryJournal.cs writes temporary-state.v1.json (16 KiB) with at most three entries: msi-power, msi-fans and physical-controller mode. On start the plugin restores an entry whose firmware identity matches, and blocks the service after a failed restore. Charge limit and lighting are persistent user choices and are not reverted.

The package is MIT, because a reference nobody may copy is not a reference. A plugin links only the MIT SDK, never WSGM, so a derived plugin can carry any licence.

Device Lab

wsgm-device (src/WSGM.DeviceLab, MIT) is a GUI and CLI over the same code. It exists because writing a plugin means answering questions no documentation holds, like whether a power write actually took, before the plugin exists.

The flow is doctor and inventory (--shareable redacts identity for a bug report), then capture run, an attended observe-only capture whose export requires typing OBSERVE and then EXPORT after a preview of the sanitized bundle. inspect, compare and correlate read captures offline. scaffold turns a capture into a buildable plugin linking only the SDK. validate checks the package without loading code. test plugin loads code in a contained worker and runs DetectAsync only. pack produces a deterministic .wsgmpkg.

The attended split is enforced. test hardware is the only mutation path: one explicit action, a new state directory, an interactive terminal, no CI, elevation and the typed confirmation RUN HARDWARE. There is no --yes, no bulk route and no remembered consent. It reserves the same Global\WSGM.DeviceOwner object as WSGM and keeps it until the process exits if cleanup was not verified. Hardware writes demand presence because a capability write is only proven on a real machine, and an uncertain write cannot be undone by a tool that was not watching. The tool never touches live %LOCALAPPDATA%\WSGM state.

Common plugins

src/WSGM.Plugin.Sdk is the MIT, dependency-free contract for integrations that are not a device. IPlugin starts with a host-owned instance and generation, receives Desktop/Game and suspend/resume transitions, then stops and disposes. IPluginHost.PublishState publishes effective observations with generation, sequence and origin. PluginManifestReader accepts at most 64 KiB of JSON, rejects unknown members, checks the API and dependency ranges, and admits only a DLL file name at the package root.

Categories are stable strings. Device is wsgm.device with zero or one active instance; other categories are open and the host decides multiplicity. A manifest cannot grant itself multiplicity or privileges.

Configuration and state are different records. IConfigurablePlugin declares bounded preferences, and the host saves only explicit edits carrying the revision the UI read. PluginStatePublication is effective state only, at most 128 keys per instance, and cannot reach the configuration store.

IPluginActions declares named operations. Each invocation gets a fresh operation id, the current generation, an origin and a deadline. Outcomes are Dispatched (sent), Unconfirmed (no or mismatched reply), Rejected, and AppliedVerified, which requires independent evidence of the declared effect. Nothing is retried. IPluginUi supplies status, button, toggle and slider descriptions, and WSGM renders them on the overlay's Tools page and owns every control. Toggles and sliders need an explicit Apply. A plugin may declare up to 32 widgets of one to eight contribution links each, and you pin them to Quick Access, bounded to 64 pins.

CommonPluginCatalog reads %ProgramFiles%\WSGM\Plugins\<id> without executing code. Nothing runs until AppConfig.PluginInstances names it enabled, and the default is empty. CommonPluginDependencyPlan orders enabled packages before their consumers and rejects missing, duplicate, incompatible and cyclic dependencies. Loading reuses the same collectible, host-first PluginLoadContext as the Device runtime, which is hosted through the same registry by its adapter. Execution is trusted in-process code, and load contexts isolate dependencies, not security. A stop that times out keeps its slot reserved until the task actually ends.

The IR plugin and XIAO IR Mate firmware

src/WSGM.Plugin.Ir is the first independent package, category wsgm.infrared, using only the common SDK, and a Device plugin runs beside it. It owns its command library, the endpoint protocol (protocol.md) and the firmware for Seeed's XIAO IR Mate, an ESP32-C3 with an IR transmitter and receiver.

Protocol 1 is one compact JSON object per line, each request carrying v: 1, an id and an op; only a matching id can complete host work. It travels over USB CDC at 115200 or, once paired, over one plain TCP connection to port 7521, advertised by mDNS as wsgm-ir-<last three MAC octets>.local.

Pairing always runs over USB, whatever connection is selected, because holding the cable is the proof of possession. The plugin mints a random 48-character token, stores the network credentials and token on the endpoint, and keeps only the token, host name and last address in endpoint.json; the password is never written to plugin state. Every network request except identify must carry the token or the endpoint answers unauthorized, and wifi over the network answers usb-only. The token travels unencrypted on the LAN. Firmware 0.1.0 has no network support.

A learned payload is complete: alternating mark and space timings plus carrierHz and a carrierSource of assumed, protocol, measured or manual. This firmware's capture path does not measure the carrier and reports an explicitly assumed 38 kHz; whether the receiver hardware can measure one is unverified. Commands and scenes live in the host's library.json, not in firmware slots. Firmware 0.3.0 added sendCode, sendAc and protocols; 0.4.0 carries built-in remotes fixed at build time, serves them over HTTP behind Basic authentication and a required X-WSGM-IR: 1 header, and exposes them as host actions.

Every send returns Dispatched. The endpoint's acknowledgement proves emission, not that a television changed input. Session automation in Settings > Display runs these as ordered steps at Game Mode entry and leave and at desktop startup and wake; entry stops at the first step that did not succeed, nothing is retried, and a rejected step earns no compensation, so a refusal never emits.

Hardware acceptance passed on 2026-09-11. The plugin paired the endpoint over USB, it joined my network as wsgm-ir-15ef50.local, answered identity over Wi-Fi, and refused an unpaired LAN client. It learned a real HDMI switch remote button over Wi-Fi as a 71-timing NEC frame (address 128, command 1) and replayed it twice, and the switch changed to input 1 each time. The assumed 38 kHz carrier was enough for that switch; other appliances are still unverified.

Windows Device Control

The Windows calls behind radios, audio, brightness, displays and power were extracted into external/windows-device-control, an MIT library published as WindowsDeviceControl. The 1.x Rust helper (WSGM.Radio.dll) is gone: WSGM is CoreCLR now, so the library calls WinRT and Win32 from managed code, with no native component and no helper process.

It owns WindowsRadio (radio power, Wi-Fi, Bluetooth discovery and pairing), WifiProfile, CoreAudio (endpoints, default switching through the undocumented IPolicyConfig, volume), Backlight (the ACPI \\.\LCD device, unelevated), WaveOutFeedback, the DisplayTopology, DisplayLayouts, DisplayScaling, DisplayColor and DisplayModes family, WindowsPower (schemes, AC and DC values, the hidden hybrid-core placement settings), WindowsPowerRequest, WindowsWakeSecurity and ModernStandby (wake sources, programmable or fixed). WSGM keeps only wording and policy on top, in Shell/RadioManager.cs and Shell/AudioManager.cs.

What still holds for radios

Each radio task still has exactly one API that works from an unpackaged process. Radio power is Windows.Devices.Radios: an AnyCPU process that lands on x86 enumerates nothing, and SetStateAsync is gated by the "Allow apps to control device radios" privacy setting while reads are not. Wi-Fi is wlanapi, because WinRT's WiFiAdapter needs a wiFiControl capability an unpackaged shell cannot declare. Bluetooth discovery is WinRT because the Win32 API cannot see LE devices, and 32feet.NET still fails three ways.

Two constraints hang pairing rather than failing it: the deferral must stay alive until the answer is applied, and each token may complete it at most once, including when a timeout races a late answer. A cancel completes only that attempt's deferrals and then cancels the OS-side operation, and one retry offering DisplayPin is kept for devices that reject the first mask. Discovery is push through a DeviceWatcher, the radio list is cached briefly because WinRT enumeration can stall, and state aggregates across every radio of a kind.

Joining Wi-Fi is still a minefield. Profiles are authored per what the network advertises, keyed on raw SSID bytes, written all-user because a user-scope profile cannot connect from a shell-less or elevated context, and given collision-free temporary names. The verdict callback is registered before WlanConnect, whose acceptance is not success. Only an authentication or key failure re-prompts for the password. On Windows 11 24H2 a scan denied with error 5 is the location-consent gate, which neither elevating nor retrying fixes. The consent store is a diagnostic only: a 25H2 machine reported radios = Deny there while RequestAccessAsync returned Allowed.

WSGM.exe --radio-probe remains as a read-only diagnostic for a shell-less session.

Clone this wiki locally