Hooked audio mirroring played back pitch-shifted on games we inject into that render at a non-device sample rate (e.g. Godot/Brotato render 44100 Hz on a 48000 Hz endpoint via WASAPI AUTOCONVERTPCM). We attach to an already-running game, so the render-hook never saw its IAudioClient:: Initialize and assumed the device mix format -- right channels/bits, wrong rate -- so 44100 audio was rendered as 48000 (+~1.5 semitones). Fix: treat a pre-existing client's format as a guess and measure its true sample rate from the render cadence (frames/sec over a steady-state window, snapped to the nearest standard rate) before publishing it, deferring capture until verified. Discard the first measurement window so the buffer-fill burst at attach time doesn't over-count. Streams created after we inject still carry their exact Initialize format. Channels/bit-depth genuinely can't be recovered for a pre-existing client: AUTOCONVERTPCM hands GetBuffer a fixed staging buffer (no buffer stride to measure -- confirmed empirically) and WASAPI exposes no API for the format. They stay the device-mix guess, which is correct for the common case (engines render stereo float, matching the endpoint). To keep a wrong guess safe, a VirtualQuery clamp stops the capture copy from ever over-reading the source buffer when the guessed bytes/frame is too large. Surface all of this: a per-stream AudioFormatState (known / measuring / measured rate (ch/bits assumed)) in HookStatus, shown in the Audio panel for the hooked path and as "device endpoint (known)" for loopback; clear hook logs; and enriched mirror status strings. Documented in README (Limitations + Lessons learned). The loopback fallback was always correct (post-mix at the device format). Tests: extract a shared, configurable ToneSource (used by coop_tone and the hook self-test); coop_tone takes rate/channels/bits/format args. Rewrite audio_hook_test to a format matrix x both code paths -- see-init (exact) and guess (rate measured) -- plus a byte-incompatible guess that asserts the clamp keeps capture safe. The matrix caught the attach-burst over-count. audio_loopback_test now spawns coop_tone at several source formats to confirm loopback is format-agnostic. 11/11 x64 + 3/3 x86 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
6.8 KiB
CMake
149 lines
6.8 KiB
CMake
cmake_minimum_required(VERSION 3.21)
|
|
|
|
project(CoopAllTheThings
|
|
VERSION 0.0.1
|
|
DESCRIPTION "Steam Remote Play Together for any XInput game"
|
|
LANGUAGES CXX)
|
|
|
|
if(NOT WIN32)
|
|
message(FATAL_ERROR "CoopAllTheThings targets Windows only.")
|
|
endif()
|
|
|
|
set(CMAKE_CXX_STANDARD 20)
|
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
|
|
|
# Emit compile_commands.json so clangd resolves includes/flags. Ignored by the
|
|
# Visual Studio generator, so configure the Ninja build (build-clangd/) for this;
|
|
# .clangd points clangd at that compilation database.
|
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
|
|
|
# Single output dir keeps the donor-launch / inject workflow simple: host exe and
|
|
# hook dll land next to each other under /bin (already in .gitignore).
|
|
set(COOP_OUTPUT_DIR "${CMAKE_SOURCE_DIR}/bin/$<CONFIG>")
|
|
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/<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)
|
|
add_compile_options(/W4 /permissive- /Zc:__cplusplus /utf-8 /MP)
|
|
add_compile_definitions(UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX)
|
|
endif()
|
|
|
|
# --- x86 sub-build (internal, re-entrant) ----------------------------------
|
|
# When the ExternalProject below reconfigures this same tree with -A Win32 and
|
|
# -DCOOP_X86_HELPER_BUILD=ON, build ONLY the 32-bit hook DLL + injector helper
|
|
# (staged alongside the x64 binaries) and stop -- no host, tests, or imgui, and
|
|
# crucially no recursive ExternalProject.
|
|
option(COOP_X86_HELPER_BUILD "Internal: x86 sub-build (hook DLL + injector helper only)" OFF)
|
|
if(COOP_X86_HELPER_BUILD)
|
|
add_subdirectory(common)
|
|
set(SAFETYHOOK_BUILD_TEST OFF CACHE BOOL "" FORCE)
|
|
set(SAFETYHOOK_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
|
set(SAFETYHOOK_BUILD_DOCS OFF CACHE BOOL "" FORCE)
|
|
add_subdirectory(third_party/safetyhook)
|
|
add_subdirectory(hook)
|
|
add_subdirectory(tools/inject_helper)
|
|
|
|
# x86 reproduction of the XInput self-test. The x64 hook_selftest passes, so an
|
|
# x86 build of the same logic is how we reproduce (and then guard against) the
|
|
# 32-bit input-forwarding crash on real games like Slaps and Beans.
|
|
enable_testing()
|
|
add_executable(hook_selftest_x86
|
|
tests/hook_selftest.cpp
|
|
hook/src/xinput_hook.cpp
|
|
hook/src/hook_registry.cpp)
|
|
target_include_directories(hook_selftest_x86 PRIVATE hook/src)
|
|
target_link_libraries(hook_selftest_x86 PRIVATE coop_common safetyhook::safetyhook xinput)
|
|
add_test(NAME hook_selftest_x86 COMMAND hook_selftest_x86)
|
|
|
|
# x86 build of the Present-hook test. It drives a real swapchain through the
|
|
# SafetyHook trampoline, so it catches the x86-only calling-convention bug that
|
|
# froze 32-bit games (SafetyHook's call() is __cdecl; Present is __stdcall ->
|
|
# ESP imbalance / Run-Time Check Failure #0). The x64 present_hook_test can't see
|
|
# it (one calling convention), so this 32-bit build is the regression guard.
|
|
add_executable(present_hook_test_x86
|
|
tests/present_hook_test.cpp
|
|
hook/src/present_hook.cpp
|
|
hook/src/debug_log.cpp
|
|
hook/src/hook_registry.cpp)
|
|
target_include_directories(present_hook_test_x86 PRIVATE hook/src)
|
|
target_link_libraries(present_hook_test_x86 PRIVATE coop_common safetyhook::safetyhook d3d11 dxgi)
|
|
add_test(NAME present_hook_test_x86 COMMAND present_hook_test_x86)
|
|
|
|
# x86 build of the audio render-hook test. It installs the WASAPI hooks and then
|
|
# creates a *fresh* IAudioClient/IAudioRenderClient that calls Initialize /
|
|
# GetService / GetBuffer / ReleaseBuffer back through the SafetyHook trampolines
|
|
# -- the exact ordering that crashed 32-bit Slaps & Beans (FMOD) when audio hooks
|
|
# were live as the game initialized its output. The x64 audio_hook_test passes,
|
|
# so this 32-bit build is the regression guard for any x86-only fault in the
|
|
# audio setup detours / trampoline relocation of AudioSes.dll prologues.
|
|
add_executable(audio_hook_test_x86
|
|
tests/audio_hook_test.cpp
|
|
hook/src/audio_hook.cpp
|
|
hook/src/debug_log.cpp
|
|
hook/src/hook_registry.cpp)
|
|
target_include_directories(audio_hook_test_x86 PRIVATE hook/src tools/audio_tone)
|
|
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()
|
|
|
|
add_subdirectory(third_party)
|
|
add_subdirectory(common)
|
|
add_subdirectory(host)
|
|
|
|
# The injected hook DLL pulls in SafetyHook, which fetches Zydis via FetchContent
|
|
# at configure time (allowed for transitive deps).
|
|
option(COOP_BUILD_HOOK "Build the injected game-side hook DLL" ON)
|
|
if(COOP_BUILD_HOOK)
|
|
# SafetyHook's own tests/examples/docs are noise for us.
|
|
set(SAFETYHOOK_BUILD_TEST OFF CACHE BOOL "" FORCE)
|
|
set(SAFETYHOOK_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
|
set(SAFETYHOOK_BUILD_DOCS OFF CACHE BOOL "" FORCE)
|
|
add_subdirectory(third_party/safetyhook)
|
|
add_subdirectory(hook)
|
|
enable_testing()
|
|
add_subdirectory(tools/audio_tone) # coop_tone: audio source for the loopback test
|
|
add_subdirectory(tools/audio_probe) # coop_audio_probe: inject + diagnose the render-hook
|
|
add_subdirectory(tools/input_probe) # coop_input_probe: inject + forward synthetic input
|
|
add_subdirectory(tests)
|
|
endif()
|
|
|
|
# --- x86 helper artifacts for 32-bit (WOW64) games -------------------------
|
|
# A 64-bit process can't cleanly inject a 32-bit one, so build the x86 hook DLL
|
|
# (coop_hook_x86.dll) + a 32-bit injector helper (coop_inject_x86.exe) via a
|
|
# nested Win32 configure of this same tree, staged next to the x64 binaries. Only
|
|
# the multi-config Visual Studio generator is supported here (the Ninja
|
|
# build-clangd/ tree is just for compile_commands and skips this).
|
|
option(COOP_BUILD_X86_HELPER "Build the x86 hook DLL + injector helper for 32-bit games" ON)
|
|
if(COOP_BUILD_HOOK AND COOP_BUILD_X86_HELPER AND CMAKE_GENERATOR MATCHES "Visual Studio")
|
|
include(ExternalProject)
|
|
ExternalProject_Add(coop_x86
|
|
SOURCE_DIR "${CMAKE_SOURCE_DIR}"
|
|
BINARY_DIR "${CMAKE_BINARY_DIR}/x86"
|
|
CMAKE_GENERATOR "${CMAKE_GENERATOR}"
|
|
CMAKE_GENERATOR_PLATFORM "Win32"
|
|
CMAKE_ARGS "-DCOOP_X86_HELPER_BUILD=ON"
|
|
BUILD_COMMAND "${CMAKE_COMMAND}" --build <BINARY_DIR> --config $<CONFIG>
|
|
INSTALL_COMMAND ""
|
|
BUILD_ALWAYS ON
|
|
USES_TERMINAL_CONFIGURE ON
|
|
USES_TERMINAL_BUILD ON)
|
|
endif()
|