Docs/memory: Vulkan opt-in layer done; remove the completed Current-work roadmap

All of M1 (UI-fit + live inspection) and M2 (every backend mock + capture, incl.
the Vulkan implicit layer + checkbox) are done, so the "Current work" roadmap
section is removed -- only Future work (Raw Input / DirectInput MKB) remains.
Architecture lists the Vulkan layer as the implemented early-presence path;
lessons-learned add the chain-dispatch + loader sType 47/48 gotcha; build/test
docs note the layer artifact + coverage; submodule list updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 13:34:22 +02:00
parent 23a6408e52
commit 04dcd0f41e

103
README.md
View File

@@ -49,8 +49,9 @@ intercept the resolution chain (`vkCreateInstance` / `vkCreateDevice` /
`vkCreateSwapchainKHR` / `vkQueuePresentKHR`) and reads the presented image back with `vkCreateSwapchainKHR` / `vkQueuePresentKHR`) and reads the presented image back with
`vkCmdCopyImageToBuffer` — but only when the hook is present **before** the game initializes `vkCmdCopyImageToBuffer` — but only when the hook is present **before** the game initializes
Vulkan (it caches its present pointer at init), so the Vulkan path needs early presence via Vulkan (it caches its present pointer at init), so the Vulkan path needs early presence via
Auto-attach (and an opt-in implicit layer — see Roadmap); a too-late attach shows a red Auto-attach or the opt-in implicit **capture layer** (`coop_vk_layer`, registered per-user by the
relaunch banner and falls back to WGC. The host samples the copy as plain UNORM Injection panel's "Set up Vulkan layer" checkbox and scoped to the target game); a too-late attach
shows a red relaunch banner and falls back to WGC. The host samples the copy as plain UNORM
(`srgb_to_unorm`) so `*_SRGB`-backbuffer games mirror at correct brightness. **WGC remains the (`srgb_to_unorm`) so `*_SRGB`-backbuffer games mirror at correct brightness. **WGC remains the
default** and covers anything the hooked path doesn't. default** and covers anything the hooked path doesn't.
@@ -107,77 +108,6 @@ default** and covers anything the hooked path doesn't.
## Roadmap ## Roadmap
### Current work — per-API capture, end to end
Each rendering API is built as **one milestone**: first its `coop_mock_game` backend (an
animated, frame-numbered A/V source), then the injected **capture** for that same API
directly after — so every API reaches *verified end-to-end* (the mirror decodes the frame
counter and asserts a *monotonic, advancing* sequence) before the next one starts, rather
than building all the mock backends first and all the capture later. Order is
easiest-to-hardest, dependencies last.
Conventions for every milestone below:
- Each mock backend implements the existing
[`RenderBackend`](tools/mock_game/render_backend.hpp) interface (animated background +
moving bar + frame-counter block), is selectable on the command line, and renders
**clear/fill-only** — no geometry, no shaders, so **no shader-compiler dependency**.
- Sub-steps are **separate commits**; the README, CMake, and (where needed) `.gitmodules`
move with each. A mock-backend commit lands with a *liveness* smoke check (launches,
presents N frames, exits clean); the frame-accurate decode-through-the-hook assertion
lands with that API's capture commit right after.
- The host samples the **standard shared keyed-mutex texture** unchanged regardless of
source API: every backend publishes into it — native D3D11 on the game's device, D3D12 via
D3D11On12, and D3D9 / D3D10 / OpenGL / Vulkan via a **hook-owned D3D11 device**.
- New submodules are **not** auto-cloned — CMake checks each is populated and stops with a
`FATAL_ERROR` naming `git submodule update --init --recursive` (a reusable
`coop_require_submodule()` helper).
- **M1 — End-user UI fit pass.** Verify the shipping ImGui overlay (the *end-user* view, not
just the dev layout) with the debug-driving harness + F10 screenshot, and guarantee **every
panel fits its assigned window size even with Debug details enabled** — the
maximum-information case. Drive each panel to its fullest state via the harness (inject,
enable audio + video, expand Debug details, show the override controls, multiple audio
streams, the longest status / fallback strings), screenshot, and check nothing is clipped
or scrolled out of view. Where the densest case overflows, **resize and/or rearrange** the
panel so it fits (content can be moved between columns / rows — the most detailed case
should still fit). Land an automated harness check (drive-to-max → screenshot → assert no
overflow) so later milestones that add UI keep it green. *Independent of the backend work;
every milestone below must preserve this test* (M2's red banner and Vulkan-layer checkbox
in particular).
- **Mandatory final visual inspection.** The headless `ui_fit_test` proves content *fits*
its window, but not that it *looks* right. Before this milestone is considered done, run
the **actual `coop_host.exe`** (build with `-DCOOP_TEST_HARNESS=ON`), drive it to its
maximum-information state (inject a target, audio on, Debug details on), take an **F10
screenshot of the running tool**, and **eyeball the real overlay** — every panel readable,
nothing clipped/overlapping, colours/labels correct. A green unit test is not a substitute
for looking at the product; this real-screenshot check is required, not optional.
- **M2 — Vulkan: opt-in reliability layer (the one piece left).** The Vulkan **mock backend and
hooked capture are done and tested**: the hook inline-hooks the `vulkan-1.dll`
`vkGetInstanceProcAddr` export and intercepts the resolution chain (`vkCreateInstance` /
`vkCreateDevice` / `vkCreateSwapchainKHR` / `vkQueuePresentKHR`), reads the presented image back
with `vkCmdCopyImageToBuffer`, and re-chains the present's wait semaphores so the copy orders
after rendering. `mock_game_test` decodes Vulkan frames through it (via a suspended-launch +
early-load path) and verifies the **too-late detection**; the host shows the **red relaunch
banner** (verified live). **Best-effort auto-attach** already works (the existing poll-and-inject
re-injects on relaunch, the vk hook retries until `vulkan-1.dll` loads), enough for games that
don't initialize Vulkan instantly. Remaining:
- **Opt-in implicit Vulkan layer** for games that init Vulkan *immediately* (where even a
relaunch+auto-attach injects too late). A real chain-aware Vulkan layer (`coop_vk_layer.dll`
+ JSON manifest) the loader loads at `vkCreateInstance` — guaranteed before init — registered
per-user (HKCU, no admin) and scoped to the target image, with an **opt-in** "Set up Vulkan
layer" Injection-panel checkbox that registers/unregisters it (self-deactivating for non-target
apps so a stale entry after a host crash is harmless). This is a separate component from the
inline-hook capture (layer-chain dispatch + manifest/registry + the checkbox + IPC handshake),
and it needs the loader/layer interface header `vk_layer.h` — which is **not** in
`Vulkan-Headers` (it lives in `Vulkan-Loader` / the SDK), so it requires either a new submodule
or hand-declared `VkLayer*CreateInfo` link structs. Until it lands, the banner directs
immediate-init Vulkan games to WGC (which mirrors them fine, just at higher latency).
Each of 1–3 is its own commit; test the early/layer path against the mock (decode frames) and
the too-late path (assert the status + banner fire).
### Future work ### Future work
- **Mouse + keyboard forwarding for Raw Input / DirectInput games.** The MKB - **Mouse + keyboard forwarding for Raw Input / DirectInput games.** The MKB
@@ -199,7 +129,7 @@ git submodule update --init --recursive
cmake -S . -B build -G "Visual Studio 17 2022" -A x64 cmake -S . -B build -G "Visual Studio 17 2022" -A x64
cmake --build build --config Debug cmake --build build --config Debug
# output: bin/Debug/coop_host.exe (+ coop_hook.dll, test exes) # output: bin/Debug/coop_host.exe (+ coop_hook.dll, coop_vk_layer.dll + manifest, test exes)
``` ```
The x64 build also drives a nested Win32 sub-build (CMake `ExternalProject`, The x64 build also drives a nested Win32 sub-build (CMake `ExternalProject`,
@@ -207,8 +137,10 @@ configured into `build/x86/`) that produces `coop_hook_x86.dll` and
`coop_inject_x86.exe` for 32-bit games, staged next to the x64 binaries. Disable `coop_inject_x86.exe` for 32-bit games, staged next to the x64 binaries. Disable
it with `-DCOOP_BUILD_X86_HELPER=OFF` if you don't need 32-bit support. it with `-DCOOP_BUILD_X86_HELPER=OFF` if you don't need 32-bit support.
Third-party dependencies (Dear ImGui, SafetyHook) are git submodules under Third-party dependencies (Dear ImGui, SafetyHook, the official Khronos Vulkan-Headers, and the
`third_party/`. No vcpkg / package manager is used. `zeux/volk` Vulkan meta-loader) are git submodules under `third_party/`. No vcpkg / package
manager is used; CMake fails with a clear `git submodule update --init --recursive` hint if one
is missing (`coop_require_submodule`).
**Steam Input is optional.** It's enabled automatically when the Steamworks SDK is **Steam Input is optional.** It's enabled automatically when the Steamworks SDK is
vendored at `third_party/steamworks_sdk/` (extract the `steamworks_sdk_*.zip` vendored at `third_party/steamworks_sdk/` (extract the `steamworks_sdk_*.zip`
@@ -294,8 +226,10 @@ ctest --test-dir build -C Debug --output-on-failure
backend (the bar for no dropped / stale / out-of-order frames — what the DX12 rotating-backbuffer backend (the bar for no dropped / stale / out-of-order frames — what the DX12 rotating-backbuffer
bug broke). **Vulkan** is special: since its present pointer is cached at init, the test launches bug broke). **Vulkan** is special: since its present pointer is cached at init, the test launches
the mock **suspended**, injects, then resumes (the mock loads Vulkan and waits so the hook arms the mock **suspended**, injects, then resumes (the mock loads Vulkan and waits so the hook arms
first) to decode frames; it also late-injects a Vulkan game and asserts the **too-late** flag first) to decode frames; it also exercises the **implicit capture layer** (registered via
trips (which drives the host's relaunch banner). It launches the game at several **audio formats** `VK_LAYER_PATH` / `VK_INSTANCE_LAYERS`, decoding frames through the loader-inserted layer) and
late-injects a Vulkan game to assert the **too-late** flag trips (which drives the host's
relaunch banner). It launches the game at several **audio formats**
(44100/48000/96000, PCM + float) and asserts the hook measures each one's rate through the full (44100/48000/96000, PCM + float) and asserts the hook measures each one's rate through the full
inject path, then injects with audio + video, checks both stream, cycles the audio subsystem inject path, then injects with audio + video, checks both stream, cycles the audio subsystem
off/on (hook/unhook stress), and confirms the game never crashes and capture resumes. This suite off/on (hook/unhook stress), and confirms the game never crashes and capture resumes. This suite
@@ -472,6 +406,19 @@ Non-obvious things that cost time and constrain the design:
everything up before us) → red relaunch banner, WGC meanwhile. Testing needs an **early-load** everything up before us) → red relaunch banner, WGC meanwhile. Testing needs an **early-load**
path (suspended launch + inject + resume; the mock loads Vulkan and waits), since the normal path (suspended launch + inject + resume; the mock loads Vulkan and waits), since the normal
late-inject flow can't catch a Vulkan present. late-inject flow can't catch a Vulkan present.
- **The reliable Vulkan path is a real implicit layer — and the loader tags its link structs with
small `sType`s.** Games that init Vulkan instantly resolve their present pointer before any
inject can land, so the only robust early presence is an implicit layer (`coop_vk_layer`) the
loader inserts at `vkCreateInstance`. It must do **chain dispatch** (pull
`pfnNextGetInstanceProcAddr` / `pfnNextGetDeviceProcAddr` out of the `VkLayer*CreateInfo` link in
`pNext`, advance the link, call down) rather than inline-hook. The trap that cost time: those
link structs use **loader-internal `sType` values 47 (instance) / 48 (device)** — *not* the
`1000000000`-range I assumed (and `vk_layer.h` isn't in Vulkan-Headers, so the structs are
hand-declared) — matching the wrong value silently failed the device-chain walk
(`vkCreateDevice: Failed to create device chain`). An implicit layer loads into *every* Vulkan
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.
- **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