diff --git a/README.md b/README.md index 6bf58fe..13cb074 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,6 @@ as its own commit; "verify" items are confirmed real before any change, and drop Test coverage: - **Dedicated hook tests** — `focus_spoof`, `vk_hook` (present), `d3d9_hook`. -- **Misc units** — `SharedMemory` RAII/move, `wav` malformed input, `tool_paths` resolution, injector - bitness check; strengthen the `audio_ring` overrun-at-seam case. Features: - **Static CRT (`/MT`) for `coop_hook.dll`** (x64 + x86) so it loads in games without the VC++ redist. diff --git a/common/include/coop/wav.hpp b/common/include/coop/wav.hpp index a6671e8..fc38c18 100644 --- a/common/include/coop/wav.hpp +++ b/common/include/coop/wav.hpp @@ -132,7 +132,15 @@ inline bool wav_read(const std::wstring& path, WavData& out) out.pcm.assign(all.begin() + body, all.begin() + body + n); have_data = true; } - pos = body + chunk_size + (chunk_size & 1); // chunks are word-aligned + // Advance to the next chunk (word-aligned). Guard a corrupt over-long chunk_size: it would + // wrap `pos` on a 32-bit size_t (x86) and spin the loop on garbage, and there's nothing valid + // past a chunk that claims more than the file holds anyway. + const std::size_t advance = static_cast(chunk_size) + (chunk_size & 1); + if (advance > all.size() - body) + { + break; + } + pos = body + advance; } return have_fmt && have_data; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ad21659..72eecd8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -89,6 +89,22 @@ target_include_directories(protocol_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) target_link_libraries(protocol_test PRIVATE coop_common) add_test(NAME protocol_test COMMAND protocol_test) +# Unit test for the SharedMemory RAII wrapper (create-or-open aliasing, move, reset, open-missing). +add_executable(shared_memory_test shared_memory_test.cpp) +target_link_libraries(shared_memory_test PRIVATE coop_common) +add_test(NAME shared_memory_test COMMAND shared_memory_test) + +# Unit test for the WAV reader's robustness on malformed input (truncation, bad magic, missing/ +# over-long/odd/corrupt chunks). Header-only. +add_executable(wav_test wav_test.cpp) +target_link_libraries(wav_test PRIVATE coop_common) +add_test(NAME wav_test COMMAND wav_test) + +# Unit test for deployed-artifact path resolution (next-to-exe vs one-dir-up vs not-found fallback). +add_executable(tool_paths_test tool_paths_test.cpp) +target_link_libraries(tool_paths_test PRIVATE coop_common) +add_test(NAME tool_paths_test COMMAND tool_paths_test) + # Unit test for the audio mixer math (decode/sum/soft-clip/encode). Header-only. add_executable(audio_mix_test audio_mix_test.cpp) target_include_directories(audio_mix_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src) @@ -336,6 +352,9 @@ coop_output_subdir(tests mkb_ring_test log_ring_test protocol_test + shared_memory_test + wav_test + tool_paths_test mkb_map_test audio_mix_test tone_analysis_test diff --git a/tests/audio_ring_test.cpp b/tests/audio_ring_test.cpp index d05c0bf..fd7eb75 100644 --- a/tests/audio_ring_test.cpp +++ b/tests/audio_ring_test.cpp @@ -126,6 +126,47 @@ int main() check(h->overruns.load() == 1, "overruns not bumped on success"); } + // --- Seam interaction: with the write head near the end, a wrapping push fits while an + // over-capacity wrapping push is dropped whole (the overrun + wrap split happening together). --- + { + const std::uint32_t cap = 512; + std::vector storage; + AudioRingHeader* h = make_ring(storage, cap); + + // Advance the write head near the end so subsequent writes wrap the buffer seam. + std::vector pre(400, 0x55); + check(audio_ring_push(*h, pre.data(), 400, 1), "seam: prime to advance the write head"); + std::vector sink(400, 0); + check(audio_ring_pop(*h, sink.data(), 400) == 400, "seam: drain it (head now near the end)"); + check(audio_ring_available(*h) == 0, "seam: empty again, write head near the seam"); + + // A 200-byte push now splits across the seam; it fits, so the data must survive the split. + std::vector wrap(200); + for (std::uint32_t i = 0; i < 200; ++i) + { + wrap[i] = static_cast(i); + } + check(audio_ring_push(*h, wrap.data(), 200, 1), "seam: a wrapping push that fits is accepted"); + check(audio_ring_available(*h) == 200, "seam: 200 bytes present after the wrapping push"); + + // A 400-byte push would also wrap but can't fit (free = 312) -> dropped whole, overruns++. + const std::uint64_t before = h->overruns.load(); + std::vector big(400, 0xEE); + check(!audio_ring_push(*h, big.data(), 400, 1), "seam: an over-capacity wrapping push is rejected whole"); + check(h->overruns.load() == before + 1, "seam: overrun counted"); + check(audio_ring_available(*h) == 200, "seam: rejected wrapping push left the ring untouched"); + + // Pop the wrapped payload back and verify integrity across the seam. + std::vector out(200, 0); + check(audio_ring_pop(*h, out.data(), 200) == 200, "seam: pop the wrapped payload"); + bool ok = true; + for (std::uint32_t i = 0; i < 200; ++i) + { + ok = ok && out[i] == static_cast(i); + } + check(ok, "seam: wrapping push/pop preserved data across the buffer seam"); + } + std::printf(g_failures == 0 ? "AUDIO RING TEST PASS\n" : "AUDIO RING TEST FAILED (%d)\n", g_failures); return g_failures == 0 ? 0 : 1; } diff --git a/tests/shared_memory_test.cpp b/tests/shared_memory_test.cpp new file mode 100644 index 0000000..68b956a --- /dev/null +++ b/tests/shared_memory_test.cpp @@ -0,0 +1,80 @@ +// Unit test for the SharedMemory RAII wrapper (common/include/coop/shared_memory.hpp): create-or-open +// aliasing, move (steal handles + leave the source empty, no double-free), reset, and open-missing. +#include +#include +#include + +#include + +#include "coop/shared_memory.hpp" + +using namespace coop; + +namespace +{ +int g_failures = 0; +void check(bool ok, const char* what) +{ + std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what); + if (!ok) + { + ++g_failures; + } +} + +std::wstring uniq_name() +{ + return L"Local\\coop_shmtest_" + std::to_wstring(GetCurrentProcessId()); +} +} // namespace + +int main() +{ + const std::wstring name = uniq_name(); + constexpr std::size_t kSize = 4096; + + check(!SharedMemory{}.valid(), "default-constructed: not valid"); + + { + SharedMemory a; + check(a.create(name, kSize) && a.valid(), "create a named section"); + a.as()[0] = 0xC0FFEEu; + + // open() the same name -> a second view of the SAME section (aliases a's memory). + SharedMemory b; + check(b.open(name, kSize) && b.valid(), "open the existing section"); + check(b.as()[0] == 0xC0FFEEu, "open aliases the same memory (sees the write)"); + b.as()[1] = 0x1234u; + check(a.as()[1] == 0x1234u, "writes through one view are visible in the other"); + + // create() again with the same name -> opens the existing section (create-or-open). + SharedMemory c; + check(c.create(name, kSize) && c.valid() && c.as()[0] == 0xC0FFEEu, + "create-or-open: a second create sees the existing data"); + + // Move: the destination owns the mapping, the source is emptied (no double-free at scope end). + const void* a_ptr = a.data(); + SharedMemory moved = std::move(a); + check(moved.valid() && moved.data() == a_ptr, "move-construct steals the view"); + check(!a.valid() && a.data() == nullptr, "moved-from source is emptied"); + check(moved.as()[0] == 0xC0FFEEu, "moved view still maps the section"); + + SharedMemory move_assigned; + move_assigned.create(uniq_name() + L"_x", kSize); // give it something to reset first + move_assigned = std::move(moved); + check(move_assigned.valid() && !moved.valid(), "move-assign steals and empties the source"); + check(move_assigned.as()[1] == 0x1234u, "move-assigned view sees prior writes"); + + move_assigned.reset(); + check(!move_assigned.valid() && move_assigned.data() == nullptr, "reset releases the mapping"); + } + + // open() a name that no longer exists (all handles closed at scope end above) -> false. + { + SharedMemory gone; + check(!gone.open(name, kSize), "open a non-existent section returns false"); + } + + std::printf(g_failures == 0 ? "PASS shared_memory_test\n" : "FAILED shared_memory_test (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +} diff --git a/tests/tool_paths_test.cpp b/tests/tool_paths_test.cpp new file mode 100644 index 0000000..915c273 --- /dev/null +++ b/tests/tool_paths_test.cpp @@ -0,0 +1,84 @@ +// Unit test for the deployed-artifact path resolution (common/include/coop/tool_paths.hpp): a probe +// staged under bin//tools/ must still find coop_hook.dll etc. at the deployable root one level +// up. Drives the real probe logic by dropping marker files next to the exe and one directory up. +#include +#include + +#include + +#include "coop/tool_paths.hpp" + +using namespace coop; + +namespace +{ +int g_failures = 0; +void check(bool ok, const char* what) +{ + std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what); + if (!ok) + { + ++g_failures; + } +} + +bool touch(const std::wstring& path) +{ + HANDLE h = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) + { + return false; + } + CloseHandle(h); + return true; +} + +std::wstring parent_of(std::wstring dir) // dir has a trailing separator +{ + if (!dir.empty()) + { + dir.pop_back(); + } + const std::size_t slash = dir.find_last_of(L"\\/"); + return slash == std::wstring::npos ? std::wstring() : dir.substr(0, slash + 1); +} +} // namespace + +int main() +{ + const std::wstring here = exe_directory(); + check(!here.empty() && (here.back() == L'\\' || here.back() == L'/'), "exe_directory ends with a separator"); + + const std::wstring tag = std::to_wstring(GetCurrentProcessId()); + const std::wstring near_name = L"coop_tp_near_" + tag + L".marker"; + const std::wstring up_name = L"coop_tp_up_" + tag + L".marker"; + const std::wstring missing_name = L"coop_tp_missing_" + tag + L".marker"; + + const std::wstring up_dir = parent_of(here); + const bool have_up = !up_dir.empty(); + + // next-to-exe wins. + check(touch(here + near_name), "created a marker next to the exe"); + check(deployed_artifact_path(near_name.c_str()) == here + near_name, "resolves an artifact next to the exe"); + + // one directory up (the deployable root) when it isn't next to the exe. + if (have_up) + { + check(touch(up_dir + up_name), "created a marker one directory up"); + check(deployed_artifact_path(up_name.c_str()) == up_dir + up_name, + "falls back to the artifact one directory up"); + } + + // missing -> the next-to-exe path (so the caller can report a sensible 'not found'). + check(deployed_artifact_path(missing_name.c_str()) == here + missing_name, + "missing artifact -> next-to-exe fallback path"); + + DeleteFileW((here + near_name).c_str()); + if (have_up) + { + DeleteFileW((up_dir + up_name).c_str()); + } + + std::printf(g_failures == 0 ? "PASS tool_paths_test\n" : "FAILED tool_paths_test (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +} diff --git a/tests/wav_test.cpp b/tests/wav_test.cpp new file mode 100644 index 0000000..58b58ef --- /dev/null +++ b/tests/wav_test.cpp @@ -0,0 +1,160 @@ +// Unit test for the WAV reader's robustness on malformed input (common/include/coop/wav.hpp). +// tone_analysis_test already round-trips the writer's own output; this feeds hand-built byte streams: +// truncated headers, bad magic, missing chunks, an over-long `data` size (must clamp), an odd-sized +// chunk before data (word-align skip), and a corrupt huge chunk_size (must not hang/overflow). +#include +#include +#include +#include + +#include + +#include "coop/wav.hpp" + +using namespace coop; + +namespace +{ +int g_failures = 0; +int g_counter = 0; +void check(bool ok, const char* what) +{ + std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what); + if (!ok) + { + ++g_failures; + } +} + +std::wstring write_temp(const std::vector& bytes) +{ + wchar_t dir[MAX_PATH] = {}; + GetTempPathW(MAX_PATH, dir); + std::wstring path = std::wstring(dir) + L"coop_wavtest_" + std::to_wstring(GetCurrentProcessId()) + L"_" + + std::to_wstring(g_counter++) + L".wav"; + FILE* f = nullptr; + if (_wfopen_s(&f, path.c_str(), L"wb") == 0 && f != nullptr) + { + if (!bytes.empty()) + { + std::fwrite(bytes.data(), 1, bytes.size(), f); + } + std::fclose(f); + } + return path; +} + +void put4(std::vector& b, const char* s) +{ + b.insert(b.end(), s, s + 4); +} + +// Build a WAV where the `data` chunk's declared size can differ from the bytes actually appended +// (declared_data_size < 0 means "use the real size"), and an optional junk chunk can be inserted +// before `data` with a chosen size field (to exercise the chunk walk). +std::vector build_wav(std::uint16_t tag, std::uint16_t channels, std::uint32_t rate, + std::uint16_t bits, const std::vector& data, + long long declared_data_size = -1, const char* junk_id = nullptr, + std::uint32_t junk_size = 0) +{ + std::vector b; + put4(b, "RIFF"); + detail::wav_put_u32(b, 0); // riff size (reader ignores it) + put4(b, "WAVE"); + put4(b, "fmt "); + detail::wav_put_u32(b, 16); + detail::wav_put_u16(b, tag); + detail::wav_put_u16(b, channels); + detail::wav_put_u32(b, rate); + detail::wav_put_u32(b, rate * channels * (bits / 8)); + detail::wav_put_u16(b, static_cast(channels * (bits / 8))); + detail::wav_put_u16(b, bits); + if (junk_id != nullptr) + { + put4(b, junk_id); + detail::wav_put_u32(b, junk_size); + b.insert(b.end(), junk_size, 0xAB); // junk body + if (junk_size & 1) + { + b.push_back(0); // RIFF pads odd chunks to an even boundary (what the reader's & 1 skips) + } + } + put4(b, "data"); + detail::wav_put_u32(b, declared_data_size < 0 ? static_cast(data.size()) + : static_cast(declared_data_size)); + b.insert(b.end(), data.begin(), data.end()); + return b; +} +} // namespace + +int main() +{ + WavData out; + + // Truncated (< 44 bytes). + check(!wav_read(write_temp({'R', 'I', 'F', 'F', 0, 0, 0, 0}), out), "truncated file (<44B) rejected"); + + // Right size but wrong magic. + { + std::vector b(64, 0); + b[0] = 'R'; // not "RIFF"...."WAVE" + check(!wav_read(write_temp(b), out), "bad RIFF/WAVE magic rejected"); + } + + // Valid 16-bit PCM round-trips. + { + const std::vector data(800, 0x42); + out = WavData{}; + check(wav_read(write_temp(build_wav(1, 2, 44100, 16, data)), out), "valid PCM parses"); + check(out.format_tag == 1 && out.channels == 2 && out.sample_rate == 44100 && out.bits == 16, + "valid PCM: format fields correct"); + check(out.pcm.size() == 800, "valid PCM: full data recovered"); + } + + // fmt present but no data chunk -> rejected (have_data == false). + { + std::vector b; + put4(b, "RIFF"); + detail::wav_put_u32(b, 0); + put4(b, "WAVE"); + put4(b, "fmt "); + detail::wav_put_u32(b, 16); + detail::wav_put_u16(b, 1); + detail::wav_put_u16(b, 2); + detail::wav_put_u32(b, 48000); + detail::wav_put_u32(b, 48000 * 4); + detail::wav_put_u16(b, 4); + detail::wav_put_u16(b, 16); + check(!wav_read(write_temp(b), out), "fmt-only (no data chunk) rejected"); + } + + // data chunk declares MORE than the file holds -> clamp to what's there, still parse. + { + const std::vector data(100, 0x7F); + out = WavData{}; + check(wav_read(write_temp(build_wav(3, 1, 48000, 32, data, /*declared=*/1000000)), out), + "over-long data size still parses"); + check(out.pcm.size() == 100, "over-long data size clamps to available bytes"); + } + + // An odd-sized junk chunk before data -> the word-align skip must still find data. + { + const std::vector data(40, 0x11); + out = WavData{}; + check(wav_read(write_temp(build_wav(1, 2, 44100, 16, data, -1, "LIST", /*odd*/ 3)), out), + "odd-sized chunk before data: word-align skip finds data"); + check(out.pcm.size() == 40, "data after an odd chunk recovered"); + } + + // A corrupt, huge chunk_size before data must not hang or overflow -- the walk stops. + { + const std::vector data(40, 0x22); + out = WavData{}; + // junk chunk claims ~4 GB; the guard breaks before reaching the real data chunk -> no data found. + const bool parsed = wav_read(write_temp(build_wav(1, 2, 44100, 16, data, -1, "junk", 0xFFFFFFF0u)), out); + check(!parsed, "corrupt huge chunk_size: walk stops cleanly (no hang/overflow), data not reached"); + } + + std::printf(g_failures == 0 ? "PASS wav_test\n" : "FAILED wav_test (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +}