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
`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
Auto-attach (and an opt-in implicit layer — see Roadmap); a too-late attach shows a red
relaunch banner and falls back to WGC. The host samples the copy as plain UNORM
Auto-attach or the opt-in implicit **capture layer** (`coop_vk_layer`, registered per-user by the
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
default** and covers anything the hooked path doesn't.
@@ -107,77 +108,6 @@ default** and covers anything the hooked path doesn't.
## 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
- **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 --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`,
@@ -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
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/`. No vcpkg / package manager is used.
Third-party dependencies (Dear ImGui, SafetyHook, the official Khronos Vulkan-Headers, and the
`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
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
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
first) to decode frames; it also late-injects a Vulkan game and asserts the **too-late** flag
trips (which drives the host's relaunch banner). It launches the game at several **audio formats**
first) to decode frames; it also exercises the **implicit capture layer** (registered via
`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
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
@@ -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**
path (suspended launch + inject + resume; the mock loads Vulkan and waits), since the normal
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.**
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