Restructure bin/: deployables at root, tests and tools in subfolders

Only the shipping artifacts (coop_host.exe, coop_hook.dll, coop_hook_x86.dll,
coop_inject_x86.exe, steam_api64.dll, steam_input_actions.vdf) now land in the
bin/<config>/ root, so it can be copied wholesale into a donor game folder. Test
exes (plus the coop_tone fixture) build into bin/<config>/tests/ and the dev probes
into bin/<config>/tools/, via a new coop_output_subdir() CMake helper.

The probes resolve coop_hook.dll / the x86 injector from the deployable root one
level up (new common/coop/tool_paths.hpp: deployed_artifact_path checks next-to-exe
then parent). coop_tone is co-located with the tests so audio_loopback_test's
"spawn coop_tone.exe next to me" lookup is unchanged.

Verified from a clean bin/: root holds only deployables; ctest x64 7/7 and x86 3/3
green (incl. audio_loopback_test driving coop_tone from tests/).

Also convert the roadmap Planned list to bullets and drop this (now-done) item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 00:59:03 +02:00
parent e9d1253b8f
commit 61049a620c
10 changed files with 223 additions and 174 deletions

View File

@@ -24,6 +24,17 @@ set(COOP_OUTPUT_DIR "${CMAKE_SOURCE_DIR}/bin/$<CONFIG>")
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${COOP_OUTPUT_DIR}") set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${COOP_OUTPUT_DIR}")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${COOP_OUTPUT_DIR}") set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${COOP_OUTPUT_DIR}")
# Only the deployable artifacts (host, hook DLLs, injector helper, Steam files) live
# in the bin/<config>/ root, so it can be copied wholesale into a donor game folder.
# Tests and dev tools are staged into subfolders via this helper instead.
# Usage: coop_output_subdir(<subdir> target1 [target2 ...]).
function(coop_output_subdir subdir)
foreach(tgt IN LISTS ARGN)
set_target_properties(${tgt} PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${COOP_OUTPUT_DIR}/${subdir}")
endforeach()
endfunction()
if(MSVC) if(MSVC)
add_compile_options(/W4 /permissive- /Zc:__cplusplus /utf-8 /MP) add_compile_options(/W4 /permissive- /Zc:__cplusplus /utf-8 /MP)
add_compile_definitions(UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX) add_compile_definitions(UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX)
@@ -86,6 +97,10 @@ if(COOP_X86_HELPER_BUILD)
target_compile_definitions(audio_hook_test_x86 PRIVATE NTDDI_VERSION=0x0A00000B) target_compile_definitions(audio_hook_test_x86 PRIVATE NTDDI_VERSION=0x0A00000B)
target_link_libraries(audio_hook_test_x86 PRIVATE coop_common safetyhook::safetyhook ole32 mmdevapi) target_link_libraries(audio_hook_test_x86 PRIVATE coop_common safetyhook::safetyhook ole32 mmdevapi)
add_test(NAME audio_hook_test_x86 COMMAND audio_hook_test_x86) add_test(NAME audio_hook_test_x86 COMMAND audio_hook_test_x86)
# x86 test exes go in tests/; the x86 hook DLL + injector helper stay in the
# deployable root (set via CMAKE_RUNTIME_OUTPUT_DIRECTORY above).
coop_output_subdir(tests hook_selftest_x86 present_hook_test_x86 audio_hook_test_x86)
return() return()
endif() endif()

284
README.md
View File

@@ -75,159 +75,129 @@ is removed from this list once done — so the top item is always next. The
self-verifiable tooling / UI / input items come first; the game-pipeline items that self-verifiable tooling / UI / input items come first; the game-pipeline items that
need a real game (and Remote Play) to fully validate come last. need a real game (and Remote Play) to fully validate come last.
1. **Stage only deployable artifacts directly under `bin/`.** Today every target — - **Detect a terminated target and reflect it in the UI.** The Injection panel keeps
host, both hook DLLs, the injector helper, tests, probes, `coop_tone` — lands in showing "Attached" after the game exits. Add a **Terminated** state: the host
`bin/<config>/`, so deploying to a donor folder means hand-picking files. Keep already knows the target pid and tracks a DLL heartbeat (`InjectionPanel`); on top
the **deployable set** at the `bin/<config>/` root (`coop_host.exe`, of that, hold the `OpenProcess` handle from injection (or re-open with
`coop_hook.dll`, `coop_hook_x86.dll`, `coop_inject_x86.exe`, and — when Steam is `PROCESS_QUERY_LIMITED_INFORMATION`) and poll `GetExitCodeProcess` /
built — `steam_api64.dll` + `steam_input_actions.vdf`) and push everything else `WaitForSingleObject(h, 0)` each tick. When the process is gone, switch to
into subfolders: tests → `bin/<config>/tests/`, debug/probe tools Terminated, gray out / disable the per-subsystem controls and mirror toggles, and
(`coop_audio_probe`, `coop_input_probe`, `coop_tone`, `coop_steam_input_probe`) → show a clear banner; the Video and Audio panels should drop to idle (their hook
`bin/<config>/tools/`. *How:* give those targets a per-target channels are stale) rather than freezing on the last live frame/state. Note a live
`RUNTIME_OUTPUT_DIRECTORY[_<CONFIG>]` (the global `CMAKE_RUNTIME_OUTPUT_DIRECTORY` process isn't proof it's running — also flag a **stalled heartbeat** (no advance
stays the deployable root; a small helper or `set_target_properties` overrides the for ~2 s while the process still exists) as a distinct "hung / not responding"
non-deployable ones). The x86 sub-build must still stage `coop_hook_x86.dll` + state, since games here can freeze without exiting.
`coop_inject_x86.exe` into the deployable root while its x86 *test* exes go to - **Re-attach to a relaunched target.** A killed-and-relaunched game gets a new pid,
`tests/`. Watch the cross-target paths: `audio_loopback_test` spawns but the UI still holds the stale one. In the Terminated state, remember the
`coop_tone.exe`, and the host's post-build copy of `steam_api64.dll` / the `.vdf` target's image name (the panel already keeps the selected exe name) and offer a
must follow the host. End result: copying `bin/<config>/` non-recursively yields a **Re-attach** button that injects only if a live process with that *same name*
clean deployable bundle. exists, rebinding the IPC server to the new pid via the existing `inject_dll` path.
If several live processes share that name, don't guess — surface the matches in the
2. **Detect a terminated target and reflect it in the UI.** The Injection panel keeps window/process picker for a manual choice.
showing "Attached" after the game exits. Add a **Terminated** state: the host - **Select targets by window, not just process.** A flat process list is fine as an
already knows the target pid and tracks a DLL heartbeat (`InjectionPanel`); on top advanced/debug view, but the default should be a **window list** — there are far
of that, hold the `OpenProcess` handle from injection (or re-open with fewer top-level windows than processes, and a window directly yields the HWND the
`PROCESS_QUERY_LIMITED_INFORMATION`) and poll `GetExitCodeProcess` / WGC capturer and focus spoof already want. *How:* add a window enumerator
`WaitForSingleObject(h, 0)` each tick. When the process is gone, switch to (`EnumWindows`, keeping visible, titled, non-tool top-level windows —
Terminated, gray out / disable the per-subsystem controls and mirror toggles, and `IsWindowVisible`, `GetWindowTextLength > 0`, exclude `WS_EX_TOOLWINDOW` and our own
show a clear banner; the Video and Audio panels should drop to idle (their hook HWND, resolve to the root owner) and map each via `GetWindowThreadProcessId` → pid →
channels are stale) rather than freezing on the last live frame/state. Note a live image name. Show **title + process name + pid** with a filter box like the process
process isn't proof it's running — also flag a **stalled heartbeat** (no advance list; injecting by window injects into its pid and hands the HWND straight to
for ~2 s while the process still exists) as a distinct "hung / not responding" capture. Keep the process list behind "Debug details" as the advanced path.
state, since games here can freeze without exiting. - **Auto-size and lay out the overlay windows so none need manual resizing.** Panels
currently `Begin` at default cascade positions, so they overlap and clip. Give each
3. **Re-attach to a relaunched target.** A killed-and-relaunched game gets a new pid, `ImGuiWindowFlags_AlwaysAutoResize` and an initial position computed from
but the UI still holds the stale one. In the Terminated state (task 2), remember `ImGui::GetMainViewport()->WorkPos/WorkSize`, applied with `ImGuiCond_FirstUseEver`
the target's image name (the panel already keeps the selected exe name) and offer a (still movable), plus a **View → Reset layout** menu item that re-applies it. Target
**Re-attach** button that injects only if a live process with that *same name* layout: **Injection** left/top (room to grow downward for hook diagnostics);
exists, rebinding the IPC server to the new pid via the existing `inject_dll` path. **Controllers** top-center; **Video mirror** center, below Controllers; **Audio
If several live processes share that name, don't guess — surface the matches in the mirror** below Video; **Log** right edge, full height (most room for the log
picker (task 4) for a manual choice. stream). Auto-resize fits these because they're all control/debug panels — the live
mirror image is drawn to the whole host window *behind* the overlay, not inside a
4. **Select targets by window, not just process.** A flat process list is fine as an panel.
advanced/debug view, but the default should be a **window list** — there are far - **Fix the Audio panel "live" column.** It overlays a green dot and grey "idle"
fewer top-level windows than processes, and a window directly yields the HWND the because liveness is recomputed each frame from the per-stream `frames_rendered`
WGC capturer and focus spoof already want. *How:* add a window enumerator delta, which is zero on most frames (buffers release in bursts), so it flickers.
(`EnumWindows`, keeping visible, titled, non-tool top-level windows — Replace it with a **debounced activity indicator**: keep a per-stream "last
`IsWindowVisible`, `GetWindowTextLength > 0`, exclude `WS_EX_TOOLWINDOW` and our own advanced" timestamp (the panel already stores the previous frame counts) and show
HWND, resolve to the root owner) and map each via `GetWindowThreadProcessId` → pid → **live** if frames advanced within the last ~300–500 ms, else **idle** — optionally
image name. Show **title + process name + pid** with a filter box like the process a small frames/s or activity bar so multi-stream games read clearly.
list; injecting by window injects into its pid and hands the HWND straight to - **Move the synthetic-input toggle to the Controllers panel, under Debug details.**
capture. Keep the process list behind "Debug details" as the advanced path. The "Forward synthetic test input" checkbox is a controller-debugging aid, so move
it out of the Injection panel into `ControllersPanel` and gate it behind
5. **Auto-size and lay out the overlay windows so none need manual resizing.** Panels `debug_details`. The publish path is unchanged (the Injection panel already
currently `Begin` at default cascade positions, so they overlap and clip. Give each substitutes the synthetic pattern in `publish`); the flag just moves with it (or is
`ImGuiWindowFlags_AlwaysAutoResize` and an initial position computed from passed from Controllers into the publish call).
`ImGui::GetMainViewport()->WorkPos/WorkSize`, applied with `ImGuiCond_FirstUseEver` - **Mouse & keyboard forwarding (messages + polling-state hooks).** Forward guest
(still movable), plus a **View → Reset layout** menu item that re-applies it. Target clicks and keystrokes into the unfocused game via a new **MKB hook subsystem** in
layout: **Injection** left/top (room to grow downward for hook diagnostics); `coop_hook.dll` — the toggle *is* the hook (not installed → no forwarding).
**Controllers** top-center; **Video mirror** center, below Controllers; **Audio *Delivery:* `PostMessage` window-message input (`WM_KEYDOWN`/`WM_KEYUP`/`WM_CHAR`,
mirror** below Video; **Log** right edge, full height (most room for the log `WM_*BUTTONDOWN`/`UP`, `WM_MOUSEWHEEL`) to the game HWND, **plus** hook
stream). Auto-resize fits these because they're all control/debug panels — the live `GetAsyncKeyState` / `GetKeyboardState` / `GetCursorPos` in the DLL so polling games
mirror image is drawn to the whole host window *behind* the overlay, not inside a see the synthesized keyboard/cursor state (RawInput and DirectInput games are out of
panel. scope for this version). *Keyboard* is always forwarded; *mouse* only while video is
mirrored (otherwise the operator can't see where they click), and only **clicks +
6. **Fix the Audio panel "live" column.** It overlays a green dot and grey "idle" wheel, not movement** (one cursor can't be in two places). *Coordinate mapping* (the
because liveness is recomputed each frame from the per-stream `frames_rendered` part that must be exact): WGC + decorated windowed → translate by the window
delta, which is zero on most frames (buffers release in bursts), so it flickers. decoration / client-area offset; hooked capture → relative to the mirrored viewport
Replace it with a **debounced activity indicator**: keep a per-stream "last only (decorations aren't mirrored); borderless → the same under both backends.
advanced" timestamp (the panel already stores the previous frame counts) and show *Critical gating:* forward only when the host's main window is focused **and** ImGui
**live** if frames advanced within the last ~300–500 ms, else **idle** — optionally doesn't want the event (`ImGuiIO::WantCaptureMouse` / `WantCaptureKeyboard`), so
a small frames/s or activity bar so multi-stream games read clearly. interacting with the overlay's own windows never leaks input into the game. The host
sends MKB events to the hook over a new (or extended) IPC region.
7. **Move the synthetic-input toggle to the Controllers panel, under Debug details.** - **Rumble / haptics forwarding (both backends).** Currently unsupported — the XInput
The "Forward synthetic test input" checkbox is a controller-debugging aid, so move hook swallows `XInputSetState`. Add a reverse path: the hook captures the game's
it out of the Injection panel into `ControllersPanel` and gate it behind `XInputSetState` (left/right motor) and publishes it over a hook→host channel (the
`debug_details`. The publish path is unchanged (the Injection panel already back-channel already exists), and the host drives the guest's actuators per backend —
substitutes the synthetic pattern in `publish`); the flag just moves with it (or is **XInput:** call `XInputSetState` on the guest's slot (the viability unknown is
passed from Controllers into the publish call). whether Steam's RPT virtual pad accepts vibration and routes it to the guest);
**Steam Input:** `SteamInput()->TriggerVibration` / `Legacy_TriggerHapticPulse`.
8. **Mouse & keyboard forwarding (messages + polling-state hooks).** Forward guest Map each guest slot to the right actuator.
clicks and keystrokes into the unfocused game via a new **MKB hook subsystem** in - **Per-backend input debug visualization.** To separate "wrong input *into* the
`coop_hook.dll` — the toggle *is* the hook (not installed → no forwarding). tool" from "wrong input *out to* the game", show three distinct views in the
*Delivery:* `PostMessage` window-message input (`WM_KEYDOWN`/`WM_KEYUP`/`WM_CHAR`, Controllers panel (under Debug details): (a) **received via XInput** (raw
`WM_*BUTTONDOWN`/`UP`, `WM_MOUSEWHEEL`) to the game HWND, **plus** hook `XInputSource` state), (b) **received via Steam Input** (raw `SteamInputSource`
`GetAsyncKeyState` / `GetKeyboardState` / `GetCursorPos` in the DLL so polling games action values) — so it's obvious which backend delivered what — and (c)
see the synthesized keyboard/cursor state (RawInput and DirectInput games are out of **forwarded to the game** (the `PadInfo` we write to shared memory, alongside what
scope for this version). *Keyboard* is always forwarded; *mouse* only while video is the game actually read back via the hook's per-slot channel). The hook already
mirrored (otherwise the operator can't see where they click), and only **clicks + reports per-slot poll counts; extend it to echo the last state the game read so (c)
wheel, not movement** (one cursor can't be in two places). *Coordinate mapping* (the is a true round-trip.
part that must be exact): WGC + decorated windowed → translate by the window - **Release the mouse cursor for cursor-clipping games.** Games that confine the
decoration / client-area offset; hooked capture → relative to the mirrored viewport cursor while focused (e.g. Trails through Daybreak via `ClipCursor` / per-frame
only (decorations aren't mirrored); borderless → the same under both backends. `SetCursorPos` re-centering) trap the operator's mouse permanently, because the
*Critical gating:* forward only when the host's main window is focused **and** ImGui focus spoof makes the game believe it's always focused — so the operator can't
doesn't want the event (`ImGuiIO::WantCaptureMouse` / `WantCaptureKeyboard`), so reach the ImGui overlay. Add a cursor-release capability to the Focus subsystem:
interacting with the overlay's own windows never leaks input into the game. The host hook `ClipCursor` (force `ClipCursor(NULL)` and swallow the game's clip) and the
sends MKB events to the hook over a new (or extended) IPC region. re-centering `SetCursorPos`, gated by a new host→hook flag driven by a host
toggle + hotkey. Defaults to released (the guest plays via the pad, so the game's
9. **Rumble / haptics forwarding (both backends).** Currently unsupported — the XInput own cursor clip is operator-only), with the option to re-enable clipping per game.
hook swallows `XInputSetState`. Add a reverse path: the hook captures the game's - **Real capture metrics + latency stats.** The current FPS readout only measures
`XInputSetState` (left/right motor) and publishes it over a hook→host channel (the how fast the host renders its own window, which hides capture stutter. Add a
back-channel already exists), and the host drives the guest's actuators per backend — three-line frametime/FPS graph — **game present rate** (from `VideoShare` present
**XInput:** call `XInputSetState` on the guest's slot (the viability unknown is deltas), **capture rate** (generation deltas / WGC arrivals), and **tool render
whether Steam's RPT virtual pad accepts vibration and routes it to the guest); rate** — plus a **capture→display latency** stat: stamp each published frame with
**Steam Input:** `SteamInput()->TriggerVibration` / `Legacy_TriggerHapticPulse`. a `QueryPerformanceCounter` value in `VideoShare`, and the host reports
Map each guest slot to the right actuator. `host-present QPC − game-present QPC` (min/avg/max ms) for the matched frame. QPC
is system-wide, so the two processes' timestamps compare directly.
10. **Per-backend input debug visualization.** To separate "wrong input *into* the - **DX12 hooked capture (Spider-Man: Miles Morales).** Miles Morales is D3D12, so
tool" from "wrong input *out to* the game", show three distinct views in the the Present hook fires but `GetBuffer(0)` as `ID3D11Texture2D` fails (the
Controllers panel (under Debug details): (a) **received via XInput** (raw backbuffer is an `ID3D12Resource`) and the hook idles; WGC works but stutters. Add
`XInputSource` state), (b) **received via Steam Input** (raw `SteamInputSource` a D3D12 path via a **D3D11On12 bridge**: capture the game's D3D12 command queue
action values) — so it's obvious which backend delivered what — and (c) (hook `ID3D12CommandQueue::ExecuteCommandLists`), create an `ID3D11On12Device`,
**forwarded to the game** (the `PadInfo` we write to shared memory, alongside what `CreateWrappedResource` around the backbuffer, and `CopyResource` into the
the game actually read back via the hook's per-slot channel). The hook already *existing* D3D11 shared keyed-mutex texture — so the host side is unchanged.
reports per-slot poll counts; extend it to echo the last state the game read so (c) - **Multi-stream audio capture + mixing, with per-stream format detection.** Games
is a true round-trip. with several concurrent WASAPI render streams (e.g. Miles Morales) only get their
first ("primary") stream mirrored today; the rest keep playing locally and never
11. **Release the mouse cursor for cursor-clipping games.** Games that confine the reach the guest. Capture every tracked render stream into its own shared ring,
cursor while focused (e.g. Trails through Daybreak via `ClipCursor` / per-frame silence each, and add a host-side mixer that resamples each ring to the render
`SetCursorPos` re-centering) trap the operator's mouse permanently, because the format and sums them (with soft-clip). **Fold in real per-stream format
focus spoof makes the game believe it's always focused — so the operator can't detection** here, since it touches the same hook + ring plumbing: a stream that
reach the ImGui overlay. Add a cursor-release capability to the Focus subsystem: already existed when we injected is never seen at `Initialize`, so the hook
hook `ClipCursor` (force `ClipCursor(NULL)` and swallow the game's clip) and the currently assumes the device **mix format** and a shared-mode stream opened at a
re-centering `SetCursorPos`, gated by a new host→hook flag driven by a host different format comes out wrong-pitched. Resolve each stream's true format (the
toggle + hotkey. Defaults to released (the guest plays via the pad, so the game's stream's own `Initialize` when caught, else the original `GetMixFormat`) so every
own cursor clip is operator-only), with the option to re-enable clipping per game. mixed ring is pitched correctly.
12. **Real capture metrics + latency stats.** The current FPS readout only measures
how fast the host renders its own window, which hides capture stutter. Add a
three-line frametime/FPS graph — **game present rate** (from `VideoShare` present
deltas), **capture rate** (generation deltas / WGC arrivals), and **tool render
rate** — plus a **capture→display latency** stat: stamp each published frame with
a `QueryPerformanceCounter` value in `VideoShare`, and the host reports
`host-present QPC − game-present QPC` (min/avg/max ms) for the matched frame. QPC
is system-wide, so the two processes' timestamps compare directly.
13. **DX12 hooked capture (Spider-Man: Miles Morales).** Miles Morales is D3D12, so
the Present hook fires but `GetBuffer(0)` as `ID3D11Texture2D` fails (the
backbuffer is an `ID3D12Resource`) and the hook idles; WGC works but stutters. Add
a D3D12 path via a **D3D11On12 bridge**: capture the game's D3D12 command queue
(hook `ID3D12CommandQueue::ExecuteCommandLists`), create an `ID3D11On12Device`,
`CreateWrappedResource` around the backbuffer, and `CopyResource` into the
*existing* D3D11 shared keyed-mutex texture — so the host side is unchanged.
14. **Multi-stream audio capture + mixing, with per-stream format detection.** Games
with several concurrent WASAPI render streams (e.g. Miles Morales) only get their
first ("primary") stream mirrored today; the rest keep playing locally and never
reach the guest. Capture every tracked render stream into its own shared ring,
silence each, and add a host-side mixer that resamples each ring to the render
format and sums them (with soft-clip). **Fold in real per-stream format
detection** here, since it touches the same hook + ring plumbing: a stream that
already existed when we injected is never seen at `Initialize`, so the hook
currently assumes the device **mix format** and a shared-mode stream opened at a
different format comes out wrong-pitched. Resolve each stream's true format (the
stream's own `Initialize` when caught, else the original `GetMixFormat`) so every
mixed ring is pitched correctly.
### Future work ### Future work
@@ -332,9 +302,11 @@ input layer sees a real state change. `disable_mask` (hex bits `0x1`=input
Present-hook crash was isolated. Present-hook crash was isolated.
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. Run them from `bin/<config>/`. `coop_hook_x86.dll`, exactly like the host. The probes build into
**Kill the game between runs** — the loaded DLL locks `coop_hook.dll` against the `bin/<config>/tools/` (the deployable `bin/<config>/` root holds only shipping
next rebuild. artifacts; tests build into `bin/<config>/tests/`) and resolve `coop_hook.dll` from
the root one level up, so run them from there. **Kill the game between runs** — the
loaded DLL locks `coop_hook.dll` against the next rebuild.
## Running the tool (manual, end-to-end) ## Running the tool (manual, end-to-end)

View File

@@ -0,0 +1,52 @@
// Resolve artifacts that ship in the deployable bin/<config>/ root even when the
// running executable lives in a sibling subfolder (e.g. dev tools staged under
// bin/<config>/tools/). The probes use this to find coop_hook.dll and the x86
// injector helper, which stay at the root while the probes themselves do not.
#pragma once
#include <cstddef>
#include <string>
#include <windows.h>
namespace coop
{
// Directory of the current executable, with a trailing separator.
inline std::wstring exe_directory()
{
wchar_t buf[MAX_PATH] = {};
GetModuleFileNameW(nullptr, buf, MAX_PATH);
std::wstring path(buf);
const std::size_t slash = path.find_last_of(L"\\/");
return slash == std::wstring::npos ? std::wstring() : path.substr(0, slash + 1);
}
// Full path to a deployed artifact `name`: prefer one next to the running exe, else
// one directory up (the deployable root, when the exe runs from a tools/ subfolder).
// Falls back to the next-to-exe path so callers can report a sensible "not found".
inline std::wstring deployed_artifact_path(const wchar_t* name)
{
const std::wstring here = exe_directory() + name;
if (GetFileAttributesW(here.c_str()) != INVALID_FILE_ATTRIBUTES)
{
return here;
}
std::wstring dir = exe_directory();
if (!dir.empty())
{
dir.pop_back(); // drop the trailing separator before going up a level
}
const std::size_t slash = dir.find_last_of(L"\\/");
if (slash != std::wstring::npos)
{
const std::wstring up = dir.substr(0, slash + 1) + name;
if (GetFileAttributesW(up.c_str()) != INVALID_FILE_ATTRIBUTES)
{
return up;
}
}
return here;
}
} // namespace coop

View File

@@ -76,6 +76,8 @@ if(EXISTS "${COOP_STEAM_SDK_DIR}/public/steam/steam_api.h")
coop_common coop_common
xinput xinput
"${COOP_STEAM_SDK_DIR}/redistributable_bin/win64/steam_api64.lib") "${COOP_STEAM_SDK_DIR}/redistributable_bin/win64/steam_api64.lib")
# Dev tool -> bin/<config>/tools/ (its steam_api64.dll copy follows via TARGET_FILE_DIR).
coop_output_subdir(tools coop_steam_input_probe)
add_custom_command(TARGET coop_steam_input_probe POST_BUILD add_custom_command(TARGET coop_steam_input_probe POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${COOP_STEAM_SDK_DIR}/redistributable_bin/win64/steam_api64.dll" "${COOP_STEAM_SDK_DIR}/redistributable_bin/win64/steam_api64.dll"

View File

@@ -107,3 +107,15 @@ target_link_libraries(opengl_hook_test PRIVATE
gdi32) gdi32)
add_test(NAME opengl_hook_test COMMAND opengl_hook_test) add_test(NAME opengl_hook_test COMMAND opengl_hook_test)
# Keep the bin/<config>/ root deployable: stage every test exe under tests/. coop_tone
# (the audio_loopback_test fixture) is staged there too, next to its consumer, so the
# test's "coop_tone.exe alongside me" lookup keeps working.
coop_output_subdir(tests
hook_selftest
audio_ring_test
audio_loopback_test
audio_hook_test
srgb_format_test
present_hook_test
opengl_hook_test)

View File

@@ -4,3 +4,4 @@
add_executable(coop_audio_probe main.cpp) add_executable(coop_audio_probe main.cpp)
target_link_libraries(coop_audio_probe PRIVATE coop_common) target_link_libraries(coop_audio_probe PRIVATE coop_common)
set_target_properties(coop_audio_probe PROPERTIES OUTPUT_NAME "coop_audio_probe") set_target_properties(coop_audio_probe PROPERTIES OUTPUT_NAME "coop_audio_probe")
coop_output_subdir(tools coop_audio_probe) # dev tool -> bin/<config>/tools/

View File

@@ -23,21 +23,16 @@
#include "coop/log_ring.hpp" #include "coop/log_ring.hpp"
#include "coop/protocol.hpp" #include "coop/protocol.hpp"
#include "coop/shared_memory.hpp" #include "coop/shared_memory.hpp"
#include "coop/tool_paths.hpp"
namespace namespace
{ {
// coop_hook.dll ships in the deployable bin/<config>/ root; this probe runs from
// bin/<config>/tools/, so resolve next-to-self first, then one level up.
std::wstring dll_path_next_to_self() std::wstring dll_path_next_to_self()
{ {
wchar_t exe[MAX_PATH] = {}; return coop::deployed_artifact_path(L"coop_hook.dll");
GetModuleFileNameW(nullptr, exe, MAX_PATH);
std::wstring path(exe);
const size_t slash = path.find_last_of(L"\\/");
if (slash != std::wstring::npos)
{
path.resize(slash + 1);
}
return path + L"coop_hook.dll";
} }
std::wstring sibling_of(const std::wstring& path, const wchar_t* name) std::wstring sibling_of(const std::wstring& path, const wchar_t* name)

View File

@@ -3,3 +3,7 @@
add_executable(coop_tone main.cpp) add_executable(coop_tone main.cpp)
target_link_libraries(coop_tone PRIVATE ole32) target_link_libraries(coop_tone PRIVATE ole32)
set_target_properties(coop_tone PROPERTIES OUTPUT_NAME "coop_tone") set_target_properties(coop_tone PROPERTIES OUTPUT_NAME "coop_tone")
# Fixture for audio_loopback_test -> stage it next to the tests, not in the
# deployable root. audio_loopback_test spawns coop_tone.exe from its own directory.
coop_output_subdir(tests coop_tone)

View File

@@ -5,3 +5,4 @@
add_executable(coop_input_probe main.cpp) add_executable(coop_input_probe main.cpp)
target_link_libraries(coop_input_probe PRIVATE coop_common) target_link_libraries(coop_input_probe PRIVATE coop_common)
set_target_properties(coop_input_probe PROPERTIES OUTPUT_NAME "coop_input_probe") set_target_properties(coop_input_probe PROPERTIES OUTPUT_NAME "coop_input_probe")
coop_output_subdir(tools coop_input_probe) # dev tool -> bin/<config>/tools/

View File

@@ -21,21 +21,16 @@
#include "coop/log_ring.hpp" #include "coop/log_ring.hpp"
#include "coop/protocol.hpp" #include "coop/protocol.hpp"
#include "coop/shared_memory.hpp" #include "coop/shared_memory.hpp"
#include "coop/tool_paths.hpp"
namespace namespace
{ {
// coop_hook.dll ships in the deployable bin/<config>/ root; this probe runs from
// bin/<config>/tools/, so resolve next-to-self first, then one level up.
std::wstring dll_path_next_to_self() std::wstring dll_path_next_to_self()
{ {
wchar_t exe[MAX_PATH] = {}; return coop::deployed_artifact_path(L"coop_hook.dll");
GetModuleFileNameW(nullptr, exe, MAX_PATH);
std::wstring path(exe);
const size_t slash = path.find_last_of(L"\\/");
if (slash != std::wstring::npos)
{
path.resize(slash + 1);
}
return path + L"coop_hook.dll";
} }
std::wstring sibling_of(const std::wstring& path, const wchar_t* name) std::wstring sibling_of(const std::wstring& path, const wchar_t* name)