From 61049a620c2e7199cff0a8249ef24aa9f66513e3 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sun, 21 Jun 2026 00:59:03 +0200 Subject: [PATCH] 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// root, so it can be copied wholesale into a donor game folder. Test exes (plus the coop_tone fixture) build into bin//tests/ and the dev probes into bin//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 --- CMakeLists.txt | 15 ++ README.md | 284 +++++++++++++---------------- common/include/coop/tool_paths.hpp | 52 ++++++ host/CMakeLists.txt | 2 + tests/CMakeLists.txt | 12 ++ tools/audio_probe/CMakeLists.txt | 1 + tools/audio_probe/main.cpp | 13 +- tools/audio_tone/CMakeLists.txt | 4 + tools/input_probe/CMakeLists.txt | 1 + tools/input_probe/main.cpp | 13 +- 10 files changed, 223 insertions(+), 174 deletions(-) create mode 100644 common/include/coop/tool_paths.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8311fd4..77a9827 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,17 @@ set(COOP_OUTPUT_DIR "${CMAKE_SOURCE_DIR}/bin/$") set(CMAKE_RUNTIME_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// 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( 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) add_compile_options(/W4 /permissive- /Zc:__cplusplus /utf-8 /MP) 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_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) + + # 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() endif() diff --git a/README.md b/README.md index fa9d36f..5aa2e1d 100644 --- a/README.md +++ b/README.md @@ -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 need a real game (and Remote Play) to fully validate come last. -1. **Stage only deployable artifacts directly under `bin/`.** Today every target — - host, both hook DLLs, the injector helper, tests, probes, `coop_tone` — lands in - `bin//`, so deploying to a donor folder means hand-picking files. Keep - the **deployable set** at the `bin//` root (`coop_host.exe`, - `coop_hook.dll`, `coop_hook_x86.dll`, `coop_inject_x86.exe`, and — when Steam is - built — `steam_api64.dll` + `steam_input_actions.vdf`) and push everything else - into subfolders: tests → `bin//tests/`, debug/probe tools - (`coop_audio_probe`, `coop_input_probe`, `coop_tone`, `coop_steam_input_probe`) → - `bin//tools/`. *How:* give those targets a per-target - `RUNTIME_OUTPUT_DIRECTORY[_]` (the global `CMAKE_RUNTIME_OUTPUT_DIRECTORY` - stays the deployable root; a small helper or `set_target_properties` overrides the - non-deployable ones). The x86 sub-build must still stage `coop_hook_x86.dll` + - `coop_inject_x86.exe` into the deployable root while its x86 *test* exes go to - `tests/`. Watch the cross-target paths: `audio_loopback_test` spawns - `coop_tone.exe`, and the host's post-build copy of `steam_api64.dll` / the `.vdf` - must follow the host. End result: copying `bin//` non-recursively yields a - clean deployable bundle. - -2. **Detect a terminated target and reflect it in the UI.** The Injection panel keeps - showing "Attached" after the game exits. Add a **Terminated** state: the host - already knows the target pid and tracks a DLL heartbeat (`InjectionPanel`); on top - of that, hold the `OpenProcess` handle from injection (or re-open with - `PROCESS_QUERY_LIMITED_INFORMATION`) and poll `GetExitCodeProcess` / - `WaitForSingleObject(h, 0)` each tick. When the process is gone, switch to - Terminated, gray out / disable the per-subsystem controls and mirror toggles, and - show a clear banner; the Video and Audio panels should drop to idle (their hook - channels are stale) rather than freezing on the last live frame/state. Note a live - process isn't proof it's running — also flag a **stalled heartbeat** (no advance - for ~2 s while the process still exists) as a distinct "hung / not responding" - state, since games here can freeze without exiting. - -3. **Re-attach to a relaunched target.** A killed-and-relaunched game gets a new pid, - but the UI still holds the stale one. In the Terminated state (task 2), remember - the target's image name (the panel already keeps the selected exe name) and offer a - **Re-attach** button that injects only if a live process with that *same name* - 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 - picker (task 4) for a manual choice. - -4. **Select targets by window, not just process.** A flat process list is fine as an - advanced/debug view, but the default should be a **window list** — there are far - fewer top-level windows than processes, and a window directly yields the HWND the - WGC capturer and focus spoof already want. *How:* add a window enumerator - (`EnumWindows`, keeping visible, titled, non-tool top-level windows — - `IsWindowVisible`, `GetWindowTextLength > 0`, exclude `WS_EX_TOOLWINDOW` and our own - HWND, resolve to the root owner) and map each via `GetWindowThreadProcessId` → pid → - image name. Show **title + process name + pid** with a filter box like the process - list; injecting by window injects into its pid and hands the HWND straight to - capture. Keep the process list behind "Debug details" as the advanced path. - -5. **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 - `ImGuiWindowFlags_AlwaysAutoResize` and an initial position computed from - `ImGui::GetMainViewport()->WorkPos/WorkSize`, applied with `ImGuiCond_FirstUseEver` - (still movable), plus a **View → Reset layout** menu item that re-applies it. Target - layout: **Injection** left/top (room to grow downward for hook diagnostics); - **Controllers** top-center; **Video mirror** center, below Controllers; **Audio - mirror** below Video; **Log** right edge, full height (most room for the log - 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 - panel. - -6. **Fix the Audio panel "live" column.** It overlays a green dot and grey "idle" - because liveness is recomputed each frame from the per-stream `frames_rendered` - delta, which is zero on most frames (buffers release in bursts), so it flickers. - Replace it with a **debounced activity indicator**: keep a per-stream "last - advanced" timestamp (the panel already stores the previous frame counts) and show - **live** if frames advanced within the last ~300–500 ms, else **idle** — optionally - a small frames/s or activity bar so multi-stream games read clearly. - -7. **Move the synthetic-input toggle to the Controllers panel, under Debug details.** - 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 - `debug_details`. The publish path is unchanged (the Injection panel already - substitutes the synthetic pattern in `publish`); the flag just moves with it (or is - passed from Controllers into the publish call). - -8. **Mouse & keyboard forwarding (messages + polling-state hooks).** Forward guest - clicks and keystrokes into the unfocused game via a new **MKB hook subsystem** in - `coop_hook.dll` — the toggle *is* the hook (not installed → no forwarding). - *Delivery:* `PostMessage` window-message input (`WM_KEYDOWN`/`WM_KEYUP`/`WM_CHAR`, - `WM_*BUTTONDOWN`/`UP`, `WM_MOUSEWHEEL`) to the game HWND, **plus** hook - `GetAsyncKeyState` / `GetKeyboardState` / `GetCursorPos` in the DLL so polling games - see the synthesized keyboard/cursor state (RawInput and DirectInput games are out of - 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 + - wheel, not movement** (one cursor can't be in two places). *Coordinate mapping* (the - part that must be exact): WGC + decorated windowed → translate by the window - decoration / client-area offset; hooked capture → relative to the mirrored viewport - only (decorations aren't mirrored); borderless → the same under both backends. - *Critical gating:* forward only when the host's main window is focused **and** ImGui - doesn't want the event (`ImGuiIO::WantCaptureMouse` / `WantCaptureKeyboard`), so - 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. - -9. **Rumble / haptics forwarding (both backends).** Currently unsupported — the XInput - hook swallows `XInputSetState`. Add a reverse path: the hook captures the game's - `XInputSetState` (left/right motor) and publishes it over a hook→host channel (the - back-channel already exists), and the host drives the guest's actuators per backend — - **XInput:** call `XInputSetState` on the guest's slot (the viability unknown is - whether Steam's RPT virtual pad accepts vibration and routes it to the guest); - **Steam Input:** `SteamInput()->TriggerVibration` / `Legacy_TriggerHapticPulse`. - Map each guest slot to the right actuator. - -10. **Per-backend input debug visualization.** To separate "wrong input *into* the - tool" from "wrong input *out to* the game", show three distinct views in the - Controllers panel (under Debug details): (a) **received via XInput** (raw - `XInputSource` state), (b) **received via Steam Input** (raw `SteamInputSource` - action values) — so it's obvious which backend delivered what — and (c) - **forwarded to the game** (the `PadInfo` we write to shared memory, alongside what - the game actually read back via the hook's per-slot channel). The hook already - reports per-slot poll counts; extend it to echo the last state the game read so (c) - is a true round-trip. - -11. **Release the mouse cursor for cursor-clipping games.** Games that confine the - cursor while focused (e.g. Trails through Daybreak via `ClipCursor` / per-frame - `SetCursorPos` re-centering) trap the operator's mouse permanently, because the - focus spoof makes the game believe it's always focused — so the operator can't - reach the ImGui overlay. Add a cursor-release capability to the Focus subsystem: - hook `ClipCursor` (force `ClipCursor(NULL)` and swallow the game's clip) and the - 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 - own cursor clip is operator-only), with the option to re-enable clipping per game. - -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. +- **Detect a terminated target and reflect it in the UI.** The Injection panel keeps + showing "Attached" after the game exits. Add a **Terminated** state: the host + already knows the target pid and tracks a DLL heartbeat (`InjectionPanel`); on top + of that, hold the `OpenProcess` handle from injection (or re-open with + `PROCESS_QUERY_LIMITED_INFORMATION`) and poll `GetExitCodeProcess` / + `WaitForSingleObject(h, 0)` each tick. When the process is gone, switch to + Terminated, gray out / disable the per-subsystem controls and mirror toggles, and + show a clear banner; the Video and Audio panels should drop to idle (their hook + channels are stale) rather than freezing on the last live frame/state. Note a live + process isn't proof it's running — also flag a **stalled heartbeat** (no advance + for ~2 s while the process still exists) as a distinct "hung / not responding" + state, since games here can freeze without exiting. +- **Re-attach to a relaunched target.** A killed-and-relaunched game gets a new pid, + but the UI still holds the stale one. In the Terminated state, remember the + target's image name (the panel already keeps the selected exe name) and offer a + **Re-attach** button that injects only if a live process with that *same name* + 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 + window/process picker for a manual choice. +- **Select targets by window, not just process.** A flat process list is fine as an + advanced/debug view, but the default should be a **window list** — there are far + fewer top-level windows than processes, and a window directly yields the HWND the + WGC capturer and focus spoof already want. *How:* add a window enumerator + (`EnumWindows`, keeping visible, titled, non-tool top-level windows — + `IsWindowVisible`, `GetWindowTextLength > 0`, exclude `WS_EX_TOOLWINDOW` and our own + HWND, resolve to the root owner) and map each via `GetWindowThreadProcessId` → pid → + image name. Show **title + process name + pid** with a filter box like the process + list; injecting by window injects into its pid and hands the HWND straight to + capture. Keep the process list behind "Debug details" as the advanced path. +- **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 + `ImGuiWindowFlags_AlwaysAutoResize` and an initial position computed from + `ImGui::GetMainViewport()->WorkPos/WorkSize`, applied with `ImGuiCond_FirstUseEver` + (still movable), plus a **View → Reset layout** menu item that re-applies it. Target + layout: **Injection** left/top (room to grow downward for hook diagnostics); + **Controllers** top-center; **Video mirror** center, below Controllers; **Audio + mirror** below Video; **Log** right edge, full height (most room for the log + 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 + panel. +- **Fix the Audio panel "live" column.** It overlays a green dot and grey "idle" + because liveness is recomputed each frame from the per-stream `frames_rendered` + delta, which is zero on most frames (buffers release in bursts), so it flickers. + Replace it with a **debounced activity indicator**: keep a per-stream "last + advanced" timestamp (the panel already stores the previous frame counts) and show + **live** if frames advanced within the last ~300–500 ms, else **idle** — optionally + a small frames/s or activity bar so multi-stream games read clearly. +- **Move the synthetic-input toggle to the Controllers panel, under Debug details.** + 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 + `debug_details`. The publish path is unchanged (the Injection panel already + substitutes the synthetic pattern in `publish`); the flag just moves with it (or is + passed from Controllers into the publish call). +- **Mouse & keyboard forwarding (messages + polling-state hooks).** Forward guest + clicks and keystrokes into the unfocused game via a new **MKB hook subsystem** in + `coop_hook.dll` — the toggle *is* the hook (not installed → no forwarding). + *Delivery:* `PostMessage` window-message input (`WM_KEYDOWN`/`WM_KEYUP`/`WM_CHAR`, + `WM_*BUTTONDOWN`/`UP`, `WM_MOUSEWHEEL`) to the game HWND, **plus** hook + `GetAsyncKeyState` / `GetKeyboardState` / `GetCursorPos` in the DLL so polling games + see the synthesized keyboard/cursor state (RawInput and DirectInput games are out of + 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 + + wheel, not movement** (one cursor can't be in two places). *Coordinate mapping* (the + part that must be exact): WGC + decorated windowed → translate by the window + decoration / client-area offset; hooked capture → relative to the mirrored viewport + only (decorations aren't mirrored); borderless → the same under both backends. + *Critical gating:* forward only when the host's main window is focused **and** ImGui + doesn't want the event (`ImGuiIO::WantCaptureMouse` / `WantCaptureKeyboard`), so + 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. +- **Rumble / haptics forwarding (both backends).** Currently unsupported — the XInput + hook swallows `XInputSetState`. Add a reverse path: the hook captures the game's + `XInputSetState` (left/right motor) and publishes it over a hook→host channel (the + back-channel already exists), and the host drives the guest's actuators per backend — + **XInput:** call `XInputSetState` on the guest's slot (the viability unknown is + whether Steam's RPT virtual pad accepts vibration and routes it to the guest); + **Steam Input:** `SteamInput()->TriggerVibration` / `Legacy_TriggerHapticPulse`. + Map each guest slot to the right actuator. +- **Per-backend input debug visualization.** To separate "wrong input *into* the + tool" from "wrong input *out to* the game", show three distinct views in the + Controllers panel (under Debug details): (a) **received via XInput** (raw + `XInputSource` state), (b) **received via Steam Input** (raw `SteamInputSource` + action values) — so it's obvious which backend delivered what — and (c) + **forwarded to the game** (the `PadInfo` we write to shared memory, alongside what + the game actually read back via the hook's per-slot channel). The hook already + reports per-slot poll counts; extend it to echo the last state the game read so (c) + is a true round-trip. +- **Release the mouse cursor for cursor-clipping games.** Games that confine the + cursor while focused (e.g. Trails through Daybreak via `ClipCursor` / per-frame + `SetCursorPos` re-centering) trap the operator's mouse permanently, because the + focus spoof makes the game believe it's always focused — so the operator can't + reach the ImGui overlay. Add a cursor-release capability to the Focus subsystem: + hook `ClipCursor` (force `ClipCursor(NULL)` and swallow the game's clip) and the + 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 + own cursor clip is operator-only), with the option to re-enable clipping per game. +- **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. +- **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. +- **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 @@ -332,9 +302,11 @@ input layer sees a real state change. `disable_mask` (hex bits `0x1`=input Present-hook crash was isolated. 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//`. -**Kill the game between runs** — the loaded DLL locks `coop_hook.dll` against the -next rebuild. +`coop_hook_x86.dll`, exactly like the host. The probes build into +`bin//tools/` (the deployable `bin//` root holds only shipping +artifacts; tests build into `bin//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) diff --git a/common/include/coop/tool_paths.hpp b/common/include/coop/tool_paths.hpp new file mode 100644 index 0000000..ab669ce --- /dev/null +++ b/common/include/coop/tool_paths.hpp @@ -0,0 +1,52 @@ +// Resolve artifacts that ship in the deployable bin// root even when the +// running executable lives in a sibling subfolder (e.g. dev tools staged under +// bin//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 +#include + +#include + +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 diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index 061d7bd..64fecf0 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -76,6 +76,8 @@ if(EXISTS "${COOP_STEAM_SDK_DIR}/public/steam/steam_api.h") coop_common xinput "${COOP_STEAM_SDK_DIR}/redistributable_bin/win64/steam_api64.lib") + # Dev tool -> bin//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 COMMAND ${CMAKE_COMMAND} -E copy_if_different "${COOP_STEAM_SDK_DIR}/redistributable_bin/win64/steam_api64.dll" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f4c2c4f..794ee75 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -107,3 +107,15 @@ target_link_libraries(opengl_hook_test PRIVATE gdi32) add_test(NAME opengl_hook_test COMMAND opengl_hook_test) + +# Keep the bin// 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) diff --git a/tools/audio_probe/CMakeLists.txt b/tools/audio_probe/CMakeLists.txt index cf3c566..881ebb6 100644 --- a/tools/audio_probe/CMakeLists.txt +++ b/tools/audio_probe/CMakeLists.txt @@ -4,3 +4,4 @@ add_executable(coop_audio_probe main.cpp) target_link_libraries(coop_audio_probe PRIVATE coop_common) set_target_properties(coop_audio_probe PROPERTIES OUTPUT_NAME "coop_audio_probe") +coop_output_subdir(tools coop_audio_probe) # dev tool -> bin//tools/ diff --git a/tools/audio_probe/main.cpp b/tools/audio_probe/main.cpp index ea60bdc..e624984 100644 --- a/tools/audio_probe/main.cpp +++ b/tools/audio_probe/main.cpp @@ -23,21 +23,16 @@ #include "coop/log_ring.hpp" #include "coop/protocol.hpp" #include "coop/shared_memory.hpp" +#include "coop/tool_paths.hpp" namespace { +// coop_hook.dll ships in the deployable bin// root; this probe runs from +// bin//tools/, so resolve next-to-self first, then one level up. std::wstring dll_path_next_to_self() { - wchar_t exe[MAX_PATH] = {}; - 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"; + return coop::deployed_artifact_path(L"coop_hook.dll"); } std::wstring sibling_of(const std::wstring& path, const wchar_t* name) diff --git a/tools/audio_tone/CMakeLists.txt b/tools/audio_tone/CMakeLists.txt index 7e56f11..2037523 100644 --- a/tools/audio_tone/CMakeLists.txt +++ b/tools/audio_tone/CMakeLists.txt @@ -3,3 +3,7 @@ add_executable(coop_tone main.cpp) target_link_libraries(coop_tone PRIVATE ole32) 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) diff --git a/tools/input_probe/CMakeLists.txt b/tools/input_probe/CMakeLists.txt index cd1928a..8c8acb5 100644 --- a/tools/input_probe/CMakeLists.txt +++ b/tools/input_probe/CMakeLists.txt @@ -5,3 +5,4 @@ add_executable(coop_input_probe main.cpp) target_link_libraries(coop_input_probe PRIVATE coop_common) set_target_properties(coop_input_probe PROPERTIES OUTPUT_NAME "coop_input_probe") +coop_output_subdir(tools coop_input_probe) # dev tool -> bin//tools/ diff --git a/tools/input_probe/main.cpp b/tools/input_probe/main.cpp index 9383403..4722c0a 100644 --- a/tools/input_probe/main.cpp +++ b/tools/input_probe/main.cpp @@ -21,21 +21,16 @@ #include "coop/log_ring.hpp" #include "coop/protocol.hpp" #include "coop/shared_memory.hpp" +#include "coop/tool_paths.hpp" namespace { +// coop_hook.dll ships in the deployable bin// root; this probe runs from +// bin//tools/, so resolve next-to-self first, then one level up. std::wstring dll_path_next_to_self() { - wchar_t exe[MAX_PATH] = {}; - 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"; + return coop::deployed_artifact_path(L"coop_hook.dll"); } std::wstring sibling_of(const std::wstring& path, const wchar_t* name)