M2(DX9): D3D9 capture via Present read-back (covers plain D3D9 + D3D9Ex)
New d3d9_hook.cpp (own file like opengl_hook): inline-hooks IDirect3DDevice9::Present (vtable index 17, discovered from a throwaway device; clean prologue so inline is safe, stdcall() on x86) and read-backs the backbuffer with GetRenderTargetData into a D3DPOOL_SYSTEMMEM surface, swizzles BGRA->RGBA, and uploads it into the shared keyed-mutex texture on a hook-owned D3D11 device (a D3D9 surface isn't D3D11-shareable). Both plain D3D9 and D3D9Ex call this same Present, so one read-back path serves both -- the GPU shared-surface fast path the plan sketched for D3D9Ex wasn't worth it. Wired into the video subsystem (install/remove in dllmain); hook links d3d9. mock_game_test decodes DX9 + DX9Ex frames through the hook; 15/15 ctest (incl. the x86 sub-build). Roadmap: DX9 milestone done and removed (remaining renumbered); architecture + lessons updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
51
README.md
51
README.md
@@ -32,18 +32,21 @@ and forwards guest controllers back into it.
|
||||
| Mirror audio | Injected render-hook copies each of the game's WASAPI render streams into its own shared ring and silences the game locally (no echo); the host mixes the streams (soft-clipped); WASAPI process loopback is the automatic fallback | `coop_hook.dll` + `coop_host.exe` |
|
||||
| Host ↔ hook IPC | Named shared memory (seqlock for input, status back-channel, video/audio/log shares) | `common/` |
|
||||
|
||||
The hooked video path has two producers: **Direct3D (DXGI)** hooks
|
||||
The hooked video path has three producers: **Direct3D (DXGI)** hooks
|
||||
`IDXGISwapChain::Present` / `Present1` and copies the backbuffer — directly for
|
||||
D3D11 games (the backbuffer is an `ID3D11Texture2D`), via a **D3D11On12
|
||||
bridge** for D3D12 games (wrap the `ID3D12Resource` backbuffer, `CopyResource` into
|
||||
the shared texture), and via a **D3D10 read-back** for D3D10 games (their backbuffer's
|
||||
D3D11 view is empty and a feature-level-10 device can't host the shared texture, so
|
||||
read it through the game's own D3D10 device and upload it via a hook-owned D3D11
|
||||
device); **OpenGL** hooks `SwapBuffers` / `wglSwapBuffers` and reads the
|
||||
device); **Direct3D 9** inline-hooks `IDirect3DDevice9::Present` (vtable index 17) and
|
||||
read-backs the backbuffer with `GetRenderTargetData` (a D3D9 surface isn't D3D11-shareable),
|
||||
swizzling BGRA→RGBA and uploading via a hook-owned D3D11 device — one path covers both plain
|
||||
D3D9 and D3D9Ex; **OpenGL** hooks `SwapBuffers` / `wglSwapBuffers` and reads the
|
||||
backbuffer with `glReadPixels` (for games that never touch DXGI, e.g. Phantom
|
||||
Brave). 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 (Vulkan, D3D9 — see Roadmap).
|
||||
and covers anything the hooked path doesn't (Vulkan — see Roadmap).
|
||||
|
||||
## Limitations
|
||||
|
||||
@@ -134,34 +137,10 @@ Conventions for every milestone below:
|
||||
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* (M4's red banner and Vulkan-layer checkbox
|
||||
every milestone below must preserve this test* (M3's red banner and Vulkan-layer checkbox
|
||||
in particular).
|
||||
|
||||
- **M2 — DX9 (mock → D3D9Ex capture → plain-D3D9 capture).**
|
||||
1. **Mock backend** (`render_dx09.cpp`). `IDirect3DDevice9` / `IDirect3DDevice9Ex` + present;
|
||||
Windows SDK only (`d3d9`), **no new deps**. `Clear` for the background and `ColorFill` for
|
||||
the bar + block (D3D9's built-in rect fill — exactly the primitive DX10/11 lack). Top-left
|
||||
origin. Runs in **two selectable modes** — `dx9ex` (default) and plain `dx9`
|
||||
(`Direct3DCreate9`) — so both capture paths have a matching game.
|
||||
2. **D3D9Ex capture (clean GPU path).** Hook `IDirect3DDevice9::Present` by vtable — discover
|
||||
the slot from a throwaway `Direct3DCreate9Ex` device (`stdcall()` on x86 per the SafetyHook
|
||||
trap); the vtable is shared across all devices of the class, so the game's existing device
|
||||
is caught → **late-attach works**. On Present, `GetBackBuffer(0)` + `StretchRect` (GPU-side)
|
||||
into a `CreateRenderTarget(..., pSharedHandle=&h)` surface (`D3DPOOL_DEFAULT`); share that
|
||||
into the hook's own D3D11 device and copy into the standard keyed-mutex texture (host
|
||||
unchanged, proper keyed-mutex sync). `A8R8G8B8` is **BGRA** → record the swizzle. Re-create
|
||||
the surface on `Reset` / resize. `d3d9_present_hook_test` decodes the mock's frames (Ex mode).
|
||||
3. **Plain (non-Ex) D3D9 capture (slow path).** Plain `Direct3DCreate9` devices **cannot**
|
||||
produce a D3D11-shareable surface (WGC remains their default mirror until this lands).
|
||||
Detect via a failed `QueryInterface(IID_IDirect3DDevice9Ex)` (else take the D3D9Ex path). On
|
||||
Present, `GetRenderTargetData` the backbuffer into a
|
||||
`CreateOffscreenPlainSurface(..., D3DPOOL_SYSTEMMEM)` surface, `LockRect`, copy out
|
||||
(respect `Pitch`; BGRA→RGBA), and upload into the standard keyed-mutex texture via the
|
||||
hook's own D3D11 device (`Map` / `UpdateSubresource`) — so only *how pixels reach the
|
||||
texture* differs (CPU copy, not GPU). `GetRenderTargetData` is a GPU→sysmem stall, so
|
||||
**drop / throttle** mirror frames rather than back-pressure the game. Same test, plain mode.
|
||||
|
||||
- **M3 — OpenGL (mock → capture coverage).**
|
||||
- **M2 — OpenGL (mock → capture coverage).**
|
||||
1. **Mock backend** (`render_gl.cpp`). Raw WGL context (`wglCreateContextAttribsARB`) with the
|
||||
**glad** loader (new submodule, `Dav1dde/glad`). Background via `glClearColor`/`glClear`;
|
||||
bar + block via `glScissor` + clear (shader-free GL 1.x). GL's framebuffer is
|
||||
@@ -172,7 +151,7 @@ Conventions for every milestone below:
|
||||
ships; add a mock-backed regression that decodes the mock's frames through it (upgrading the
|
||||
synthetic `opengl_hook_test` to a real animated game). Small.
|
||||
|
||||
- **M4 — Vulkan (mock → capture).** The largest.
|
||||
- **M3 — Vulkan (mock → capture).** The largest.
|
||||
1. **Mock backend** (`render_vk.cpp`). New submodules **Vulkan-Headers**
|
||||
(`KhronosGroup/Vulkan-Headers`, official) + **volk** (`zeux/volk`); raw
|
||||
`vkCreateWin32SurfaceKHR`, swap chain, per-frame acquire → clear → present. Background via
|
||||
@@ -317,7 +296,7 @@ ctest --test-dir build -C Debug --output-on-failure
|
||||
endpoint format). Skips cleanly if the machine has no audio endpoint.
|
||||
- **`mock_game_test`** — comprehensive capture/audio/hook stress test against
|
||||
**`coop_mock_game`** (an animated, frame-numbered A/V test game under
|
||||
[`tools/mock_game`](tools/mock_game) with selectable **DX10 / DX11 / DX12** backends and a
|
||||
[`tools/mock_game`](tools/mock_game) with selectable **DX9 / DX9Ex / DX10 / DX11 / DX12** backends and a
|
||||
configurable WASAPI tone). It launches the game, injects `coop_hook.dll`, opens the
|
||||
hook's shared video texture, and **decodes the frame number out of the captured pixels**
|
||||
to assert the mirror sees a *monotonic, advancing* sequence for each backend (the bar
|
||||
@@ -468,6 +447,16 @@ Non-obvious things that cost time and constrain the design:
|
||||
`UpdateSubresource`-ing it into the shared texture on a **hook-owned D3D11 device** (the game
|
||||
has no usable D3D11 device of its own). The staging `Map` blocks until the GPU copy completes,
|
||||
so there's no cross-device race.
|
||||
- **D3D9 capture: one read-back path covers plain D3D9 *and* D3D9Ex.** D3D9 has no DXGI
|
||||
swapchain, so inline-hook `IDirect3DDevice9::Present` (vtable index **17**, found from a
|
||||
throwaway device — clean prologue, so inline is safe; `stdcall()` on x86). A D3D9 surface
|
||||
isn't D3D11-shareable, so read the backbuffer back with `GetRenderTargetData` into a
|
||||
`D3DPOOL_SYSTEMMEM` surface (`LockRect` blocks until the copy → no race), **swizzle BGRA→RGBA**
|
||||
(`X8R8G8B8`/`A8R8G8B8` store as little-endian `0xAARRGGBB` → bytes B,G,R,A) and upload into the
|
||||
shared texture on a hook-owned D3D11 device. Both plain D3D9 and D3D9Ex call this same Present,
|
||||
so **one path serves both** — the GPU shared-surface fast path the plan sketched for D3D9Ex
|
||||
wasn't worth it (D3D9 games are light enough that the read-back cost is fine, and it dodges the
|
||||
cross-device keyed-mutex-less sync of a legacy shared surface).
|
||||
- **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
|
||||
|
||||
Reference in New Issue
Block a user