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:
@@ -138,6 +138,7 @@ if(COOP_BUILD_HOOK)
|
|||||||
add_subdirectory(tools/audio_probe) # coop_audio_probe: inject + diagnose the render-hook
|
add_subdirectory(tools/audio_probe) # coop_audio_probe: inject + diagnose the render-hook
|
||||||
add_subdirectory(tools/audio_validate) # coop_audio_validate: quantify capture fidelity (pitch/SNR/clicks)
|
add_subdirectory(tools/audio_validate) # coop_audio_validate: quantify capture fidelity (pitch/SNR/clicks)
|
||||||
add_subdirectory(tools/input_probe) # coop_input_probe: inject + forward synthetic input
|
add_subdirectory(tools/input_probe) # coop_input_probe: inject + forward synthetic input
|
||||||
|
add_subdirectory(tools/vk_validate) # coop_vk_validate: validate the Vulkan backend vs a real game
|
||||||
add_subdirectory(tests)
|
add_subdirectory(tests)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -108,19 +108,12 @@ 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),
|
||||||
|
two-path audio-format correlation (rate + channels/bit-depth recovery), mouse + keyboard forwarding
|
||||||
- **Validate the Vulkan backend against a real game.** Exercise the Vulkan capture path
|
for DirectInput and Raw Input games, and real-game Vulkan validation (`coop_vk_validate` against
|
||||||
end-to-end on a shipping title — **Sphere Spectacle** (Steam appid 1123040,
|
Sphere Spectacle). See **Lessons learned** and the test suite for each. Open directions: per-game
|
||||||
`start steam://rungameid/1123040`) — not just `coop_mock_game`. Cover **both** early-presence
|
profiles, multi-guest virtual-pad mapping, and continuous raw-mouse *movement* forwarding (the MKB
|
||||||
methods: **Auto-attach** (suspended-launch + inject + resume, so the hook arms before the game
|
event stream is position-based today).
|
||||||
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).
|
|
||||||
|
|
||||||
## Building
|
## 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
|
**bisect which injected subsystem affects a game** — this is how the 32-bit
|
||||||
Present-hook crash was isolated.
|
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` +
|
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
|
||||||
`bin/<config>/tools/` (the deployable `bin/<config>/` root holds only shipping
|
`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,
|
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
|
||||||
|
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.**
|
- **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
|
||||||
|
|||||||
@@ -92,6 +92,43 @@ bool SharedTextureSource::reopen(unsigned long pid, const VideoShareView& share)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool SharedTextureSource::read_frame(std::vector<std::uint8_t>& out, std::uint32_t& w, std::uint32_t& h)
|
||||||
|
{
|
||||||
|
if (private_ == nullptr || ctx_ == nullptr || device_ == nullptr)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
D3D11_TEXTURE2D_DESC desc{};
|
||||||
|
private_->GetDesc(&desc);
|
||||||
|
D3D11_TEXTURE2D_DESC staging_desc = desc;
|
||||||
|
staging_desc.Usage = D3D11_USAGE_STAGING;
|
||||||
|
staging_desc.BindFlags = 0;
|
||||||
|
staging_desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
|
||||||
|
staging_desc.MiscFlags = 0;
|
||||||
|
Microsoft::WRL::ComPtr<ID3D11Texture2D> staging;
|
||||||
|
if (FAILED(device_->CreateTexture2D(&staging_desc, nullptr, &staging)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ctx_->CopyResource(staging.Get(), private_.Get());
|
||||||
|
D3D11_MAPPED_SUBRESOURCE map{};
|
||||||
|
if (FAILED(ctx_->Map(staging.Get(), 0, D3D11_MAP_READ, 0, &map)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
w = desc.Width;
|
||||||
|
h = desc.Height;
|
||||||
|
out.resize(static_cast<std::size_t>(w) * h * 4);
|
||||||
|
for (std::uint32_t y = 0; y < h; ++y)
|
||||||
|
{
|
||||||
|
memcpy(out.data() + static_cast<std::size_t>(y) * w * 4,
|
||||||
|
static_cast<const std::uint8_t*>(map.pData) + static_cast<std::size_t>(y) * map.RowPitch,
|
||||||
|
static_cast<std::size_t>(w) * 4);
|
||||||
|
}
|
||||||
|
ctx_->Unmap(staging.Get(), 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool SharedTextureSource::read_pixel(std::uint32_t x, std::uint32_t y, std::uint8_t out[4])
|
bool SharedTextureSource::read_pixel(std::uint32_t x, std::uint32_t y, std::uint8_t out[4])
|
||||||
{
|
{
|
||||||
if (private_ == nullptr || ctx_ == nullptr || device_ == nullptr)
|
if (private_ == nullptr || ctx_ == nullptr || device_ == nullptr)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include <d3d11_1.h>
|
#include <d3d11_1.h>
|
||||||
#include <wrl/client.h>
|
#include <wrl/client.h>
|
||||||
@@ -31,6 +32,11 @@ public:
|
|||||||
// Drop the opened resources (target changed / mirror turned off).
|
// Drop the opened resources (target changed / mirror turned off).
|
||||||
void reset();
|
void reset();
|
||||||
|
|
||||||
|
// Read the whole last-copied frame as tightly-packed RGBA8 into `out` (sized w*h*4). For
|
||||||
|
// screenshots / external verification. One staging copy + map; not a hot path. False if no
|
||||||
|
// frame yet or the readback failed.
|
||||||
|
bool read_frame(std::vector<std::uint8_t>& out, std::uint32_t& w, std::uint32_t& h);
|
||||||
|
|
||||||
// Read the RGBA8 pixel at (x,y) of the last copied frame into out[4]. For verification
|
// Read the RGBA8 pixel at (x,y) of the last copied frame into out[4]. For verification
|
||||||
// (e.g. decoding the mock game's frame-number block in a capture test). Copies the
|
// (e.g. decoding the mock game's frame-number block in a capture test). Copies the
|
||||||
// private texture to a CPU-readable staging texture and maps it -- not a hot path.
|
// private texture to a CPU-readable staging texture and maps it -- not a hot path.
|
||||||
|
|||||||
16
tools/vk_validate/CMakeLists.txt
Normal file
16
tools/vk_validate/CMakeLists.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Dev harness: validate the injected Vulkan capture backend against a REAL game (Sphere Spectacle).
|
||||||
|
# Drives both early-presence methods (implicit layer / suspended-inject), asserts capture works +
|
||||||
|
# the image is sane, saves a BMP screenshot for visual confirmation, and measures the present rate
|
||||||
|
# while capturing. Reuses the shipping shared-texture reader.
|
||||||
|
add_executable(coop_vk_validate
|
||||||
|
main.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/host/src/capture/shared_texture.cpp)
|
||||||
|
|
||||||
|
target_include_directories(coop_vk_validate PRIVATE ${CMAKE_SOURCE_DIR}/host/src)
|
||||||
|
|
||||||
|
target_link_libraries(coop_vk_validate PRIVATE coop_common d3d11 dxgi advapi32 shell32)
|
||||||
|
|
||||||
|
# Needs coop_hook.dll (inject method) + coop_vk_layer.dll/.json (layer method) at the deployable root.
|
||||||
|
add_dependencies(coop_vk_validate coop_hook coop_vk_layer)
|
||||||
|
|
||||||
|
coop_output_subdir(tools coop_vk_validate) # dev tool -> bin/<config>/tools/
|
||||||
443
tools/vk_validate/main.cpp
Normal file
443
tools/vk_validate/main.cpp
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
// coop_vk_validate: validate the injected Vulkan capture backend against a REAL game.
|
||||||
|
//
|
||||||
|
// Vulkan can't be late-hooked (it caches its present pointer at init), so capture needs early
|
||||||
|
// presence. This drives both productized early-presence methods end-to-end against a shipping
|
||||||
|
// Vulkan title (Sphere Spectacle, Steam appid 1123040 by default):
|
||||||
|
// layer - register the implicit coop_vk_layer (scoped to the game image), launch via Steam, and
|
||||||
|
// read the frames it publishes. The realistic path for a Steam-launched game.
|
||||||
|
// inject - suspended-launch the game's exe directly, inject coop_hook.dll, resume (the hook arms
|
||||||
|
// before vkCreateInstance). Only works if the title runs when launched outside Steam.
|
||||||
|
//
|
||||||
|
// For each method it asserts capture works (frames advance), the image is sane (non-black, the
|
||||||
|
// captured resolution), saves a BMP screenshot for visual confirmation, and measures the game's
|
||||||
|
// present rate while capturing (a healthy rate at the refresh cap = capture stays off the critical
|
||||||
|
// path). Usage: coop_vk_validate <layer|inject> [seconds] [exe-path]
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <shellapi.h>
|
||||||
|
#include <tlhelp32.h>
|
||||||
|
|
||||||
|
#include <d3d11.h>
|
||||||
|
|
||||||
|
#include "capture/shared_texture.hpp"
|
||||||
|
#include "coop/protocol.hpp"
|
||||||
|
#include "coop/shared_memory.hpp"
|
||||||
|
#include "coop/tool_paths.hpp"
|
||||||
|
|
||||||
|
using namespace coop;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr const wchar_t* kDefaultExe =
|
||||||
|
L"G:\\SteamLibrary\\steamapps\\common\\Sphere Spectacle\\sphere.exe";
|
||||||
|
constexpr const wchar_t* kSteamUrl = L"steam://rungameid/1123040";
|
||||||
|
|
||||||
|
unsigned long find_pid(const wchar_t* image)
|
||||||
|
{
|
||||||
|
unsigned long pid = 0;
|
||||||
|
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||||
|
if (snap == INVALID_HANDLE_VALUE)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
PROCESSENTRY32W pe{};
|
||||||
|
pe.dwSize = sizeof(pe);
|
||||||
|
for (BOOL ok = Process32FirstW(snap, &pe); ok; ok = Process32NextW(snap, &pe))
|
||||||
|
{
|
||||||
|
if (_wcsicmp(pe.szExeFile, image) == 0)
|
||||||
|
{
|
||||||
|
pid = pe.th32ProcessID;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CloseHandle(snap);
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void kill_pid(unsigned long pid)
|
||||||
|
{
|
||||||
|
if (HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pid))
|
||||||
|
{
|
||||||
|
TerminateProcess(h, 0);
|
||||||
|
CloseHandle(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register / unregister the implicit Vulkan layer, scoped to `image_basename`. Mirrors
|
||||||
|
// host vk_layer_setup but resolves the manifest from the deployable root (one dir up from tools/).
|
||||||
|
void layer_register(const std::wstring& image_basename)
|
||||||
|
{
|
||||||
|
wchar_t tmp[MAX_PATH] = {};
|
||||||
|
if (GetTempPathW(MAX_PATH, tmp) != 0)
|
||||||
|
{
|
||||||
|
const std::wstring sf = std::wstring(tmp) + L"coop_vk_target.txt";
|
||||||
|
char utf8[260] = {};
|
||||||
|
const int n = WideCharToMultiByte(CP_UTF8, 0, image_basename.c_str(), -1, utf8, sizeof(utf8), nullptr,
|
||||||
|
nullptr);
|
||||||
|
HANDLE f = CreateFileW(sf.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, 0, nullptr);
|
||||||
|
if (f != INVALID_HANDLE_VALUE)
|
||||||
|
{
|
||||||
|
DWORD wr = 0;
|
||||||
|
WriteFile(f, utf8, n > 0 ? static_cast<DWORD>(n - 1) : 0, &wr, nullptr); // drop the NUL
|
||||||
|
CloseHandle(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HKEY key = nullptr;
|
||||||
|
if (RegCreateKeyExW(HKEY_CURRENT_USER, L"SOFTWARE\\Khronos\\Vulkan\\ImplicitLayers", 0, nullptr, 0,
|
||||||
|
KEY_SET_VALUE, nullptr, &key, nullptr) == ERROR_SUCCESS)
|
||||||
|
{
|
||||||
|
const std::wstring mp = deployed_artifact_path(L"coop_vk_layer.json");
|
||||||
|
DWORD enabled = 0;
|
||||||
|
RegSetValueExW(key, mp.c_str(), 0, REG_DWORD, reinterpret_cast<const BYTE*>(&enabled), sizeof(enabled));
|
||||||
|
RegCloseKey(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void layer_unregister()
|
||||||
|
{
|
||||||
|
HKEY key = nullptr;
|
||||||
|
if (RegOpenKeyExW(HKEY_CURRENT_USER, L"SOFTWARE\\Khronos\\Vulkan\\ImplicitLayers", 0, KEY_SET_VALUE, &key) ==
|
||||||
|
ERROR_SUCCESS)
|
||||||
|
{
|
||||||
|
RegDeleteValueW(key, deployed_artifact_path(L"coop_vk_layer.json").c_str());
|
||||||
|
RegCloseKey(key);
|
||||||
|
}
|
||||||
|
wchar_t tmp[MAX_PATH] = {};
|
||||||
|
if (GetTempPathW(MAX_PATH, tmp) != 0)
|
||||||
|
{
|
||||||
|
DeleteFileW((std::wstring(tmp) + L"coop_vk_target.txt").c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool inject(unsigned long pid)
|
||||||
|
{
|
||||||
|
const std::wstring dll = deployed_artifact_path(L"coop_hook.dll");
|
||||||
|
HANDLE process = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION |
|
||||||
|
PROCESS_VM_WRITE | PROCESS_VM_READ,
|
||||||
|
FALSE, pid);
|
||||||
|
if (process == nullptr)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const SIZE_T bytes = (dll.size() + 1) * sizeof(wchar_t);
|
||||||
|
void* remote = VirtualAllocEx(process, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
|
||||||
|
bool ok = false;
|
||||||
|
if (remote != nullptr && WriteProcessMemory(process, remote, dll.c_str(), bytes, nullptr))
|
||||||
|
{
|
||||||
|
auto load = reinterpret_cast<LPTHREAD_START_ROUTINE>(
|
||||||
|
GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW"));
|
||||||
|
if (HANDLE th = CreateRemoteThread(process, nullptr, 0, load, remote, 0, nullptr))
|
||||||
|
{
|
||||||
|
WaitForSingleObject(th, INFINITE);
|
||||||
|
DWORD code = 0;
|
||||||
|
GetExitCodeThread(th, &code);
|
||||||
|
CloseHandle(th);
|
||||||
|
ok = code != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (remote != nullptr)
|
||||||
|
{
|
||||||
|
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
|
||||||
|
}
|
||||||
|
CloseHandle(process);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
SharedBlock* make_ipc(SharedMemory& shm, unsigned long pid)
|
||||||
|
{
|
||||||
|
if (!shm.create(shared_memory_name(pid), sizeof(SharedBlock)))
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
auto* b = shm.as<SharedBlock>();
|
||||||
|
b->version = kProtocolVersion;
|
||||||
|
b->pad_count = 0;
|
||||||
|
b->sequence.store(0, std::memory_order_relaxed);
|
||||||
|
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
|
||||||
|
{
|
||||||
|
b->control.subsystem_disabled[s].store(0, std::memory_order_release); // all on (video included)
|
||||||
|
}
|
||||||
|
b->magic = kProtocolMagic;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
ID3D11Device* make_device()
|
||||||
|
{
|
||||||
|
ID3D11Device* dev = nullptr;
|
||||||
|
const D3D_FEATURE_LEVEL fl[] = {D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0};
|
||||||
|
if (FAILED(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, fl,
|
||||||
|
static_cast<UINT>(std::size(fl)), D3D11_SDK_VERSION, &dev, nullptr, nullptr)))
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return dev;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool write_bmp(const std::wstring& path, const std::vector<std::uint8_t>& rgba, std::uint32_t w, std::uint32_t h)
|
||||||
|
{
|
||||||
|
const std::uint32_t row = (w * 3 + 3) & ~3u;
|
||||||
|
const std::uint32_t imgsize = row * h;
|
||||||
|
BITMAPFILEHEADER fh{};
|
||||||
|
BITMAPINFOHEADER ih{};
|
||||||
|
fh.bfType = 0x4D42;
|
||||||
|
fh.bfOffBits = sizeof(fh) + sizeof(ih);
|
||||||
|
fh.bfSize = fh.bfOffBits + imgsize;
|
||||||
|
ih.biSize = sizeof(ih);
|
||||||
|
ih.biWidth = static_cast<LONG>(w);
|
||||||
|
ih.biHeight = static_cast<LONG>(h);
|
||||||
|
ih.biPlanes = 1;
|
||||||
|
ih.biBitCount = 24;
|
||||||
|
ih.biCompression = BI_RGB;
|
||||||
|
ih.biSizeImage = imgsize;
|
||||||
|
HANDLE f = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, 0, nullptr);
|
||||||
|
if (f == INVALID_HANDLE_VALUE)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
DWORD wr = 0;
|
||||||
|
WriteFile(f, &fh, sizeof(fh), &wr, nullptr);
|
||||||
|
WriteFile(f, &ih, sizeof(ih), &wr, nullptr);
|
||||||
|
std::vector<std::uint8_t> line(row, 0);
|
||||||
|
for (int y = static_cast<int>(h) - 1; y >= 0; --y) // BMP is bottom-up
|
||||||
|
{
|
||||||
|
for (std::uint32_t x = 0; x < w; ++x)
|
||||||
|
{
|
||||||
|
const std::uint8_t* p = &rgba[(static_cast<std::size_t>(y) * w + x) * 4];
|
||||||
|
line[x * 3 + 0] = p[2]; // B
|
||||||
|
line[x * 3 + 1] = p[1]; // G
|
||||||
|
line[x * 3 + 2] = p[0]; // R
|
||||||
|
}
|
||||||
|
WriteFile(f, line.data(), row, &wr, nullptr);
|
||||||
|
}
|
||||||
|
CloseHandle(f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoShareView read_share(const SharedBlock* b)
|
||||||
|
{
|
||||||
|
VideoShareView v;
|
||||||
|
v.generation = b->video.generation.load(std::memory_order_acquire);
|
||||||
|
v.width = b->video.width;
|
||||||
|
v.height = b->video.height;
|
||||||
|
v.format = b->video.format;
|
||||||
|
v.present_calls = b->video.present_calls;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char** argv)
|
||||||
|
{
|
||||||
|
const std::string mode = argc > 1 ? argv[1] : "layer";
|
||||||
|
const int seconds = argc > 2 ? std::atoi(argv[2]) : 12;
|
||||||
|
std::wstring exe = kDefaultExe;
|
||||||
|
if (argc > 3)
|
||||||
|
{
|
||||||
|
const std::string a = argv[3];
|
||||||
|
exe.assign(a.begin(), a.end());
|
||||||
|
}
|
||||||
|
const bool layer_mode = mode != "inject";
|
||||||
|
std::printf("== Vulkan backend validation: method=%s game=%ls ==\n", layer_mode ? "layer" : "inject",
|
||||||
|
exe.c_str());
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
auto check = [&](bool ok, const char* what) {
|
||||||
|
std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
++failures;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean slate.
|
||||||
|
if (unsigned long old = find_pid(L"sphere.exe"))
|
||||||
|
{
|
||||||
|
kill_pid(old);
|
||||||
|
Sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
PROCESS_INFORMATION pi{};
|
||||||
|
unsigned long pid = 0;
|
||||||
|
if (layer_mode)
|
||||||
|
{
|
||||||
|
layer_register(L"sphere.exe");
|
||||||
|
ShellExecuteW(nullptr, L"open", kSteamUrl, nullptr, nullptr, SW_SHOWNORMAL);
|
||||||
|
std::printf(" launched via Steam; waiting for sphere.exe...\n");
|
||||||
|
for (int i = 0; i < 40 && pid == 0; ++i)
|
||||||
|
{
|
||||||
|
Sleep(500);
|
||||||
|
pid = find_pid(L"sphere.exe");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
STARTUPINFOW si{};
|
||||||
|
si.cb = sizeof(si);
|
||||||
|
std::wstring cmd = exe;
|
||||||
|
if (!CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, CREATE_SUSPENDED, nullptr,
|
||||||
|
nullptr, &si, &pi))
|
||||||
|
{
|
||||||
|
check(false, "suspended-launch the game exe directly");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
pid = pi.dwProcessId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pid == 0)
|
||||||
|
{
|
||||||
|
check(false, "game process appeared");
|
||||||
|
layer_unregister();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
std::printf(" game pid=%lu\n", pid);
|
||||||
|
|
||||||
|
// Create the IPC block right away so the layer/hook can connect + publish present counts.
|
||||||
|
SharedMemory shm;
|
||||||
|
SharedBlock* block = make_ipc(shm, pid);
|
||||||
|
if (block == nullptr)
|
||||||
|
{
|
||||||
|
check(false, "create IPC block");
|
||||||
|
kill_pid(pid);
|
||||||
|
layer_unregister();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layer_mode)
|
||||||
|
{
|
||||||
|
const bool injected = inject(pid);
|
||||||
|
ResumeThread(pi.hThread);
|
||||||
|
check(injected, "inject coop_hook.dll early (pre-vkCreateInstance)");
|
||||||
|
CloseHandle(pi.hThread);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// The layer does video; also inject coop_hook.dll (late) so its FOCUS subsystem keeps the
|
||||||
|
// game rendering at full rate while unfocused -- the realistic co-injected setup, and
|
||||||
|
// required for a meaningful present-rate measurement (an unfocused game throttles itself,
|
||||||
|
// which is exactly why the hook spoofs focus). Its Vulkan video hook is inert (too late);
|
||||||
|
// the layer is the video producer.
|
||||||
|
Sleep(2000); // let the game create its window first
|
||||||
|
const bool injected = inject(pid);
|
||||||
|
std::printf(" focus/input hook injected (late, for focus spoof): %d\n", injected ? 1 : 0);
|
||||||
|
Sleep(1500); // let the hook attach + spoof focus before we measure
|
||||||
|
}
|
||||||
|
|
||||||
|
ID3D11Device* device = make_device();
|
||||||
|
if (device == nullptr)
|
||||||
|
{
|
||||||
|
check(false, "create a D3D11 device to read the shared texture");
|
||||||
|
kill_pid(pid);
|
||||||
|
layer_unregister();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
SharedTextureSource src;
|
||||||
|
src.init(device);
|
||||||
|
|
||||||
|
// Capture loop: wait for frames, track advance + non-black, grab a screenshot.
|
||||||
|
std::uint32_t first_gen = 0, last_gen = 0, cap_w = 0, cap_h = 0;
|
||||||
|
std::uint64_t nonblack_frames = 0;
|
||||||
|
std::vector<std::uint8_t> shot;
|
||||||
|
std::uint32_t shot_w = 0, shot_h = 0;
|
||||||
|
const DWORD end = GetTickCount() + static_cast<DWORD>(seconds) * 1000;
|
||||||
|
bool alive = true;
|
||||||
|
while (GetTickCount() < end && (alive = find_pid(L"sphere.exe") == pid))
|
||||||
|
{
|
||||||
|
Sleep(50);
|
||||||
|
const VideoShareView sv = read_share(block);
|
||||||
|
if (!src.update(sv, pid))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cap_w = src.width();
|
||||||
|
cap_h = src.height();
|
||||||
|
if (first_gen == 0)
|
||||||
|
{
|
||||||
|
first_gen = sv.generation;
|
||||||
|
}
|
||||||
|
last_gen = sv.generation;
|
||||||
|
// Sample a few pixels for non-black; grab a full screenshot mid-run.
|
||||||
|
std::uint8_t px[4] = {};
|
||||||
|
if (src.read_pixel(cap_w / 2, cap_h / 2, px) && (px[0] | px[1] | px[2]) != 0)
|
||||||
|
{
|
||||||
|
++nonblack_frames;
|
||||||
|
}
|
||||||
|
if (shot.empty() && src.frames_copied() > 10)
|
||||||
|
{
|
||||||
|
src.read_frame(shot, shot_w, shot_h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::uint64_t presents = block->video.present_calls;
|
||||||
|
std::printf(" captured %ux%u copied=%llu present_calls=%llu gen %u..%u nonblack=%llu\n", cap_w, cap_h,
|
||||||
|
static_cast<unsigned long long>(src.frames_copied()),
|
||||||
|
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).
|
||||||
|
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");
|
||||||
|
kill_pid(pid);
|
||||||
|
device->Release();
|
||||||
|
std::printf("SKIP vk_validate (inject not applicable to this title)\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
check(alive, "game stayed alive through capture");
|
||||||
|
check(presents > 0, "hook/layer saw the game's Vulkan presents");
|
||||||
|
check(src.frames_copied() >= 10, "host copied many shared frames (capture works)");
|
||||||
|
check(last_gen > first_gen + 5, "captured frames advance (live mirror, not a stuck frame)");
|
||||||
|
check(cap_w >= 320 && cap_h >= 240, "captured a sensible resolution");
|
||||||
|
check(nonblack_frames >= 5, "captured frames are non-black (real image content)");
|
||||||
|
|
||||||
|
// Screenshot for visual confirmation of correctness (colors / brightness / no swizzle).
|
||||||
|
if (!shot.empty())
|
||||||
|
{
|
||||||
|
const std::wstring out = exe_directory() + (layer_mode ? L"vk_validate_layer.bmp" : L"vk_validate_inject.bmp");
|
||||||
|
if (write_bmp(out, shot, shot_w, shot_h))
|
||||||
|
{
|
||||||
|
std::printf(" screenshot: %ls (%ux%u)\n", out.c_str(), shot_w, shot_h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
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).
|
||||||
|
if (alive && find_pid(L"sphere.exe") == pid)
|
||||||
|
{
|
||||||
|
const std::uint64_t p0 = block->video.present_calls;
|
||||||
|
Sleep(3000);
|
||||||
|
const std::uint64_t p1 = block->video.present_calls;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
kill_pid(pid);
|
||||||
|
device->Release();
|
||||||
|
if (layer_mode)
|
||||||
|
{
|
||||||
|
layer_unregister();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf(failures == 0 ? "PASS vk_validate (%s)\n" : "FAILED vk_validate (%s, %d)\n",
|
||||||
|
layer_mode ? "layer" : "inject", failures);
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user