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
### Current tasks
- **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),
The near-term 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
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
to ~3 FPS now runs off the present thread (shared `coop::hook::VkCapture`), so the game keeps its
frame rate while mirroring (guarded by `vk_capture_perf_test`; every GPU backend's hook test also
asserts a present-thread overhead bound). 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).
Spectacle), the **Vulkan capture performance fix** — the read-back that dropped a 144 FPS game to ~3
FPS now runs off the present thread (shared `coop::hook::VkCapture`), so the game keeps its frame
rate while mirroring (guarded by `vk_capture_perf_test`; every GPU backend's hook test also asserts a
present-thread overhead bound) — and the **inline-hook (suspended-inject) Vulkan path**, which now
captures Sphere Spectacle correctly once the harness launches the exe with the right working
directory and `vk_hook` intercepts present/swapchain resolved via `vkGetInstanceProcAddr` (not just
`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
@@ -334,12 +325,16 @@ Present-hook crash was isolated.
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
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
asserts frames reach the shared texture and advance, the captured resolution/colors are sane, saves
a BMP screenshot for visual confirmation, and reports the present rate while capturing. Confirmed:
the layer path mirrors Sphere Spectacle correctly (1920×1080, right colors, no swizzle/darkening);
the suspended-inject path is **not applicable** to titles that must launch through Steam (their exe
renders nothing when launched directly) — the layer is the method there.
unfocused) and **inject** (suspended-launch the exe *with the game's own folder as the working
directory* + early-inject before `vkCreateInstance`) — and asserts frames reach the shared texture
and advance, the captured resolution/colors are sane, saves a BMP screenshot for visual
confirmation, and **gates on the game keeping a healthy present rate while capturing** (so a
present-thread stall fails the tool, not just gets reported). Confirmed against Sphere Spectacle:
**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` +
`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,
else pure pass-through. Register it per-user (HKCU `…\Vulkan\ImplicitLayers`, no admin) and
unregister on untick / host exit.
- **On a real Steam Vulkan game, the layer is the *only* usable early-presence method.** Validating
against Sphere Spectacle (`coop_vk_validate`) confirmed the layer path mirrors it correctly, but
also that the suspended-launch "Auto-attach" (the early-inject path the mock uses) **doesn't apply
to a Steam title**: launching its `.exe` directly — even with Steam running — renders nothing
under our injected process (the title requires launching *through* Steam, which we can't suspend),
so there's no Vulkan present to catch. The implicit layer sidesteps this entirely (it's in the
loader chain however Steam launches the game), which is why it's the productized path. Two more
real-game lessons: (1) **the layer does video, but the game still needs `coop_hook.dll` co-injected
for focus-spoofing** — without it an unfocused game throttles *itself* to a few fps (an
event-driven title presents only on change), which looks like a capture slowdown but isn't; (2)
the layer captures **every** present (copied ≈ present_calls, no drops), so the read-back stays off
the critical path — a present-*rate* number alone can't prove "no FPS impact" because it's the
game's own cadence, so a hard FPS gate there is meaningless; validate feel by playing.
- **Both Vulkan early-presence paths work on a real game — the "only the layer works" belief was two
bugs in disguise.** Against Sphere Spectacle (`coop_vk_validate`), the inline **inject** path
(suspended-launch + early-inject before `vkCreateInstance`) initially captured nothing, which was
wrongly written off as "the title requires launching through Steam." It does not. Two real causes:
(1) the harness launched the exe with **our** working directory, so the game couldn't load
`steam_api64.dll` / `resources/` and never rendered — launch it with the game's own folder as cwd
and it runs fine directly; and (2) the game (volk-style) resolves `vkQueuePresentKHR` /
`vkCreateSwapchainKHR` via **`vkGetInstanceProcAddr`**, but `vk_hook` only substituted those when
resolved via `vkGetDeviceProcAddr` — so the present bypassed us. Intercepting them in the
instance-level resolver too fixed it. The division of labor is about *who launches the game*, not
capability: for a game launched **through Steam** we can't suspend the launch, so the implicit
**layer** (always in the loader chain) is the path; when **we** launch the exe, suspended-inject
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.**
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

View File

@@ -271,6 +271,20 @@ VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL hk_vkGetInstanceProcAddr(VkInstance ins
{
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);
}

View File

@@ -278,8 +278,13 @@ int main(int argc, char** argv)
STARTUPINFOW si{};
si.cb = sizeof(si);
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,
nullptr, &si, &pi))
workdir.empty() ? nullptr : workdir.c_str(), &si, &pi))
{
check(false, "suspended-launch the game exe directly");
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>(nonblack_frames));
// Not-applicable skip: a Steam title that requires launching through Steam renders nothing when
// its exe is suspended-launched directly, so the inject method can't reach it (the layer can).
// Not-applicable skip: some titles produce no Vulkan presents when their exe is suspended-launched
// 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)
{
std::printf(" the directly-launched exe produced no Vulkan presents -- this title requires launching\n"
" through Steam, so the suspended-inject (Auto-attach) method isn't applicable to it; use\n"
" the layer method. (The early-inject mechanism itself is covered by mock_game_test.)\n");
std::printf(" the directly-launched exe produced no Vulkan presents -- it may require a real Steam\n"
" launch (or failed to initialize), so the suspended-inject method can't reach it here;\n"
" use the layer method. (The early-inject mechanism itself is covered by mock_game_test.)\n");
kill_pid(pid);
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;
}
@@ -412,22 +420,26 @@ int main(int argc, char** argv)
check(false, "grabbed a screenshot frame");
}
// Performance: the layer captured every present (copied ~= present_calls, no drops), so capture
// keeps up with whatever rate the game emits and the read-back stays off the critical path (own
// queue + present-semaphore re-chain). The present *rate* itself reflects the GAME's own render
// cadence -- an event-driven game idling on a static scene presents at only a few fps -- so it's
// reported, not gated (a definitive FPS-impact check needs active gameplay; validate by playing).
// Performance gate (this is the check the earlier version refused to make -- it reported the rate
// and rationalized it, which hid the 144->3 FPS stall). The capture runs off the present thread,
// so the game must keep a healthy present rate while we mirror. present_calls counts EVERY present
// (not throttled); the mirror is intentionally throttled to ~150 Hz, so copied < present is normal
// and not a drop. A present rate that collapses (the bug was ~3/s) fails here.
if (alive && find_pid(L"sphere.exe") == pid)
{
const std::uint64_t p0 = block->video.present_calls;
const std::uint32_t g0 = block->video.generation.load(std::memory_order_acquire);
Sleep(3000);
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 std::uint64_t copied0 = src.frames_copied();
std::printf(" present rate while capturing = %.1f /s (the game's own cadence; capture copied every "
"present, no drops -> off the critical path)\n",
fps);
(void)copied0;
// The hook bumps video.generation on every mirrored frame (independent of the host reading),
// so its delta is the true mirror rate even while we're just sleeping here.
const double mirror = static_cast<double>(g1 - g0) / 3.0;
std::printf(" present rate while capturing = %.1f /s; mirror rate = %.1f /s (capture is off the "
"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);