diff --git a/AGENTS.md b/AGENTS.md index 4d2523a..5a6fe59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ A Windows game launcher that finds installed games on its own (stores, external ## Commands - Install frontend deps: `cd frontend && npm ci` - Dev (app): `wails3 dev -port 9245` (or `task dev`) -- Dev (interface only, made-up games): `cd frontend && npm run dev:mock` +- Dev (interface only, made-up games): `cd frontend && npm run dev:mock` (`?layout=console|orbit`, `?games=N`, `?art=steam`, `?syncer=missing|old|off`, `?controller=ready|restart|missing`; `window.mockPad("down")` presses a controller button) - Build: `wails3 build` → `bin/Seaglass.exe` - Installer: `wails3 task installer VERSION=vX.Y.Z MAKENSIS=".../makensis.exe"` - Regenerate bindings after changing exported service methods or their types: `wails3 generate bindings -f '-tags production' -clean=true -ts -i` diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt index aa92594..f46a4c6 100644 --- a/THIRD_PARTY_NOTICES.txt +++ b/THIRD_PARTY_NOTICES.txt @@ -25,10 +25,12 @@ Go standard library and runtime BS golang.org/x/image v0.46.0 BSD-3-Clause Seaglass.exe golang.org/x/net v0.56.0 BSD-3-Clause Seaglass.exe golang.org/x/sys v0.48.0 BSD-3-Clause Seaglass.exe +HidHide (ported IOCTL definitions) MIT Seaglass.exe (internal/vpad) Microsoft Edge WebView2 Runtime bootstrapper Microsoft redistributable Seaglass-setup.exe NSIS (Nullsoft Scriptable Install System) Zlib Seaglass-setup.exe (installer) SDL 3 3.4.16 Zlib Seaglass.exe (embedded SDL3.dll) svelte 5.57.1 MIT Seaglass.exe (interface) +ViGEmClient (ported protocol definitions) MIT Seaglass.exe (internal/vpad) ======================================================================== @@ -568,6 +570,24 @@ Same license text as Go standard library and runtime above. Same license text as Go standard library and runtime above. +======================================================================== +HidHide (ported IOCTL definitions) +License: MIT +Included in: Seaglass.exe (internal/vpad) +HidHide's control IOCTL codes are ported to Go from https://github.com/nefarius/HidHide (Shared/HidHideIoctlContract.h). + +MIT License + +Copyright (c) 2020 Eric Korff de Gidts +Copyright (c) 2021-2024 Benjamin Höglinger-Stelzer + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ======================================================================== Microsoft Edge WebView2 Runtime bootstrapper License: Microsoft redistributable @@ -618,3 +638,33 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +======================================================================== +ViGEmClient (ported protocol definitions) +License: MIT +Included in: Seaglass.exe (internal/vpad) +The ViGEmBus client protocol (IOCTL codes, XUSB report and request structures) is ported to Go from https://github.com/nefarius/ViGEmClient (include/ViGEm/km/BusShared.h, include/ViGEm/Common.h, src/ViGEmClient.cpp). + +MIT License + +Copyright (c) 2016-2023 Nefarius Software Solutions e.U. and Contributors +Copyright (c) 2018 Benjamin Höglinger-Stelzer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c1fcc4a..70c4de5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,15 @@ The DualSense was found 25 to 300 s late after Seaglass started, and again after - `pad.Manager` times `SDL_Init`, `SDL_Quit` and each `SDL_PollEvent` (SDL looks for new controllers inside it). A call over 5 s is logged with its duration and sets `State.Slow` until SDL next starts. The interface shows a note telling the person to reconnect the controller (`lib/padstate.ts`), and diagnostics flag it. +Seaglass also got its own Xbox controller, so Xbox-only games no longer need Steam to work with a DualSense. Steam Input worked badly for this, and Steam had to be installed, restarted and told about every game. + +- **Seaglass's Xbox controller**: a virtual Xbox controller (ViGEmBus) that plays what the real controller does, with HidHide hiding the real one from the game. Settings → Xbox controller for games sets up both drivers: Seaglass downloads the official releases, checks them against hashes pinned in Seaglass and runs the installers (Windows asks for permission for each). +- **Steam stays**: Settings has a choice of what turns a PlayStation controller into an Xbox one in Auto, Seaglass (default) or Steam (`padTranslator`, per PC). Games set to Steam Input still use Steam. +- **New per-game mode, Xbox controller**: always starts with Seaglass's Xbox controller (`padMode` `xbox`). Without the drivers the game starts directly, and the launch says how to set them up. +- **The real controller is hidden only while the game runs** (`hidePadWhilePlaying`, on, per PC), so games that also read it directly don't see every press twice. HidHide's released versions (up to 1.5.230) only have the block list saved with its settings, so Seaglass writes each controller it adds there to `hidden-controllers.json` first. It takes exactly those off again when the game ends, when Seaglass starts or quits (so a crash is undone at the next start), and from **Unhide controller** in Settings. A HidHide with the per-process list (1.7, unreleased so far) is used instead when it's there. +- **Without ViGEmBus, Auto starts Xbox-only games directly** with a hint to set it up, instead of going through Steam Input, which worked badly. Steam can still be chosen in Settings or per game. +- ViGEmBus results are handled as ViGEmClient does: a report submit only fails on "access denied" (the live test against ViGEmBus 1.22 found the bus ends good submits with other errors). + ## 1.10.0 (2026-10-03): controller and audit fixes A DualSense stopped being detected, and the mouse wheel did nothing in big picture. No cause could be reproduced without the controller, but the controller layer had three ways to stay released: mode switches from two places at once reaching the SDL thread in the wrong order (after which `SetMode` ignored the fix-up), an SDL start that failed and was never tried again, and the controller coming back only after the steps after a game. `pad.Manager` now signals a mode change and the SDL thread reads the newest mode itself; failed starts are retried every 30 s; the controller is active again at `Finishing`. An `SDL_OpenGamepad` failure is logged with SDL's error, and diagnostics show the mode SDL is really in (`Manager.InMode`). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 259a8fd..8d1d6ef 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,7 +26,7 @@ These need the maintainer's accounts or decisions; nothing else can do them. - A Windows game launcher that finds games on its own: store installs and external copies (games installed outside a store launcher: standalone and DRM-free installers, Steam API emulators, plain folders), and adds art and metadata the way Playnite does for Steam games. - Two modes: **Desktop** (mouse, layout D) and **Big picture** (controller). Big picture has three layouts, picked in Settings: **Deck (default)**, Console, Orbit. -- First-class DualSense: native input, glyphs, haptics, lightbar, and the PS button to summon the launcher. Games without DualSense support start through Steam Input automatically. Games that support it start directly. +- First-class DualSense: native input, glyphs, haptics, lightbar, and the PS button to summon the launcher. Games without DualSense support get an Xbox controller automatically: Seaglass's own bridge (ViGEmBus and HidHide) is built; Steam Input stays as a choice in Settings and per game. Games that support it start directly. - Syncer integration: save status per game, sync before launch, back up after exit, conflicts, and each person's playtime, achievements and settings following them between PCs. - Pretty, responsive, secure and light: the interface unloads while you play. @@ -71,7 +71,9 @@ These need the maintainer's accounts or decisions; nothing else can do them. | Risk | Mitigation | |---|---| | Wails v3 is a beta that ships every few days | Pinned version, deliberate upgrades, thin Wails-specific layer | -| Steam only picks up `shortcuts.vdf` changes after a restart | Batch changes, apply when Steam is closed or after asking; only games that need Steam Input | +| Steam only picks up `shortcuts.vdf` changes after a restart | Batch changes, apply when Steam is closed or after asking; only games that need Steam Input. Applies only to the Steam fallback now | +| ViGEmBus was archived in 2023, so its driver is frozen | Seaglass talks to it through a Bridge interface, so VIIPER over usbip-win2 can replace it | +| HidHide's per-process hiding needs 1.7, which has no release yet | Seaglass uses the saved block list of 1.5.230 with a ledger of what it added, undone at game end, start, quit and from Settings; switch the pin to 1.7 once it is released | | DualSense shared between the launcher and a game (Bluetooth report mode) | Passive listener while playing, no output reports, can be turned off | | Steam also claims the PS button | Setting explained in Controller settings; detect and warn | | Overlay over exclusive full-screen games | Recommend borderless windowed; overlay is optional; no injection | diff --git a/docs/design/controller-without-steam.md b/docs/design/controller-without-steam.md new file mode 100644 index 0000000..3cd26d4 --- /dev/null +++ b/docs/design/controller-without-steam.md @@ -0,0 +1,189 @@ +# Controller support without Steam + +Status: built (phase 1). + +## Problem + +When a game supports Xbox controllers but not PlayStation ones, Seaglass starts it through Steam so that Steam Input makes a DualSense or DualShock 4 look like an Xbox pad. Florian reports that this works badly. The goal is to make PlayStation (and other) controllers work in XInput-only games without Steam. + +## How the Steam route works today + +1. **Choosing the route.** `route()` in `internal/app/launch.go:126-146` decides first, and `frontend/src/lib/route.ts` repeats the same logic for the interface. + - A store link always wins. + - Next comes the per-game `PadMode` (`internal/library/library.go:72`): `""` is Auto, the others are `native` and `steam`. + - In Auto, a game goes through Steam Input only when all of these hold: a PlayStation pad is connected, the game's metadata says Xbox controller support is `full` or `partial`, and the game has no DualSense support. +2. **Adding the shortcut.** `steamInputStep` (`launch.go:298-350`) looks for a non-Steam shortcut in `shortcuts.vdf` (`internal/steaminput`). + - If the shortcut is missing and Steam is running, Seaglass asks: restart Steam, start without it, or cancel. + - Steam is shut down, every Steam-mode game is added in one batch, and the step sets `steam://rungameid/`. +3. **Starting the game.** `platform.OpenURI(steam://…)` returns no process id. Tracking therefore falls back to the folder rule only (`internal/launch/track.go:123`, `:100-105`). Seaglass's own pad layer goes Passive (`padForGame`, `launch.go:235-245`). + +## Why it works badly + +These failure modes come from reading the code and docs. None was reproduced on a PC. + +- **Steam has to restart to see a new shortcut.** It only reads `shortcuts.vdf` when it starts (ROADMAP risk table). The first launch of each game asks to restart Steam, and a Steam that hangs while closing fails the launch (1-minute cap, `steaminput.go:225-250`). +- **Steam has to be installed, signed in and running.** `ErrNoSteam` only checks for a `userdata` folder (`steaminput.go:53-66`). Steam's own updates and its login screen sit between the user and the game. +- **The result depends on a Steam setting Seaglass can't see.** Steam's *Enable Steam Input for PlayStation controllers* decides whether anything gets translated (`docs/releases/v0.4.0.md:32`). Seaglass never reads it. Steam's per-game layouts can also change the mapping. +- **Steam and Seaglass both want the PS button.** Seaglass uses Home for its overlay. Steam opens its own overlay or Big Picture on the same press. The docs tell people to turn Steam's shortcut off, but the "detect and warn" item in the ROADMAP was never built. +- **Playtime tracking gets weaker.** With no process id, a game whose exe is outside the known folders is never seen. A Steam start that takes more than 5 minutes (updates, shader caches) ends the session unseen (`launch.go:179-181`). +- **Shortcuts pile up.** The shortcut id comes from the exe and the name (`steaminput.go:41-43`). Renaming a game or moving its exe adds a new shortcut and leaves the old one in Steam. Nothing tidies them up (open ROADMAP item). +- **A broken `shortcuts.vdf` breaks every Steam-mode launch** (`steaminput.go:100-108`). Steam could also start between Seaglass's "is Steam running" check and its write (`steaminput.go:115`). + +## Options + +Seaglass already *reads* every common controller through the embedded SDL3 (DS4, DualSense including Bluetooth, Switch Pro, Xbox, generic HID with SDL's mapping database). That part is solved. What's missing is a way to show the game an **Xbox controller**, and to **hide the physical pad** so that games which also read it directly don't get every press twice. + +### A. ViGEmBus (virtual Xbox 360 pad) plus HidHide (hide the real pad) + +This is what DS4Windows has used for years. + +- **ViGEmBus** is a kernel bus driver that plugs in virtual Xbox 360 or DS4 pads, which games see as real XInput devices. + - Licence: BSD-3-Clause. The client protocol comes from ViGEmClient (MIT): the app opens the bus's device interface and sends `DeviceIoControl` requests (plug a target, submit a report, wait for rumble notifications). Go can do that through `golang.org/x/sys/windows` without cgo. No Go client exists; a pure Rust one does (`vigem-client` crate), which shows the protocol can be reimplemented without the C SDK. + - Status: archived on 2023-11-02 after a trademark dispute; the last release is v1.22.0 (installer `ViGEmBus_1.22.0_x64_x86_arm64.exe`). + - Signing: the driver is Microsoft-signed for Windows 10/11 x86, x64 and ARM64, and existing installs keep working. No breakage on Windows 11 24H2/25H2 was found in the sources, but none were checked against it either. **Risk:** the driver is frozen, so a future Windows change would not be fixed. +- **HidHide** is a filter driver that hides chosen HID devices from every app except allowed ones. + - Licence: MIT. The last release found is v1.5.230 (May 2024), and the maintainer has no capacity for major work. + - It is configured through `\\.\HidHide` with documented IOCTLs (allow list, block list, active flag). These take MULTI_SZ lists, so Go can drive them without cgo. + - It also has a **session block list** for feeder apps. Entries live only in kernel memory and disappear when the owning process exits, so a crash of Seaglass can't leave a controller hidden. It needs checking whether the owner keeps seeing the device it hid, or whether Seaglass must also be on the allow list. +- **Controllers:** every controller SDL reads, presented to the game as an Xbox 360 pad. +- **Install:** two signed installers from the projects' GitHub releases. Each needs one UAC prompt, and HidHide may ask for a restart. Not bundled; see *Licences*. +- **Anti-cheat:** some games refuse to start while DS4Windows runs (for example the Battlefield 6 beta). No bans tied to ViGEmBus alone were found. Expect a few games to block it; the per-game mode then switches that game back to Native or Steam. + +### B. VIIPER over usbip-win2 (virtual USB devices) + +- **What it is:** VIIPER (Alia5, the author of SISR/GlosSI) is written in Go. It is a USB/IP *server* that emulates real USB devices: Xbox 360, DS4, DualSense (including Edge) and Switch 2 Pro. +- **Windows driver:** VIIPER needs **usbip-win2** (BSD-2, WHLK/attestation signed, actively developed) attached to localhost. +- **Licences:** the server is GPL-3.0 and its clients are MIT. GPL-3.0 code may be combined with Seaglass's AGPL-3.0 (AGPL §13), so it could even be linked in as a Go module. +- **Pros:** maintained, can present a DualSense as a DualSense, and the driver is current. +- **Cons:** + - The usbip-win2 install restarts the USB 3 hubs, and its README advises making a restore point first. + - VIIPER describes itself as early (v0.7.0, June 2026). + - There is no way to hide the physical pad, so HidHide is still needed. + - Neither project has been tried with Seaglass. + +### C. Driverless + +- **Windows.Gaming.Input.Custom and GameInput** can't *create* a gamepad. GameInput 3.5 improves DualSense *reading*, not virtual devices. +- **InputInjector gamepad injection** needs a restricted capability in a packaged app, so it doesn't fit an unpackaged Go exe. +- **Proxy `xinput1_3.dll` / x360ce-style DLLs** in the game folder need no driver. But they mean writing into game folders, break with GameInput or system32 loading, and look like cheating to anti-cheat. Seaglass's rule against modifying games rules this out. +- **Windows** still doesn't expose a DualSense through XInput. + +### D. Keep Steam, but fix its rough edges + +This would cover detecting Steam's PS-controller and Guide settings, cleaning up old shortcuts, and better tracking. It doesn't remove the dependency the request is about, but these remain useful fixes for anyone who keeps choosing Steam. + +## Recommendation + +**Build A: a "controller bridge" inside Seaglass on ViGEmBus + HidHide, with the drivers installed on request from their official releases.** Keep the Steam route as the per-game and global fallback. Put the virtual-pad side behind a small interface so a VIIPER/usbip-win2 backend (B) can replace ViGEmBus later if ViGEmBus stops working on a future Windows. + +Why A over B now: it is the setup that DS4Windows users have run for years on Windows 10/11, with a one-click signed install and no USB hub restart. Its weak point is maintenance, not function, and the backend interface leaves room to switch. + +### Design + +**Components** + +- **`internal/vpad`** (new, Windows only; pure parts testable everywhere): + - `report.go` (pure): maps an SDL gamepad snapshot to an XUSB report. + - Buttons go into the XInput bitmask. + - Triggers scale from 0..32767 to 0..255. + - Stick Y is inverted (SDL down = +, XInput up = +), with `-32768` clamped when negated. + - The touchpad click and Create both become Back. Guide is **not** forwarded: the PS button stays Seaglass's overlay button. + - `vigem.go`: + - finds the bus interface with `CM_Get_Device_Interface_List` and opens it; + - plugs an X360 target, then waits until the device is ready; + - submits reports and unplugs; + - runs a goroutine that waits for rumble notifications (overlapped `DeviceIoControl`) and passes rumble back to the physical pad. + - The struct layouts and IOCTL codes are ported from ViGEmClient (MIT, credited in the notices), with size tests. + - `hidhide.go`: + - opens `\\.\HidHide`, adds the physical pad to the session block list and clears it on exit; + - checks HidHide is active; + - a pure helper turns SDL's device path (`\\?\HID#VID_054C&PID_0CE6#…`) into the instance id HidHide wants (`HID\VID_054C&PID_0CE6\…`), with MULTI_SZ encoding. + - `Bridge` (the interface `Plug / Send / Rumble / Close`) and `Status()` (`{viGEm: installed+version, hidHide: installed+active}`). +- **`internal/pad`**: + - A new `Bridge` mode, like Active but with HIDAPI on, so the DualSense works fully over Bluetooth and rumble reaches it. + - Each poll (about 4 ms in this mode, so latency stays low) reads the gamepad's axes and buttons through SDL and hands a snapshot to a sink. + - Home still produces the overlay action. + - If the pad disconnects, the virtual pad stays plugged in at rest; when a pad comes back it carries on. The game keeps player 1. +- **`internal/app/launch.go`**: + - A new route `RouteBridge = "bridge"`. + - A plan step `controller` ("Xbox controller"), run before the game starts because some games only look for XInput pads at start-up: + 1. hide the pad; + 2. plug the virtual pad; + 3. put the pad layer in Bridge mode. + - `onSession` undoes it when the game ends: unplug the virtual pad, clear the session block list, return to Active. + - Since Seaglass starts the game directly, it gets a real process id, and the tracking problems of the Steam route go away. + +**Route choice (Go and `route.ts` kept in step)** + +- **Per-game `PadMode`:** `""` Auto, `native`, `xbox` (new: Seaglass's virtual Xbox controller), `steam`. +- **Auto:** where today's logic picks Steam Input, it picks the global *translator* instead: + - Seaglass's bridge when ViGEmBus is installed; + - otherwise Steam if the user chose it as the fallback; + - otherwise direct, with a one-line notice: "Set up Seaglass's Xbox controller in Settings to play this game with your controller." + +**Settings** (`internal/settings`, per PC because drivers are per PC): + +- `PadTranslator string`: `"seaglass"` (default) or `"steam"`. It decides which tool turns a PlayStation controller into an Xbox one in Auto. +- `HidePadWhilePlaying bool`, default on, used only when HidHide is installed. +- Validation goes in `normalize`. The fields are mirrored in `types.ts` and `api.mock.ts`. + +**Interface** (Settings → Controller, and BPSettings): + +- A status row: "Xbox controller for games: ready" / "needs two small drivers". +- A **Set up** button. +- The translator choice. +- The hide toggle. +- Per-game modes gain *Xbox controller* in `Details.svelte`, `GameSheet.svelte` and `Console.svelte`. +- The launch text reads "Your controller works as an Xbox controller in this game." + +**First-run setup** + +- **Set up** downloads the two official installers from the projects' GitHub releases. + - It checks them against SHA-256 values pinned in the code, the same way `SDL3.dll` is pinned. + - It runs them; each shows its own UAC prompt. + - It then re-checks `Status()`. If HidHide asks for a restart, Seaglass says so. +- Nothing is installed silently or without the button. +- A service method `ControllerService.SetUpDrivers()` backs the button, with a mock that walks through the states. + +**When a driver is missing** + +- **Without ViGEmBus:** Auto falls back as described (Steam or direct), and `xbox` mode shows the setup notice instead of failing the launch. +- **Without HidHide**, or if hiding fails: the game still starts with the virtual pad, and the launch panel warns "Some games may see your controller twice." Most XInput-only games ignore the DualSense anyway, which is why they need the bridge. +- **If Seaglass crashes mid-game**, ViGEmBus removes the virtual pad when the owning handle closes (verified on claude-dev with ViGEmBus 1.22). +- **Built differently (2026-10-17):** no HidHide release has the session block list (it is only in the unreleased 1.7 tags). Seaglass therefore uses HidHide's saved block list. It notes each controller it adds in `%APPDATA%\Seaglass\hidden-controllers.json` before adding it, and takes exactly those off at game end, at every start and quit, and from **Unhide controller** in Settings. It also puts its own exe on HidHide's allow list, since HidHide blocks the hiding app too. A HidHide with the session list is used when present. +- **Auto without ViGEmBus** starts the game directly with the setup hint. The maintainer chose this over falling back to Steam, which works badly. + +**Licences:** nothing third-party is bundled. + +- The installers are downloaded from their official releases at the user's request. +- The Go IOCTL client is our own code, ported from MIT/BSD sources and credited in `tools/notices`. +- BSD-3, MIT and BSD-2 (and GPL-3.0, for B later) are all compatible with AGPL-3.0. + +### Testing + +- **Unit tests (CI, claude-dev):** + - report mapping: every button, stick inversion edges `-32768/32767`, trigger scaling, Guide not forwarded; + - device path → instance id and MULTI_SZ encoding; + - `unsafe.Sizeof` of the IOCTL structs against ViGEmClient's sizes; + - route choice for each mode, translator and driver status, in Go and in `route.test.ts`; + - settings normalize; + - the launch plan includes and undoes the `controller` step (fake Bridge); + - mock mode for the setup flow and the new labels, in both themes. +- **Live test, gated** (`WL_REAL_VIGEM=1 go test -run RealViGEm ./internal/vpad`): plugs an X360 target, sends a report, and checks it with `XInputGetState` from `xinput1_4.dll`. It needs ViGEmBus installed, so it isn't run on claude-dev unless installing the driver there is approved. It needs no physical controller. +- **Only on a real PC with a real controller (Florian):** + - Set up from the button: two UAC prompts, plus a restart if asked. + - DualSense over USB, then Bluetooth, in an XInput-only game: input, rumble, no double input in a game that also reads HID. + - The PS button opens the overlay during the game and doesn't reach it. + - Unplug and replug the pad mid-game. + - Kill Seaglass mid-game: the pad must be visible to other apps again and the virtual pad gone. + - A DS4 and a Switch Pro if available. + - One anti-cheat game, to see whether it refuses to start. + +### Tier + +**Standard.** + +- The goal is clear and acceptance can be written down. +- It touches about four areas: a new `vpad` package, pad modes, launch plan/route, settings plus UI. +- A wrong design costs moderately: the IOCTL interop and the pad threading are fiddly, but contained behind one interface. +- The `vigem.go` / `hidhide.go` / pad-thread pieces go to the Opus lead-implementer, and the settings/UI/route plumbing to the Sonnet implementer. diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/controllerservice.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/controllerservice.ts new file mode 100644 index 0000000..0a45f7c --- /dev/null +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/controllerservice.ts @@ -0,0 +1,42 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * ControllerService is Seaglass's Xbox controller for games: whether its + * two drivers are there, and setting them up. + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as vpad$0 from "../vpad/models.js"; + +/** + * SetUpDrivers downloads the official installers of the drivers that are + * missing, checks them against the hashes pinned in Seaglass, and runs + * them one after the other (each asks Windows for permission itself). It + * returns the status once they're done. Closing an installer isn't an + * error; the status then says what's still missing. + */ +export function SetUpDrivers(): $CancellablePromise { + return $Call.ByID(329195502); +} + +/** + * Status says what the drivers can do on this PC. + */ +export function Status(): $CancellablePromise { + return $Call.ByID(2836154352); +} + +/** + * UnhideControllers shows every controller Seaglass hid from games again, + * including during a game (which then may see it twice). + */ +export function UnhideControllers(): $CancellablePromise { + return $Call.ByID(2946532150); +} diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/index.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/index.ts index b214bc4..5145d35 100644 --- a/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/index.ts +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/index.ts @@ -3,6 +3,7 @@ import * as AccountsService from "./accountsservice.js"; import * as AchievementsService from "./achievementsservice.js"; +import * as ControllerService from "./controllerservice.js"; import * as LaunchService from "./launchservice.js"; import * as LibraryService from "./libraryservice.js"; import * as PadService from "./padservice.js"; @@ -14,6 +15,7 @@ import * as UpdateService from "./updateservice.js"; export { AccountsService, AchievementsService, + ControllerService, LaunchService, LibraryService, PadService, diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/launchservice.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/launchservice.ts index 27f0e0e..e265041 100644 --- a/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/launchservice.ts +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/launchservice.ts @@ -66,7 +66,7 @@ export function QuitGame(): $CancellablePromise { } /** - * Route tells how a game would start now: direct, store or steamInput. + * Route tells how a game would start now: direct, store, steamInput or bridge. */ export function Route(id: number): $CancellablePromise { return $Call.ByID(468420342, id); diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/libraryservice.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/libraryservice.ts index 004876b..68a48a4 100644 --- a/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/libraryservice.ts +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/libraryservice.ts @@ -195,7 +195,8 @@ export function SetMatch(id: number, steamAppID: number, name: string): $Cancell } /** - * SetPadMode picks how a controller reaches the game: "" (auto), "native" or "steam". + * SetPadMode picks how a controller reaches the game: "" (auto), "native", + * "xbox" (Seaglass's virtual Xbox controller) or "steam" (Steam Input). */ export function SetPadMode(id: number, mode: string): $CancellablePromise { return $Call.ByID(880089469, id, mode); diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/models.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/models.ts index d8906ac..1bf635e 100644 --- a/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/models.ts +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/models.ts @@ -117,7 +117,7 @@ export interface Game { "hidden"?: boolean; /** - * "" = auto, "native", "steam" + * "" = auto, "native", "xbox", "steam" */ "padMode"?: string; diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/settings/models.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/settings/models.ts index d12f9df..b837e89 100644 --- a/frontend/bindings/github.com/ApolloF/Seaglass/internal/settings/models.ts +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/settings/models.ts @@ -106,6 +106,20 @@ export interface Settings { */ "noticeExternal": boolean; + /** + * PadTranslator turns a PlayStation controller into an Xbox one for + * games that only know Xbox controllers: "seaglass" (its own virtual + * controller, once ViGEmBus is set up) or "steam" (Steam Input). Per PC, + * like the drivers it depends on. + */ + "padTranslator": string; + + /** + * HidePadWhilePlaying hides the real controller from the game while + * Seaglass's virtual one stands in for it (needs HidHide). Per PC. + */ + "hidePadWhilePlaying": boolean; + /** * Saves, through Syncer * sync a game's saves before it starts diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/vpad/index.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/vpad/index.ts new file mode 100644 index 0000000..4158350 --- /dev/null +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/vpad/index.ts @@ -0,0 +1,6 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export type { + Status +} from "./models.js"; diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/vpad/models.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/vpad/models.ts new file mode 100644 index 0000000..2e33c40 --- /dev/null +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/vpad/models.ts @@ -0,0 +1,49 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Status is what the two drivers can do on this PC. + */ +export interface Status { + /** + * ViGEmBus answers: games can get a virtual Xbox controller + */ + "viGEm": boolean; + + /** + * its driver's version, when known + */ + "viGEmVersion"?: string; + + /** + * HidHide answers + */ + "hidHide": boolean; + + /** + * HidHideVersion is its driver's version, when known. + */ + "hidHideVersion"?: string; + + /** + * its hiding is switched on (block lists only work then) + */ + "hidHideActive": boolean; + + /** + * CanHide: HidHide answers and its hiding is switched on. + */ + "canHide": boolean; + + /** + * Hidden: Seaglass left controllers on HidHide's saved block list (a + * game is running, or Seaglass didn't get to show them again). + */ + "hidden": boolean; + + /** + * Restart: a driver is installed but doesn't answer yet; Windows has to + * restart first. + */ + "restart": boolean; +} diff --git a/frontend/src/bigpicture/BPSettings.svelte b/frontend/src/bigpicture/BPSettings.svelte index 2ecde3c..9233c64 100644 --- a/frontend/src/bigpicture/BPSettings.svelte +++ b/frontend/src/bigpicture/BPSettings.svelte @@ -3,6 +3,7 @@ // left/right or ✕ to change it. The list scrolls to keep the row in view. import { api } from "../lib/api"; import { feedback, useInput } from "../lib/input.svelte"; + import { controllerSummary, UNHIDE_DETAIL } from "../lib/controller"; import { syncerSummary } from "../lib/saves"; import { lib } from "../lib/store.svelte"; import type { Settings, SyncerStatus } from "../lib/types"; @@ -14,7 +15,8 @@ | { key: keyof Settings; title: string; detail: string; kind: "toggle"; group?: string } | { key: keyof Settings; title: string; detail: string; kind: "choice"; options: [string | number, string][]; group?: string } | { key: "syncer"; title: string; detail: string; kind: "status"; group?: string } - | { key: "padtest"; title: string; detail: string; kind: "action"; group?: string } + | { key: "xbox"; title: string; detail: string; kind: "xboxStatus"; group?: string } + | { key: "padtest" | "unhide"; title: string; detail: string; kind: "action"; group?: string } | { key: `library:${string}`; source: string; title: string; detail: string; kind: "library"; group?: string }; const fixed: Row[] = [ @@ -59,6 +61,24 @@ ["off", "Off"], ], }, + { group: "Xbox controller", key: "xbox", title: "Xbox controller for games", detail: "", kind: "xboxStatus" }, + { + key: "padTranslator", + title: "Turn PlayStation controllers into Xbox ones with", + detail: "What Auto uses for a game that supports Xbox controllers but not PlayStation ones.", + kind: "choice", + options: [ + ["seaglass", "Seaglass"], + ["steam", "Steam"], + ], + }, + { + key: "hidePadWhilePlaying", + title: "Hide the controller from games while playing", + detail: "So games that also read it directly don't see every press twice. Needs HidHide.", + kind: "toggle", + }, + { key: "unhide", title: "Unhide controller", detail: UNHIDE_DETAIL, kind: "action" }, { group: "Saves", key: "syncer", title: "Syncer", detail: "", kind: "status" }, { key: "syncSavesBefore", title: "Sync saves before playing", detail: "Saves come up to date from your other PCs first.", kind: "toggle" }, { @@ -107,6 +127,18 @@ }); const s = $derived(lib.settings); + $effect(() => { + void lib.refreshController(); + }); + const xbox = $derived(controllerSummary(lib.controller)); + let settingUp = $state(false); + async function setUpController() { + if (settingUp) return; + settingUp = true; + await lib.setUpController(); + settingUp = false; + } + let syncer = $state(null); let checking = $state(false); async function check(start: boolean) { @@ -137,10 +169,13 @@ if (!s || !row) return; if (row.kind === "action") { feedback.confirm(); - onpadtest(); + if (row.key === "unhide") lib.unhideController(); + else onpadtest(); return; } - if (row.kind === "status") { + if (row.kind === "xboxStatus") { + if (xbox.canSetUp) setUpController(); + } else if (row.kind === "status") { // ✕ on Syncer: start it (or look again), or open it to sort things out. if (syncerSum?.action === "open") lib.run(() => api.saves.openSyncer()); else if (syncerSum?.action === "get" || syncerSum?.action === "update") install(); @@ -177,7 +212,7 @@ ); function valueLabel(row: Row): string { - if (!s || row.kind === "status" || row.kind === "action" || row.kind === "library") return ""; + if (!s || row.kind === "status" || row.kind === "xboxStatus" || row.kind === "action" || row.kind === "library") return ""; if (row.kind === "toggle") return s[row.key] ? "On" : "Off"; return row.options.find((o) => String(o[0]) === String(s[row.key]))?.[1] ?? ""; } @@ -189,7 +224,11 @@ {#each rows as row, k (row.key)} {#if row.group}{row.group}{/if} {/each} diff --git a/frontend/src/desktop/LaunchPanel.svelte b/frontend/src/desktop/LaunchPanel.svelte index 24c5b84..7d646c2 100644 --- a/frontend/src/desktop/LaunchPanel.svelte +++ b/frontend/src/desktop/LaunchPanel.svelte @@ -31,6 +31,7 @@ const waiting = $derived.by(() => { if (s?.phase !== "starting") return ""; if (s.route === "steamInput") return "Waiting for the game to open through Steam…"; + if (s.route === "bridge") return "Your controller works as an Xbox controller in this game."; if (s.route === "store") return "Waiting for the game to open through its store…"; return "Waiting for the game to open…"; }); diff --git a/frontend/src/desktop/Settings.svelte b/frontend/src/desktop/Settings.svelte index 03dd411..e5b124e 100644 --- a/frontend/src/desktop/Settings.svelte +++ b/frontend/src/desktop/Settings.svelte @@ -6,6 +6,7 @@ import SyncerSettings from "./SyncerSettings.svelte"; import UpdateStatus from "./UpdateStatus.svelte"; import { api } from "../lib/api"; + import { controllerSummary, SET_UP_DETAIL, UNHIDE_DETAIL } from "../lib/controller"; import { lib } from "../lib/store.svelte"; import type { Settings, Startup } from "../lib/types"; @@ -35,8 +36,17 @@ api.autoFolders().then((f) => (autoFolders = f)); api.hasSteamGridDBKey().then((k) => (hasKey = k)); api.startWithWindows().then((v) => (startup = v)); + void lib.refreshController(); }); + const xbox = $derived(controllerSummary(lib.controller)); + let settingUp = $state(false); + async function setUpController() { + settingUp = true; + await lib.setUpController(); + settingUp = false; + } + async function copyDiagnostics() { const ok = await lib.run(() => api.copyDiagnostics().then(() => true)); if (ok) lib.toast("Diagnostics copied. Paste them into your bug report."); @@ -226,6 +236,42 @@ set({ noticeExternal: v })} /> set({ padWhilePlaying: v ? "listen" : "off" })} /> +
+ Xbox controller +
+ + + Xbox controller for games + {xbox.detail} + + {#if xbox.canSetUp} + + {/if} +
+ {#if xbox.canSetUp}

{SET_UP_DETAIL}

{/if} + Turn PlayStation controllers into Xbox ones with +
+ {#each [["seaglass", "Seaglass"], ["steam", "Steam"]] as [id, label] (id)} + + {/each} +
+ set({ hidePadWhilePlaying: v })} + /> + {#if lib.controller?.hidHide || lib.controller?.hidden} +
+ + Show my controller again + {lib.controller?.hidden ? "Seaglass is hiding your controller from games right now." : UNHIDE_DETAIL} + + +
+ {/if} +
{:else if tab === "saves"} {:else if tab === "experimental"} @@ -369,6 +415,37 @@ flex-direction: column; gap: 8px; } + .xstatus { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-radius: var(--radius); + background: var(--surface-2); + } + .xtext { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + } + .xtitle { + font-weight: 600; + } + .xdetail { + font-size: 13px; + color: var(--muted); + } + .dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--muted); + flex: none; + } + .dot.ok { + background: var(--accent); + } .glabel { font-size: 14px; font-weight: 700; diff --git a/frontend/src/lib/api.mock.ts b/frontend/src/lib/api.mock.ts index 6315a7e..708f037 100644 --- a/frontend/src/lib/api.mock.ts +++ b/frontend/src/lib/api.mock.ts @@ -1,7 +1,7 @@ // Made-up library for `npm run dev:mock`: the games from the design canvas, // covering every way a game can be found. import type { Api } from "./api"; -import type { Accounts, Achievement, Achievements, AppInfo, Completion, Game, MetaState, PadState, Profile, Saves, ScanState, Session, SessionAchievements, Settings, Startup, UpdateState } from "./types"; +import type { Accounts, Achievement, Achievements, AppInfo, Completion, Game, MetaState, PadState, Profile, Saves, ScanState, Session, SessionAchievements, Settings, Startup, UpdateState, ControllerStatus, StepState } from "./types"; import { sessionActive } from "./types"; import { mockStore, mockStoreSettings } from "./api.mock.store"; @@ -43,6 +43,7 @@ let games: Game[] = [ game({ title: "Lumen Drift", steamAppId: 620, launchUri: "steam://rungameid/620", addedAt: now - day, sizeBytes: 6e9 }), game({ title: "Kestrel", source: "steam", installed: false, playtime: 6 * 3600, lastPlayed: now - 400 * day }), game({ title: "Tidebreaker", source: "installer", sourceLabel: "Standalone", external: true, steamAppId: 2840770, how: "Installer entry in Windows", matchHow: "Matched by title", confidence: 85, playtime: 2 * 3600, lastPlayed: now - 2 * day, sizeBytes: 90e9 }), + game({ title: "Brass Circuit", source: "folder", sourceLabel: "Folder", how: "Game folder in D:\\Games", matchHow: "Matched by title", confidence: 80, playtime: 3 * 3600, lastPlayed: now - 5 * day, padMode: "xbox", sizeBytes: 12e9 }), game({ title: "Copper Fields", source: "gog", sourceLabel: "GOG", installed: false, playtime: 21 * 3600, lastPlayed: now - 700 * day }), ]; @@ -107,6 +108,8 @@ let settings: Settings = { glyphs: "auto", closeWhilePlaying: true, padWhilePlaying: "listen", + padTranslator: "seaglass", + hidePadWhilePlaying: true, noticeExternal: true, showOwned: false, ownedGOG: false, @@ -319,11 +322,26 @@ function setSession(p: Partial) { sessionListeners.forEach((cb) => cb(clone(session))); } +// ?controller=ready|hidden|restart|missing sets how Seaglass's Xbox controller starts. +const NO_DRIVERS: ControllerStatus = { viGEm: false, hidHide: false, hidHideActive: false, canHide: false, restart: false, hidden: false }; +const READY: ControllerStatus = { viGEm: true, viGEmVersion: "1.22.0", hidHide: true, hidHideVersion: "1.5.230", hidHideActive: true, canHide: true, restart: false, hidden: false }; +let controllerStatus: ControllerStatus = + mockParams.get("controller") === "ready" + ? READY + : mockParams.get("controller") === "hidden" + ? { ...READY, hidden: true } + : mockParams.get("controller") === "restart" + ? { ...NO_DRIVERS, restart: true } + : NO_DRIVERS; + async function runMockSession(g: Game) { const steam = g.padMode === "steam" && !g.launchUri; + const xboxMode = g.padMode === "xbox" && !g.launchUri; + const bridge = xboxMode && controllerStatus.viGEm; + const xboxStep = (status: StepState["status"], detail = ""): StepState[] => [{ id: "controller", label: "Xbox controller", status, detail }]; setSession({ id: session.id + 1, gameId: g.id, title: g.customTitle || g.title, phase: "preparing", from: uiMode, - route: "", before: steam ? [{ id: "steamInput", label: "Steam Input", status: "running" }] : [], + route: "", before: steam ? [{ id: "steamInput", label: "Steam Input", status: "running" }] : xboxMode ? xboxStep("running") : [], after: [], seconds: 0, startedAt: 0, error: "", note: "", question: undefined, }); const id = session.id; @@ -341,9 +359,17 @@ async function runMockSession(g: Game) { await wait(skipStep ? 0 : 1400); setSession({ before: [{ id: "steamInput", label: "Steam Input", status: "done", detail: answer === "restart" ? "Added to Steam" : "Starting without Steam Input" }] }); } + if (xboxMode) { + await wait(skipStep ? 0 : 900); + setSession({ + before: bridge + ? xboxStep("done", "Your controller works as an Xbox controller in this game.") + : xboxStep("skipped", "Set up Seaglass's Xbox controller in Settings to play this game with your controller."), + }); + } const cur = () => session; // read fresh after each await if (cur().id !== id || cur().phase !== "preparing") return; - setSession({ phase: "starting", route: steam ? "steamInput" : g.launchUri ? "store" : "direct" }); + setSession({ phase: "starting", route: steam ? "steamInput" : bridge ? "bridge" : g.launchUri ? "store" : "direct" }); await update(g.id, (x) => (x.lastPlayed = Math.floor(Date.now() / 1000))); await wait(1800); if (cur().id !== id || cur().phase !== "starting") return; @@ -548,6 +574,21 @@ export const mockApi: Api = { onMetaState() { return () => {}; }, + controller: { + async status() { + return clone(controllerStatus); + }, + async setUp() { + await wait(1500); + controllerStatus = READY; + return clone(controllerStatus); + }, + async unhide() { + controllerStatus = { ...controllerStatus, hidden: false }; + return clone(controllerStatus); + }, + }, + saves: { async get(id) { await wait(250); diff --git a/frontend/src/lib/api.real.ts b/frontend/src/lib/api.real.ts index df009dc..3f93828 100644 --- a/frontend/src/lib/api.real.ts +++ b/frontend/src/lib/api.real.ts @@ -1,8 +1,8 @@ import { Events, Window } from "@wailsio/runtime"; -import { AccountsService, AchievementsService, LaunchService, LibraryService, PadService, ProfileService, SavesService, SettingsService, StoreService, UpdateService } from "../../bindings/github.com/ApolloF/Seaglass/internal/app"; +import { AccountsService, AchievementsService, ControllerService, LaunchService, LibraryService, PadService, ProfileService, SavesService, SettingsService, StoreService, UpdateService } from "../../bindings/github.com/ApolloF/Seaglass/internal/app"; import type { Api } from "./api"; import { normalizeHome } from "./storefront-home"; -import type { CompletionCandidate, LibraryCompletion, SteamAccount, WishlistImport, DiscoveryChange, DiscoveryStatus, Enrichment, GameDetails, PreparedRelease, ReviewPage, SearchProgress, SearchResult, StoreHome, WishlistItem, SourceSnapshot, SourceRelease, DownloadLanguageOptions, Accounts, Achievements, AppInfo, ArtChoice, CatalogEntry, CatalogPage, Download, StoreArt, EngineStatus, FeedInfo, Game, MetaState, PadRaw, PadState, Profile, Saves, ScanState, Session, SessionAchievements, Settings, Startup, StoreHit, SyncerStatus, TorrentInterface, UpdateState } from "./types"; +import type { CompletionCandidate, LibraryCompletion, SteamAccount, WishlistImport, DiscoveryChange, DiscoveryStatus, Enrichment, GameDetails, PreparedRelease, ReviewPage, SearchProgress, SearchResult, StoreHome, WishlistItem, SourceSnapshot, SourceRelease, DownloadLanguageOptions, Accounts, Achievements, AppInfo, ArtChoice, CatalogEntry, CatalogPage, Download, StoreArt, EngineStatus, FeedInfo, Game, MetaState, PadRaw, PadState, Profile, Saves, ScanState, Session, SessionAchievements, Settings, Startup, StoreHit, SyncerStatus, TorrentInterface, UpdateState, ControllerStatus } from "./types"; // The generated bindings return the Go structs; their JSON matches ./types. const g = (p: Promise) => p as Promise; @@ -208,6 +208,12 @@ export const realApi: Api = { fullscreen: (on) => void (on ? Window.Fullscreen() : Window.UnFullscreen()), }, + controller: { + status: () => ControllerService.Status() as Promise as Promise, + setUp: () => ControllerService.SetUpDrivers() as Promise as Promise, + unhide: () => ControllerService.UnhideControllers() as Promise as Promise, + }, + pad: { state: () => PadService.State() as Promise as Promise, rumble: (effect) => void PadService.Rumble(effect), diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cb1e5e4..48c83ce 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,7 +1,7 @@ // The frontend's one door to the Go side. In mock mode (`npm run dev:mock`) // the same interface is served by made-up data, so the interface can be // built and checked in a normal browser. Vite drops the unused one. -import type { BrowseQuery, CompletionCandidate, LibraryCompletion, SteamAccount, WishlistImport, DiscoveryChange, DiscoveryStatus, Enrichment, GameDetails, PreparedRelease, ReviewPage, ReviewQuery, SearchProgress, SearchResult, StoreHome, WishlistItem, SourceSnapshot, SourceRelease, DownloadLanguageOptions, Accounts, Achievements, AppInfo, ArtChoice, ArtKind, CatalogEntry, CatalogPage, CatalogQuery, Download, InstallOptions, StoreArt, DownloadAction, EngineStatus, FeedInfo, Game, MetaState, PadRaw, PadState, Profile, Saves, ScanState, Session, SessionAchievements, Settings, Startup, StoreHit, SyncerStatus, TorrentInterface, UpdateState } from "./types"; +import type { BrowseQuery, CompletionCandidate, LibraryCompletion, SteamAccount, WishlistImport, DiscoveryChange, DiscoveryStatus, Enrichment, GameDetails, PreparedRelease, ReviewPage, ReviewQuery, SearchProgress, SearchResult, StoreHome, WishlistItem, SourceSnapshot, SourceRelease, DownloadLanguageOptions, Accounts, Achievements, AppInfo, ArtChoice, ArtKind, CatalogEntry, CatalogPage, CatalogQuery, Download, InstallOptions, StoreArt, DownloadAction, EngineStatus, FeedInfo, Game, MetaState, PadRaw, PadState, Profile, Saves, ScanState, Session, SessionAchievements, Settings, Startup, StoreHit, SyncerStatus, TorrentInterface, UpdateState, ControllerStatus } from "./types"; import { realApi } from "./api.real"; import { mockApi } from "./api.mock"; @@ -79,6 +79,15 @@ export interface Api { onState(cb: (s: UpdateState) => void): () => void; }; + controller: { + /** What Seaglass's Xbox controller (two drivers) can do on this PC. */ + status(): Promise; + /** Downloads and runs the driver installers; resolves with the status when they're done. */ + setUp(): Promise; + /** Shows every controller Seaglass hid from games again. */ + unhide(): Promise; + }; + saves: { /** Syncer's view of a game's saves; fresh skips the short cache. */ get(id: number, fresh?: boolean): Promise; diff --git a/frontend/src/lib/bp.ts b/frontend/src/lib/bp.ts index bf06362..3424f90 100644 --- a/frontend/src/lib/bp.ts +++ b/frontend/src/lib/bp.ts @@ -1,6 +1,6 @@ // Game lists and helpers shared by the big picture layouts. import { artFor } from "./art"; -import { isFresh } from "./store.svelte"; +import { isFresh, lib } from "./store.svelte"; import { lastPlayed, played, title, type Game } from "./types"; import { playtime, ago } from "./format"; import { pad } from "./input.svelte"; @@ -60,5 +60,5 @@ export { title }; /** What the controller mode will do, in words, for the big picture cards. */ export function padSummary(g: Game): { short: string; long: string } { - return padExplain(g, pad); + return padExplain(g, pad, lib.translator); } diff --git a/frontend/src/lib/controller.test.ts b/frontend/src/lib/controller.test.ts new file mode 100644 index 0000000..e6e4638 --- /dev/null +++ b/frontend/src/lib/controller.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { controllerSummary } from "./controller"; +import type { ControllerStatus } from "./types"; + +const none: ControllerStatus = { viGEm: false, hidHide: false, hidHideActive: false, canHide: false, restart: false, hidden: false }; + +describe("controllerSummary", () => { + it("waits for the status", () => { + expect(controllerSummary(null)).toMatchObject({ detail: "Checking…", canSetUp: false }); + }); + + it("asks for the drivers when they are missing", () => { + expect(controllerSummary(none)).toEqual({ detail: "Needs two small drivers (ViGEmBus and HidHide).", ok: false, canSetUp: true }); + }); + + it("says when Windows must restart, with nothing more to set up", () => { + expect(controllerSummary({ ...none, viGEm: true, restart: true })).toEqual({ detail: "Restart Windows to finish setting up.", ok: false, canSetUp: false }); + }); + + it("offers to install HidHide when only it is missing", () => { + expect(controllerSummary({ ...none, viGEm: true })).toEqual({ + detail: "Ready. Your controller can't be hidden from games yet.", + ok: true, + canSetUp: true, + }); + }); + + it("is ready and done when the controller can be hidden too", () => { + expect(controllerSummary({ ...none, viGEm: true, hidHide: true, hidHideActive: true, canHide: true })).toEqual({ detail: "Ready.", ok: true, canSetUp: false }); + }); + + it("says how to switch hiding on when HidHide is there but off", () => { + expect(controllerSummary({ ...none, viGEm: true, hidHide: true })).toEqual({ + detail: "Ready. Turn on hiding in HidHide so games don't see your controller twice.", + ok: true, + canSetUp: false, + }); + }); +}); diff --git a/frontend/src/lib/controller.ts b/frontend/src/lib/controller.ts new file mode 100644 index 0000000..9080e12 --- /dev/null +++ b/frontend/src/lib/controller.ts @@ -0,0 +1,31 @@ +// Seaglass's Xbox controller (two drivers) in words, for Settings. +import type { ControllerStatus } from "./types"; + +export interface ControllerSummary { + detail: string; + ok: boolean; + /** Whether the Set up button shows. */ + canSetUp: boolean; +} + +export function controllerSummary(s: ControllerStatus | null): ControllerSummary { + if (!s) return { detail: "Checking…", ok: false, canSetUp: false }; + if (s.restart) return { detail: "Restart Windows to finish setting up.", ok: false, canSetUp: false }; + if (s.viGEm) + return { + detail: s.canHide + ? "Ready." + : s.hidHide + ? "Ready. Turn on hiding in HidHide so games don't see your controller twice." + : "Ready. Your controller can't be hidden from games yet.", + ok: true, + // Setting up only installs what's missing: an old or switched-off + // HidHide is the person's to update or switch on. + canSetUp: !s.hidHide, + }; + return { detail: "Needs two small drivers (ViGEmBus and HidHide).", ok: false, canSetUp: true }; +} + +export const SET_UP_DETAIL = "Downloads the official installers. Windows asks for permission for each."; + +export const UNHIDE_DETAIL = "Seaglass hides your controller from games while one plays. If it stays hidden, this shows it again."; diff --git a/frontend/src/lib/route.test.ts b/frontend/src/lib/route.test.ts index 270f752..93c2076 100644 --- a/frontend/src/lib/route.test.ts +++ b/frontend/src/lib/route.test.ts @@ -1,28 +1,96 @@ import { describe, expect, it } from "vitest"; -import { padExplain, routeOf } from "./route"; +import { needsSetup, padExplain, routeOf, translatorOf, type Translator } from "./route"; import type { Game, PadState } from "./types"; const ds: PadState = { connected: true, name: "DualSense", kind: "playstation", dualSense: true, battery: -1, wireless: false }; const xbox: PadState = { ...ds, kind: "xbox", dualSense: false }; +const none: PadState = { ...ds, connected: false }; const base = { id: 1, key: "k", title: "G", sortTitle: "g", source: "folder", sourceLabel: "Folder", external: false, installed: true, dir: "D:\\G", how: "", matchHow: "", confidence: 100, needsReview: false, addedAt: 0, seenAt: 0 } as Game; +const bridge: Translator = { viGEm: true, steam: false }; +const bare: Translator = { viGEm: false, steam: false }; +const steam: Translator = { viGEm: false, steam: true }; +const both: Translator = { viGEm: true, steam: true }; +const all = [bare, bridge, steam, both]; + describe("routeOf", () => { - it("matches the Go side", () => { - const xinput = { ...base, exe: "D:\\G\\g.exe", meta: { controller: "full" } }; - expect(routeOf({ ...xinput, launchUri: "steam://rungameid/1" }, ds)).toBe("store"); - expect(routeOf(xinput, ds)).toBe("steamInput"); - expect(routeOf(xinput, xbox)).toBe("direct"); - expect(routeOf({ ...xinput, meta: { controller: "full", dualSense: "yes" } }, ds)).toBe("direct"); - expect(routeOf({ ...xinput, padHint: "libScePad" }, ds)).toBe("direct"); - expect(routeOf({ ...xinput, padMode: "native" }, ds)).toBe("direct"); - expect(routeOf({ ...base, padMode: "steam" }, xbox)).toBe("steamInput"); - expect(routeOf(base, ds)).toBe("direct"); + const xinput = { ...base, exe: "D:\\G\\g.exe", meta: { controller: "full" } }; + + it("sends a store link to its store whatever the tools are", () => { + for (const tr of all) expect(routeOf({ ...xinput, launchUri: "steam://rungameid/1" }, ds, tr)).toBe("store"); + }); + + it("starts native games directly and Steam games through Steam Input", () => { + for (const tr of all) { + expect(routeOf({ ...xinput, padMode: "native" }, ds, tr)).toBe("direct"); + expect(routeOf({ ...base, padMode: "steam" }, xbox, tr)).toBe("steamInput"); + } + }); + + it("uses Seaglass's Xbox controller in xbox mode only when it is set up", () => { + expect(routeOf({ ...base, padMode: "xbox" }, xbox, bridge)).toBe("bridge"); + expect(routeOf({ ...base, padMode: "xbox" }, none, both)).toBe("bridge"); + expect(routeOf({ ...base, padMode: "xbox" }, ds, bare)).toBe("direct"); + expect(routeOf({ ...base, padMode: "xbox" }, ds, steam)).toBe("direct"); + }); + + it("in auto picks the chosen tool for an Xbox-only game with a PlayStation pad", () => { + expect(routeOf(xinput, ds, bridge)).toBe("bridge"); + expect(routeOf(xinput, ds, steam)).toBe("steamInput"); + expect(routeOf(xinput, ds, both)).toBe("steamInput"); + expect(routeOf(xinput, ds, bare)).toBe("direct"); + expect(routeOf({ ...xinput, meta: { controller: "Partial" } }, ds, bridge)).toBe("bridge"); + expect(routeOf({ ...xinput, meta: { controller: "full", dualSense: "no" } }, ds, bridge)).toBe("bridge"); + }); + + it("in auto starts directly when nothing needs translating", () => { + for (const tr of all) { + expect(routeOf(xinput, xbox, tr)).toBe("direct"); + expect(routeOf(xinput, none, tr)).toBe("direct"); + expect(routeOf({ ...xinput, meta: { controller: "full", dualSense: "yes" } }, ds, tr)).toBe("direct"); + expect(routeOf({ ...xinput, padHint: "libScePad" }, ds, tr)).toBe("direct"); + expect(routeOf({ ...xinput, meta: undefined }, ds, tr)).toBe("direct"); + expect(routeOf(base, ds, tr)).toBe("direct"); + } + }); +}); + +describe("needsSetup", () => { + const xinput = { ...base, meta: { controller: "full" } }; + + it("asks for setup when the game would use Seaglass's Xbox controller", () => { + expect(needsSetup({ ...base, padMode: "xbox" }, xbox, bare)).toBe(true); + expect(needsSetup({ ...base, padMode: "xbox" }, xbox, steam)).toBe(true); + expect(needsSetup(xinput, ds, bare)).toBe(true); + }); + + it("does not when it is set up, Steam is chosen, or the game does not want it", () => { + expect(needsSetup({ ...base, padMode: "xbox" }, xbox, bridge)).toBe(false); + expect(needsSetup(xinput, ds, bridge)).toBe(false); + expect(needsSetup(xinput, ds, steam)).toBe(false); + expect(needsSetup(xinput, xbox, bare)).toBe(false); + expect(needsSetup({ ...xinput, padMode: "native" }, ds, bare)).toBe(false); + expect(needsSetup({ ...xinput, padMode: "steam" }, ds, bare)).toBe(false); + expect(needsSetup({ ...xinput, launchUri: "steam://rungameid/1" }, ds, bare)).toBe(false); + }); +}); + +describe("translatorOf", () => { + it("is empty until the status and settings are known", () => { + expect(translatorOf(null, null)).toEqual({ viGEm: false, steam: false }); + }); + + it("reads the driver and the chosen tool", () => { + const status = { viGEm: true, hidHide: false, hidHideActive: false, canHide: false, restart: false, hidden: false }; + expect(translatorOf(status, { padTranslator: "steam" } as never)).toEqual({ viGEm: true, steam: true }); + expect(translatorOf(status, { padTranslator: "seaglass" } as never)).toEqual({ viGEm: true, steam: false }); }); }); describe("padExplain", () => { + const short = (g: Game, pad = ds, tr = bare) => padExplain(g, pad, tr).short; + it("names the reason a game starts the way it does", () => { - const short = (g: Game, pad = ds) => padExplain(g, pad).short; expect(short({ ...base, launchUri: "steam://rungameid/1" })).toBe("Steam"); expect(short({ ...base, launchUri: "com.epicgames.launcher://apps/x?action=launch" })).toBe("Store"); expect(short({ ...base, padMode: "native" })).toBe("Native"); @@ -31,14 +99,31 @@ describe("padExplain", () => { expect(short({ ...base, padHint: "libScePad" })).toBe("DualSense"); expect(short({ ...base, padHint: "SDL" })).toBe("Native"); expect(short({ ...base, meta: { dualSense: "dualshock" } })).toBe("DualShock"); - expect(short({ ...base, meta: { controller: "full" } })).toBe("Steam Input"); - expect(padExplain({ ...base, meta: { controller: "partial" } }, xbox)).toMatchObject({ short: "Auto", long: expect.stringMatching(/Xbox controllers/) }); - expect(padExplain(base, ds).long).toMatch(/no controller support known/); + expect(padExplain(base, ds, bare).long).toMatch(/no controller support known/); + }); + + it("explains the xbox mode and asks for setup when it is missing", () => { + expect(padExplain({ ...base, padMode: "xbox" }, ds, bridge)).toEqual({ + short: "Xbox controller", + long: "Always starts with Seaglass's Xbox controller, so your controller works as an Xbox one.", + }); + expect(padExplain({ ...base, padMode: "xbox" }, ds, bare).long).toMatch(/Set it up in Settings first\.$/); }); - it("agrees with routeOf on Steam Input", () => { + it("agrees with routeOf in auto", () => { const g = { ...base, meta: { controller: "Full" } }; - expect(routeOf(g, ds)).toBe("steamInput"); - expect(padExplain(g, ds).short).toBe("Steam Input"); + expect(routeOf(g, ds, bridge)).toBe("bridge"); + expect(padExplain(g, ds, bridge).long).toMatch(/works as an Xbox controller/); + expect(short(g, ds, bridge)).toBe("Xbox controller"); + expect(routeOf(g, ds, steam)).toBe("steamInput"); + expect(short(g, ds, steam)).toBe("Steam Input"); + expect(short(g, ds, both)).toBe("Steam Input"); + }); + + it("mentions the chosen tool when auto has nothing to translate yet", () => { + const g = { ...base, meta: { controller: "partial" } }; + expect(padExplain(g, xbox, bare)).toMatchObject({ short: "Auto", long: expect.stringMatching(/Seaglass's Xbox controller/) }); + expect(padExplain(g, xbox, steam).long).toMatch(/Steam Input/); + expect(padExplain(g, ds, bare).long).toMatch(/set up in Settings/); }); }); diff --git a/frontend/src/lib/route.ts b/frontend/src/lib/route.ts index 2eba714..e9dd4f7 100644 --- a/frontend/src/lib/route.ts +++ b/frontend/src/lib/route.ts @@ -1,22 +1,45 @@ // How a game will start, mirroring route() in internal/app/launch.go, so // the interface can explain it before the game is played. -import type { Game, PadState } from "./types"; +import type { ControllerStatus, Game, PadState, Settings } from "./types"; -export type Route = "direct" | "store" | "steamInput"; +export type Route = "direct" | "store" | "steamInput" | "bridge"; -export function routeOf(g: Game, pad: PadState): Route { +/** What can turn a PlayStation controller into an Xbox one: Seaglass's own + * controller (viGEm: its driver is there) and whether Steam is the chosen tool. */ +export type Translator = { viGEm: boolean; steam: boolean }; + +export function translatorOf(status: ControllerStatus | null, settings: Settings | null): Translator { + return { viGEm: status?.viGEm ?? false, steam: settings?.padTranslator === "steam" }; +} + +/** Auto mode would hand the game an Xbox controller: a PlayStation controller + * is in use and the game supports controllers but not PlayStation ones. */ +function wantsXbox(g: Game, pad: PadState): boolean { + if (!pad.connected || pad.kind !== "playstation" || g.padHint || !g.meta) return false; + const ds = g.meta.dualSense ?? ""; + if (ds && ds !== "no") return false; + const c = (g.meta.controller ?? "").toLowerCase(); + return c === "full" || c === "partial"; +} + +export function routeOf(g: Game, pad: PadState, tr: Translator): Route { if (g.launchUri) return "store"; if (g.padMode === "native") return "direct"; if (g.padMode === "steam") return "steamInput"; - if (!pad.connected || pad.kind !== "playstation" || g.padHint || !g.meta) return "direct"; - const ds = g.meta.dualSense ?? ""; - if (ds && ds !== "no") return "direct"; - const c = (g.meta.controller ?? "").toLowerCase(); - return c === "full" || c === "partial" ? "steamInput" : "direct"; + if (g.padMode === "xbox") return tr.viGEm ? "bridge" : "direct"; + if (!wantsXbox(g, pad)) return "direct"; + if (tr.steam) return "steamInput"; + return tr.viGEm ? "bridge" : "direct"; +} + +/** The game would get Seaglass's Xbox controller if it were set up. */ +export function needsSetup(g: Game, pad: PadState, tr: Translator): boolean { + if (g.launchUri || tr.viGEm) return false; + return g.padMode === "xbox" || (!g.padMode && !tr.steam && wantsXbox(g, pad)); } /** The controller mode in words: a short label and a sentence. */ -export function padExplain(g: Game, pad: PadState): { short: string; long: string } { +export function padExplain(g: Game, pad: PadState, tr: Translator): { short: string; long: string } { const mode = g.padMode || "auto"; if (g.launchUri) { return g.launchUri.startsWith("steam:") @@ -25,15 +48,26 @@ export function padExplain(g: Game, pad: PadState): { short: string; long: strin } if (mode === "native") return { short: "Native", long: "Always starts directly. The game handles the controller itself." }; if (mode === "steam") return { short: "Steam Input", long: "Always starts through Steam Input, so a DualSense acts as an Xbox controller." }; + if (mode === "xbox") + return { + short: "Xbox controller", + long: "Always starts with Seaglass's Xbox controller, so your controller works as an Xbox one." + (tr.viGEm ? "" : " Set it up in Settings first."), + }; const ds = g.meta?.dualSense ?? ""; if (ds === "yes") return { short: "DualSense", long: "Auto: Steam lists DualSense support, so it starts directly." }; if (g.padHint === "libScePad") return { short: "DualSense", long: "Auto: the game ships Sony's DualSense library, so it starts directly." }; if (g.padHint === "SDL") return { short: "Native", long: "Auto: the game uses SDL, which handles a DualSense itself, so it starts directly." }; if (ds === "dualshock") return { short: "DualShock", long: "Auto: Steam lists DualShock support, so it starts directly." }; - if (routeOf(g, pad) === "steamInput") + const r = routeOf(g, pad, tr); + if (r === "bridge") + return { short: "Xbox controller", long: "Auto: the game supports Xbox controllers but not PlayStation ones, so your controller works as an Xbox controller." }; + if (r === "steamInput") return { short: "Steam Input", long: "Auto: the game supports Xbox controllers but not PlayStation ones, so it starts through Steam Input." }; const c = (g.meta?.controller ?? "").toLowerCase(); if (c === "full" || c === "partial") - return { short: "Auto", long: "Auto: the game supports Xbox controllers. With a PlayStation controller it starts through Steam Input." }; + return { + short: "Auto", + long: `Auto: the game supports Xbox controllers. With a PlayStation controller it starts ${tr.steam ? "through Steam Input" : "with Seaglass's Xbox controller, once it's set up in Settings"}.`, + }; return { short: "Auto", long: "Auto: no controller support known, so the game starts directly." }; } diff --git a/frontend/src/lib/store.svelte.ts b/frontend/src/lib/store.svelte.ts index f00feee..e9f4f19 100644 --- a/frontend/src/lib/store.svelte.ts +++ b/frontend/src/lib/store.svelte.ts @@ -1,7 +1,8 @@ import { api } from "./api"; -import type { Achievement, AppInfo, Game, MetaState, Profile, ScanState, Session, SessionAchievements, Settings, UpdateState } from "./types"; +import type { Achievement, AppInfo, ControllerStatus, Game, MetaState, Profile, ScanState, Session, SessionAchievements, Settings, UpdateState } from "./types"; import { unlockedText } from "./achievements"; import { lastPlayed, ownedOnly, played, title } from "./types"; +import { translatorOf } from "./route"; export type FilterKind = "all" | "installed" | "notinstalled" | "favorites" | "recent" | "found" | "hidden"; export type Filter = { kind: FilterKind } | { kind: "source"; source: string } | { kind: "collection"; name: string }; @@ -67,6 +68,10 @@ class LibraryStore { achSession = $state(null); /** Who's playing on this PC (Syncer's accounts). */ profile = $state(null); + /** What Seaglass's Xbox controller can do on this PC (null until read). */ + controller = $state(null); + /** The tools that can turn a PlayStation controller into an Xbox one. */ + translator = $derived(translatorOf(this.controller, this.settings)); filter = $state({ kind: "all" }); sort = $state("title"); @@ -214,7 +219,7 @@ class LibraryStore { this.achSession = s; if (s.unlocked.length) this.toast(`${s.title}: ${unlockedText(s.unlocked.length)}`, "info", s.unlocked.slice(0, 5)); }); - const [games, settings, scan, info, meta, session, update, profile] = await Promise.all([ + const [games, settings, scan, info, meta, session, update, profile, controller] = await Promise.all([ api.games(), api.settings(), api.scanState(), @@ -223,7 +228,9 @@ class LibraryStore { api.launch.session(), api.updates.state(), api.profile.get(), + api.controller.status().catch(() => null), ]); + this.controller = controller; if (!fresh.has("profile")) this.profile = profile; if (!fresh.has("update")) this.update = update; this.announceVersion(info.version); @@ -238,6 +245,30 @@ class LibraryStore { this.loaded = true; } + /** Reads the drivers' state again (after a setup, or when Settings opens). */ + async refreshController() { + try { + this.controller = await api.controller.status(); + } catch (e) { + this.toast(errText(e), "error"); + } + } + + /** Downloads and runs the driver installers. */ + async setUpController() { + const status = await this.run(() => api.controller.setUp()); + if (status) this.controller = status; + else await this.refreshController(); + return !!status; + } + + /** Shows every controller Seaglass hid from games again. */ + async unhideController() { + const status = await this.run(() => api.controller.unhide()); + if (status) this.controller = status; + else await this.refreshController(); + } + /** Says so once after Seaglass was updated. */ private announceVersion(version: string) { try { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b623a94..ad04d87 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -67,7 +67,7 @@ export interface Game { /** The user's own groups the game is in ("Co-op", "Finished"). */ collections?: string[]; hidden?: boolean; - padMode?: string; // "" | "native" | "steam" + padMode?: string; // "" | "native" | "xbox" | "steam" meta?: Meta; } @@ -94,6 +94,10 @@ export interface Settings { glyphs: "auto" | "playstation" | "xbox"; closeWhilePlaying: boolean; padWhilePlaying: "listen" | "off"; + /** Which tool turns a PlayStation controller into an Xbox one in Auto (per PC). */ + padTranslator: "seaglass" | "steam"; + /** Hide the real controller from games while one plays; needs HidHide (per PC). */ + hidePadWhilePlaying: boolean; noticeExternal: boolean; syncSavesBefore: boolean; backupSavesAfter: boolean; @@ -552,7 +556,7 @@ export interface Session { title: string; phase: Phase; /** "external": started outside Seaglass and noticed. */ - route: "direct" | "store" | "steamInput" | "external" | ""; + route: "direct" | "store" | "steamInput" | "bridge" | "external" | ""; before: StepState[]; after: StepState[]; question?: Question; @@ -1078,3 +1082,19 @@ export interface LibraryCompletion { key: string; completion: Completion; } + +/** What Seaglass's Xbox controller (two drivers) can do on this PC. */ +export interface ControllerStatus { + /** The virtual Xbox controller works. */ + viGEm: boolean; + viGEmVersion?: string; + hidHide: boolean; + hidHideVersion?: string; + hidHideActive: boolean; + /** The real controller can be hidden while playing. */ + canHide: boolean; + /** A driver is installed, but Windows must restart first. */ + restart: boolean; + /** Seaglass has controllers on HidHide's saved block list right now. */ + hidden: boolean; +} diff --git a/internal/app/controller.go b/internal/app/controller.go new file mode 100644 index 0000000..904e1c0 --- /dev/null +++ b/internal/app/controller.go @@ -0,0 +1,393 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/ApolloF/Seaglass/internal/launch" + "github.com/ApolloF/Seaglass/internal/logx" + "github.com/ApolloF/Seaglass/internal/pad" + "github.com/ApolloF/Seaglass/internal/platform" + "github.com/ApolloF/Seaglass/internal/update" + "github.com/ApolloF/Seaglass/internal/vpad" +) + +// Texts shown in the launch panel. +const ( + setupNotice = "Set up Seaglass's Xbox controller in Settings to play this game with your controller." + twiceWarning = "Some games may see your controller twice." +) + +// ControllerService is Seaglass's Xbox controller for games: whether its +// two drivers are there, and setting them up. +type ControllerService struct{ c *Core } + +// NewControllerService binds the controller bridge to core. +func NewControllerService(c *Core) *ControllerService { return &ControllerService{c} } + +// Status says what the drivers can do on this PC. +func (s *ControllerService) Status() vpad.Status { return s.c.ctl.deps.status() } + +// SetUpDrivers downloads the official installers of the drivers that are +// missing, checks them against the hashes pinned in Seaglass, and runs +// them one after the other (each asks Windows for permission itself). It +// returns the status once they're done. Closing an installer isn't an +// error; the status then says what's still missing. +func (s *ControllerService) SetUpDrivers() (vpad.Status, error) { + return s.c.ctl.setUp(s.c.ctx) +} + +// UnhideControllers shows every controller Seaglass hid from games again, +// including during a game (which then may see it twice). +func (s *ControllerService) UnhideControllers() (vpad.Status, error) { + err := s.c.ctl.unhide() + return s.c.ctl.deps.status(), err +} + +// hider hides physical controllers from games (HidHide). +type hider interface { + Active() (bool, error) + Hide(ids []string) error + Clear() error + Close() error +} + +// bridgeDeps are the drivers and installers, swapped for fakes in tests. +type bridgeDeps struct { + status func() vpad.Status + newBridge func() (vpad.Bridge, error) + newHider func() (hider, error) + // unhide shows the controllers a session left hidden (a crash, say). + unhide func() error + // install downloads one installer and runs it; restart reports that + // it asked for Windows to restart, cancelled that it was closed. + install func(ctx context.Context, in vpad.Installer) (restart, cancelled bool, err error) +} + +var realBridgeDeps = bridgeDeps{ + status: func() vpad.Status { return vpad.GetStatus(hiddenLedger()) }, + newBridge: func() (vpad.Bridge, error) { return vpad.NewX360() }, + newHider: func() (hider, error) { return vpad.OpenHider(hiddenLedger()) }, + unhide: func() error { return vpad.Unhide(hiddenLedger()) }, + install: installDriver, +} + +// hiddenLedger is the file listing the controllers Seaglass put on +// HidHide's saved block list, so they can be shown again after a crash. +func hiddenLedger() string { return filepath.Join(platform.AppDir(), "hidden-controllers.json") } + +// controllerState is the controller bridge of the game being played. +type controllerState struct { + c *Core + deps bridgeDeps + + setting sync.Mutex // SetUpDrivers runs + + mu sync.Mutex + bridge vpad.Bridge // nil when no game uses it + hider hider + // hideErr is why hiding didn't work, for the launch panel; hidOnce is + // closed once the controllers SDL opened in Bridge mode were handled. + hideErr error + hidOnce chan struct{} + hidShut bool + lastSent time.Time +} + +func newControllerState(c *Core) *controllerState { + return &controllerState{c: c, deps: realBridgeDeps} +} + +// active reports whether a game plays through the bridge now. +func (cs *controllerState) active() bool { + cs.mu.Lock() + defer cs.mu.Unlock() + return cs.bridge != nil +} + +// translator is what turns a PlayStation controller into an Xbox one on +// this PC. +func (c *Core) translator() translator { + return translator{viGEm: c.ctl.deps.status().ViGEm, steam: c.Settings.Get().PadTranslator == "steam"} +} + +// hideWait is how long the controller step waits for SDL to open the +// controllers in Bridge mode, so they're hidden before the game looks. +const hideWait = 3 * time.Second + +// begin plugs in the virtual controller, hands the controller layer to it +// and hides the physical controllers (when hide). The game can start once +// it returns. err means there is no virtual controller; warn is set when +// it works but the real controller couldn't be hidden. +func (cs *controllerState) begin(hide bool) (warn string, err error) { + cs.end() // a session that never ended cleanly + b, err := cs.deps.newBridge() + if err != nil { + return "", err + } + if err := b.Plug(); err != nil { + _ = b.Close() + return "", err + } + m := cs.c.padManager() + b.Rumble(func(large, small uint8) { + if m != nil { + m.BridgeRumble(large, small) + } + }) + cs.mu.Lock() + cs.bridge, cs.hider, cs.hideErr = b, nil, nil + cs.hidOnce, cs.hidShut = make(chan struct{}), false + cs.mu.Unlock() + + if hide { + h, herr := cs.openHider() + cs.mu.Lock() + cs.hider, cs.hideErr = h, herr + cs.mu.Unlock() + } + if m == nil { + cs.handled() + } else { + m.SetBridge(cs.send, cs.hidePads) + m.SetMode(pad.Bridge) + } + cs.mu.Lock() + once := cs.hidOnce + cs.mu.Unlock() + select { + case <-once: + case <-time.After(hideWait): + } + cs.mu.Lock() + herr := cs.hideErr + cs.mu.Unlock() + if hide && herr != nil { + logx.Printf("controller bridge: not hiding the controller: %v", herr) + warn = twiceWarning + } + return warn, nil +} + +// openHider opens HidHide when it can hide a controller for this session. +func (cs *controllerState) openHider() (hider, error) { + h, err := cs.deps.newHider() + if err != nil { + return nil, err + } + if on, err := h.Active(); err != nil || !on { + _ = h.Close() + if err == nil { + err = errors.New("HidHide's hiding is switched off") + } + return nil, err + } + return h, nil +} + +// send passes the controller's state to the virtual controller. It runs on +// the SDL thread. +func (cs *controllerState) send(r pad.Raw) { + cs.mu.Lock() + b := cs.bridge + cs.mu.Unlock() + if b == nil { + return + } + if err := b.Send(vpad.FromSDL(r.Buttons, r.Axes)); err != nil { + cs.mu.Lock() + quiet := time.Since(cs.lastSent) < time.Minute + cs.lastSent = time.Now() + cs.mu.Unlock() + if !quiet { + logx.Printf("controller bridge: %v", err) + } + } +} + +// hidePads hides the controllers SDL has open (by device path) from games. +// It runs on the SDL thread whenever they change: a controller plugged in +// mid-game is hidden too. +func (cs *controllerState) hidePads(paths []string) { + defer cs.handled() + cs.mu.Lock() + h := cs.hider + cs.mu.Unlock() + if h == nil { + return + } + var ids []string + for _, p := range paths { + if id, ok := vpad.InstanceID(p); ok { + ids = append(ids, id) + } + } + err := h.Clear() + if err == nil { + err = h.Hide(ids) + } + if err == nil && len(paths) > 0 && len(ids) == 0 { + err = errors.New("the controller isn't a HID device") + } + cs.mu.Lock() + cs.hideErr = err + cs.mu.Unlock() + if err == nil && len(ids) > 0 { + logx.Printf("controller bridge: hiding %d controller(s) from games", len(ids)) + } +} + +// handled says the controllers were looked at once. +func (cs *controllerState) handled() { + cs.mu.Lock() + defer cs.mu.Unlock() + if cs.hidOnce != nil && !cs.hidShut { + close(cs.hidOnce) + cs.hidShut = true + } +} + +// end unplugs the virtual controller and shows the real one again. Safe to +// call when nothing runs. The controller layer's mode is the caller's. +func (cs *controllerState) end() { + cs.mu.Lock() + b, h := cs.bridge, cs.hider + cs.bridge, cs.hider = nil, nil + cs.mu.Unlock() + if b == nil && h == nil { + return + } + if m := cs.c.padManager(); m != nil { + m.SetBridge(nil, nil) + } + if b != nil { + if err := b.Close(); err != nil { + logx.Printf("controller bridge: unplugging: %v", err) + } + } + if h != nil { + if err := h.Close(); err != nil { + logx.Printf("controller bridge: showing the controller again: %v", err) + } + } + cs.handled() +} + +// unhide shows every controller Seaglass hid again: the ones hidden for +// the game being played (which stay visible until it ends) and any a +// session left on HidHide's saved block list. +func (cs *controllerState) unhide() error { + cs.mu.Lock() + h := cs.hider + cs.hider = nil + cs.mu.Unlock() + var err error + if h != nil { + err = h.Close() + } + if cs.deps.unhide != nil { + err = errors.Join(err, cs.deps.unhide()) + } + if err != nil { + logx.Printf("controller bridge: showing the controller again: %v", err) + } + return err +} + +// controllerStep starts the virtual controller before the game, since some +// games only look for controllers as they start. Without one the game +// still starts, the way it would without Seaglass. +func (c *Core) controllerStep(bridged *bool) launch.Step { + return launch.Step{ID: "controller", Label: "Xbox controller", Timeout: 30 * time.Second, + Run: func(ctx context.Context, sc *launch.StepContext) error { + warn, err := c.ctl.begin(c.Settings.Get().HidePadWhilePlaying) + if err != nil { + logx.Printf("controller bridge: %v", err) + if errors.Is(err, vpad.ErrNoViGEm) { + sc.Progress(setupNotice) + } else { + sc.Progress("Couldn't start the Xbox controller. Starting without it.") + } + return nil + } + *bridged = true + if warn != "" { + sc.Progress(warn) + } else { + sc.Progress("Your controller works as an Xbox controller in this game.") + } + return nil + }} +} + +// setupStep only tells the person the game would need Seaglass's Xbox +// controller, which isn't set up. +func setupStep() launch.Step { + return launch.Step{ID: "controller", Label: "Xbox controller", Timeout: time.Second, + Run: func(ctx context.Context, sc *launch.StepContext) error { + sc.Progress(setupNotice) + return nil + }} +} + +// setUp installs the missing drivers. +func (cs *controllerState) setUp(ctx context.Context) (vpad.Status, error) { + if !cs.setting.TryLock() { + return cs.deps.status(), errors.New("the drivers are being set up already") + } + defer cs.setting.Unlock() + restart := false + for _, in := range vpad.Needed(cs.deps.status()) { + logx.Printf("controller: installing %s", in.Name) + r, cancelled, err := cs.deps.install(ctx, in) + if err != nil { + return cs.deps.status(), err + } + if cancelled { + logx.Printf("controller: %s's installer was closed", in.Name) + break + } + restart = restart || r + } + st := cs.deps.status() + st.Restart = st.Restart || restart + return st, nil +} + +// Windows Installer exit codes the driver installers (WiX bundles) use. +const ( + exitRebootRequired = 3010 // ERROR_SUCCESS_REBOOT_REQUIRED + exitRebootInitiated = 1641 // ERROR_SUCCESS_REBOOT_INITIATED + exitUserCancelled = 1602 // ERROR_INSTALL_USEREXIT +) + +// installDriver downloads a driver's installer, checks it and runs it. +func installDriver(ctx context.Context, in vpad.Installer) (restart, cancelled bool, err error) { + feed := update.Feed{AssetPrefix: in.Repo + "/releases/download/", Hosts: update.GitHub.Hosts} + dctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + dir := platform.CacheDir("drivers") + file, err := feed.DownloadPinned(dctx, in.URL, in.SHA256, dir) + if err != nil { + return false, false, fmt.Errorf("couldn't download %s: %w", in.Name, err) + } + defer os.Remove(file) + code, err := platform.RunInstaller(file, dir) + if err != nil { + return false, false, fmt.Errorf("couldn't start %s's installer: %w", in.Name, err) + } + switch code { + case 0: + return false, false, nil + case exitRebootRequired, exitRebootInitiated: + return true, false, nil + case exitUserCancelled: + return false, true, nil + } + return false, false, fmt.Errorf("%s's installer failed (exit code %d)", in.Name, code) +} diff --git a/internal/app/controller_test.go b/internal/app/controller_test.go new file mode 100644 index 0000000..1bea902 --- /dev/null +++ b/internal/app/controller_test.go @@ -0,0 +1,340 @@ +package app + +import ( + "context" + "errors" + "path/filepath" + "reflect" + "sync" + "testing" + "time" + + "github.com/ApolloF/Seaglass/internal/launch" + "github.com/ApolloF/Seaglass/internal/library" + "github.com/ApolloF/Seaglass/internal/pad" + "github.com/ApolloF/Seaglass/internal/settings" + "github.com/ApolloF/Seaglass/internal/vpad" +) + +type fakeBridge struct { + mu sync.Mutex + plugged, closed, sent int + last vpad.Report + plugErr error +} + +func (b *fakeBridge) Plug() error { + b.mu.Lock() + defer b.mu.Unlock() + if b.plugErr != nil { + return b.plugErr + } + b.plugged++ + return nil +} + +func (b *fakeBridge) Send(r vpad.Report) error { + b.mu.Lock() + defer b.mu.Unlock() + b.sent++ + b.last = r + return nil +} + +func (b *fakeBridge) Rumble(func(large, small uint8)) {} + +func (b *fakeBridge) closedCount() int { + b.mu.Lock() + defer b.mu.Unlock() + return b.closed +} + +func (b *fakeBridge) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + b.closed++ + return nil +} + +type fakeHider struct { + mu sync.Mutex + active bool + hidden []string + cleared int + closed int +} + +func (h *fakeHider) Active() (bool, error) { return h.active, nil } + +func (h *fakeHider) closedCount() int { + h.mu.Lock() + defer h.mu.Unlock() + return h.closed +} + +func (h *fakeHider) Hide(ids []string) error { + h.mu.Lock() + defer h.mu.Unlock() + h.hidden = append([]string{}, ids...) + return nil +} + +func (h *fakeHider) Clear() error { + h.mu.Lock() + defer h.mu.Unlock() + h.hidden = nil + h.cleared++ + return nil +} + +func (h *fakeHider) Close() error { + h.mu.Lock() + defer h.mu.Unlock() + h.closed++ + h.hidden = nil + return nil +} + +// ctlTestCore is a core whose drivers are fakes: ViGEmBus is there when +// viGEm, and HidHide answers through h (nil: not installed). +func ctlTestCore(t *testing.T, viGEm bool, b *fakeBridge, h *fakeHider) *Core { + t.Helper() + root := t.TempDir() + lib, err := library.Open(filepath.Join(root, "library.json")) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + c := &Core{Lib: lib, Settings: settings.Open(filepath.Join(root, "settings.json")), ctx: ctx} + c.ctl = &controllerState{c: c, deps: bridgeDeps{ + status: func() vpad.Status { return vpad.Status{ViGEm: viGEm, HidHide: h != nil} }, + newBridge: func() (vpad.Bridge, error) { + if !viGEm { + return nil, vpad.ErrNoViGEm + } + return b, nil + }, + newHider: func() (hider, error) { + if h == nil { + return nil, vpad.ErrNoHidHide + } + return h, nil + }, + }} + return c +} + +func padRaw(buttons uint32, axes [6]int16) pad.Raw { return pad.Raw{Buttons: buttons, Axes: axes} } + +const dsPath = `\\?\HID#VID_054C&PID_0CE6&MI_03#7&1a2b3c4d&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}` + +func TestBridgePlugsHidesAndUndoes(t *testing.T) { + b, h := &fakeBridge{}, &fakeHider{active: true} + c := ctlTestCore(t, true, b, h) + warn, err := c.ctl.begin(true) + if err != nil || warn != "" { + t.Fatalf("begin: warn %q, err %v", warn, err) + } + if b.plugged != 1 || !c.ctl.active() { + t.Fatalf("plugged %d, active %v", b.plugged, c.ctl.active()) + } + // SDL opened the DualSense in Bridge mode: it's hidden by instance id. + c.ctl.hidePads([]string{dsPath, "XInput#1"}) + if want := []string{`HID\VID_054C&PID_0CE6&MI_03\7&1A2B3C4D&0&0000`}; !reflect.DeepEqual(h.hidden, want) { + t.Errorf("hidden %q, want %q", h.hidden, want) + } + // The controller's state reaches the virtual one, Y turned around. + c.ctl.send(padRaw(1<<0, [6]int16{0, 1000, 0, 0, 0, 32767})) + if b.sent != 1 || b.last.Buttons != vpad.A || b.last.ThumbLY != -1000 || b.last.RightTrigger != 255 { + t.Errorf("sent %d: %+v", b.sent, b.last) + } + c.ctl.end() + if b.closed != 1 || h.closed != 1 || c.ctl.active() { + t.Errorf("after end: bridge closed %d, hider closed %d, active %v", b.closed, h.closed, c.ctl.active()) + } + c.ctl.end() // nothing left to undo + if b.closed != 1 || h.closed != 1 { + t.Errorf("a second end closed again: %d %d", b.closed, h.closed) + } + c.ctl.send(padRaw(1, [6]int16{})) // after the game: dropped + if b.sent != 1 { + t.Errorf("sent after the end: %d", b.sent) + } +} + +// Without HidHide, or with its hiding off, the game still gets the virtual +// controller and the launch warns about double input. +func TestBridgeWarnsWhenItCantHide(t *testing.T) { + for name, h := range map[string]*fakeHider{"no HidHide": nil, "hiding off": {active: false}} { + b := &fakeBridge{} + c := ctlTestCore(t, true, b, h) + warn, err := c.ctl.begin(true) + if err != nil || warn != twiceWarning { + t.Errorf("%s: warn %q, err %v", name, warn, err) + } + if b.plugged != 1 { + t.Errorf("%s: not plugged", name) + } + if h != nil && h.closed != 1 { + t.Errorf("%s: HidHide left open", name) + } + c.ctl.end() + } + // With hiding switched off in Settings, HidHide isn't touched. + b, h := &fakeBridge{}, &fakeHider{active: true} + c := ctlTestCore(t, true, b, h) + if warn, err := c.ctl.begin(false); err != nil || warn != "" { + t.Errorf("hide off: warn %q, err %v", warn, err) + } + c.ctl.hidePads([]string{dsPath}) + if len(h.hidden) != 0 || h.cleared != 0 { + t.Errorf("hide off: HidHide was used: %+v", h) + } + c.ctl.end() +} + +func TestBridgeFailsWithoutViGEm(t *testing.T) { + c := ctlTestCore(t, false, nil, nil) + if _, err := c.ctl.begin(true); !errors.Is(err, vpad.ErrNoViGEm) || c.ctl.active() { + t.Errorf("err %v, active %v", err, c.ctl.active()) + } + b := &fakeBridge{plugErr: errors.New("no free slot")} + c = ctlTestCore(t, true, b, nil) + if _, err := c.ctl.begin(true); err == nil || b.closed != 1 || c.ctl.active() { + t.Errorf("plug failure: err %v, closed %d, active %v", err, b.closed, c.ctl.active()) + } +} + +// The plan has a controller step for the bridge route and for a game that +// would need the bridge that isn't set up; none otherwise. +func TestPlanControllerStep(t *testing.T) { + game := func(mode string) library.Game { + return library.Game{ID: 1, Title: "G", Installed: true, Dir: `C:\G`, Exe: `C:\G\g.exe`, PadMode: mode} + } + for _, tc := range []struct { + name string + viGEm bool + mode string + want bool + }{ + {"xbox mode, ViGEmBus", true, "xbox", true}, + {"xbox mode, nothing set up", false, "xbox", true}, + {"native", true, "native", false}, + {"auto without a PlayStation pad", true, "", false}, + } { + c := ctlTestCore(t, tc.viGEm, &fakeBridge{}, nil) + p := c.plan(game(tc.mode)) + has := false + for _, st := range p.Before { + has = has || st.ID == "controller" + } + if has != tc.want { + t.Errorf("%s: controller step %v, want %v", tc.name, has, tc.want) + } + } +} + +// A launch through the bridge plugs the virtual controller in before the +// game starts and unplugs it when the session ends, also when the game +// failed to start. +func TestLaunchUndoesTheBridge(t *testing.T) { + b, h := &fakeBridge{}, &fakeHider{active: true} + c := ctlTestCore(t, true, b, h) + c.Launch = launch.NewManager(c.onSession) + t.Cleanup(c.Launch.Close) + bridged := false + pluggedAtStart := 0 + err := c.Launch.Launch(c.ctx, launch.Plan{GameID: 1, Title: "G", + Before: []launch.Step{c.controllerStep(&bridged)}, + Start: func() (uint32, string, error) { + pluggedAtStart = b.plugged + return 0, RouteBridge, errors.New("the exe is gone") + }}) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for !c.Launch.Current().Phase.Done() { + if time.Now().After(deadline) { + t.Fatalf("session still %s", c.Launch.Current().Phase) + } + time.Sleep(10 * time.Millisecond) + } + s := c.Launch.Current() + if !bridged || pluggedAtStart != 1 { + t.Errorf("bridged %v, plugged before the start: %d", bridged, pluggedAtStart) + } + if len(s.Before) != 1 || s.Before[0].Detail != "Your controller works as an Xbox controller in this game." { + t.Errorf("step: %+v", s.Before) + } + // The session reads as over a moment before onSession has run. + for b.closedCount() != 1 || h.closedCount() != 1 || c.ctl.active() { + if time.Now().After(deadline) { + t.Fatalf("after the session: bridge closed %d, hider closed %d, active %v", b.closedCount(), h.closedCount(), c.ctl.active()) + } + time.Sleep(10 * time.Millisecond) + } +} + +// Setting up runs the installers of the missing drivers only, stops when +// one is closed, and passes on a restart request. +func TestSetUpDrivers(t *testing.T) { + c := ctlTestCore(t, false, nil, nil) + var ran []string + installed := map[string]bool{} + c.ctl.deps.status = func() vpad.Status { return vpad.Status{ViGEm: installed["ViGEmBus"], HidHide: installed["HidHide"]} } + c.ctl.deps.install = func(_ context.Context, in vpad.Installer) (bool, bool, error) { + ran = append(ran, in.Name) + installed[in.Name] = true + return in.Name == "HidHide", false, nil + } + st, err := c.ctl.setUp(c.ctx) + if err != nil || !st.ViGEm || !st.HidHide || !st.Restart { + t.Errorf("status %+v, err %v", st, err) + } + if !reflect.DeepEqual(ran, []string{"ViGEmBus", "HidHide"}) { + t.Errorf("ran %v", ran) + } + ran = nil + if _, err := c.ctl.setUp(c.ctx); err != nil || len(ran) != 0 { + t.Errorf("everything installed, ran %v (err %v)", ran, err) + } + + installed = map[string]bool{} + c.ctl.deps.install = func(_ context.Context, in vpad.Installer) (bool, bool, error) { + ran = append(ran, in.Name) + return false, true, nil // closed + } + if st, err := c.ctl.setUp(c.ctx); err != nil || st.ViGEm || !reflect.DeepEqual(ran, []string{"ViGEmBus"}) { + t.Errorf("closed installer: status %+v, ran %v, err %v", st, ran, err) + } +} + +// Unhide from Settings shows the controller at once, even mid-game, and +// a later change of controllers doesn't hide it again; leftovers from a +// crash are shown too. +func TestUnhideShowsTheControllerAgain(t *testing.T) { + b, h := &fakeBridge{}, &fakeHider{active: true} + c := ctlTestCore(t, true, b, h) + leftovers := 0 + c.ctl.deps.unhide = func() error { leftovers++; return nil } + if _, err := c.ctl.begin(true); err != nil { + t.Fatal(err) + } + c.ctl.hidePads([]string{dsPath}) + if err := c.ctl.unhide(); err != nil { + t.Fatal(err) + } + if h.closedCount() != 1 || leftovers != 1 { + t.Fatalf("hider closed %d, leftovers shown %d", h.closedCount(), leftovers) + } + c.ctl.hidePads([]string{dsPath}) // the controller reconnects mid-game + if len(h.hidden) != 0 { + t.Errorf("hidden again after unhiding: %q", h.hidden) + } + if !c.ctl.active() { + t.Error("unhiding ended the virtual controller") + } + c.ctl.end() +} diff --git a/internal/app/core.go b/internal/app/core.go index ca31ef1..eb6c99d 100644 --- a/internal/app/core.go +++ b/internal/app/core.go @@ -76,8 +76,9 @@ type Core struct { shell *Shell pad atomic.Pointer[pad.Manager] - lastSession int64 // the last session whose end was handled - registered atomic.Bool // the game list reached Syncer + ctl *controllerState // Seaglass's virtual Xbox controller for games + lastSession int64 // the last session whose end was handled + registered atomic.Bool // the game list reached Syncer ctx context.Context cancel context.CancelFunc @@ -118,6 +119,7 @@ func NewCore(version string) (*Core, error) { c.discovery = newDiscoveryState(c) c.wishlist = newWishlistState(c) c.enrich = newEnrichState(c) + c.ctl = newControllerState(c) return c, nil } @@ -138,6 +140,9 @@ func (c *Core) Start() { go c.enrich.loop(c.ctx) return } + // A crash while a game ran can leave the controller hidden from + // every other app; show it again before anything else uses it. + _ = c.ctl.unhide() // only reads a file when nothing was left hidden go c.scanLoop() c.profile.start() heapDiag("idle") @@ -189,6 +194,10 @@ func (c *Core) Start() { // Stop ends background work and saves the library. func (c *Core) Stop() { c.Launch.Close() + if c.ctl != nil { + c.ctl.end() + _ = c.ctl.unhide() + } c.cancel() c.store.close() c.discovery.save() diff --git a/internal/app/launch.go b/internal/app/launch.go index 02caa20..b2cdcf9 100644 --- a/internal/app/launch.go +++ b/internal/app/launch.go @@ -36,6 +36,7 @@ const ( RouteDirect = "direct" // its exe, by Seaglass RouteStore = "store" // handed to its store (Steam, Epic, …) RouteSteamInput = "steamInput" // a non-Steam shortcut, so Steam Input handles the controller + RouteBridge = "bridge" // its exe, with Seaglass's virtual Xbox controller standing in for the real one ) // LaunchService starts games and follows them while they run. @@ -109,21 +110,29 @@ func (s *LaunchService) OpenMain() { s.c.shell.OpenMain() } -// Route tells how a game would start now: direct, store or steamInput. +// Route tells how a game would start now: direct, store, steamInput or bridge. func (s *LaunchService) Route(id int64) string { g, ok := s.c.Lib.Get(id) if !ok { return RouteDirect } - return route(g, s.c.padState()) + return route(g, s.c.padState(), s.c.translator()) +} + +// translator is what can turn a PlayStation controller into an Xbox one. +type translator struct { + viGEm bool // Seaglass's virtual Xbox controller is set up (ViGEmBus) + steam bool // the person chose Steam Input for it } // route picks how a game starts. Store games go through their store -// (Steam applies Steam Input to its own games). Other games go through -// Steam Input when the user chose it, or in auto mode when a PlayStation -// controller is in use and the game is known to support controllers but -// not PlayStation ones. -func route(g library.Game, ps pad.State) string { +// (Steam applies Steam Input to its own games). Other games get an Xbox +// controller when the user chose that for the game, or in auto mode when +// a PlayStation controller is in use and the game is known to support +// controllers but not PlayStation ones: Steam Input when the user chose +// Steam for that, else Seaglass's own virtual controller once it's set up. +// Without either the game starts directly (with a hint to set it up). +func route(g library.Game, ps pad.State, tr translator) string { if g.LaunchURI != "" { return RouteStore } @@ -132,24 +141,54 @@ func route(g library.Game, ps pad.State) string { return RouteDirect case "steam": return RouteSteamInput - } - if !ps.Connected || ps.Kind != pad.PlayStation || g.PadHint != "" || g.Meta == nil { + case "xbox": + if tr.viGEm { + return RouteBridge + } return RouteDirect } - if g.Meta.DualSense != "" && g.Meta.DualSense != "no" { + if !wantsXbox(g, ps) { return RouteDirect } - if c := strings.ToLower(g.Meta.Controller); c == "full" || c == "partial" { + switch { + case tr.steam: return RouteSteamInput + case tr.viGEm: + return RouteBridge } return RouteDirect } +// wantsXbox reports whether auto mode would hand the game an Xbox +// controller: a PlayStation controller is in use, and the game is known to +// support controllers but not PlayStation ones. +func wantsXbox(g library.Game, ps pad.State) bool { + if !ps.Connected || ps.Kind != pad.PlayStation || g.PadHint != "" || g.Meta == nil { + return false + } + if g.Meta.DualSense != "" && g.Meta.DualSense != "no" { + return false + } + c := strings.ToLower(g.Meta.Controller) + return c == "full" || c == "partial" +} + +// needsSetup reports whether the game would get Seaglass's Xbox controller +// if it were set up, so the launch can say how to get it. +func needsSetup(g library.Game, ps pad.State, tr translator) bool { + if g.LaunchURI != "" || tr.viGEm { + return false + } + return g.PadMode == "xbox" || g.PadMode == "" && !tr.steam && wantsXbox(g, ps) +} + // plan builds the launch of one game. func (c *Core) plan(g library.Game) launch.Plan { title := g.DisplayTitle() - r := route(g, c.padState()) + ps, tr := c.padState(), c.translator() + r := route(g, ps, tr) var steamURI string + bridged := false var before, after []launch.Step cfg := c.Settings.Get() if _, ok := syncer.Installed(); ok { @@ -168,15 +207,20 @@ func (c *Core) plan(g library.Game) launch.Plan { after = append(after, c.savesAfterStep(g, &known, true)) } } - if r == RouteSteamInput { + switch { + case r == RouteSteamInput: before = append(before, c.steamInputStep(g, &steamURI)) + case r == RouteBridge: + before = append(before, c.controllerStep(&bridged)) + case needsSetup(g, ps, tr): + before = append(before, setupStep()) } dirs := []string{g.Dir} if g.Exe != "" && !platform.Within(g.Dir, g.Exe) { dirs = append(dirs, filepath.Dir(g.Exe)) } detect := 2 * time.Minute // a UAC prompt can wait a while - if r != RouteDirect { + if r == RouteStore || r == RouteSteamInput { detect = 5 * time.Minute // stores update, sign in, build shader caches } return launch.Plan{ @@ -193,6 +237,9 @@ func (c *Core) plan(g library.Game) launch.Plan { err = platform.OpenURI(g.LaunchURI) default: used = RouteDirect + if bridged { + used = RouteBridge + } pid, err = platform.StartProcess(g.Exe, g.Args, g.WorkDir) } if err != nil { @@ -231,15 +278,19 @@ func (c *Core) plan(g library.Game) launch.Plan { } } -// padForGame steps the controller layer back while a game runs. +// padForGame steps the controller layer back while a game runs, or keeps +// feeding Seaglass's virtual controller when the game plays through it. func (c *Core) padForGame() { m := c.padManager() if m == nil { return } - if c.Settings.Get().PadWhilePlaying == "off" { + switch { + case c.ctl != nil && c.ctl.active(): + m.SetMode(pad.Bridge) + case c.Settings.Get().PadWhilePlaying == "off": m.SetMode(pad.Off) - } else { + default: m.SetMode(pad.Passive) } } @@ -250,6 +301,11 @@ func (c *Core) onSession(s launch.Session) { c.emit(EventSession, s) // The game has exited: the controller comes back now, not after the // steps that follow it (a save backup can take minutes). + // The virtual controller goes first, so the layer coming back doesn't + // pick it up as a controller. + if c.ctl != nil && (s.Phase == launch.Finishing || s.Phase.Done()) { + c.ctl.end() + } if s.Phase == launch.Finishing { if m := c.padManager(); m != nil { m.SetMode(pad.Active) diff --git a/internal/app/launch_test.go b/internal/app/launch_test.go index 4bdff86..627d827 100644 --- a/internal/app/launch_test.go +++ b/internal/app/launch_test.go @@ -28,12 +28,55 @@ func TestRoute(t *testing.T) { {"forced native", library.Game{Exe: `C:\g.exe`, PadMode: "native", Meta: xinputOnly}, ds, RouteDirect}, {"forced Steam Input", library.Game{Exe: `C:\g.exe`, PadMode: "steam"}, xbox, RouteSteamInput}, } { - if got := route(tc.g, tc.pad); got != tc.want { + // The Steam route for Auto is the one a person who chose Steam gets. + if got := route(tc.g, tc.pad, translator{steam: true}); got != tc.want { t.Errorf("%s: route = %s, want %s", tc.name, got, tc.want) } } } +// Auto picks the translator the person chose: Steam Input, or Seaglass's +// own virtual controller once ViGEmBus is there, else it starts directly. +// The xbox mode needs ViGEmBus; native and steam ignore the drivers. +func TestRouteTranslator(t *testing.T) { + ds := pad.State{Connected: true, Kind: pad.PlayStation, DualSense: true} + xbox := pad.State{Connected: true, Kind: pad.Xbox} + xinputOnly := &library.Meta{Controller: "partial"} + auto := library.Game{Exe: `C:\g.exe`, Meta: xinputOnly} + forced := library.Game{Exe: `C:\g.exe`, PadMode: "xbox"} + none, vigem := translator{}, translator{viGEm: true} + steam, both := translator{steam: true}, translator{viGEm: true, steam: true} + for _, tc := range []struct { + name string + g library.Game + pad pad.State + tr translator + want string + setup bool + }{ + {"auto, ViGEmBus", auto, ds, vigem, RouteBridge, false}, + {"auto, nothing set up", auto, ds, none, RouteDirect, true}, + {"auto, Steam chosen", auto, ds, steam, RouteSteamInput, false}, + {"auto, Steam chosen over ViGEmBus", auto, ds, both, RouteSteamInput, false}, + {"auto, Xbox pad", auto, xbox, vigem, RouteDirect, false}, + {"auto, Xbox pad, nothing set up", auto, xbox, none, RouteDirect, false}, + {"auto, DualSense game", library.Game{Exe: `C:\g.exe`, Meta: &library.Meta{Controller: "full", DualSense: "yes"}}, ds, none, RouteDirect, false}, + {"xbox mode, ViGEmBus", forced, xbox, vigem, RouteBridge, false}, + {"xbox mode, no pad", forced, pad.State{}, both, RouteBridge, false}, + {"xbox mode, nothing set up", forced, ds, steam, RouteDirect, true}, + {"native mode", library.Game{Exe: `C:\g.exe`, PadMode: "native", Meta: xinputOnly}, ds, vigem, RouteDirect, false}, + {"steam mode", library.Game{Exe: `C:\g.exe`, PadMode: "steam", Meta: xinputOnly}, ds, vigem, RouteSteamInput, false}, + {"store game", library.Game{LaunchURI: "steam://rungameid/1", PadMode: "xbox"}, ds, none, RouteStore, false}, + } { + if got := route(tc.g, tc.pad, tc.tr); got != tc.want { + t.Errorf("%s: route = %s, want %s", tc.name, got, tc.want) + } + if got := needsSetup(tc.g, tc.pad, tc.tr); got != tc.setup { + t.Errorf("%s: needsSetup = %v, want %v", tc.name, got, tc.setup) + } + } +} + func TestPlayArg(t *testing.T) { if id, ok := PlayArg([]string{`C:\Seaglass.exe`, "--play", "42"}); !ok || id != 42 { t.Errorf("PlayArg = %d, %v", id, ok) diff --git a/internal/app/services.go b/internal/app/services.go index 9cae42b..5a581ec 100644 --- a/internal/app/services.go +++ b/internal/app/services.go @@ -68,12 +68,13 @@ func (s *LibraryService) SetHidden(id int64, on bool) (library.Game, error) { return s.update(id, func(g *library.Game) { g.Hidden = on }) } -// SetPadMode picks how a controller reaches the game: "" (auto), "native" or "steam". +// SetPadMode picks how a controller reaches the game: "" (auto), "native", +// "xbox" (Seaglass's virtual Xbox controller) or "steam" (Steam Input). func (s *LibraryService) SetPadMode(id int64, mode string) (library.Game, error) { switch mode { case "", "auto": mode = "" - case "native", "steam": + case "native", "xbox", "steam": default: return library.Game{}, errors.New("unknown controller mode") } diff --git a/internal/app/store_enrich.go b/internal/app/store_enrich.go index 6d84e4a..5b2df1c 100644 --- a/internal/app/store_enrich.go +++ b/internal/app/store_enrich.go @@ -203,10 +203,13 @@ func (e *enrichState) refreshChart() bool { } e.chart = true e.mu.Unlock() + // The goroutine keeps the client it started with, so swapping e.client + // (tests do) doesn't race with a fetch in flight. + client := e.client go func() { ctx, cancel := context.WithTimeout(e.c.ctx, time.Minute) defer cancel() - p := e.client.Popularity(ctx) + p := client.Popularity(ctx) e.mu.Lock() e.chart = false e.mu.Unlock() diff --git a/internal/library/library.go b/internal/library/library.go index e9d4001..ad5dc90 100644 --- a/internal/library/library.go +++ b/internal/library/library.go @@ -69,7 +69,7 @@ type Game struct { Favorite bool `json:"favorite,omitempty"` Hidden bool `json:"hidden,omitempty"` - PadMode string `json:"padMode,omitempty"` // "" = auto, "native", "steam" + PadMode string `json:"padMode,omitempty"` // "" = auto, "native", "xbox", "steam" // Collections are the user's own groups ("Co-op", "Finished"); a game // can be in several. Collections []string `json:"collections,omitempty"` diff --git a/internal/pad/bridge.go b/internal/pad/bridge.go new file mode 100644 index 0000000..ebeb80a --- /dev/null +++ b/internal/pad/bridge.go @@ -0,0 +1,151 @@ +package pad + +import ( + "sort" + "time" +) + +// SetBridge sets where Bridge mode hands the controller: sink gets the +// state of the controller in use whenever it changes (all zero, at rest, +// while none is connected), pads the device paths of the controllers SDL +// has open whenever they change (so they can be hidden from the game). +// Both are called on the SDL thread and must return quickly; nil stops them. +func (m *Manager) SetBridge(sink func(Raw), pads func(paths []string)) { + m.mu.Lock() + m.bridgeSink, m.bridgePads = sink, pads + m.mu.Unlock() +} + +func (m *Manager) bridgeCallbacks() (func(Raw), func([]string)) { + m.mu.Lock() + defer m.mu.Unlock() + return m.bridgeSink, m.bridgePads +} + +// BridgeRumble passes the game's rumble (0–255 for the large and the small +// motor, as an Xbox controller gets it) on to the controller in use. It +// lasts until the next call; only in Bridge mode. +func (m *Manager) BridgeRumble(large, small uint8) { + if m.Mode() != Bridge { + return + } + lo, hi := uint16(large)*257, uint16(small)*257 + select { + case m.cmds <- func(s *sdl) { + if !m.bridging { + return + } + m.rumbleLo, m.rumbleHi = lo, hi + m.rumbleAt = time.Time{} + m.playBridgeRumble(s, time.Now()) + }: + default: // the SDL thread is busy; the game sends the next change soon + } +} + +// rumbleFor is how long one SDL rumble call lasts; a game's rumble has no +// end of its own, so it is renewed before then while it's on. +const ( + rumbleFor = 10 * time.Second + rumbleRenew = 8 * time.Second +) + +func (m *Manager) playBridgeRumble(s *sdl, now time.Time) { + if !m.rumbleAt.IsZero() && (now.Sub(m.rumbleAt) < rumbleRenew || m.rumbleLo == 0 && m.rumbleHi == 0) { + return + } + m.rumbleAt = now + if gp := m.current(); gp != 0 { + s.rumbleGamepad.Call(gp, uintptr(m.rumbleLo), uintptr(m.rumbleHi), uintptr(rumbleFor/time.Millisecond)) + } +} + +// feedBridge reads the controller in use and hands its state to the sink +// when it changed, and the open controllers' paths when they changed. +// Runs on the SDL thread after every poll in Bridge mode. +func (m *Manager) feedBridge(s *sdl) { + sink, padsFn := m.bridgeCallbacks() + if m.padsDirty { + m.padsDirty = false + if padsFn != nil { + padsFn(m.openPaths(s)) + } + } + var r Raw + if gp := m.current(); gp != 0 { + r = readGamepad(s, gp) + } + if sink != nil && (!m.bridgeSent || r != m.bridgeLast) { + sink(r) + m.bridgeLast, m.bridgeSent = r, true + } + m.playBridgeRumble(s, time.Now()) +} + +// readGamepad reads every button and axis of a gamepad. +func readGamepad(s *sdl, gp uintptr) Raw { + var r Raw + for b := range 21 { // up to the touchpad click (20) + if v, _, _ := s.gamepadButton.Call(gp, uintptr(b)); ok(v) { + r.Buttons |= 1 << b + } + } + for a := range r.Axes { + v, _, _ := s.gamepadAxis.Call(gp, uintptr(a)) + r.Axes[a] = int16(uint16(v)) + } + return r +} + +// openPaths are the device paths of the open controllers, sorted. +func (m *Manager) openPaths(s *sdl) []string { + padsMu.Lock() + defer padsMu.Unlock() + out := []string{} + for _, gp := range pads { + if p, _, _ := s.gamepadPath.Call(gp.ptr); p != 0 { + out = append(out, gostr(p)) + } + } + sort.Strings(out) + return out +} + +// sameHints reports whether SDL runs with the same hints in both modes, +// so switching between them needs no restart. +func sameHints(a, b Mode) bool { + return (a == Active || a == Bridge) && (b == Active || b == Bridge) +} + +// switchBridge turns Bridge mode on or off while SDL keeps running. +func (m *Manager) switchBridge(s *sdl, on bool) { + if !on && (m.rumbleLo != 0 || m.rumbleHi != 0) { + if gp := m.current(); gp != 0 { + s.rumbleGamepad.Call(gp, 0, 0, 0) + } + } + m.bridging, m.bridgeSent, m.padsDirty = on, false, on + m.rumbleLo, m.rumbleHi, m.rumbleAt = 0, 0, time.Time{} + m.pulses = nil + if on { + // The virtual controller may have been opened before Bridge mode. + padsMu.Lock() + var virtual []uint32 + for id, gp := range pads { + if gp.virtual { + virtual = append(virtual, id) + } + } + padsMu.Unlock() + for _, id := range virtual { + m.close(s, id) + } + } +} + +// isVirtualX360 reports whether a controller has the Xbox 360 wired +// controller's ids, which ViGEmBus's virtual controller reports. In Bridge +// mode those are left alone: reading the virtual controller back would +// feed it its own input. A real Xbox 360 controller is skipped too, which +// costs nothing: games read it directly anyway. +func isVirtualX360(vendor, product uint16) bool { return vendor == 0x045E && product == 0x028E } diff --git a/internal/pad/dll_windows.go b/internal/pad/dll_windows.go index ec7eede..c20021a 100644 --- a/internal/pad/dll_windows.go +++ b/internal/pad/dll_windows.go @@ -37,6 +37,13 @@ type sdl struct { setGamepadLED *windows.Proc gamepadPowerInfo *windows.Proc gamepadConnectionState *windows.Proc + // For the controller bridge: reading a gamepad's whole state, its + // device path (to hide it) and its ids before opening it. + gamepadAxis *windows.Proc + gamepadButton *windows.Proc + gamepadPath *windows.Proc + gamepadVendorForID *windows.Proc + gamepadProductForID *windows.Proc } // loadSDL writes the embedded DLL to Seaglass's own folder (once), @@ -80,6 +87,8 @@ func loadSDL() (*sdl, error) { {"SDL_GetGamepadName", &s.gamepadName}, {"SDL_GetGamepadNameForID", &s.gamepadNameForID}, {"SDL_GetGamepadType", &s.gamepadType}, {"SDL_RumbleGamepad", &s.rumbleGamepad}, {"SDL_SetGamepadLED", &s.setGamepadLED}, {"SDL_GetGamepadPowerInfo", &s.gamepadPowerInfo}, {"SDL_GetGamepadConnectionState", &s.gamepadConnectionState}, + {"SDL_GetGamepadAxis", &s.gamepadAxis}, {"SDL_GetGamepadButton", &s.gamepadButton}, {"SDL_GetGamepadPath", &s.gamepadPath}, + {"SDL_GetGamepadVendorForID", &s.gamepadVendorForID}, {"SDL_GetGamepadProductForID", &s.gamepadProductForID}, } { p, err := d.FindProc(f.name) if err != nil { diff --git a/internal/pad/pad.go b/internal/pad/pad.go index 7e82009..bad0d36 100644 --- a/internal/pad/pad.go +++ b/internal/pad/pad.go @@ -134,6 +134,7 @@ const ( pollActive = 8 * time.Millisecond // a controller is in use pollIdle = 16 * time.Millisecond // connected, untouched for a few seconds pollPassive = 33 * time.Millisecond // a game runs: only the PS button matters + pollBridge = 4 * time.Millisecond // a game reads the controller through Seaglass: latency counts pollNoPad = 250 * time.Millisecond // nothing connected: only hotplug // slowCall is how long one SDL call may take before the controller @@ -185,6 +186,21 @@ type Manager struct { // Whether an SDL call was slow since SDL last started quickly; only // touched on the SDL thread. slow bool + + // The controller bridge (Bridge mode): bridgeSink gets the state of + // the controller in use whenever it changes, bridgePads the device + // paths of the controllers SDL has open. Set under mu. + bridgeSink func(Raw) + bridgePads func([]string) + // Only touched on the SDL thread: whether SDL runs in Bridge mode, the + // state last handed to the sink, and the game's rumble. + bridging bool + bridgeLast Raw + bridgeSent bool + padsDirty bool + rumbleLo uint16 + rumbleHi uint16 + rumbleAt time.Time } // timed runs an SDL call and notes when it was slow. @@ -265,6 +281,12 @@ const ( Passive // Off releases controllers completely. Off + // Bridge reads the controller for a game that sees it through + // Seaglass's virtual Xbox controller: like Active (SDL's HIDAPI drivers + // on, so a DualSense works fully), polled often, its whole state handed + // to the bridge sink, and the game's rumble passed on to it. Actions + // keep coming (the PS button opens the overlay). + Bridge ) func (m Mode) String() string { @@ -273,6 +295,8 @@ func (m Mode) String() string { return "active" case Passive: return "passive" + case Bridge: + return "bridge" } return "off" } @@ -399,6 +423,9 @@ type gamepad struct { name string kind Kind ds bool + // virtual: it has the ids of ViGEmBus's virtual Xbox 360 controller + // (see isVirtualX360). + virtual bool } var ( @@ -482,6 +509,8 @@ func (m *Manager) loop() { return time.Hour // nothing to read case n == 0: return pollNoPad + case m.bridging: + return pollBridge case passive: return pollPassive case time.Since(lastInput) < 5*time.Second || len(held) > 0 || len(m.pulses) > 0 || m.rawListener() != nil: @@ -504,6 +533,18 @@ func (m *Manager) loop() { if want == applied && !failed { return } + // Active and Bridge read controllers the same way, so SDL keeps + // running between them: a start can take many seconds with a + // controller that is slow to answer, and the game is waiting. + if !off && !failed && sameHints(applied, want) { + m.switchBridge(s, want == Bridge) + applied = want + m.mu.Lock() + m.inMode = want + m.mu.Unlock() + retune() + return + } if !off { m.closeAll(s) m.timed("to stop", func() { s.quit.Call() }) @@ -515,6 +556,8 @@ func (m *Manager) loop() { clear(hats) m.pulses = nil off, passive, applied, failed = true, want == Passive, want, false + m.bridging, m.bridgeSent, m.padsDirty = want == Bridge, false, want == Bridge + m.rumbleLo, m.rumbleHi = 0, 0 m.padErr = "" m.slow = false if want != Off { @@ -705,6 +748,9 @@ func (m *Manager) loop() { if m.slow && !wasSlow { m.refreshState(s) } + if m.bridging { + m.feedBridge(s) + } if stickMoved { stick() } @@ -749,6 +795,15 @@ func (m *Manager) loop() { } func (m *Manager) open(s *sdl, id uint32) { + vid, _, _ := s.gamepadVendorForID.Call(uintptr(id)) + pid, _, _ := s.gamepadProductForID.Call(uintptr(id)) + virtual := isVirtualX360(uint16(vid), uint16(pid)) + if m.bridging { + if virtual { + return // most likely the virtual controller itself; reading it back would loop + } + m.padsDirty = true + } r, _, _ := s.openGamepad.Call(uintptr(id)) if r == 0 { // Usually another program holds the controller for itself @@ -759,7 +814,7 @@ func (m *Manager) open(s *sdl, id uint32) { } name, _, _ := s.gamepadName.Call(r) t, _, _ := s.gamepadType.Call(r) - gp := &gamepad{ptr: r, name: gostr(name)} + gp := &gamepad{ptr: r, name: gostr(name), virtual: virtual} switch int32(t) { case typePS3, typePS4, typePS5: gp.kind = PlayStation @@ -796,6 +851,7 @@ func (m *Manager) close(s *sdl, id uint32) { padsMu.Unlock() if gp != nil { s.closeGamepad.Call(gp.ptr) + m.padsDirty = m.padsDirty || m.bridging } m.refreshState(s) } diff --git a/internal/pad/pad_test.go b/internal/pad/pad_test.go index faedf73..8739b64 100644 --- a/internal/pad/pad_test.go +++ b/internal/pad/pad_test.go @@ -82,3 +82,60 @@ func TestSlowNoteOnlyForSlowCalls(t *testing.T) { t.Errorf("a 25 s start: %q", n) } } + +// Bridge mode runs SDL and hands the controller's state to the sink at +// least once (at rest when no controller is connected), so the virtual +// controller never keeps a stale press. +func TestBridgeModeFeedsTheSink(t *testing.T) { + m := Start(func(string, bool) {}, func(State) {}) + defer m.Stop() + got := make(chan Raw, 64) + m.SetBridge(func(r Raw) { + select { + case got <- r: + default: + } + }, func([]string) {}) + m.SetMode(Bridge) + select { + case <-got: + case <-time.After(5 * time.Second): + t.Fatal("the bridge sink never heard from the controller layer") + } + if in, running := m.InMode(); in != Bridge || !running { + t.Errorf("in %s, running %v", in, running) + } + m.BridgeRumble(200, 100) // no controller: nothing to do, but it mustn't block + m.SetMode(Active) + m.BridgeRumble(200, 100) // ignored outside Bridge mode +} + +func TestVirtualX360IsNotBridged(t *testing.T) { + if !isVirtualX360(0x045E, 0x028E) { + t.Error("the Xbox 360 ids ViGEmBus reports aren't recognised") + } + for _, ids := range [][2]uint16{{0x054C, 0x0CE6}, {0x045E, 0x0B13}, {0x057E, 0x2009}} { + if isVirtualX360(ids[0], ids[1]) { + t.Errorf("%04x:%04x counted as the virtual controller", ids[0], ids[1]) + } + } + if Bridge.String() != "bridge" { + t.Errorf("Bridge.String() = %q", Bridge.String()) + } +} + +// Active and Bridge read controllers alike, so switching between them +// keeps SDL running; every other switch starts it over. +func TestSameHints(t *testing.T) { + for _, c := range []struct { + a, b Mode + want bool + }{ + {Active, Bridge, true}, {Bridge, Active, true}, {Active, Passive, false}, + {Passive, Bridge, false}, {Bridge, Off, false}, {Off, Active, false}, + } { + if got := sameHints(c.a, c.b); got != c.want { + t.Errorf("sameHints(%s, %s) = %v", c.a, c.b, got) + } + } +} diff --git a/internal/platform/installer.go b/internal/platform/installer.go new file mode 100644 index 0000000..595d1fd --- /dev/null +++ b/internal/platform/installer.go @@ -0,0 +1,42 @@ +package platform + +import ( + "context" + "errors" + "os/exec" + + "golang.org/x/sys/windows" +) + +// RunInstaller runs an installer from dir and waits for it to exit, +// returning its exit code. An installer that asks for administrator rights +// in its manifest is started through the shell, so Windows shows its UAC +// prompt; declining that prompt counts as cancelling (exit code 1602). +func RunInstaller(exe, dir string) (uint32, error) { + cmd := exec.Command(exe) + cmd.Dir = dir + err := cmd.Run() + var exit *exec.ExitError + switch { + case err == nil: + return 0, nil + case errors.As(err, &exit): + return uint32(exit.ExitCode()), nil + case !errors.Is(err, windows.ERROR_ELEVATION_REQUIRED): + return 0, err + } + return runElevated(exe, dir) +} + +// exitInstallUserExit is what a declined UAC prompt counts as. +const exitInstallUserExit = 1602 + +// runElevated goes through the Store's RunElevated (elevate.go), which +// already wraps ShellExecuteEx. +func runElevated(exe, dir string) (uint32, error) { + code, err := RunElevated(context.Background(), exe, "", dir) + if errors.Is(err, ErrCancelled) { + return exitInstallUserExit, nil + } + return code, err +} diff --git a/internal/platform/installer_test.go b/internal/platform/installer_test.go new file mode 100644 index 0000000..d3549b0 --- /dev/null +++ b/internal/platform/installer_test.go @@ -0,0 +1,16 @@ +package platform + +import ( + "testing" + "unsafe" +) + +// SHELLEXECUTEINFOW is 112 bytes on x64, with the process handle last. +func TestShellExecuteInfoLayout(t *testing.T) { + if s := unsafe.Sizeof(shellExecuteInfo{}); s != 112 { + t.Errorf("sizeof = %d, want 112", s) + } + if o := unsafe.Offsetof(shellExecuteInfo{}.process); o != 104 { + t.Errorf("hProcess at %d, want 104", o) + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index c8d29d1..6adcc30 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -46,6 +46,14 @@ type Settings struct { CloseWhilePlaying bool `json:"closeWhilePlaying"` // close the interface while a game runs (frees its memory) PadWhilePlaying string `json:"padWhilePlaying"` // listen (PS button opens the overlay), off (release the controller) NoticeExternal bool `json:"noticeExternal"` // follow games started outside Seaglass (playtime, controller) + // PadTranslator turns a PlayStation controller into an Xbox one for + // games that only know Xbox controllers: "seaglass" (its own virtual + // controller, once ViGEmBus is set up) or "steam" (Steam Input). Per PC, + // like the drivers it depends on. + PadTranslator string `json:"padTranslator"` + // HidePadWhilePlaying hides the real controller from the game while + // Seaglass's virtual one stands in for it (needs HidHide). Per PC. + HidePadWhilePlaying bool `json:"hidePadWhilePlaying"` // Saves, through Syncer SyncSavesBefore bool `json:"syncSavesBefore"` // sync a game's saves before it starts @@ -126,6 +134,7 @@ func Defaults() Settings { Theme: "system", BigPictureLayout: "deck", OpenBigPictureOnController: true, Haptics: true, Lightbar: true, PSButton: true, Glyphs: "auto", CloseWhilePlaying: true, PadWhilePlaying: "listen", NoticeExternal: true, + PadTranslator: "seaglass", HidePadWhilePlaying: true, SyncSavesBefore: true, BackupSavesAfter: true, SyncWait: 60, StartSyncer: true, SyncProfile: true, SameSettings: true, AutoUpdate: true, Achievements: true, @@ -362,6 +371,11 @@ func normalize(v Settings) Settings { v.PadWhilePlaying = "listen" } v.Store = normalizeStore(v.Store) + switch v.PadTranslator { + case "seaglass", "steam": + default: + v.PadTranslator = "seaglass" + } switch v.Glyphs { case "auto", "playstation", "xbox": default: diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 7712bc4..486b90a 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -271,3 +271,34 @@ func TestDamagedFileFallsBackToBackup(t *testing.T) { t.Errorf("the damaged file wasn't set aside: %v", broken) } } + +// The controller translator and hiding are per PC (the drivers are), on +// for settings saved before they existed, and an unknown translator falls +// back to Seaglass's own. +func TestControllerTranslatorSettings(t *testing.T) { + old := filepath.Join(t.TempDir(), "old.json") + if err := os.WriteFile(old, []byte(`{"theme":"dark","padTranslator":"ds4windows"}`), 0o644); err != nil { + t.Fatal(err) + } + if v := Open(old).Get(); v.PadTranslator != "seaglass" || !v.HidePadWhilePlaying { + t.Errorf("translator %q, hide %v", v.PadTranslator, v.HidePadWhilePlaying) + } + a := Defaults() + a.PadTranslator, a.HidePadWhilePlaying = "steam", false + b, err := json.Marshal(a.Portable()) + if err != nil { + t.Fatal(err) + } + got, err := Defaults().WithPortable(b) + if err != nil { + t.Fatal(err) + } + if got.PadTranslator != "seaglass" || !got.HidePadWhilePlaying { + t.Errorf("another PC's controller settings were taken: %q %v", got.PadTranslator, got.HidePadWhilePlaying) + } + steam := Defaults() + steam.PadTranslator = "steam" + if normalize(steam).PadTranslator != "steam" { + t.Error("steam isn't kept") + } +} diff --git a/internal/update/download.go b/internal/update/download.go index 0f40c5c..eb64625 100644 --- a/internal/update/download.go +++ b/internal/update/download.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "os" + "path" "path/filepath" "strings" ) @@ -55,6 +56,36 @@ func (f Feed) Download(ctx context.Context, rel Release, name, dir string, progr return dst, got, nil } +// DownloadPinned fetches one file from a known URL below the feed's prefix +// into dir and checks it against a SHA-256 written into Seaglass's code, +// for third-party files whose release is fixed (driver installers). It +// returns the file's path. +func (f Feed) DownloadPinned(ctx context.Context, rawURL, sha256Hex, dir string) (string, error) { + if !isSHA256(sha256Hex) { + return "", errors.New("no pinned SHA-256") + } + name := safeName(path.Base(rawURL)) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + dst := filepath.Join(dir, name) + part := dst + ".part" + got, err := f.fetch(ctx, Asset{Name: name, URL: rawURL}, part, nil) + if err != nil { + _ = os.Remove(part) + return "", err + } + if !strings.EqualFold(got, sha256Hex) { + _ = os.Remove(part) + return "", fmt.Errorf("%s doesn't match the SHA-256 Seaglass expects", name) + } + if err := os.Rename(part, dst); err != nil { + _ = os.Remove(part) + return "", err + } + return dst, nil +} + // expected is the SHA-256 the release promises for name. func (f Feed) expected(ctx context.Context, rel Release, name string) (string, error) { if len(f.Keys) == 0 { diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 5c6f5a5..9eade87 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -569,3 +569,30 @@ func TestReleasesFeedOrder(t *testing.T) { t.Errorf("ReleasesPage = %s", ReleasesPage) } } + +// A pinned download keeps a file only when it matches the hash in the code, +// and never fetches from outside the feed's prefix. +func TestDownloadPinned(t *testing.T) { + f, feed := newFake(t) + body := []byte("MZ driver installer") + f.files["Driver_1.0_x64.exe"] = body + sum := sha256.Sum256(body) + dir := t.TempDir() + p, err := feed.DownloadPinned(context.Background(), f.srv.URL+"/download/Driver_1.0_x64.exe", hex.EncodeToString(sum[:]), dir) + if err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(p); string(b) != string(body) || filepath.Base(p) != "Driver_1.0_x64.exe" { + t.Errorf("saved %s: %q", p, b) + } + other := sha256.Sum256([]byte("something else")) + if _, err := feed.DownloadPinned(context.Background(), f.srv.URL+"/download/Driver_1.0_x64.exe", hex.EncodeToString(other[:]), t.TempDir()); err == nil { + t.Error("a file that doesn't match its pinned hash was accepted") + } + if _, err := feed.DownloadPinned(context.Background(), "https://example.com/x.exe", hex.EncodeToString(sum[:]), t.TempDir()); err == nil { + t.Error("a file outside the feed was fetched") + } + if _, err := feed.DownloadPinned(context.Background(), f.srv.URL+"/download/Driver_1.0_x64.exe", "", t.TempDir()); err == nil { + t.Error("a download without a pinned hash was accepted") + } +} diff --git a/internal/vpad/hidhide.go b/internal/vpad/hidhide.go new file mode 100644 index 0000000..adce30b --- /dev/null +++ b/internal/vpad/hidhide.go @@ -0,0 +1,385 @@ +package vpad + +import ( + "encoding/json" + "errors" + "os" + "slices" + "strings" + "sync" + "unicode/utf16" + "unsafe" + + "golang.org/x/sys/windows" +) + +// HidHide's control device and request codes, from HidHide's +// Shared/HidHideIoctlContract.h (MIT, Eric Korff de Gidts and +// Benjamin Höglinger-Stelzer). +const hidHideDevice = `\\.\HidHide` + +const hidHideDeviceType = 32769 + +func hidHideIoctl(n uint32) uint32 { + return ctlCode(hidHideDeviceType, n, methodBuffered, fileReadData) +} + +var ( + ioctlGetWhitelist = hidHideIoctl(2048) + ioctlSetWhitelist = hidHideIoctl(2049) + ioctlGetBlacklist = hidHideIoctl(2050) + ioctlSetBlacklist = hidHideIoctl(2051) + ioctlGetActive = hidHideIoctl(2052) + ioctlGetInverse = hidHideIoctl(2054) + ioctlAddSessionBL = hidHideIoctl(2056) + ioctlClearSessionB = hidHideIoctl(2057) +) + +// InstanceID turns the device path SDL reports for a HID controller +// (\\?\HID#VID_054C&PID_0CE6&MI_03#7&1a2b&0&0000#{4d1e55b2-…}) into the +// device instance id HidHide lists (HID\VID_054C&PID_0CE6&MI_03\7&1A2B&0&0000). +// ok is false for anything that isn't a HID device path. +func InstanceID(path string) (string, bool) { + p, ok := strings.CutPrefix(path, `\\?\`) + if !ok { + if p, ok = strings.CutPrefix(path, `\\.\`); !ok { + return "", false + } + } + parts := strings.Split(p, "#") + // The last part is the interface class GUID; Bluetooth device ids carry + // a GUID of their own in the second part, which stays. + if n := len(parts); n > 0 && strings.HasPrefix(parts[n-1], "{") { + parts = parts[:n-1] + } + if len(parts) < 3 || !strings.EqualFold(parts[0], "HID") { + return "", false + } + for _, s := range parts { + if s == "" || strings.ContainsAny(s, `\/`+"\x00") { + return "", false + } + } + return strings.ToUpper(strings.Join(parts, `\`)), true +} + +// multiSZ encodes strings as a REG_MULTI_SZ: each NUL-terminated UTF-16, +// then one more NUL. An empty list is two NULs, which HidHide requires as +// the least it accepts. +func multiSZ(list []string) []uint16 { + var out []uint16 + for _, s := range list { + if s == "" { + continue // an empty string would end the list early + } + out = append(out, utf16.Encode([]rune(s))...) + out = append(out, 0) + } + if len(out) == 0 { + out = append(out, 0) + } + return append(out, 0) +} + +// parseMultiSZ decodes a REG_MULTI_SZ. +func parseMultiSZ(b []uint16) []string { + var out []string + for len(b) > 0 && b[0] != 0 { + end := 0 + for end < len(b) && b[end] != 0 { + end++ + } + out = append(out, string(utf16.Decode(b[:end]))) + if end == len(b) { + break + } + b = b[end+1:] + } + return out +} + +// Hider hides physical controllers from games. A HidHide with the +// per-process (session) block list gets them there: entries live only in +// the driver's memory and go when Seaglass exits, even if it crashes. +// Older ones (every release up to 1.5) only have the block list saved with +// HidHide's settings. Seaglass then notes each controller it adds in a +// ledger file before adding it, and takes exactly those off again when the +// game ends, when Seaglass starts or quits, or from Settings. +type Hider struct { + mu sync.Mutex + h windows.Handle + ledger string // the file listing what Seaglass put on the saved block list +} + +// OpenHider opens HidHide. It fails with ErrNoHidHide when it isn't there. +// ledger is the file that remembers the controllers Seaglass hid through +// the saved block list. +func OpenHider(ledger string) (*Hider, error) { + h, err := openHidHide() + if err != nil { + return nil, ErrNoHidHide + } + return &Hider{h: h, ledger: ledger}, nil +} + +func openHidHide() (windows.Handle, error) { + p, _ := windows.UTF16PtrFromString(hidHideDevice) + return windows.CreateFile(p, windows.GENERIC_READ, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) +} + +// syncIoctl sends a request on a handle opened without FILE_FLAG_OVERLAPPED. +func syncIoctl(h windows.Handle, code uint32, in []uint16, out []byte) (uint32, error) { + var inPtr, outPtr *byte + if len(in) > 0 { + inPtr = (*byte)(unsafe.Pointer(&in[0])) + } + if len(out) > 0 { + outPtr = &out[0] + } + var n uint32 + err := windows.DeviceIoControl(h, code, inPtr, uint32(len(in)*2), outPtr, uint32(len(out)), &n, nil) + return n, err +} + +// unsupported reports whether HidHide refused a request it doesn't know. +func unsupported(err error) bool { + return errors.Is(err, windows.ERROR_INVALID_PARAMETER) || errors.Is(err, windows.ERROR_INVALID_FUNCTION) +} + +// Active reports whether HidHide's hiding is switched on. Its block lists +// only take effect while it is. +func (d *Hider) Active() (bool, error) { + d.mu.Lock() + defer d.mu.Unlock() + out := make([]byte, 1) + if _, err := syncIoctl(d.h, ioctlGetActive, nil, out); err != nil { + return false, err + } + return out[0] != 0, nil +} + +// Hide puts controllers (by device instance id) on a block list. Opening +// a hidden device fails for every app not on HidHide's allow list, this +// one included, so Seaglass then puts itself on it (see allowSelf). +// Handles open already keep working meanwhile. +func (d *Hider) Hide(ids []string) error { + if len(ids) == 0 { + return nil + } + d.mu.Lock() + defer d.mu.Unlock() + _, err := syncIoctl(d.h, ioctlAddSessionBL, multiSZ(ids), nil) + if unsupported(err) { + err = d.hideSaved(ids) + } + if err != nil { + return err + } + if err := d.allowSelf(); err != nil { + // Hidden from Seaglass too, it would lose the controller the next + // time SDL looks for devices: better not to hide it at all. + _ = d.clear() + return err + } + return nil +} + +// hideSaved adds controllers to the saved block list. It notes them in the +// ledger first, so that a crash right after still leaves a record to undo. +// Entries the person had on the list already stay out of the ledger, so +// they stay hidden after the game. +func (d *Hider) hideSaved(ids []string) error { + list, err := d.readList(ioctlGetBlacklist) + if err != nil { + return err + } + mine := readLedger(d.ledger) + var add []string + for _, id := range ids { + if containsFold(list, id) { + continue + } + add = append(add, id) + if !containsFold(mine, id) { + mine = append(mine, id) + } + } + if len(add) == 0 { + return nil + } + if err := writeLedger(d.ledger, mine); err != nil { + return err + } + _, err = syncIoctl(d.h, ioctlSetBlacklist, multiSZ(append(list, add...)), nil) + return err +} + +// Clear shows every controller Seaglass hid again. +func (d *Hider) Clear() error { + d.mu.Lock() + defer d.mu.Unlock() + return d.clear() +} + +func (d *Hider) clear() error { + _, err := syncIoctl(d.h, ioctlClearSessionB, nil, nil) + if unsupported(err) { + err = nil + } + return errors.Join(err, d.clearSaved()) +} + +// clearSaved takes the controllers in the ledger off the saved block list. +func (d *Hider) clearSaved() error { + mine := readLedger(d.ledger) + if len(mine) == 0 { + return nil + } + list, err := d.readList(ioctlGetBlacklist) + if err != nil { + return err + } + keep := slices.DeleteFunc(slices.Clone(list), func(e string) bool { return containsFold(mine, e) }) + if len(keep) != len(list) { + if _, err := syncIoctl(d.h, ioctlSetBlacklist, multiSZ(keep), nil); err != nil { + return err + } + } + return removeLedger(d.ledger) +} + +// Close shows the controllers again and lets go of HidHide. +func (d *Hider) Close() error { + err := d.Clear() + d.mu.Lock() + defer d.mu.Unlock() + if d.h != 0 { + windows.CloseHandle(d.h) + d.h = 0 + } + return err +} + +// Unhide shows every controller Seaglass left on HidHide's saved block +// list, for example after a crash. With an empty ledger there is nothing +// to do. Without HidHide nothing can be hidden, so the ledger goes. +func Unhide(ledger string) error { + if !Hidden(ledger) { + return nil + } + d, err := OpenHider(ledger) + if err != nil { + return removeLedger(ledger) + } + return d.Close() +} + +// Hidden reports whether the ledger lists controllers Seaglass hid. +func Hidden(ledger string) bool { return len(readLedger(ledger)) > 0 } + +func containsFold(list []string, s string) bool { + return slices.ContainsFunc(list, func(e string) bool { return strings.EqualFold(e, s) }) +} + +// readLedger lists the controllers in the ledger. A missing or unreadable +// file lists none. +func readLedger(path string) []string { + b, err := os.ReadFile(path) + if err != nil { + return nil + } + var ids []string + if json.Unmarshal(b, &ids) != nil { + return nil + } + return ids +} + +// writeLedger saves the ledger atomically (tmp, then rename). +func writeLedger(path string, ids []string) error { + b, err := json.Marshal(ids) + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func removeLedger(path string) error { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +// allowSelf puts Seaglass's exe on HidHide's allow list, the only way for +// it to keep reading a controller it hid: SDL opens the device again when +// devices change (the virtual controller plugging in is one such change), +// and a controller that's unplugged and plugged back in is a new open too. +// It only ever adds its own entry, as HidHide asks of apps, and leaves the +// list alone when HidHide's "inverse" mode turns it into a deny list. +func (d *Hider) allowSelf() error { + inv := make([]byte, 1) + if _, err := syncIoctl(d.h, ioctlGetInverse, nil, inv); err == nil && inv[0] != 0 { + return nil + } + exe, err := os.Executable() + if err != nil { + return err + } + self, err := fullImageName(exe) + if err != nil { + return err + } + list, err := d.readList(ioctlGetWhitelist) + if err != nil { + return err + } + for _, e := range list { + if strings.EqualFold(e, self) { + return nil + } + } + _, err = syncIoctl(d.h, ioctlSetWhitelist, multiSZ(append(list, self)), nil) + return err +} + +// readList reads one of HidHide's lists (allow or block). +func (d *Hider) readList(code uint32) ([]string, error) { + // Asked without a buffer, HidHide says how many bytes the list takes. + n, err := syncIoctl(d.h, code, nil, nil) + if err != nil { + return nil, err + } + if n < 4 { + n = 4 + } + buf := make([]byte, n) + got, err := syncIoctl(d.h, code, nil, buf) + if err != nil { + return nil, err + } + u := make([]uint16, got/2) + for i := range u { + u[i] = uint16(buf[2*i]) | uint16(buf[2*i+1])<<8 + } + return parseMultiSZ(u), nil +} + +// fullImageName turns C:\path\app.exe into \Device\HarddiskVolume3\path\app.exe, +// the form HidHide's allow list uses (the kernel's name for the image). +func fullImageName(path string) (string, error) { + vol := path[:min(2, len(path))] + if len(vol) != 2 || vol[1] != ':' { + return "", errors.New("Seaglass isn't on a drive letter") + } + buf := make([]uint16, windows.MAX_PATH) + v, _ := windows.UTF16PtrFromString(vol) + if _, err := windows.QueryDosDevice(v, &buf[0], uint32(len(buf))); err != nil { + return "", err + } + return windows.UTF16ToString(buf) + path[2:], nil +} diff --git a/internal/vpad/install.go b/internal/vpad/install.go new file mode 100644 index 0000000..0eb5e91 --- /dev/null +++ b/internal/vpad/install.go @@ -0,0 +1,41 @@ +package vpad + +// Installer is a driver's official installer, pinned to one release. +type Installer struct { + Name string // the driver, for messages + Repo string // https://github.com//, where it's published + URL string // the release asset + SHA256 string // of that file, checked before it runs +} + +// Installers are downloaded when the person asks to set the drivers up, +// never bundled. The hashes were taken from the files GitHub serves for +// these releases; a file that doesn't match is not run. +var Installers = []Installer{ + { + Name: "ViGEmBus", + Repo: "https://github.com/nefarius/ViGEmBus", + URL: "https://github.com/nefarius/ViGEmBus/releases/download/v1.22.0/ViGEmBus_1.22.0_x64_x86_arm64.exe", + SHA256: "89220a7865076b342892f98865f3499fb7c4cfd673159e89d352c360fd014c6a", + }, + { + Name: "HidHide", + Repo: "https://github.com/nefarius/HidHide", + URL: "https://github.com/nefarius/HidHide/releases/download/v1.5.230.0/HidHide_1.5.230_x64.exe", + SHA256: "f4bbbcb82e6258641b887c74bc81c4c5f66e4aa811808dfc304347687b7605f6", + }, +} + +// Needed lists the installers a PC with status s still needs. +func Needed(s Status) []Installer { + var out []Installer + for _, in := range Installers { + switch { + case in.Name == "ViGEmBus" && (s.ViGEm || s.ViGEmVersion != ""): + case in.Name == "HidHide" && (s.HidHide || s.HidHideVersion != ""): + default: + out = append(out, in) + } + } + return out +} diff --git a/internal/vpad/ioctl_test.go b/internal/vpad/ioctl_test.go new file mode 100644 index 0000000..d014e37 --- /dev/null +++ b/internal/vpad/ioctl_test.go @@ -0,0 +1,121 @@ +package vpad + +import ( + "reflect" + "strings" + "testing" + "unsafe" +) + +// The structures match the C ones in ViGEmClient's BusShared.h (x64, no +// packing pragmas): the driver rejects a request whose size is off. +func TestViGEmStructSizes(t *testing.T) { + for name, c := range map[string]struct{ got, want uintptr }{ + "VIGEM_PLUGIN_TARGET": {unsafe.Sizeof(pluginTarget{}), 16}, + "VIGEM_UNPLUG_TARGET": {unsafe.Sizeof(sizeAndSerial{}), 8}, + "XUSB_REQUEST_NOTIFICATION": {unsafe.Sizeof(xusbNotification{}), 12}, + "XUSB_SUBMIT_REPORT": {unsafe.Sizeof(xusbSubmit{}), 20}, + "XUSB_GET_USER_INDEX": {unsafe.Sizeof(xusbUserIndex{}), 12}, + } { + if c.got != c.want { + t.Errorf("sizeof %s = %d, want %d", name, c.got, c.want) + } + } + if o := unsafe.Offsetof(xusbSubmit{}.Report); o != 8 { + t.Errorf("XUSB_SUBMIT_REPORT.Report at %d, want 8", o) + } + if o := unsafe.Offsetof(xusbNotification{}.LargeMotor); o != 8 { + t.Errorf("XUSB_REQUEST_NOTIFICATION.LargeMotor at %d, want 8", o) + } +} + +// The codes CTL_CODE gives for ViGEmBus's and HidHide's requests. +func TestIoctlCodes(t *testing.T) { + for name, c := range map[string]struct{ got, want uint32 }{ + "IOCTL_VIGEM_PLUGIN_TARGET": {ioctlPlugIn, 0x2AA004}, + "IOCTL_VIGEM_UNPLUG_TARGET": {ioctlUnplug, 0x2AA008}, + "IOCTL_VIGEM_CHECK_VERSION": {ioctlCheckVersion, 0x2AA00C}, + "IOCTL_VIGEM_WAIT_DEVICE_READY": {ioctlWaitReady, 0x2AA010}, + "IOCTL_XUSB_REQUEST_NOTIFICATION": {ioctlXUSBNotify, 0x2AE804}, + "IOCTL_XUSB_SUBMIT_REPORT": {ioctlXUSBSubmit, 0x2AA808}, + "IOCTL_XUSB_GET_USER_INDEX": {ioctlXUSBUser, 0x2AE81C}, + "IOCTL_GET_WHITELIST": {ioctlGetWhitelist, 0x80016000}, + "IOCTL_SET_WHITELIST": {ioctlSetWhitelist, 0x80016004}, + "IOCTL_GET_ACTIVE": {ioctlGetActive, 0x80016010}, + "IOCTL_GET_WLINVERSE": {ioctlGetInverse, 0x80016018}, + "IOCTL_ADD_SESSION_BLACKLIST": {ioctlAddSessionBL, 0x80016020}, + "IOCTL_CLR_SESSION_BLACKLIST": {ioctlClearSessionB, 0x80016024}, + } { + if c.got != c.want { + t.Errorf("%s = %#x, want %#x", name, c.got, c.want) + } + } +} + +func TestInstanceIDFromSDLPath(t *testing.T) { + for path, want := range map[string]string{ + // DualSense over USB + `\\?\HID#VID_054C&PID_0CE6&MI_03#7&1a2b3c4d&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}`: `HID\VID_054C&PID_0CE6&MI_03\7&1A2B3C4D&0&0000`, + // lower case, as Windows often hands it out + `\\?\hid#vid_054c&pid_09cc&mi_03#8&2f0e&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}`: `HID\VID_054C&PID_09CC&MI_03\8&2F0E&0&0000`, + // Bluetooth: the device id carries a GUID of its own + `\\?\HID#{00001124-0000-1000-8000-00805f9b34fb}_VID&0002054c_PID&0ce6#9&3a1c&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}`: `HID\{00001124-0000-1000-8000-00805F9B34FB}_VID&0002054C_PID&0CE6\9&3A1C&0&0000`, + } { + got, ok := InstanceID(path) + if !ok || got != want { + t.Errorf("InstanceID(%s) = %q, %v; want %q", path, got, ok, want) + } + } +} + +// Only HID device paths can be hidden; other paths SDL reports aren't. +func TestInstanceIDRejectsOtherPaths(t *testing.T) { + for _, path := range []string{ + "", "XInput#0", `\\?\USB#VID_054C&PID_0CE6#5&1#{a5dcbf10-6530-11d2-901f-00c04fb951ed}`, + `\\?\HID#VID_054C#{4d1e55b2-f16f-11cf-88cb-001111000030}`, `HID\VID_054C&PID_0CE6\7&1`, + `\\?\HID##7&1#{4d1e55b2-f16f-11cf-88cb-001111000030}`, + } { + if got, ok := InstanceID(path); ok { + t.Errorf("InstanceID(%q) = %q, accepted", path, got) + } + } +} + +func TestMultiSZ(t *testing.T) { + got := multiSZ([]string{"AB", "", "C"}) + want := []uint16{'A', 'B', 0, 'C', 0, 0} + if !reflect.DeepEqual(got, want) { + t.Errorf("multiSZ = %v, want %v", got, want) + } + // HidHide takes no less than two NULs, which is also the empty list. + if got := multiSZ(nil); !reflect.DeepEqual(got, []uint16{0, 0}) { + t.Errorf("empty multiSZ = %v", got) + } + in := []string{`\Device\HarddiskVolume3\Games\Seaglass.exe`, "ü€"} + if back := parseMultiSZ(multiSZ(in)); !reflect.DeepEqual(back, in) { + t.Errorf("round trip = %q", back) + } + if back := parseMultiSZ([]uint16{0, 0}); len(back) != 0 { + t.Errorf("empty list parsed as %q", back) + } +} + +func TestInstallersArePinnedToOfficialReleases(t *testing.T) { + if len(Installers) != 2 { + t.Fatalf("%d installers", len(Installers)) + } + for _, in := range Installers { + if !strings.HasPrefix(in.URL, in.Repo+"/releases/download/") || !strings.HasPrefix(in.Repo, "https://github.com/nefarius/") { + t.Errorf("%s: %s isn't a release of %s", in.Name, in.URL, in.Repo) + } + if len(in.SHA256) != 64 { + t.Errorf("%s: hash %q", in.Name, in.SHA256) + } + } + if n := Needed(Status{ViGEm: true, HidHide: true}); len(n) != 0 { + t.Errorf("both installed, still needs %v", n) + } + if n := Needed(Status{HidHideVersion: "1.5.230.0"}); len(n) != 1 || n[0].Name != "ViGEmBus" { + t.Errorf("HidHide waiting for a restart, needs %v", n) + } +} diff --git a/internal/vpad/real_test.go b/internal/vpad/real_test.go new file mode 100644 index 0000000..c9a3659 --- /dev/null +++ b/internal/vpad/real_test.go @@ -0,0 +1,74 @@ +package vpad + +import ( + "os" + "path/filepath" + "testing" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// xinputState is XINPUT_STATE. +type xinputState struct { + PacketNumber uint32 + Gamepad Report // XINPUT_GAMEPAD has XUSB_REPORT's layout +} + +// Plugs a virtual Xbox 360 controller into the real ViGEmBus, feeds it +// known input and checks Windows sees it through XInput, then unplugs it. +// Needs ViGEmBus installed; no physical controller. +// +// WL_REAL_VIGEM=1 go test -run RealViGEm -v ./internal/vpad +func TestRealViGEm(t *testing.T) { + if os.Getenv("WL_REAL_VIGEM") == "" { + t.Skip("set WL_REAL_VIGEM=1 to plug a virtual controller into this PC's ViGEmBus") + } + t.Logf("status: %+v", GetStatus(filepath.Join(t.TempDir(), "hidden-controllers.json"))) + x, err := NewX360() + if err != nil { + t.Fatal(err) + } + defer x.Close() + if err := x.Plug(); err != nil { + t.Fatal(err) + } + user, err := x.userIndex() + if err != nil { + t.Fatalf("user index: %v", err) + } + t.Logf("XInput slot %d", user) + + want := FromSDL(1<<0|1<<3|1<<11|1<<20, [6]int16{12000, -20000, -32768, 32767, 32767, 16384}) + if err := x.Send(want); err != nil { + t.Fatal(err) + } + xinput := windows.NewLazySystemDLL("xinput1_4.dll").NewProc("XInputGetState") + var st xinputState + deadline := time.Now().Add(3 * time.Second) + for { + r, _, _ := xinput.Call(uintptr(user), uintptr(unsafe.Pointer(&st))) + if r == 0 && st.Gamepad == want { + break + } + if time.Now().After(deadline) { + t.Fatalf("XInputGetState(%d) = %d, %+v; want %+v", user, r, st.Gamepad, want) + } + time.Sleep(50 * time.Millisecond) + } + if err := x.Close(); err != nil { + t.Fatalf("unplug: %v", err) + } + deadline = time.Now().Add(3 * time.Second) + for { + r, _, _ := xinput.Call(uintptr(user), uintptr(unsafe.Pointer(&st))) + if r == uintptr(windows.ERROR_DEVICE_NOT_CONNECTED) { + break + } + if time.Now().After(deadline) { + t.Fatalf("after unplugging, XInputGetState(%d) = %d", user, r) + } + time.Sleep(50 * time.Millisecond) + } +} diff --git a/internal/vpad/report.go b/internal/vpad/report.go new file mode 100644 index 0000000..b447cf1 --- /dev/null +++ b/internal/vpad/report.go @@ -0,0 +1,79 @@ +package vpad + +// Report is what an Xbox 360 controller reports: XUSB_REPORT in +// ViGEmClient, the same layout as XInput's XINPUT_GAMEPAD. +type Report struct { + Buttons uint16 + LeftTrigger uint8 + RightTrigger uint8 + ThumbLX int16 + ThumbLY int16 + ThumbRX int16 + ThumbRY int16 +} + +// XUSB button bits (XUSB_BUTTON in ViGEmClient, XINPUT_GAMEPAD_* in XInput). +const ( + DpadUp = 0x0001 + DpadDown = 0x0002 + DpadLeft = 0x0004 + DpadRight = 0x0008 + Start = 0x0010 + Back = 0x0020 + LeftThumb = 0x0040 + RightThumb = 0x0080 + LeftShoulder = 0x0100 + RightShoulder = 0x0200 + Guide = 0x0400 + A = 0x1000 + B = 0x2000 + X = 0x4000 + Y = 0x8000 +) + +// sdlToXUSB maps SDL gamepad buttons (by position, so ✕ is A) to XUSB bits. +// Guide (5) is left out on purpose: the PS button stays Seaglass's overlay +// button and never reaches the game. The touchpad click (20) is Back, like +// Create (4): it's the big button next to it, and the one people press when +// a game shows the View glyph. +var sdlToXUSB = [...]uint16{ + 0: A, 1: B, 2: X, 3: Y, + 4: Back, 6: Start, + 7: LeftThumb, 8: RightThumb, + 9: LeftShoulder, 10: RightShoulder, + 11: DpadUp, 12: DpadDown, 13: DpadLeft, 14: DpadRight, + 20: Back, +} + +// FromSDL turns an SDL gamepad's state into an Xbox 360 report. buttons has +// bit n set while SDL gamepad button n is down; axes are SDL's left stick +// x and y, right stick x and y, left and right trigger. +func FromSDL(buttons uint32, axes [6]int16) Report { + var r Report + for b, bit := range sdlToXUSB { + if bit != 0 && buttons&(1<