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:
94
README.md
94
README.md
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user