Two problems surfaced in testing: (1) no way to tell whether the injected hook was actually the input source, and (2) the final design needs the tool window focused for Steam RPT capture, which would pause/silence games that react to focus loss. Both are addressed here. - Focus spoofing (hook/focus_spoof): find the game's main window, subclass it to rewrite/swallow WM_ACTIVATE/ACTIVATEAPP/NCACTIVATE/KILLFOCUS, and inline- hook GetForegroundWindow/GetActiveWindow/GetFocus to always report the game as active. The game keeps running and polling while unfocused. - Status back-channel (protocol v2): the DLL reports attached/focus-spoof flags, game pid/hwnd, a heartbeat, and a cumulative XInputGetState counter. The host overlay turns the counter into a live poll rate, so "is the hook working" is directly observable. - Synthetic test-input toggle in the host: forwards a known automated pattern (stick circle + periodic A) to prove forwarding independent of the physical pad. - hook_selftest extended to assert the status channel; passes. Documented the windowed/borderless requirement and the new observable test flow in the README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
152 lines
7.5 KiB
Markdown
152 lines
7.5 KiB
Markdown
# CoopAllTheThings
|
|
|
|
Steam **Remote Play Together (RPT)** for any XInput game — without breaking DRM,
|
|
achievements, or playtime.
|
|
|
|
Existing "donor game" tools (e.g. RemotePlayWhatever) copy a target game's files
|
|
into a donor game's folder and rename the executable so Steam streams the target
|
|
under the donor's appid. That breaks DRM-protected games, breaks achievements,
|
|
and credits playtime to the donor.
|
|
|
|
CoopAllTheThings takes a different approach: the **real game runs normally under
|
|
its own appid** (so DRM, achievements, and playtime all work), while a lightweight
|
|
**mirror app runs under the donor appid**. The mirror presents a borderless
|
|
window that is a live copy of the game's video + audio, and forwards the guests'
|
|
input back into the real game. Steam's RPT captures the mirror window — so any
|
|
XInput game becomes Remote-Play-Together-able.
|
|
|
|
See [`docs`](docs) and the in-repo plan for the full design.
|
|
|
|
## Architecture (target)
|
|
|
|
| Concern | Mechanism | Where |
|
|
| --- | --- | --- |
|
|
| Receive guest input | Steam Input / XInput (RPT delivers guests to the focused window) | `coop_host.exe` |
|
|
| Forward input to game | DLL injection + XInput hook (SafetyHook) — game sees *only* our pad | `coop_hook.dll` |
|
|
| Mirror video | Windows Graphics Capture first; `IDXGISwapChain::Present` hook as the low-latency upgrade | host (+ hook) |
|
|
| Mirror audio | WASAPI process-loopback capture of the game, re-rendered | `coop_host.exe` |
|
|
| Host ↔ hook IPC | Named shared memory (seqlock for input, shared D3D11 texture for video) | `common/` |
|
|
|
|
## Limitations
|
|
|
|
- **Anti-cheat:** the input path injects `coop_hook.dll` into the target game.
|
|
Games protected by kernel-level anti-cheat (Easy Anti-Cheat, BattlEye,
|
|
Vanguard, etc.) will detect the injected module and may **kick the player or
|
|
issue a ban**. Such games are explicitly **out of scope and unsupported** — do
|
|
not use CoopAllTheThings with them. The tool targets single-player and
|
|
co-op/local-multiplayer titles without active anti-cheat.
|
|
- **XInput only:** the game must read controllers via XInput (the common case).
|
|
DirectInput-only / RawInput-only games are not handled.
|
|
- **Architecture match:** the host and hook DLL must match the game's bitness.
|
|
x64 is supported first; x86 support is a later phase (see the plan).
|
|
|
|
## Status
|
|
|
|
**Phase 0 — donor spike. ✅ Validated.** `coop_host.exe` is a borderless D3D11
|
|
window with an ImGui overlay listing every controller it sees. Confirmed
|
|
end-to-end: Steam RPT streams the window under a donor appid, and guest gamepads
|
|
arrive (with correct slot assignment) as XInput.
|
|
|
|
**Phase 1a — input forwarding + focus spoofing (current).** The host injects
|
|
`coop_hook.dll`; the DLL hooks XInput (via SafetyHook) so the game reads the
|
|
forwarded controller state and *only* that state. The DLL also **spoofs focus**
|
|
(hooks `GetForegroundWindow`/`GetActiveWindow`/`GetFocus` and subclasses the game
|
|
window to swallow deactivation messages) so the game keeps running and polling
|
|
while the tool holds the real OS focus — required because Steam RPT only captures
|
|
the focused window. A hook→host status back-channel shows whether the hook is
|
|
attached and how fast the game is polling it. The in-process `hook_selftest`
|
|
validates the IPC + hook core without needing a game.
|
|
|
|
Still ahead (scoped in the plan): video mirror (WGC, then a `Present` hook),
|
|
audio (WASAPI process loopback), and x86 support.
|
|
|
|
## Building
|
|
|
|
Requirements: Windows 10/11, Visual Studio 2022 (MSVC + C++ workload), CMake ≥ 3.21.
|
|
|
|
```sh
|
|
git clone --recurse-submodules <repo-url>
|
|
# or, if already cloned:
|
|
git submodule update --init --recursive
|
|
|
|
cmake -S . -B build -G "Visual Studio 17 2022" -A x64
|
|
cmake --build build --config Debug
|
|
# output: bin/Debug/coop_host.exe
|
|
```
|
|
|
|
Third-party dependencies (Dear ImGui, SafetyHook) are git submodules under
|
|
`third_party/`; the Steamworks SDK is vendored manually there when wired. No
|
|
vcpkg / package manager is used.
|
|
|
|
## Phase 0: validating the donor-launch assumption
|
|
|
|
This is a manual test — it needs Steam, a second person (or second account), and
|
|
a donor game that supports Remote Play Together.
|
|
|
|
1. **Pick a donor game** you own that supports Remote Play Together (check the
|
|
"Remote Play Together" tag on its store page). The donor only needs RPT
|
|
support; it is never actually played.
|
|
|
|
2. **Launch the host under the donor's appid.** Find the donor's appid (the
|
|
number in its store URL), then run:
|
|
|
|
```text
|
|
"C:\Program Files (x86)\Steam\steam.exe" -applaunch <donorAppId> "D:\dev\CoopAllTheThings\bin\Debug\coop_host.exe"
|
|
```
|
|
|
|
The borderless overlay window should appear and Steam should consider the
|
|
donor "running" (green status / "Stop" button in the library).
|
|
|
|
> If the donor ignores the trailing path, set the host as the donor's
|
|
> **Launch Options** instead (`"D:\...\coop_host.exe" %command%` variants),
|
|
> or use a launcher such as RemotePlayDetached. Recording which method makes
|
|
> Steam attribute our window to the donor is the main deliverable of Phase 0.
|
|
|
|
3. **Start Remote Play Together** from the Steam friends list / overlay and invite
|
|
a friend (or a second machine/account).
|
|
|
|
4. **Verify on the guest side:**
|
|
- The guest sees the borderless overlay window streamed (not a black screen).
|
|
- The guest presses buttons on their controller and the corresponding slot in
|
|
the overlay lights up. This proves RPT routes guest input into our window
|
|
as XInput — the foundation the whole tool relies on.
|
|
|
|
5. Press **Esc** in the host window to quit.
|
|
|
|
**If steps 2 and 4 both work, the core premise holds** and we proceed to Phase 1
|
|
(window capture + input injection). If not, we revisit the donor-attribution
|
|
approach before building further.
|
|
|
|
## Phase 1a: testing input forwarding locally
|
|
|
|
This needs no RPT, donor, or second account — just the host, the hook, a
|
|
controller, and a target game. `coop_host.exe` and `coop_hook.dll` must sit in
|
|
the same folder (the build places both in `bin/<Config>/`).
|
|
|
|
**Requirement:** run the target game **windowed or borderless**, not exclusive
|
|
fullscreen. Exclusive fullscreen minimizes on focus loss (defeating the focus
|
|
spoof) and can't be window-captured later. Only **controller** input is
|
|
forwarded — while the game is unfocused it won't receive OS keyboard/mouse.
|
|
|
|
1. Start a DRM-free, **non-anti-cheat**, XInput game in windowed/borderless mode
|
|
and get to a screen that reads the pad.
|
|
2. Run `bin\Debug\coop_host.exe`. In the **Injection** panel, filter for the
|
|
game's `.exe`, select it, and click **Inject & Connect**.
|
|
3. Watch the **Hook status** section. Once it shows **Attached** and a non-zero
|
|
**"XInput polled: N/s"**, the game is provably reading our hook — injection
|
|
works. **Focus spoof: active** confirms the window was found and subclassed.
|
|
4. **Prove forwarding is the source:** tick **Forward synthetic test input**.
|
|
The game should now move on its own — left stick sweeping a circle, A pressed
|
|
every other second — independent of your physical controller. Untick it to
|
|
return control to your pad.
|
|
5. Sanity-check the focus spoof: click into another window so the game loses real
|
|
focus. It should keep running/animating (not pause), and the poll rate should
|
|
stay non-zero.
|
|
6. Click **Stop forwarding** (or quit the host) to tear down the channel.
|
|
|
|
> If injection fails with an access error, run the host as administrator. If it
|
|
> reports "target is 32-bit", that game needs the x86 hook (a later phase).
|
|
|
|
For a quick sanity check of the forwarding core without a game, run
|
|
`bin\Debug\hook_selftest.exe` — it should print `SELFTEST PASS`.
|