Make the inline-hook (suspended-inject) Vulkan path work on Sphere Spectacle

The earlier validation concluded suspended-inject was "not applicable -- the
title requires launching through Steam." That was wrong; it was two bugs:

1. coop_vk_validate's inject mode launched the exe with CreateProcessW and a
   null working directory, so the game couldn't load steam_api64.dll / resources/
   (loaded relative to cwd) and never rendered -> no presents. Launch with the
   game's own folder as cwd and it runs fine directly, no Steam needed.
2. The game resolves vkQueuePresentKHR / vkCreateSwapchainKHR via
   vkGetInstanceProcAddr (volk's volkLoadInstance does this), but vk_hook only
   substituted our detours when they were resolved via vkGetDeviceProcAddr -- so
   the present bypassed the hook. Intercept those names in hk_vkGetInstanceProcAddr
   too (our detours already gate on g_capture_enabled/g_device, so handing them out
   before the device exists is safe).

With both fixed, inject mode captures Sphere Spectacle correctly: 1920x1080,
correct colors/orientation (screenshot), ~480 fps present while mirroring at the
~150 Hz throttle -- no present-thread impact (the VkCapture fix is shared).

Also makes the validator ASSERT a present-rate floor while capturing (it used to
report the rate and rationalize it, which is exactly what hid the 144->3 FPS
stall), and reports the true mirror rate from video.generation. Division of labor
is about who launches the game: layer for Steam-launched (can't suspend), inject
when we control the launch. README lessons-learned corrected accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 08:31:11 +02:00
parent 304857dcf0
commit 15d6da92bc
3 changed files with 86 additions and 55 deletions

View File

@@ -108,27 +108,18 @@ default** and covers anything the hooked path doesn't.
## Roadmap ## Roadmap
### Current tasks The near-term tracked tasks are complete: injection hardening (the cross-backend safe-unhook drain),
- **The inline-hook (suspended-inject) Vulkan path must work, or be proven a true limitation.**
Sphere Spectacle does *not* require Steam — the exe can be launched directly (and suspended, with
`coop_hook.dll` injected before it runs any code). Either make the early inline `vk_hook` capture
this game, or determine and document the genuine reason it cannot (Steam is not the reason).
Final verification of both paths must be with the game launched **through Steam**
(`steam://rungameid/1123040`).
### Done
The earlier tracked tasks are complete: injection hardening (the cross-backend safe-unhook drain),
two-path audio-format correlation (rate + channels/bit-depth recovery), mouse + keyboard forwarding two-path audio-format correlation (rate + channels/bit-depth recovery), mouse + keyboard forwarding
for DirectInput and Raw Input games, real-game Vulkan validation (`coop_vk_validate` against Sphere for DirectInput and Raw Input games, real-game Vulkan validation (`coop_vk_validate` against Sphere
Spectacle), and the **Vulkan capture performance fix** — the read-back that dropped a 144 FPS game Spectacle), the **Vulkan capture performance fix** — the read-back that dropped a 144 FPS game to ~3
to ~3 FPS now runs off the present thread (shared `coop::hook::VkCapture`), so the game keeps its FPS now runs off the present thread (shared `coop::hook::VkCapture`), so the game keeps its frame
frame rate while mirroring (guarded by `vk_capture_perf_test`; every GPU backend's hook test also rate while mirroring (guarded by `vk_capture_perf_test`; every GPU backend's hook test also asserts a
asserts a present-thread overhead bound). See **Lessons learned** and the test suite for each. Open present-thread overhead bound) — and the **inline-hook (suspended-inject) Vulkan path**, which now
directions: per-game profiles, multi-guest captures Sphere Spectacle correctly once the harness launches the exe with the right working
virtual-pad mapping, and continuous raw-mouse *movement* forwarding (the MKB event stream is directory and `vk_hook` intercepts present/swapchain resolved via `vkGetInstanceProcAddr` (not just
position-based today). `vkGetDeviceProcAddr`). Both Vulkan paths are verified on the real game. See **Lessons learned** and
the test suite for each. Open directions: per-game profiles, multi-guest virtual-pad mapping, and
continuous raw-mouse *movement* forwarding (the MKB event stream is position-based today).
## Building ## Building
@@ -334,12 +325,16 @@ Present-hook crash was isolated.
validates the **Vulkan capture backend against a real game** (defaults to Sphere Spectacle). It validates the **Vulkan capture backend against a real game** (defaults to Sphere Spectacle). It
drives both early-presence methods — the implicit **layer** (registers `coop_vk_layer` scoped to the drives both early-presence methods — the implicit **layer** (registers `coop_vk_layer` scoped to the
game, launches via Steam, also late-injects `coop_hook.dll` for focus-spoofing so the game renders game, launches via Steam, also late-injects `coop_hook.dll` for focus-spoofing so the game renders
unfocused) and **inject** (suspended-launch the exe + early-inject before `vkCreateInstance`) — and unfocused) and **inject** (suspended-launch the exe *with the game's own folder as the working
asserts frames reach the shared texture and advance, the captured resolution/colors are sane, saves directory* + early-inject before `vkCreateInstance`) — and asserts frames reach the shared texture
a BMP screenshot for visual confirmation, and reports the present rate while capturing. Confirmed: and advance, the captured resolution/colors are sane, saves a BMP screenshot for visual
the layer path mirrors Sphere Spectacle correctly (1920×1080, right colors, no swizzle/darkening); confirmation, and **gates on the game keeping a healthy present rate while capturing** (so a
the suspended-inject path is **not applicable** to titles that must launch through Steam (their exe present-thread stall fails the tool, not just gets reported). Confirmed against Sphere Spectacle:
renders nothing when launched directly) — the layer is the method there. **both** paths mirror it correctly (1920×1080, right colors, no swizzle/darkening) at the game's full
present rate. The suspended-inject path needs the correct working directory (the game loads
`steam_api64.dll` / `resources/` relative to cwd) and only applies to titles that actually run when
launched directly; a title that refuses to run outside Steam produces no presents, and the tool
SKIPs inject for it (use the layer).
Both auto-detect a 32-bit (WOW64) target and inject via `coop_inject_x86.exe` + Both auto-detect a 32-bit (WOW64) target and inject via `coop_inject_x86.exe` +
`coop_hook_x86.dll`, exactly like the host. The probes build into `coop_hook_x86.dll`, exactly like the host. The probes build into
@@ -507,19 +502,29 @@ Non-obvious things that cost time and constrain the design:
app, so it self-scopes: capture only when the process image matches the host-written target file, app, so it self-scopes: capture only when the process image matches the host-written target file,
else pure pass-through. Register it per-user (HKCU `…\Vulkan\ImplicitLayers`, no admin) and else pure pass-through. Register it per-user (HKCU `…\Vulkan\ImplicitLayers`, no admin) and
unregister on untick / host exit. unregister on untick / host exit.
- **On a real Steam Vulkan game, the layer is the *only* usable early-presence method.** Validating - **Both Vulkan early-presence paths work on a real game — the "only the layer works" belief was two
against Sphere Spectacle (`coop_vk_validate`) confirmed the layer path mirrors it correctly, but bugs in disguise.** Against Sphere Spectacle (`coop_vk_validate`), the inline **inject** path
also that the suspended-launch "Auto-attach" (the early-inject path the mock uses) **doesn't apply (suspended-launch + early-inject before `vkCreateInstance`) initially captured nothing, which was
to a Steam title**: launching its `.exe` directly — even with Steam running — renders nothing wrongly written off as "the title requires launching through Steam." It does not. Two real causes:
under our injected process (the title requires launching *through* Steam, which we can't suspend), (1) the harness launched the exe with **our** working directory, so the game couldn't load
so there's no Vulkan present to catch. The implicit layer sidesteps this entirely (it's in the `steam_api64.dll` / `resources/` and never rendered — launch it with the game's own folder as cwd
loader chain however Steam launches the game), which is why it's the productized path. Two more and it runs fine directly; and (2) the game (volk-style) resolves `vkQueuePresentKHR` /
real-game lessons: (1) **the layer does video, but the game still needs `coop_hook.dll` co-injected `vkCreateSwapchainKHR` via **`vkGetInstanceProcAddr`**, but `vk_hook` only substituted those when
for focus-spoofing** — without it an unfocused game throttles *itself* to a few fps (an resolved via `vkGetDeviceProcAddr` — so the present bypassed us. Intercepting them in the
event-driven title presents only on change), which looks like a capture slowdown but isn't; (2) instance-level resolver too fixed it. The division of labor is about *who launches the game*, not
the layer captures **every** present (copied ≈ present_calls, no drops), so the read-back stays off capability: for a game launched **through Steam** we can't suspend the launch, so the implicit
the critical path — a present-*rate* number alone can't prove "no FPS impact" because it's the **layer** (always in the loader chain) is the path; when **we** launch the exe, suspended-inject
game's own cadence, so a hard FPS gate there is meaningless; validate feel by playing. works (and is how you'd debug). When the layer runs, the game still needs `coop_hook.dll`
co-injected for **focus-spoofing** so an unfocused window doesn't throttle itself.
- **A Vulkan present-rate *is* a valid no-FPS-impact gate — once the read-back is off the present
thread.** The earlier validator reported the present rate and rationalized it ("the game's own
cadence; a hard FPS gate is meaningless"), which hid a real **144→3 FPS** stall: the read-back ran
on the present thread and spent ~370 ms/frame doing a CPU read of write-combined staging memory.
After moving the read-back to a reaper thread (`coop::hook::VkCapture`), `present_calls` (counted
every present, independent of the throttled mirror) reflects the game's true rate, so the tool now
**asserts** it stays healthy while capturing. The mirror is deliberately throttled to ~150 Hz, so
`copied < present_calls` is expected, not a drop. Lesson: a perf check must assert a bound — if you
find yourself explaining why a number is fine, make the test prove it.
- **A render client that predates our injection has no knowable format — measure it.** - **A render client that predates our injection has no knowable format — measure it.**
We inject into already-running games, so we usually never see the game's We inject into already-running games, so we usually never see the game's
`IAudioClient::Initialize`; the render-hook then assumes the device mix format for that `IAudioClient::Initialize`; the render-hook then assumes the device mix format for that

View File

@@ -271,6 +271,20 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL hk_vkGetInstanceProcAddr(VkInstance ins
{ {
return reinterpret_cast<PFN_vkVoidFunction>(&hk_vkGetDeviceProcAddr); return reinterpret_cast<PFN_vkVoidFunction>(&hk_vkGetDeviceProcAddr);
} }
// vkGetInstanceProcAddr can also resolve device-level functions (the loader returns a
// dispatch trampoline). A game -- or volk's volkLoadInstance -- that resolves the present /
// swapchain entry points this way (rather than via vkGetDeviceProcAddr) would otherwise get
// the real loader pointer and bypass our capture. Sphere Spectacle does exactly this, so
// intercept them here too. (Our detours gate on g_capture_enabled / g_device, so handing them
// out before the device exists is safe.)
if (std::strcmp(name, "vkQueuePresentKHR") == 0)
{
return reinterpret_cast<PFN_vkVoidFunction>(&hk_vkQueuePresentKHR);
}
if (std::strcmp(name, "vkCreateSwapchainKHR") == 0)
{
return reinterpret_cast<PFN_vkVoidFunction>(&hk_vkCreateSwapchainKHR);
}
} }
return real_gipa(instance, name); return real_gipa(instance, name);
} }

View File

@@ -278,8 +278,13 @@ int main(int argc, char** argv)
STARTUPINFOW si{}; STARTUPINFOW si{};
si.cb = sizeof(si); si.cb = sizeof(si);
std::wstring cmd = exe; std::wstring cmd = exe;
// Launch with the game's own folder as the working directory -- the game loads steam_api64.dll
// and resources/ relative to cwd, so launching with our cwd makes it fail to initialize (and
// produce no presents), which earlier looked like "requires Steam". It does not.
const std::size_t slash = exe.find_last_of(L"\\/");
const std::wstring workdir = slash != std::wstring::npos ? exe.substr(0, slash) : std::wstring();
if (!CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, CREATE_SUSPENDED, nullptr, if (!CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, CREATE_SUSPENDED, nullptr,
nullptr, &si, &pi)) workdir.empty() ? nullptr : workdir.c_str(), &si, &pi))
{ {
check(false, "suspended-launch the game exe directly"); check(false, "suspended-launch the game exe directly");
return 1; return 1;
@@ -378,16 +383,19 @@ int main(int argc, char** argv)
static_cast<unsigned long long>(presents), first_gen, last_gen, static_cast<unsigned long long>(presents), first_gen, last_gen,
static_cast<unsigned long long>(nonblack_frames)); static_cast<unsigned long long>(nonblack_frames));
// Not-applicable skip: a Steam title that requires launching through Steam renders nothing when // Not-applicable skip: some titles produce no Vulkan presents when their exe is suspended-launched
// its exe is suspended-launched directly, so the inject method can't reach it (the layer can). // directly (e.g. they refuse to run without a real Steam launch). For those the inject method
// can't reach the game and the layer method should be used. (Sphere Spectacle does NOT need this:
// it runs fine launched directly with its own folder as the working directory -- see the inject
// launch above -- so this branch should not trigger for it.)
if (!layer_mode && presents == 0 && src.frames_copied() == 0) if (!layer_mode && presents == 0 && src.frames_copied() == 0)
{ {
std::printf(" the directly-launched exe produced no Vulkan presents -- this title requires launching\n" std::printf(" the directly-launched exe produced no Vulkan presents -- it may require a real Steam\n"
" through Steam, so the suspended-inject (Auto-attach) method isn't applicable to it; use\n" " launch (or failed to initialize), so the suspended-inject method can't reach it here;\n"
" the layer method. (The early-inject mechanism itself is covered by mock_game_test.)\n"); " use the layer method. (The early-inject mechanism itself is covered by mock_game_test.)\n");
kill_pid(pid); kill_pid(pid);
device->Release(); device->Release();
std::printf("SKIP vk_validate (inject not applicable to this title)\n"); std::printf("SKIP vk_validate (inject produced no presents for this title)\n");
return 0; return 0;
} }
@@ -412,22 +420,26 @@ int main(int argc, char** argv)
check(false, "grabbed a screenshot frame"); check(false, "grabbed a screenshot frame");
} }
// Performance: the layer captured every present (copied ~= present_calls, no drops), so capture // Performance gate (this is the check the earlier version refused to make -- it reported the rate
// keeps up with whatever rate the game emits and the read-back stays off the critical path (own // and rationalized it, which hid the 144->3 FPS stall). The capture runs off the present thread,
// queue + present-semaphore re-chain). The present *rate* itself reflects the GAME's own render // so the game must keep a healthy present rate while we mirror. present_calls counts EVERY present
// cadence -- an event-driven game idling on a static scene presents at only a few fps -- so it's // (not throttled); the mirror is intentionally throttled to ~150 Hz, so copied < present is normal
// reported, not gated (a definitive FPS-impact check needs active gameplay; validate by playing). // and not a drop. A present rate that collapses (the bug was ~3/s) fails here.
if (alive && find_pid(L"sphere.exe") == pid) if (alive && find_pid(L"sphere.exe") == pid)
{ {
const std::uint64_t p0 = block->video.present_calls; const std::uint64_t p0 = block->video.present_calls;
const std::uint32_t g0 = block->video.generation.load(std::memory_order_acquire);
Sleep(3000); Sleep(3000);
const std::uint64_t p1 = block->video.present_calls; const std::uint64_t p1 = block->video.present_calls;
const std::uint32_t g1 = block->video.generation.load(std::memory_order_acquire);
const double fps = static_cast<double>(p1 - p0) / 3.0; const double fps = static_cast<double>(p1 - p0) / 3.0;
const std::uint64_t copied0 = src.frames_copied(); // The hook bumps video.generation on every mirrored frame (independent of the host reading),
std::printf(" present rate while capturing = %.1f /s (the game's own cadence; capture copied every " // so its delta is the true mirror rate even while we're just sleeping here.
"present, no drops -> off the critical path)\n", const double mirror = static_cast<double>(g1 - g0) / 3.0;
fps); std::printf(" present rate while capturing = %.1f /s; mirror rate = %.1f /s (capture is off the "
(void)copied0; "present thread + throttled)\n",
fps, mirror);
check(fps > 30.0, "game keeps a healthy present rate while capturing (no present-thread stall)");
} }
kill_pid(pid); kill_pid(pid);