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

@@ -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);