Files
CoopAllTheThings/README.md
BlackMark 8059f0e488 docs: update README for Phases 0-2 done, add roadmap + lessons
- Reflect audio mirror (Phase 2) and video mirror (Phase 1b) as implemented;
  drop stale "(current)"/"still ahead" framing and the broken docs/ link.
- Add a Roadmap section for the planned work: toggle/generalize the debug UI,
  fix the local audio echo, Present-hook video path, Steam Input, x86 support.
- Add Lessons learned (RPT focus requirement, no exclusive fullscreen, the
  ActivateAudioInterfaceAsync agile-handler gotcha, WGC occlusion, audio echo).
- Document clangd/compile_commands setup and the CTest suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:27:57 +02:00

188 lines
9.6 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.
## Architecture
| Concern | Mechanism | Component | Status |
| --- | --- | --- | --- |
| Receive guest input | XInput (RPT delivers guest pads to the focused window) | `coop_host.exe` | done |
| Forward input to game | DLL injection + XInput hook (SafetyHook) — game sees *only* our pad | `coop_hook.dll` | done |
| Keep game running unfocused | Hook spoofs focus so the game polls while the host holds OS focus | `coop_hook.dll` | done |
| Mirror video | Windows Graphics Capture of the game window, letterboxed into the host window | `coop_host.exe` | done |
| Mirror audio | WASAPI process-loopback capture of the game, re-rendered on the host | `coop_host.exe` | done |
| Host ↔ hook IPC | Named shared memory (seqlock for input, status back-channel) | `common/` | done |
## 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.
- **x64 only:** the host and hook DLL must match the game's bitness, and only x64
is built today. 32-bit games need the x86 hook + injector (see Roadmap).
- **Local audio echo:** process-loopback capture does not mute the game, so the
game's audio plays locally *and* the host re-renders it — the local machine
hears it twice. Guests hear it once. Fixing this is on the Roadmap.
- **Debug-oriented UI:** the ImGui overlay is always visible and laid out for
diagnosing the pipeline, not for end use. It can't yet be toggled off.
## Status
All phases below are implemented and verified.
- **Phase 0 — donor spike. ✅** Confirmed Steam RPT streams an arbitrary
borderless window under a donor appid and routes guest gamepads into it as
XInput (correct slot assignment). This is the premise the whole tool rests on.
- **Phase 1a — input forwarding + focus spoofing. ✅** The host injects
`coop_hook.dll`; the DLL hooks XInput (SafetyHook) so the game reads the
forwarded pad state and *only* that state, and spoofs focus so the game keeps
running while the host holds the real OS focus. A hook→host status
back-channel reports attach state and poll rate.
- **Phase 1b — video mirror (WGC). ✅** The host captures the injected game's
window with Windows Graphics Capture and draws it letterboxed as its
background, so RPT streams a live mirror.
- **Phase 2 — audio mirror. ✅** The host captures the game's audio by PID via
WASAPI process loopback and re-renders it on the default endpoint, so RPT
carries game audio to guests.
## Roadmap
Future work, roughly in priority order:
- **Make the tool usable, not just demonstrable:** toggle the debug overlay
on/off so the mirror window can be used clean.
- **Generalize the UI:** rework the panels from bug-specific debug readouts into
general-purpose status, and add broader debug info (latency, frame timing,
per-channel stats).
- **Fix the local audio echo:** mute the game's local render (or otherwise avoid
the double playback) while still capturing it for the mirror.
- **Present-hook video path:** capture the game's frames by hooking
`IDXGISwapChain::Present` in the injected DLL and sharing the backbuffer via a
shared D3D11 texture, as a lower-latency / more stable alternative to WGC.
- **Steam Input:** consume guest input through the Steam Input API directly
rather than XInput.
- **x86 support:** add an x86 build of `coop_hook.dll` plus an x86 injector
helper the x64 host spawns, so 32-bit games work (a 64-bit process can't
cleanly inject a 32-bit one). Detect target bitness with `IsWow64Process2`.
The shared-memory IPC layout is already fixed-width / bitness-stable.
## 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 (+ coop_hook.dll, test exes)
```
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.
### clangd / IDE setup
The Visual Studio CMake generator does **not** emit `compile_commands.json`, so
clangd has no include paths and reports false errors. Run
[`gen-compile-commands.bat`](gen-compile-commands.bat) once (and after adding
sources or include dirs); it configures a parallel Ninja build in `build-clangd/`
that produces the database, which [`.clangd`](.clangd) points clangd at. clangd's
clang-cl driver resolves the MSVC / Windows SDK system includes on its own.
## Tests
```sh
ctest --test-dir build -C Debug --output-on-failure
```
- **`hook_selftest`** — in-process check of the IPC + XInput hook core (no game,
no controller needed).
- **`audio_loopback_test`** — spawns `coop_tone.exe` (a standalone WASAPI
sine-wave source under [`tools/audio_tone`](tools/audio_tone)) and verifies the
shipping process-loopback capture receives its audio by PID. Skips cleanly if
the machine has no audio endpoint.
## Running the tool (manual, end-to-end)
This needs Steam, a donor game that supports Remote Play Together, and a second
person/account to receive the stream.
1. **Launch the host under a donor appid.** Find the donor's appid (the number in
its store URL); the donor only needs RPT support and is never actually played:
```text
"C:\Program Files (x86)\Steam\steam.exe" -applaunch <donorAppId> "D:\dev\CoopAllTheThings\bin\Debug\coop_host.exe"
```
The borderless window appears and Steam marks the donor "running". If the donor
ignores the trailing path, set the host as the donor's **Launch Options**
(`"D:\...\coop_host.exe" %command%`) or use a launcher like RemotePlayDetached.
2. **Start the real game** windowed or borderless (not exclusive fullscreen — see
Lessons learned). In the host's **Injection** panel, filter for the game's
`.exe`, select it, and click **Inject & Connect**. Watch **Hook status** for
**Attached**, a non-zero **XInput polled: N/s**, and **Focus spoof: active**.
3. **Mirror video:** in the **Video mirror** panel, tick **Mirror game window** —
the host window now shows a live, letterboxed copy of the game.
4. **Mirror audio:** in the **Audio mirror** panel, tick **Mirror game audio**.
(You'll hear the game twice locally; that's the known echo — guests hear it
once.)
5. **Start Remote Play Together** from Steam and invite a guest. Verify the guest
sees the mirrored video, hears the audio, and that their controller drives the
real game.
Useful checks while developing without RPT: tick **Forward synthetic test input**
in the Injection panel to make the game move on its own (proving forwarding is the
source), and click away from the game to confirm focus spoofing keeps it running.
> Injection access error → run the host as administrator. "Target is 32-bit" →
> that game needs the x86 hook (see Roadmap).
## Lessons learned
Non-obvious things that cost time and constrain the design:
- **RPT only streams the *focused* window.** The game therefore can't hold focus
itself; the hook spoofs focus (`GetForegroundWindow` / `GetActiveWindow` /
`GetFocus`, plus swallowing window-deactivation messages) so the game keeps
polling and rendering while the host owns the real OS focus.
- **Run target games windowed or borderless, never exclusive fullscreen.**
Exclusive fullscreen minimizes on focus loss (defeating the focus spoof) and
can't be window-captured. While unfocused the game gets no OS keyboard/mouse —
only the forwarded controller.
- **`ActivateAudioInterfaceAsync` requires an *agile* completion handler.** If the
handler doesn't answer `QueryInterface` for `IAgileObject`, the call is rejected
**synchronously** with `E_ILLEGAL_METHOD_CALL` (`0x8000000E`) — regardless of
COM apartment, MFStartup, device path, or activation params. (WRL/wil-based
samples hide this because they make the handler agile for you.) Process loopback
also needs the Windows 10 20H1 headers — build with `NTDDI_VERSION ≥ 0x0A00000B`.
- **WGC captures occluded windows but not minimized ones.** The game may sit
behind the host window, but must not be minimized.
- **Process-loopback capture doesn't mute the source**, hence the local audio
echo — capturing a process's render does not stop it reaching the speakers.