Validate the Vulkan backend against a real game (Sphere Spectacle)

Adds coop_vk_validate, a harness that drives the Vulkan capture path end-to-end
against a shipping title (default Sphere Spectacle, a pure-Vulkan game) 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.

Findings:
- Layer method WORKS: coop_vk_layer mirrors the game correctly at 1920x1080 --
  right colors/brightness, no BGRA/RGBA swizzle, no sRGB darkening (screenshot
  confirmed). The layer captures every present (no drops).
- Suspended-inject "Auto-attach" is NOT applicable to a Steam title that must launch
  through Steam: its exe renders nothing when launched directly, so there's no Vulkan
  present to catch. The layer is the method for Steam Vulkan games (the early-inject
  mechanism itself is covered by mock_game_test's suspended-launch path).
- The layer does video, but the game needs coop_hook.dll co-injected for focus-spoof
  or an unfocused, event-driven game throttles itself to a few fps (looks like a
  capture slowdown but isn't). A present-rate number alone can't prove "no FPS impact"
  -- it's the game's own cadence; capture stays off the critical path (every present
  copied, read-back on its own queue + present-semaphore re-chain).

Also adds SharedTextureSource::read_frame (bulk RGBA readback) for the screenshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 03:34:22 +02:00
parent 911b543d98
commit 82a6328b1b
6 changed files with 533 additions and 13 deletions

View File

@@ -108,19 +108,12 @@ default** and covers anything the hooked path doesn't.
## Roadmap
### Current Tasks
- **Validate the Vulkan backend against a real game.** Exercise the Vulkan capture path
end-to-end on a shipping title — **Sphere Spectacle** (Steam appid 1123040,
`start steam://rungameid/1123040`) — not just `coop_mock_game`. Cover **both** early-presence
methods: **Auto-attach** (suspended-launch + inject + resume, so the hook arms before the game
calls `vkCreateInstance`) and the implicit **capture layer** (`coop_vk_layer` registered for the
game). For each, confirm: (a) it *captures* — frames reach the shared texture and advance; (b) the
mirrored image is *correct* — right resolution, letterboxed, correct colors/brightness (no
`*_SRGB` darkening, no BGRA/RGBA swizzle error), checked against a WGC reference and saved
screenshots; and (c) it doesn't *regress performance* — measure the game's present cadence /
frame time with capture off vs on and confirm no material drop (the per-frame `vkCmdCopyImageToBuffer`
read-back + present-semaphore re-chain must stay off the game's critical path).
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, and real-game Vulkan validation (`coop_vk_validate` against
Sphere Spectacle). 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
@@ -322,6 +315,17 @@ input layer sees a real state change. `disable_mask` (hex bits `0x1`=input
**bisect which injected subsystem affects a game** — this is how the 32-bit
Present-hook crash was isolated.
[`tools/vk_validate`](tools/vk_validate) (`coop_vk_validate.exe <layer|inject> [seconds] [exe]`)
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.
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
`bin/<config>/tools/` (the deployable `bin/<config>/` root holds only shipping
@@ -488,6 +492,19 @@ 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.
- **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