Condense README lessons; drop implemented audio-render-hook plan doc

The docs/audio-render-hook-plan.md was a fully-implemented, validated design
doc; its still-relevant gotchas (GetService idx 14, agile completion handler,
loopback doesn't mute) already live in the README. Tighten the Lessons learned
section and merge the two x86 SafetyHook traps into one bullet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 00:05:20 +02:00
parent 4985239222
commit 2432e49784
2 changed files with 39 additions and 336 deletions

View File

@@ -273,60 +273,44 @@ source), and click away from the game to confirm focus spoofing keeps it running
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
- **RPT only streams the *focused* window.** The game can't hold focus itself, so
the hook spoofs it (`GetForegroundWindow` / `GetActiveWindow` / `GetFocus` +
swallowing deactivation messages) to keep the game polling and rendering while
the host owns real OS focus.
- **Run target games windowed or borderless, never exclusive fullscreen** —
exclusive fullscreen minimizes on focus loss (defeating the spoof) and can't be
window-captured. While unfocused the game gets no OS keyboard/mouse, only the
forwarded pad.
- **WGC captures occluded windows but not minimized ones.**
- **Process-loopback capture doesn't mute the source.** Capturing a process's
render doesn't stop it reaching the speakers, so the no-echo path instead injects
a WASAPI render-hook that copies each buffer then releases it with
`AUDCLNT_BUFFERFLAGS_SILENT`; loopback stays as the (echoing) fallback.
- **`ActivateAudioInterfaceAsync` needs 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** — capturing a process's
render does not stop it reaching the speakers. That's why the echo fix instead
injects a WASAPI render-hook that silences the game's own buffer
(`AUDCLNT_BUFFERFLAGS_SILENT`) after copying it for the mirror; loopback stays
as the fallback.
- **COM has no exports, so the render-hook walks vtables — and the indices are
easy to miscount.** All instances of a COM coclass share one vtable, so hooking
one object's method (resolved by frozen-ABI vtable index) catches every
instance. But the indices must be exact: `IAudioClient::GetService` is **14**,
not 13 — `SetEventHandle` (13) sits between `Reset` and `GetService`. Count the
full interface (including every inherited `IUnknown`/base method) when adding a
new COM hook.
- **SafetyHook's `call()` is `__cdecl` on x86 — use `stdcall()` for `__stdcall`
targets.** `InlineHook::call()` invokes the trampoline through a pointer with the
compiler's default convention, which is `__cdecl` on 32-bit. Most things we hook
are `__stdcall` (COM methods like `IDXGISwapChain::Present` and the WASAPI render
interfaces, plus `WINAPI` `SwapBuffers`). On x64 every convention collapses to one,
so `call()` is fine; on x86 it double-cleans the stack → ESP imbalance → an
instant crash (Debug builds surface it as **Run-Time Check Failure #0**). This
froze 32-bit games (Slaps and Beans) the moment the Present hook ran. Always call
trampolines with the matching convention — `stdcall()` for these — which is a
no-op on x64. The XInput/focus hooks dodged it only because they never call the
trampoline (they return synthesized data).
- **Hook COM methods by swapping the vtable entry, not by inline-patching the
function — on x86.** Inline hooking relocates the target's overwritten prologue
into a trampoline. Some x86 prologues defeat that: MMDevApi/AudioSes methods open
with `push ebp; mov ebp,esp; and esp,-8` (dynamic stack alignment) and read their
arguments **EBP-relative**. SafetyHook's relocated copy leaves EBP wrong, so the
original ran with garbage arguments and faulted — this crashed 32-bit FMOD games
(Slaps and Beans) the instant audio init flowed through the hook, *after* the
`stdcall()` fix above. The robust fix is vtable-entry hooking: `VirtualProtect` the
shared vtable slot, overwrite the function pointer, call the saved original
directly. No code patching, no trampoline, pristine stack regardless of prologue.
The audio hooks use this; inline hooking is fine for `Present`/`SwapBuffers`, whose
prologues relocate cleanly. (One swap covers every instance — a coclass shares one
vtable.) Guarded by `audio_hook_test_x86`.
- **Steam Input init suppresses XInput.** Initializing the Steam Input API turns on
Steam's in-process XInput interception, which hides controllers from
`XInputGetState` unless they're bound to the running appid's action set —
defaulting to it silently broke input forwarding. XInput is the primary path;
Steam Input is opt-in.
apartment, device path, or activation params. (WRL/wil samples make the handler
agile for you.) Process loopback also needs the Win10 20H1 headers
(`NTDDI_VERSION ≥ 0x0A00000B`).
- **COM methods have no exports, so hooks walk vtables by frozen-ABI index — count
exactly.** All instances of a coclass share one vtable, so hooking one object's
slot catches every instance; but `IAudioClient::GetService` is **14**, not 13
(`SetEventHandle` sits at 13 between `Reset` and `GetService`). Count every
inherited `IUnknown`/base method when adding a hook.
- **SafetyHook on x86 has two traps that froze 32-bit Slaps and Beans.** (1)
`InlineHook::call()` invokes the trampoline as `__cdecl`, but most targets are
`__stdcall` (COM methods like `IDXGISwapChain::Present`, the WASAPI interfaces,
`WINAPI` `SwapBuffers`); on 32-bit that double-cleans the stack → ESP imbalance →
crash (Debug: **Run-Time Check Failure #0**). Use **`stdcall()`** (a no-op on
x64). (2) Don't *inline-hook* COM methods on x86 at all: MMDevApi/AudioSes
prologues do `push ebp; mov ebp,esp; and esp,-8` and read args **EBP-relative**,
which SafetyHook's trampoline relocation breaks (the original then runs with
garbage args and faults). Hook COM methods by **swapping the vtable entry**
instead (`VirtualProtect` the slot, overwrite the pointer, call the saved
original) — no code patching, pristine stack regardless of prologue. Inline
hooking stays fine for `Present`/`SwapBuffers` (clean prologues). Guarded by the
x86 hook tests.
- **Steam Input init suppresses XInput.** Initializing Steam Input turns on Steam's
in-process XInput interception, which hides controllers from `XInputGetState`
unless they're bound to the running appid's action set — defaulting to it
silently broke forwarding. XInput is primary; Steam Input is opt-in.