From 9557b9ca69acbf85a4e5dd9e793f6621fff1ed54 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Fri, 19 Jun 2026 20:25:04 +0200 Subject: [PATCH] Log window: stream the hook's logs over IPC into an in-app Log panel Add a shared log ring (common/coop/log_ring.hpp): a lossy multi-producer / single-consumer ring named coop_log_. The hook logs from several threads, so producers claim a slot with fetch_add and publish each record with a release store of its sequence; the consumer reads in order and tolerates losing the oldest lines if it falls a whole ring behind. The DLL's logf() now formats once and pushes every line to the ring (the file trace stays as an opt-in mirror); the worker attaches the ring right after IPC connect so bring-up is captured. The host (IpcServer) creates the ring at injection time and exposes drain_logs(); a new LogPanel pulls new lines each frame into a bounded rolling buffer and renders them with auto-scroll, a filter, and clear. Added to the View menu (and UiState.show_log). Verified against Phantom Brave via coop_audio_probe, which now also creates the ring and drains it: the full hook bring-up trace streamed over IPC. All four tests pass. Co-Authored-By: Claude Opus 4.8 --- README.md | 5 ++ common/include/coop/log_ring.hpp | 128 +++++++++++++++++++++++++++++++ hook/src/debug_log.cpp | 44 +++++++---- hook/src/debug_log.hpp | 19 +++-- hook/src/dllmain.cpp | 21 +++++ host/CMakeLists.txt | 1 + host/src/injection_panel.hpp | 7 ++ host/src/ipc/ipc_server.cpp | 12 +++ host/src/ipc/ipc_server.hpp | 16 ++++ host/src/log_panel.cpp | 75 ++++++++++++++++++ host/src/log_panel.hpp | 36 +++++++++ host/src/main.cpp | 7 ++ host/src/ui/app_chrome.cpp | 1 + host/src/ui/app_chrome.hpp | 1 + tools/audio_probe/main.cpp | 19 +++++ 15 files changed, 371 insertions(+), 21 deletions(-) create mode 100644 common/include/coop/log_ring.hpp create mode 100644 host/src/log_panel.cpp create mode 100644 host/src/log_panel.hpp diff --git a/README.md b/README.md index 56a641e..3fe0b3e 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,11 @@ Done: control is disabled when input forwarding is off, and the Audio panel notes when the render-hook is off (mirroring then uses loopback). Defaults to all-on so behavior is unchanged unless you toggle something. +- **In-app Log window. ✅** The injected DLL streams its log lines to the host + over a shared log ring (`coop_log_`, a lossy multi-producer ring), and the + host shows them in a **Log** window with auto-scroll, a text filter, and clear. + Replaces tailing `%TEMP%\coop_hook.log` (which stays as an opt-in file mirror). + `coop_audio_probe` drains and prints the same stream headless. Future work, roughly in priority order: diff --git a/common/include/coop/log_ring.hpp b/common/include/coop/log_ring.hpp new file mode 100644 index 0000000..de94cf0 --- /dev/null +++ b/common/include/coop/log_ring.hpp @@ -0,0 +1,128 @@ +// Shared-memory log channel: the injected hook (coop_hook.dll) streams its log +// lines to the host (coop_host.exe), which shows them in a Log window. Separate +// mapping from the input/status SharedBlock, named coop_log_. +// +// Lossy multi-producer / single-consumer ring: the hook logs from several threads +// (worker, audio render thread, window thread), so producers claim a slot with an +// atomic fetch_add and publish each record with a release store of its sequence; +// the host consumer reads in order and tolerates losing the oldest lines if it +// ever falls a whole ring behind (fine for diagnostics). POD + version-locked. +#pragma once + +#include +#include +#include +#include + +namespace coop +{ + +// 'CLOG' little-endian. +inline constexpr std::uint32_t kLogRingMagic = 0x474F4C43u; +inline constexpr std::uint32_t kLogRingVersion = 1; + +// Per-pid mapping name, mirroring the other channels: coop_log_. +inline constexpr wchar_t kLogRingPrefix[] = L"Local\\coop_log_"; + +inline constexpr std::uint32_t kLogMsgLen = 192; // chars per line (incl. NUL) +inline constexpr std::uint32_t kLogCapacity = 1024; // ring records + +struct LogRecord +{ + std::atomic seq; // 0 = empty; else (global index + 1) once written + std::uint32_t pid; + std::uint32_t level; // reserved (0) + std::uint64_t millis; // producer timestamp (GetTickCount64) + char text[kLogMsgLen]; +}; + +struct LogRing +{ + std::uint32_t magic; + std::uint32_t version; + std::uint32_t capacity; // number of records + std::uint32_t msg_len; // kLogMsgLen (sanity) + std::atomic write_index; // total records ever claimed (free-running) + std::uint8_t reserved[32]; + // LogRecord records[capacity] follows immediately. +}; + +static_assert(std::atomic::is_always_lock_free, + "log ring needs a lock-free 64-bit atomic for cross-process use"); + +inline constexpr std::size_t log_ring_total_size(std::uint32_t capacity) +{ + return sizeof(LogRing) + static_cast(capacity) * sizeof(LogRecord); +} + +inline LogRecord* log_ring_records(LogRing* r) +{ + return reinterpret_cast(reinterpret_cast(r) + sizeof(LogRing)); +} + +// Host: stamp a freshly created (zero-filled) mapping. Records start empty (seq 0). +inline void log_ring_init(LogRing& r, std::uint32_t capacity) +{ + r.capacity = capacity; + r.msg_len = kLogMsgLen; + r.write_index.store(0, std::memory_order_relaxed); + std::memset(r.reserved, 0, sizeof(r.reserved)); + r.version = kLogRingVersion; + r.magic = kLogRingMagic; // last +} + +inline bool log_ring_valid(const LogRing& r) +{ + return r.magic == kLogRingMagic && r.version == kLogRingVersion && r.capacity != 0 && + r.msg_len == kLogMsgLen; +} + +// Producer (hook): append a line. Multi-producer safe. +inline void log_ring_push(LogRing& r, std::uint32_t pid, std::uint64_t millis, const char* text) +{ + const std::uint64_t idx = r.write_index.fetch_add(1, std::memory_order_acq_rel); + LogRecord& rec = log_ring_records(&r)[idx % r.capacity]; + rec.pid = pid; + rec.level = 0; + rec.millis = millis; + std::strncpy(rec.text, text, kLogMsgLen - 1); + rec.text[kLogMsgLen - 1] = '\0'; + rec.seq.store(idx + 1, std::memory_order_release); // publish: record is ready +} + +// Consumer (host): emit each new record since `cursor` (advanced in place). Skips +// records lost to ring wrap; stops at an in-flight record and retries next call. +template +inline void log_ring_drain(LogRing& r, std::uint64_t& cursor, F&& emit) +{ + const std::uint64_t w = r.write_index.load(std::memory_order_acquire); + if (w <= cursor) + { + return; + } + const std::uint64_t lo = (w > r.capacity) ? (w - r.capacity) : 0; + std::uint64_t i = cursor < lo ? lo : cursor; // skip records already overwritten + LogRecord* recs = log_ring_records(&r); + for (; i < w; ++i) + { + LogRecord& rec = recs[i % r.capacity]; + const std::uint64_t s = rec.seq.load(std::memory_order_acquire); + if (s == i + 1) + { + emit(rec); // ready + } + else if (s <= i) + { + break; // slot not written for this generation yet (in-flight); retry later + } + // s > i + 1: overwritten before we read it; skip (lost) + } + cursor = i; +} + +inline std::wstring log_ring_name(unsigned long target_pid) +{ + return std::wstring(kLogRingPrefix) + std::to_wstring(target_pid); +} + +} // namespace coop diff --git a/hook/src/debug_log.cpp b/hook/src/debug_log.cpp index 486c1a4..52503a3 100644 --- a/hook/src/debug_log.cpp +++ b/hook/src/debug_log.cpp @@ -1,12 +1,15 @@ #define _CRT_SECURE_NO_WARNINGS #include "debug_log.hpp" +#include #include #include #include #include +#include "coop/log_ring.hpp" + namespace coop::hook { @@ -16,6 +19,7 @@ namespace std::mutex g_log_mutex; FILE* g_log_file = nullptr; bool g_log_tried = false; +std::atomic g_log_ring{nullptr}; // Logging is opt-in so an injected DLL doesn't write to disk in normal use. // Enable it by setting the COOP_HOOK_LOG environment variable for the target, or @@ -60,27 +64,37 @@ FILE* log_file_locked() } // namespace +void set_log_ring(coop::LogRing* ring) +{ + g_log_ring.store(ring, std::memory_order_release); +} + void logf(const char* fmt, ...) { - std::scoped_lock lock(g_log_mutex); - FILE* f = log_file_locked(); - if (f == nullptr) - { - return; - } - - SYSTEMTIME st; - GetLocalTime(&st); - std::fprintf(f, "[%02u:%02u:%02u.%03u pid=%lu] ", st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, - GetCurrentProcessId()); - + // Format the line once. + char line[coop::kLogMsgLen]; va_list args; va_start(args, fmt); - std::vfprintf(f, fmt, args); + std::vsnprintf(line, sizeof(line), fmt, args); va_end(args); - std::fputc('\n', f); - std::fflush(f); + // Stream to the host's Log window over the shared ring (the primary sink). + if (coop::LogRing* ring = g_log_ring.load(std::memory_order_acquire)) + { + coop::log_ring_push(*ring, GetCurrentProcessId(), GetTickCount64(), line); + } + + // Also mirror to the file when the opt-in trace is enabled. + std::scoped_lock lock(g_log_mutex); + FILE* f = log_file_locked(); + if (f != nullptr) + { + SYSTEMTIME st; + GetLocalTime(&st); + std::fprintf(f, "[%02u:%02u:%02u.%03u pid=%lu] %s\n", st.wHour, st.wMinute, st.wSecond, + st.wMilliseconds, GetCurrentProcessId(), line); + std::fflush(f); + } } } // namespace coop::hook diff --git a/hook/src/debug_log.hpp b/hook/src/debug_log.hpp index fba4141..7399eac 100644 --- a/hook/src/debug_log.hpp +++ b/hook/src/debug_log.hpp @@ -1,14 +1,21 @@ -// Lightweight file logger for diagnosing the injected hook from inside a game. -// -// We can't see stdout from an injected DLL, so route diagnostics to a file in -// %TEMP%\coop_hook.log. Thread-safe, opened lazily, append-only. Intended for -// development / bring-up; cheap enough to leave compiled in. +// Lightweight logger for the injected hook. We can't see stdout from an injected +// DLL, so each line is (a) streamed to the host over the shared log ring for the +// in-app Log window, and (b) optionally written to %TEMP%\coop_hook.log (opt-in; +// see debug_log.cpp). Thread-safe; cheap enough to leave compiled in. #pragma once +namespace coop +{ +struct LogRing; +} + namespace coop::hook { -// Append a printf-style line to %TEMP%\coop_hook.log (prefixed with pid + time). +// Append a printf-style line to the log ring (if attached) and the file (if on). void logf(const char* fmt, ...); +// Attach/detach the host's shared log ring so lines stream to the Log window. +void set_log_ring(coop::LogRing* ring); + } // namespace coop::hook diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index b5cc9cc..a072fa7 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -14,6 +14,7 @@ #include "audio_hook.hpp" #include "coop/audio_ring.hpp" +#include "coop/log_ring.hpp" #include "coop/shared_memory.hpp" #include "debug_log.hpp" #include "focus_spoof.hpp" @@ -27,6 +28,7 @@ namespace coop::hook::IpcClient g_ipc; std::atomic g_running{true}; coop::SharedMemory g_audio_shm; // the host's audio ring, opened when present +coop::SharedMemory g_log_shm; // the host's log ring, opened when present DWORD WINAPI worker_thread(LPVOID) { @@ -38,6 +40,24 @@ DWORD WINAPI worker_thread(LPVOID) coop::hook::logf("worker_thread: IPC connect FAILED (no host mapping); exiting"); return 0; } + // Attach the host's log ring first so the rest of bring-up streams to the Log + // window. The host creates it at injection time; it's normally already there. + { + const std::wstring log_name = coop::log_ring_name(GetCurrentProcessId()); + if (g_log_shm.open(log_name, coop::log_ring_total_size(coop::kLogCapacity))) + { + auto* lr = g_log_shm.as(); + if (coop::log_ring_valid(*lr)) + { + coop::hook::set_log_ring(lr); + } + else + { + g_log_shm.reset(); + } + } + } + coop::hook::logf("worker_thread: IPC connected"); // The audio render-hook instantiates a COM enumerator on this thread. @@ -161,6 +181,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) if (reserved == nullptr) { g_running.store(false, std::memory_order_relaxed); + coop::hook::set_log_ring(nullptr); coop::hook::remove_focus_spoof(); coop::hook::remove_xinput_hooks(); coop::hook::remove_audio_hooks(); diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index db409e9..66661e7 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -6,6 +6,7 @@ add_executable(coop_host WIN32 src/injection_panel.cpp src/capture_panel.cpp src/audio_panel.cpp + src/log_panel.cpp src/ui/app_chrome.cpp src/input/xinput_source.cpp src/inject/process_list.cpp diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index 1b7997b..100752d 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -42,6 +42,13 @@ public: return server_.hook_status(); } + // Drain log lines the hook streamed (for the Log window). No-op if not active. + template + void drain_logs(F&& emit) + { + server_.drain_logs(std::forward(emit)); + } + private: void refresh_processes(); void inject_selected(); diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index 03250be..673c01b 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -22,6 +22,15 @@ bool IpcServer::start(unsigned long target_pid) block_ = block; target_pid_ = target_pid; + + // Log ring: the injected hook opens this and streams its log lines back for the + // Log window. Best-effort -- the rest of the tool works without it. + if (log_shm_.create(log_ring_name(target_pid), log_ring_total_size(kLogCapacity))) + { + log_ring_ = log_shm_.as(); + log_ring_init(*log_ring_, kLogCapacity); + log_cursor_ = 0; + } return true; } @@ -96,6 +105,9 @@ void IpcServer::stop() block_ = nullptr; } shm_.reset(); + log_ring_ = nullptr; + log_shm_.reset(); + log_cursor_ = 0; target_pid_ = 0; } diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index 4b06585..72fe9a2 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -5,6 +5,7 @@ #include #include +#include "coop/log_ring.hpp" #include "coop/protocol.hpp" #include "coop/shared_memory.hpp" #include "input/input_source.hpp" @@ -56,6 +57,17 @@ public: // reconciles on its next tick. No-op if not started. void set_subsystem_enabled(std::uint32_t subsystem, bool enabled); + // Drain new log lines streamed by the hook, calling `emit(const LogRecord&)` + // for each. No-op if not started. Header-only so the callback can stay generic. + template + void drain_logs(F&& emit) + { + if (log_ring_ != nullptr) + { + log_ring_drain(*log_ring_, log_cursor_, emit); + } + } + [[nodiscard]] bool running() const { return block_ != nullptr; @@ -69,6 +81,10 @@ private: SharedMemory shm_; SharedBlock* block_ = nullptr; unsigned long target_pid_ = 0; + + SharedMemory log_shm_; // shared log ring (named coop_log_) + LogRing* log_ring_ = nullptr; + std::uint64_t log_cursor_ = 0; // consumer position into the log ring }; } // namespace coop diff --git a/host/src/log_panel.cpp b/host/src/log_panel.cpp new file mode 100644 index 0000000..bbff3bb --- /dev/null +++ b/host/src/log_panel.cpp @@ -0,0 +1,75 @@ +#include "log_panel.hpp" + +#include +#include + +#include "imgui.h" + +#include "injection_panel.hpp" + +namespace coop +{ + +void LogPanel::add_line(const LogRecord& rec) +{ + if (first_millis_ == 0) + { + first_millis_ = rec.millis; + } + const double secs = static_cast(rec.millis - first_millis_) / 1000.0; + + char buf[256]; + std::snprintf(buf, sizeof(buf), "[%8.3f] %s", secs, rec.text); + lines_.emplace_back(buf); + while (lines_.size() > kMaxLines) + { + lines_.pop_front(); + } +} + +void LogPanel::pull(InjectionPanel& injection) +{ + injection.drain_logs([this](const LogRecord& rec) { add_line(rec); }); +} + +void LogPanel::draw() +{ + ImGui::SetNextWindowPos(ImVec2(24, 760), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(720, 240), ImGuiCond_FirstUseEver); + ImGui::Begin("Log"); + + if (ImGui::Button("Clear")) + { + lines_.clear(); + first_millis_ = 0; + } + ImGui::SameLine(); + ImGui::Checkbox("Auto-scroll", &autoscroll_); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputTextWithHint("##logfilter", "filter...", filter_, sizeof(filter_)); + + ImGui::Separator(); + if (ImGui::BeginChild("loglines", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar)) + { + const bool has_filter = filter_[0] != '\0'; + for (const std::string& line : lines_) + { + if (has_filter && line.find(filter_) == std::string::npos) + { + continue; + } + ImGui::TextUnformatted(line.c_str()); + } + // Stick to the bottom while new lines arrive (unless the user scrolled up). + if (autoscroll_ && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 1.0f) + { + ImGui::SetScrollHereY(1.0f); + } + } + ImGui::EndChild(); + + ImGui::End(); +} + +} // namespace coop diff --git a/host/src/log_panel.hpp b/host/src/log_panel.hpp new file mode 100644 index 0000000..d61b50a --- /dev/null +++ b/host/src/log_panel.hpp @@ -0,0 +1,36 @@ +// Log window: shows the log lines the injected hook streams over the shared log +// ring (see coop/log_ring.hpp). Pull new lines each frame from the IPC server, +// keep a bounded rolling history, and render them with autoscroll + a filter. +#pragma once + +#include +#include +#include + +#include "coop/log_ring.hpp" + +namespace coop +{ + +class InjectionPanel; + +class LogPanel +{ +public: + // Pull any new lines the hook emitted (call once per frame before draw()). + void pull(InjectionPanel& injection); + + void draw(); + +private: + void add_line(const LogRecord& rec); + + std::deque lines_; + char filter_[96] = {}; + bool autoscroll_ = true; + std::uint64_t first_millis_ = 0; // hook clock at the first line, for relative timestamps + + static constexpr std::size_t kMaxLines = 2000; +}; + +} // namespace coop diff --git a/host/src/main.cpp b/host/src/main.cpp index 7722afd..b8622bf 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -20,6 +20,7 @@ #include "imgui_layer.hpp" #include "injection_panel.hpp" #include "input/xinput_source.hpp" +#include "log_panel.hpp" #include "ui/app_chrome.hpp" namespace @@ -68,6 +69,7 @@ int run() coop::InjectionPanel injection; coop::AudioPanel audio; coop::CapturePanel capture; + coop::LogPanel log; if (!capture.init(window.device())) { MessageBoxW(nullptr, L"Failed to initialize the video mirror.", L"CoopAllTheThings", MB_ICONERROR); @@ -91,6 +93,7 @@ int run() imgui.begin_frame(); stats.tick(ImGui::GetIO().DeltaTime * 1000.0f); + log.pull(injection); // drain hook log lines even while the Log window is hidden if (ImGui::IsKeyPressed(ImGuiKey_F1, false)) { @@ -120,6 +123,10 @@ int run() { capture.draw_ui(stats); } + if (ui.show_log) + { + log.draw(); + } } else { diff --git a/host/src/ui/app_chrome.cpp b/host/src/ui/app_chrome.cpp index 46ef490..ae9beec 100644 --- a/host/src/ui/app_chrome.cpp +++ b/host/src/ui/app_chrome.cpp @@ -24,6 +24,7 @@ float draw_main_menu_bar(UiState& ui, const FrameStats& stats) ImGui::MenuItem("Injection", nullptr, &ui.show_injection); ImGui::MenuItem("Video mirror", nullptr, &ui.show_video); ImGui::MenuItem("Audio mirror", nullptr, &ui.show_audio); + ImGui::MenuItem("Log", nullptr, &ui.show_log); ImGui::Separator(); ImGui::MenuItem("Debug details", nullptr, &ui.debug_details); ImGui::EndMenu(); diff --git a/host/src/ui/app_chrome.hpp b/host/src/ui/app_chrome.hpp index e849e6e..4620a41 100644 --- a/host/src/ui/app_chrome.hpp +++ b/host/src/ui/app_chrome.hpp @@ -18,6 +18,7 @@ struct UiState bool show_injection = true; bool show_video = true; bool show_audio = true; + bool show_log = true; bool debug_details = false; // off = general status; on = full diagnostics }; diff --git a/tools/audio_probe/main.cpp b/tools/audio_probe/main.cpp index dcffebe..93f9dc6 100644 --- a/tools/audio_probe/main.cpp +++ b/tools/audio_probe/main.cpp @@ -20,6 +20,7 @@ #include #include "coop/audio_ring.hpp" +#include "coop/log_ring.hpp" #include "coop/protocol.hpp" #include "coop/shared_memory.hpp" @@ -124,6 +125,16 @@ int wmain(int argc, wchar_t** argv) } block->magic = coop::kProtocolMagic; + // Log ring (host's role): the hook opens this and streams its log lines back. + coop::SharedMemory log_shm; + coop::LogRing* log_ring = nullptr; + std::uint64_t log_cursor = 0; + if (log_shm.create(coop::log_ring_name(pid), coop::log_ring_total_size(coop::kLogCapacity))) + { + log_ring = log_shm.as(); + coop::log_ring_init(*log_ring, coop::kLogCapacity); + } + // Enable the hook's file trace (%TEMP%\coop_hook.log) for this debug session. { wchar_t dir[MAX_PATH] = {}; @@ -259,6 +270,14 @@ int wmain(int argc, wchar_t** argv) e.installed ? "ON " : "off", static_cast(e.calls)); } + // Drain the IPC log ring to verify the hook streams its logs to the host. + if (log_ring != nullptr) + { + std::printf("\nHook log (streamed over IPC):\n"); + coop::log_ring_drain(*log_ring, log_cursor, + [](const coop::LogRecord& rec) { std::printf(" %s\n", rec.text); }); + } + std::printf("\nDone. Leaving the hook loaded in the game.\n"); block->magic = 0; // invalidate so a late hook read won't trust stale data return 0;