diff --git a/UE4SS/include/UE4SSProgram.hpp b/UE4SS/include/UE4SSProgram.hpp index 932ecb17a..7e36347c6 100644 --- a/UE4SS/include/UE4SSProgram.hpp +++ b/UE4SS/include/UE4SSProgram.hpp @@ -361,6 +361,6 @@ namespace RC friend void* HookedLoadLibraryExA(const char* dll_name, void* file, int32_t flags); friend void* HookedLoadLibraryW(const wchar_t* dll_name); friend void* HookedLoadLibraryExW(const wchar_t* dll_name, void* file, int32_t flags); - friend auto gui_render_thread_tick(Unreal::UObject*, float) -> void; + friend auto gui_render_thread_tick() -> void; }; } // namespace RC diff --git a/UE4SS/src/GUI/UFunctionCallerWidget.cpp b/UE4SS/src/GUI/UFunctionCallerWidget.cpp index 10dccc3e8..27507c031 100644 --- a/UE4SS/src/GUI/UFunctionCallerWidget.cpp +++ b/UE4SS/src/GUI/UFunctionCallerWidget.cpp @@ -136,7 +136,7 @@ namespace RC::GUI static FOutputDevice s_ar{}; static UFunction* s_function{}; static UObject* s_executor{}; - auto call_process_console_exec(UObject*, UFunction*, void*) -> void + auto call_process_console_exec(Hook::TCallbackIterationData&, UObject*, UFunction*, void*) -> void { if (s_do_call) { @@ -173,7 +173,7 @@ namespace RC::GUI if (!s_is_hooked) { s_is_hooked = true; - Hook::RegisterProcessEventPostCallback(call_process_console_exec); + Hook::RegisterProcessEventPostCallback(call_process_console_exec, {false, false, STR("UE4SS"), STR("FunctionCallerWidgetHook")}); } s_do_call = true; } diff --git a/UE4SS/src/Mod/LuaMod.cpp b/UE4SS/src/Mod/LuaMod.cpp index 2a833061d..43e566b28 100644 --- a/UE4SS/src/Mod/LuaMod.cpp +++ b/UE4SS/src/Mod/LuaMod.cpp @@ -3773,7 +3773,8 @@ No overload found for function 'IsInGameThread'. }); } - auto static process_event_hook([[maybe_unused]] Unreal::UObject* Context, + auto static process_event_hook([[maybe_unused]] Unreal::Hook::TCallbackIterationData& CallbackIterationData, + [[maybe_unused]] Unreal::UObject* Context, [[maybe_unused]] Unreal::UFunction* Function, [[maybe_unused]] void* Parms) -> void { @@ -3783,7 +3784,10 @@ No overload found for function 'IsInGameThread'. process_delayed_actions(LuaMod::m_delayed_game_thread_actions); } - auto static engine_tick_hook([[maybe_unused]] Unreal::UEngine* Context, [[maybe_unused]] float DeltaSeconds) -> void + auto static engine_tick_hook([[maybe_unused]] Unreal::Hook::TCallbackIterationData& CallbackIterationData, + [[maybe_unused]] Unreal::UEngine* Context, + [[maybe_unused]] float DeltaSeconds, + [[maybe_unused]] bool bIdle) -> void { std::lock_guard guard{LuaMod::m_thread_actions_mutex}; @@ -3807,7 +3811,7 @@ No overload found for function 'IsInGameThread'. { if (!m_is_engine_tick_hooked) { - Unreal::Hook::RegisterEngineTickPreCallback(&engine_tick_hook); + Unreal::Hook::RegisterEngineTickPreCallback(engine_tick_hook, {false, false, STR("UE4SS"), STR("LuaModImpl")}); m_is_engine_tick_hooked = true; } } @@ -3817,7 +3821,7 @@ No overload found for function 'IsInGameThread'. { if (!mod->m_is_process_event_hooked) { - Unreal::Hook::RegisterProcessEventPreCallback(&process_event_hook); + Unreal::Hook::RegisterProcessEventPreCallback(process_event_hook, {false, false, STR("UE4SS"), STR("LuaModImpl")}); mod->m_is_process_event_hooked = true; } } @@ -5562,7 +5566,7 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. LuaStatics::console_executor_enabled = false; } - static auto script_hook([[maybe_unused]] Unreal::UObject* Context, Unreal::FFrame& Stack, [[maybe_unused]] void* RESULT_DECL) -> void + static auto script_hook([[maybe_unused]] Unreal::Hook::TCallbackIterationData& CallbackIterationData, [[maybe_unused]] Unreal::UObject* Context, Unreal::FFrame& Stack, [[maybe_unused]] void* RESULT_DECL) -> void { std::lock_guard guard{LuaMod::m_thread_actions_mutex}; @@ -5699,12 +5703,10 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. auto LuaMod::on_program_start() -> void { Unreal::UObjectArray::AddUObjectDeleteListener(&LuaType::FLuaObjectDeleteListener::s_lua_object_delete_listener); - + const Unreal::Hook::FCallbackOptions common_opts {false, false, STR("UE4SS"), STR("LuaModImpl")}; Unreal::Hook::RegisterLoadMapPreCallback( - [](Unreal::UEngine* Engine, Unreal::FWorldContext& WorldContext, Unreal::FURL URL, Unreal::UPendingNetGame* PendingGame, Unreal::FString& Error) - -> std::pair { - return TRY([&] { - std::pair return_value{}; + [](Unreal::Hook::TCallbackIterationData& CallbackIterationData, Unreal::UEngine* Engine, Unreal::FWorldContext& WorldContext, Unreal::FURL URL, Unreal::UPendingNetGame* PendingGame, Unreal::FString& Error) { + TRY([&] { for (const auto& callback_data : m_load_map_pre_callbacks) { for (const auto& [lua_ptr, registry_index] : callback_data.registry_indexes) @@ -5722,7 +5724,6 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. if (callback_data.lua->is_nil()) { - return_value.first = false; callback_data.lua->discard_value(); } else if (!callback_data.lua->is_bool()) @@ -5731,20 +5732,16 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. } else { - return_value.first = true; - return_value.second = callback_data.lua->get_bool(); + CallbackIterationData.TrySetReturnValue(callback_data.lua->get_bool()); } } } - return return_value; }); - }); + }, common_opts); Unreal::Hook::RegisterLoadMapPostCallback( - [](Unreal::UEngine* Engine, Unreal::FWorldContext& WorldContext, Unreal::FURL URL, Unreal::UPendingNetGame* PendingGame, Unreal::FString& Error) - -> std::pair { - return TRY([&] { - std::pair return_value{}; + [](Unreal::Hook::TCallbackIterationData& CallbackIterationData, Unreal::UEngine* Engine, Unreal::FWorldContext& WorldContext, Unreal::FURL URL, Unreal::UPendingNetGame* PendingGame, Unreal::FString& Error) { + TRY([&] { for (const auto& callback_data : m_load_map_post_callbacks) { for (const auto& [lua_ptr, registry_index] : callback_data.registry_indexes) @@ -5762,7 +5759,6 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. if (callback_data.lua->is_nil()) { - return_value.first = false; callback_data.lua->discard_value(); } else if (!callback_data.lua->is_bool()) @@ -5771,16 +5767,14 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. } else { - return_value.first = true; - return_value.second = callback_data.lua->get_bool(); + CallbackIterationData.TrySetReturnValue(callback_data.lua->get_bool()); } } } - return return_value; }); - }); + }, common_opts); - Unreal::Hook::RegisterInitGameStatePreCallback([]([[maybe_unused]] Unreal::AGameModeBase* Context) { + Unreal::Hook::RegisterInitGameStatePreCallback([]([[maybe_unused]] Unreal::Hook::TCallbackIterationData& CallbackIterationData, [[maybe_unused]] Unreal::AGameModeBase* Context) { TRY([&] { for (const auto& callback_data : m_init_game_state_pre_callbacks) { @@ -5795,9 +5789,9 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. } } }); - }); + }, common_opts); - Unreal::Hook::RegisterInitGameStatePostCallback([]([[maybe_unused]] Unreal::AGameModeBase* Context) { + Unreal::Hook::RegisterInitGameStatePostCallback([]([[maybe_unused]] Unreal::Hook::TCallbackIterationData& CallbackIterationData, [[maybe_unused]] Unreal::AGameModeBase* Context) { TRY([&] { for (const auto& callback_data : m_init_game_state_post_callbacks) { @@ -5812,9 +5806,9 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. } } }); - }); + }, common_opts); - Unreal::Hook::RegisterBeginPlayPreCallback([]([[maybe_unused]] Unreal::AActor* Context) { + Unreal::Hook::RegisterBeginPlayPreCallback([]([[maybe_unused]] Unreal::Hook::TCallbackIterationData& CallbackIterationData, [[maybe_unused]] Unreal::AActor* Context) { TRY([&] { for (const auto& callback_data : m_begin_play_pre_callbacks) { @@ -5829,9 +5823,9 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. } } }); - }); + }, common_opts); - Unreal::Hook::RegisterBeginPlayPostCallback([]([[maybe_unused]] Unreal::AActor* Context) { + Unreal::Hook::RegisterBeginPlayPostCallback([]([[maybe_unused]] Unreal::Hook::TCallbackIterationData& CallbackIterationData, [[maybe_unused]] Unreal::AActor* Context) { TRY([&] { for (const auto& callback_data : m_begin_play_post_callbacks) { @@ -5846,9 +5840,9 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. } } }); - }); + }, common_opts); - Unreal::Hook::RegisterEndPlayPreCallback([]([[maybe_unused]] Unreal::AActor* Context, Unreal::EEndPlayReason EndPlayReason) { + Unreal::Hook::RegisterEndPlayPreCallback([]([[maybe_unused]] Unreal::Hook::TCallbackIterationData& CallbackIterationData, [[maybe_unused]] Unreal::AActor* Context, Unreal::EEndPlayReason EndPlayReason) { TRY([&] { for (const auto& callback_data : m_end_play_pre_callbacks) { @@ -5865,9 +5859,9 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. } } }); - }); + }, common_opts); - Unreal::Hook::RegisterEndPlayPostCallback([]([[maybe_unused]] Unreal::AActor* Context, Unreal::EEndPlayReason EndPlayReason) { + Unreal::Hook::RegisterEndPlayPostCallback([]([[maybe_unused]] Unreal::Hook::TCallbackIterationData& CallbackIterationData, [[maybe_unused]] Unreal::AActor* Context, Unreal::EEndPlayReason EndPlayReason) { TRY([&] { for (const auto& callback_data : m_end_play_post_callbacks) { @@ -5884,7 +5878,7 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. } } }); - }); + }, common_opts); Unreal::Hook::RegisterStaticConstructObjectPostCallback([](const Unreal::FStaticConstructObjectParameters&, Unreal::UObject* constructed_object) { return TRY([&] { @@ -6412,12 +6406,12 @@ No overload found for function 'FPackageName:IsValidLongPackageName'. if (Unreal::UObject::ProcessLocalScriptFunctionInternal.is_ready() && Unreal::Version::IsAtLeast(4, 22)) { Output::send(STR("Enabling custom events\n")); - Unreal::Hook::RegisterProcessLocalScriptFunctionPostCallback(script_hook); + Unreal::Hook::RegisterProcessLocalScriptFunctionPostCallback(script_hook, {false, false, STR("UE4SS"), STR("LuaModImplScriptHook")}); } else if (Unreal::UObject::ProcessInternalInternal.is_ready() && Unreal::Version::IsBelow(4, 22)) { Output::send(STR("Enabling custom events\n")); - Unreal::Hook::RegisterProcessInternalPostCallback(script_hook); + Unreal::Hook::RegisterProcessInternalPostCallback(script_hook, {false, false, STR("UE4SS"), STR("LuaModImplScriptHook")}); } } diff --git a/UE4SS/src/UE4SSProgram.cpp b/UE4SS/src/UE4SSProgram.cpp index 143aca4fc..cfecab160 100644 --- a/UE4SS/src/UE4SSProgram.cpp +++ b/UE4SS/src/UE4SSProgram.cpp @@ -900,7 +900,7 @@ namespace RC static bool s_gui_initialized_for_game_thread{}; static bool s_gui_initializing_for_game_thread{}; - auto gui_render_thread_tick(Unreal::UObject*, float) -> void + auto gui_render_thread_tick() -> void { if (UE4SSProgram::settings_manager.Debug.RenderMode == GUI::RenderMode::ExternalThread) { @@ -941,11 +941,11 @@ namespace RC if (settings_manager.Debug.RenderMode == GUI::RenderMode::EngineTick) { - Hook::RegisterEngineTickPostCallback(gui_render_thread_tick); + Hook::RegisterEngineTickPostCallback([](auto&,...){gui_render_thread_tick(); }, {false, false, STR("UE4SS"), STR("ImGuiRenderHook")}); } else if (settings_manager.Debug.RenderMode == GUI::RenderMode::GameViewportClientTick) { - Hook::RegisterGameViewportClientTickPostCallback(gui_render_thread_tick); + Hook::RegisterGameViewportClientTickPostCallback([](auto&,...){gui_render_thread_tick(); }, {false, false, STR("UE4SS"), STR("ImGuiRenderHook")}); } if (settings_manager.Debug.DebugConsoleEnabled) diff --git a/cppmods/CMakeLists.txt b/cppmods/CMakeLists.txt index 5024b01b3..b682e35b7 100644 --- a/cppmods/CMakeLists.txt +++ b/cppmods/CMakeLists.txt @@ -3,6 +3,7 @@ set_property(GLOBAL PROPERTY USE_FOLDERS ON) # Add C++ mods add_subdirectory("KismetDebuggerMod") +add_subdirectory("EventViewerMod") # Organize targets in the "mods" folder get_property(TARGETS DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY BUILDSYSTEM_TARGETS) diff --git a/cppmods/EventViewerMod/CMakeLists.txt b/cppmods/EventViewerMod/CMakeLists.txt new file mode 100644 index 000000000..9506a30c3 --- /dev/null +++ b/cppmods/EventViewerMod/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.22) +set(TARGET EventViewerMod) +project(${TARGET}) + +include(FetchContent) + +FetchContent_Declare( + concurrentqueue + GIT_REPOSITORY git@github.com:cameron314/concurrentqueue.git + GIT_TAG c68072129c8a5b4025122ca5a0c82ab14b30cb03 +) +FetchContent_MakeAvailable(concurrentqueue) + +add_library(${TARGET} SHARED + src/dllmain.cpp + src/EventViewer.cpp + src/Middleware.cpp + src/Client.cpp + src/Structs.cpp + src/StringPool.cpp + src/EntryCallStackRenderer.cpp + src/FilterCountRenderer.cpp +) + +target_include_directories(${TARGET} PRIVATE "include") +target_link_libraries(${TARGET} PRIVATE ImGui) +target_link_libraries(${TARGET} PRIVATE concurrentqueue) +target_link_libraries(${TARGET} PUBLIC UE4SS) \ No newline at end of file diff --git a/cppmods/EventViewerMod/README.md b/cppmods/EventViewerMod/README.md new file mode 100644 index 000000000..2b5c3454f --- /dev/null +++ b/cppmods/EventViewerMod/README.md @@ -0,0 +1,128 @@ +# EventViewerMod + +A UE4SS C++ mod that captures Unreal Engine call flow and renders it live in ImGui. + +It hooks **ProcessEvent**, **ProcessInternal**, and **ProcessLocalScriptFunction** concurrently and uses a **single +unified depth counter** so nested and recursive call chains keep a consistent indentation story (PE → PI → PLSF → …). + +## What you can do + +- Watch a **live call stack** with depth-indented entries. +- Switch between **Stack** and **Frequency** modes. +- Filter captures with **case-insensitive** whitelist/blacklist substring rules. +- Pause the stream and use right-click context menus to copy names, add filters, or open a focused call-stack modal. +- Save the current view (or everything) to a timestamped text file. + +## UI overview + +### Enable / Start / Pause + +- **Enable** toggles the mod on/off (and persists that setting). +- **Start/Stop** controls whether the middleware is actively capturing and dequeuing. +- **Pause** keeps capturing logic installed but stops dequeuing and UI growth. + +### Target filter + +The **Target** combo is a *view filter*, not a capture filter: + +- **All** shows the call stack exactly as the middleware reports it. +- **ProcessEvent / ProcessInternal / ProcessLocalScriptFunction** show only entries that originated from that hook. + +Important: depth is **not** recomputed when you filter. If you hide callers, the remaining entries keep their original +depth so you can still read the true nesting structure. + +### Modes + +- **Stack**: live call stack history (ordered by time, per thread). +- **Frequency**: aggregates by function and tracks how often it appears. + +### Thread picker + +Captures are grouped by the originating `std::thread::id`. The combo lets you switch which thread you’re viewing. The +game thread is labeled with `(Game)` when detected. + +### Performance knobs + +- **Max MS Read Time** and **Max Count Per Iteration** bound how much work `dequeue()` is allowed to do per ImGui frame. + +### Saving captures + +- **Save** writes the current thread + current mode to a timestamped file. +- **Save All** dumps both modes for all threads. + +## Filtering (case-insensitive) + +Whitelist and blacklist entries are **comma-separated tokens**. + +- Tokens are trimmed and converted to lowercase (ASCII-only lowercasing). +- Filtering is done against the entry’s cached **lower-cased** strings. +- The UI always displays the original (non-lowercased) names. + +Rules: + +- **Whitelist**: if empty, everything passes. If non-empty, an entry passes if **any** whitelist token is a substring + match. +- **Blacklist**: if any blacklist token is a substring match, the entry fails. +- **Show Tick Functions** is an additional filter gate applied on top. + +## Right-click menus + +Both stack entries and frequency entries have a right-click menu (when enabled by the current render flags) with helpers +such as: + +- Copy function/caller names to clipboard +- Add function/caller to whitelist/blacklist +- Open the call stack modal (when the stream is paused) + +## Call stack modal + +When the stream is paused, the context menu can open a modal window that shows an entry’s root call chain. + +Definitions: + +- The **root caller** of an entry is the depth `0` entry that began the call chain that ultimately led to the selected + entry. + +The modal provides: + +- **Show full context** + - Enabled: shows all calls produced by the root caller (the entire subtree under that root). + - Disabled: shows the path from the root → selected entry, plus the calls triggered by the selected entry. +- **Disable Indent Colors** + - Mirrors the main window’s behavior. + +## Architecture (high-level) + +- **Middleware** (`include/Middleware.hpp`, `src/Middleware.cpp`) + - Owns the UE hooks and pushes lightweight capture entries into a `moodycamel::ConcurrentQueue`. + - Uses thread-local producer tokens for low overhead under high call volume. + - Uses a one-time barrier/flag to prevent enqueuing until all hooks are installed (to keep depth sane). + +- **Client** (`include/Client.hpp`, `src/Client.cpp`) + - ImGui renderer + persistent UI state. + - Dequeues entries, groups by thread, maintains stack/frequency views, and applies filters. + +- **StringPool** (`include/StringPool.hpp`, `src/StringPool.cpp`) + - Interns function and caller strings and returns stable `std::string_view` pairs. + - Caches both original and lowercased variants. + - Produces a function hash (from Unreal’s `ComparisonIndex`) to avoid expensive string comparisons in hot paths. + +- **EntryCallStackRenderer** (`include/EntryCallStackRenderer.hpp`, `src/EntryCallStackRenderer.cpp`) + - Manages the call-stack modal’s state and rendering. + +## Files and persistence + +- UI state is stored as JSON at: + - `Mods/EventViewerMod/config/settings.json` +- Capture dumps are written to: + - `Mods/EventViewerMod/captures/` + +## Building + +This mod is intended to be compiled as a UE4SS C++ mod (MSVC, `/std:c++latest`). + +## Notes and gotchas + +- String views returned from `StringPool` are stable until the pool is cleared. The current implementation is designed + for “grow-only” usage during a session and never clears, but if later implementation does want to support clearing it, + they should also clear all threads. diff --git a/cppmods/EventViewerMod/include/Client.hpp b/cppmods/EventViewerMod/include/Client.hpp new file mode 100644 index 000000000..6a0044124 --- /dev/null +++ b/cppmods/EventViewerMod/include/Client.hpp @@ -0,0 +1,94 @@ +#pragma once + +// EventViewerMod: ImGui-facing front-end. +// +// Owns persistent UI state (filters, selected view, per-thread buffers) and pulls capture entries +// from the Middleware each frame. The hot path (hooking + enqueue) lives in Middleware; Client +// is deliberately written as a consumer that can be throttled (max ms / max count per frame). +// +// Threading notes: +// - render() is expected to be called only on the ImGui thread. +// - request_save_state() may be called from any thread (it uses an atomic flag). + +#include +#include + +#include +#include +#include + +#include + +namespace RC::EventViewerMod +{ + class EntryCallStackRenderer; + + class Client + { + public: + // [Thread-ImGui] + auto render() -> void; + + // [Thread-Any] Saves state on the next frame. + auto request_save_state() -> void; + + // [Thread-ImGui] + auto add_to_white_list(std::string_view item) -> void; + + // [Thread-ImGui] + auto add_to_black_list(std::string_view item) -> void; + + // [Thread-ImGui] + auto render_entry_stack_modal(const CallStackEntry* entry) -> void; + + // [Thread-Any] + static auto GetInstance() -> Client&; + + private: + Client(); + + auto render_cfg() -> void; + auto render_perf_opts() -> void; + auto render_view() -> void; + + static auto combo_with_flags(const char* label, int* current_item, const char* const items[], int items_count, ImGuiComboFlags_ flags = ImGuiComboFlags_None) + -> bool; + + auto save_state() -> void; + auto load_state() -> void; + auto check_save_request() -> bool; + + auto apply_filters_to_history(bool whitelist_changed, bool blacklist_changed, bool tick_changed) -> void; + auto dequeue() -> void; + + auto passes_filters(std::string_view test_str) const -> bool; + + enum class ESaveMode + { + none, + current, + all + }; + + auto save(ESaveMode mode) -> void; + auto serialize_view(ThreadInfo& info, EMode mode, EMiddlewareHookTarget hook_target, std::ofstream& out) const -> void; + auto serialize_all_views(std::ofstream& out) -> void; + + auto clear_threads() -> void; + + auto can_render_entry(const CallStackEntry& entry) const -> bool; + auto resize_render_set(ThreadInfo& thread, size_t max_size) const -> void; + + UIState m_state{}; + + Middleware& m_middleware; + + std::filesystem::path m_cfg_path{}; + std::filesystem::path m_dump_dir{}; + + std::unique_ptr m_entry_call_stack_renderer{}; + FilterCountRenderer m_filter_count_renderer{}; + + bool m_imgui_thread_id_set = false; + }; +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/include/EntryCallStackRenderer.hpp b/cppmods/EventViewerMod/include/EntryCallStackRenderer.hpp new file mode 100644 index 000000000..d3e4d9f39 --- /dev/null +++ b/cppmods/EventViewerMod/include/EntryCallStackRenderer.hpp @@ -0,0 +1,49 @@ +#pragma once + +// EventViewerMod: Call stack modal renderer. +// +// The main Stack view is a scrolling, time-ordered history. This helper renders a *focused slice*: +// given a selected CallStackEntry, it builds a context vector and renders it in an ImGui modal. +// +// Concepts: +// - "Root" caller: the depth==0 entry that started the call chain. An entry can be its own root. +// - Full context: show all entries produced by the root call chain. +// - Focused context: show the path leading to the selected entry, the entry itself, and then +// everything that happens underneath that entry. +// +// Note: ImGui modals require calling OpenPopup() on the frame you want the modal to begin opening. + +#include +#include + +namespace RC::EventViewerMod +{ + // Renders the call stack/context modal for a single selected entry. + // The context vector is prepared by the caller (Client) and passed in by value. + class EntryCallStackRenderer + { + public: + EntryCallStackRenderer() = delete; + EntryCallStackRenderer(const EntryCallStackRenderer& Other) = delete; + EntryCallStackRenderer(EntryCallStackRenderer&& Other) noexcept = delete; + EntryCallStackRenderer& operator=(const EntryCallStackRenderer& Other) = delete; + EntryCallStackRenderer& operator=(EntryCallStackRenderer&& Other) noexcept = delete; + + EntryCallStackRenderer(size_t target_idx, std::vector context); + + // Returns false when the modal is finished and can be destroyed. + auto render() -> bool; + + private: + size_t m_target_idx; + const CallStackEntry* m_target_ptr; + std::vector m_context; + std::string m_last_save_path; + bool m_disable_indent_colors = false; + bool m_show_full_context = false; + // ImGui popups need an explicit OpenPopup() call. We request it once so the modal can appear. + bool m_requested_open = false; + + auto save() -> void; + }; +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/include/Enums.hpp b/cppmods/EventViewerMod/include/Enums.hpp new file mode 100644 index 000000000..b738c4616 --- /dev/null +++ b/cppmods/EventViewerMod/include/Enums.hpp @@ -0,0 +1,153 @@ +#pragma once + +// EventViewerMod: UI enums and small reflection helpers. +// +// We use X-macros to define enums once and auto-generate: +// - E_Size +// - E_NameArray (for ImGui combo boxes) +// - to_string(...) and to_prefix_string(...) +// +// MiddlewareHookTarget is a *bitmask* enum (powers of two). In the UI it is used as a view filter: +// selecting a target hides entries not produced by that hook, but DOES NOT change indentation depth. + +namespace RC::EventViewerMod +{ +#define EVM_MIDDLEWARE_HOOK_TARGET_FLAGS(X, EnumName) \ + X(EnumName, All, ((1 << 0) | (1 << 1) | (1 << 2))) \ + X(EnumName, ProcessEvent, (1 << 0)) \ + X(EnumName, ProcessInternal, (1 << 1)) \ + X(EnumName, ProcessLocalScriptFunction, (1 << 2)) + +#define EVM_MODE(X, EnumName) \ + X(EnumName, Stack) \ + X(EnumName, Frequency) + +#define EVM_STRINGIZE_1(x) #x +#define EVM_STRINGIZE(x) EVM_STRINGIZE_1(x) + +// Per-item emitters +#define EVM_ENUM_ELEM(EnumName, v) v, +#define EVM_ENUM_CASE(EnumName, v) \ + case E##EnumName::v: \ + return EVM_STRINGIZE(v); +#define EVM_ENUM_STR(EnumName, v) EVM_STRINGIZE(v), +#define EVM_ENUM_COUNT(EnumName, v) +1 + +// Prefix emitters +#define EVM_ENUM_PREFIX_CASE(EnumName, v) \ + case E##EnumName::v: \ + return "(" EVM_STRINGIZE(v) ") "; + +// Per-item emitters (with values) +#define EVM_ENUM_ELEM_V(EnumName, v, val) v = val, +#define EVM_ENUM_CASE_V(EnumName, v, val) \ + case E##EnumName::v: \ + return EVM_STRINGIZE(v); +#define EVM_ENUM_STR_V(EnumName, v, val) EVM_STRINGIZE(v), +#define EVM_ENUM_VAL_V(EnumName, v, val) E##EnumName::v, +#define EVM_ENUM_COUNT_V(EnumName, v, val) +1 + +// Prefix emitters (with values) +#define EVM_ENUM_PREFIX_CASE_V(EnumName, v, val) \ + case E##EnumName::v: \ + return "(" EVM_STRINGIZE(v) ") "; + +#define EVM_DECLARE_REFLECTED_ENUM(Name, LIST2) \ + enum class E##Name : int{LIST2(EVM_ENUM_ELEM, Name)}; \ + \ + inline static constexpr int E##Name##_Size = 0 LIST2(EVM_ENUM_COUNT, Name); \ + \ + inline static constexpr const char* E##Name##_NameArray[E##Name##_Size] = {LIST2(EVM_ENUM_STR, Name)}; \ + \ + inline constexpr const char* to_string(E##Name e) noexcept \ + { \ + switch (e) \ + { \ + LIST2(EVM_ENUM_CASE, Name) \ + default: \ + return ""; \ + } \ + } \ + \ + inline constexpr const char* to_prefix_string(E##Name e) noexcept \ + { \ + switch (e) \ + { \ + LIST2(EVM_ENUM_PREFIX_CASE, Name) \ + default: \ + return "() "; \ + } \ + } + +#define EVM_DECLARE_REFLECTED_ENUM_VALUES(Name, LIST3) \ + enum class E##Name : int{LIST3(EVM_ENUM_ELEM_V, Name)}; \ + \ + inline static constexpr int E##Name##_Size = 0 LIST3(EVM_ENUM_COUNT_V, Name); \ + \ + inline static constexpr E##Name E##Name##_ValueArray[E##Name##_Size] = {LIST3(EVM_ENUM_VAL_V, Name)}; \ + \ + inline static constexpr const char* E##Name##_NameArray[E##Name##_Size] = {LIST3(EVM_ENUM_STR_V, Name)}; \ + \ + inline constexpr const char* to_string(E##Name e) noexcept \ + { \ + switch (e) \ + { \ + LIST3(EVM_ENUM_CASE_V, Name) \ + default: \ + return ""; \ + } \ + } + + EVM_DECLARE_REFLECTED_ENUM_VALUES(MiddlewareHookTarget, EVM_MIDDLEWARE_HOOK_TARGET_FLAGS); + EVM_DECLARE_REFLECTED_ENUM(Mode, EVM_MODE); + +#undef EVM_DECLARE_REFLECTED_ENUM_VALUES +#undef EVM_DECLARE_REFLECTED_ENUM +#undef EVM_ENUM_PREFIX_CASE_V +#undef EVM_ENUM_PREFIX_CASE +#undef EVM_ENUM_COUNT_V +#undef EVM_ENUM_VAL_V +#undef EVM_ENUM_STR_V +#undef EVM_ENUM_CASE_V +#undef EVM_ENUM_ELEM_V +#undef EVM_ENUM_COUNT +#undef EVM_ENUM_STR +#undef EVM_ENUM_CASE +#undef EVM_ENUM_ELEM +#undef EVM_STRINGIZE +#undef EVM_STRINGIZE_1 +#undef EVM_MODE +#undef EVM_MIDDLEWARE_HOOK_TARGET_FLAGS + + enum ECallStackEntryRenderFlags_ : uint8_t + { + ECallStackEntryRenderFlags_None = 0, + ECallStackEntryRenderFlags_WithSupportMenus = 1, + ECallStackEntryRenderFlags_WithSupportMenusCallStackModal = 3, + ECallStackEntryRenderFlags_IndentColors = 4, + ECallStackEntryRenderFlags_Highlight = 8 + }; + + enum ECallFrequencyEntryRenderFlags_ : uint8_t + { + ECallFrequencyEntryRenderFlags_None = 0, + ECallFrequencyEntryRenderFlags_WithSupportMenus = 1 + }; + + inline constexpr const char* to_prefix_string(const EMiddlewareHookTarget e) noexcept + { + switch (e) + { + case EMiddlewareHookTarget::ProcessEvent: + return "(PE) "; + case EMiddlewareHookTarget::ProcessInternal: + return "(PI) "; + case EMiddlewareHookTarget::ProcessLocalScriptFunction: + return "(PLSF) "; + case EMiddlewareHookTarget::All: + return "(ALL) "; + default: + return "(UnknownTarget) "; + } + } +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/include/EventViewer.hpp b/cppmods/EventViewerMod/include/EventViewer.hpp new file mode 100644 index 000000000..3086d2010 --- /dev/null +++ b/cppmods/EventViewerMod/include/EventViewer.hpp @@ -0,0 +1,22 @@ +#pragma once + +// EventViewerMod: UE4SS mod entry + ImGui tab registration. +// +// UE4SS calls start_mod() (see dllmain.cpp) to create this mod instance. On Unreal init we register +// a new ImGui tab and route rendering to Client. + +#include +#include + +namespace RC::EventViewerMod +{ + class EventViewerMod : public CppUserModBase + { + public: + EventViewerMod(); + auto on_unreal_init() -> void override; + + private: + std::atomic_flag m_unreal_loaded{}; + }; +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/include/FilterCountRenderer.hpp b/cppmods/EventViewerMod/include/FilterCountRenderer.hpp new file mode 100644 index 000000000..2f23f21e9 --- /dev/null +++ b/cppmods/EventViewerMod/include/FilterCountRenderer.hpp @@ -0,0 +1,16 @@ +#pragma once + +namespace RC::EventViewerMod +{ + class FilterCountRenderer + { + public: + FilterCountRenderer() = default; + auto add() -> void; + auto render_and_reset(bool show_tooltip) -> void; + static auto render(size_t count, bool show_tooltip) -> void; + + private: + size_t m_count = 0; + }; +} // namespace RC::EventViewerMod \ No newline at end of file diff --git a/cppmods/EventViewerMod/include/HelpStrings.hpp b/cppmods/EventViewerMod/include/HelpStrings.hpp new file mode 100644 index 000000000..12df0dcb1 --- /dev/null +++ b/cppmods/EventViewerMod/include/HelpStrings.hpp @@ -0,0 +1,115 @@ +#pragma once + +// clang-format off + +// EventViewerMod: Centralized UI help strings. +// +// Keeping these in one place makes it easier to tweak copy without touching the main UI code. + + +// EventViewerMod: Small user-facing help strings shown in the UI. + +#include + +#define FILTER_NOTE "Note that filters don't affect the stack depth; they will always be shown as-is to indicate "\ + "callers that may be potentially filtered out. You can right click any function while paused "\ + "and select \"Show Call Stack\" to see the unfiltered call stack for that function.\n\n"\ + "Additionally, filters are applied to both incoming calls and the current history of all calls "\ + "in all threads." + +namespace RC::EventViewerMod +{ + // From ImGui demo + inline static void HelpMarker(const char* desc) + { + ImGui::SameLine(); + ImGui::TextDisabled("(?)"); + if (ImGui::BeginItemTooltip()) + { + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::TextUnformatted(desc); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + } + + struct HelpStrings + { + inline static constexpr auto HELP_TARGET = "Filter by which target should be shown.\n\n" + "Calls are prefixed by the target that called them with their initials.\n\n" + "A common path that many function calls follow/cause is ProcessEvent->ProcessInternal->ProcessLocalScriptFunction.\n\n" + FILTER_NOTE; + + inline static constexpr auto HELP_MODE = "Select the mode that the view should run in.\n\n" + "Stack: View a filtered stream (see below) of the live call stack for the selected Target.\n\n" + "Frequency: View a table of all of the functions called so far with the number of times " + "each function has been called."; + + inline static constexpr auto HELP_LIST_FILTER = "Whitelist/Blacklist filters both accept comma-separate lists of strings with case-insensitive matching.\n\n" + "Note that you can press enter to apply them when focused as well as press the button.\n\n" + "If you don't have any filters, then all calls that match your Target and \"Show Builtin Tick Functions\" settings " + "will be shown.\n\n" + "The caller's name and the function's name make up a call's name, in the form of \"Caller.Function\".\n\n" + "For a call to pass the whitelist filter, the call must have at least one of the comma-separated strings as a substring of its name. " + "Only having a whitelist filter is effectively the same as a normal search.\n\n" + "For a call to pass the blacklist filter, the call must not have any of the comma-separated strings as a substring of its name.\n\n" + "The whitelist filter applies before the blacklist filter, and each call must pass them in that order to be visible.\n\n" + FILTER_NOTE; + + inline static constexpr auto HELP_THREAD = "Select the thread to monitor.\n\n" + "This generally tries to default to the game thread, marked with \"(Game)\"."; + + inline static constexpr auto HELP_CLEAR = "Clears this thread's call stack and frequency counter."; + + inline static constexpr auto HELP_CLEAR_ALL = "Clears all call stacks and frequency counters. This also removes all known threads."; + + inline static constexpr auto HELP_SAVE = "Save the current filtered view to a text file.\n\n" + "This means that if the Mode is \"Call Stack\", the current call stack view " + "is saved, with all of the same filters applied, and if the Mode is \"Frequency\", " + "the table of frequencies is saved.\n\n" + "The file is saved in Mods\\EventViewerMod\\captures from the ue4ss directory."; + + inline static constexpr auto HELP_SAVE_ALL = "Save all views to a text file.\n\n" + "The file is saved in Mods\\EventViewerMod\\captures from the ue4ss directory."; + + inline static constexpr auto HELP_SHOW_BUILTIN_TICK = "Attempts to automatically filter out \"Tick\" and \"ReceiveTick\" functions from the view.\n\n" + "This is inherently faster than blacklisting \"Tick\", but may not block all \"Tick\" functions."; + + inline static constexpr auto HELP_CEMODAL_SHOW_FULL_CONTEXT = "Shows all function calls associated with the selected call.\n\n" + "Note that this whole view may differ from what you see in the normal Call Stack Mode since " + "this has no filters applied at all."; + + inline static constexpr auto HELP_CEMODAL_SAVE = "Saves this entry's call stack to a text file, respecting the \"Show Full Context\" setting.\n\n" + "The file is saved in Mods\\EventViewerMod\\captures from the ue4ss directory."; + + inline static constexpr auto HELP_MAX_MS_READ_TIME = "The max amount of time the ImGui thread will spend dequeuing calls in milliseconds in a frame.\n\n" + "Basically, the ImGui thread will dequeue up to \"Max Count Per Iteration\", until the queue is empty, or " + "until it hits this time limit."; + inline static constexpr auto HELP_MAX_COUNT_PER_ITERATION = "The max amount of calls that will be dequeued in a frame.\n\n" + "Basically, the ImGui thread will dequeue up to \"Max Count Per Iteration\", until the queue is empty, or " + "until it hits the time limit in \"Max MS Read Time\"."; + inline static constexpr auto HELP_QUEUE_PROFILE_VALUES = "Enqueue Avg (microseconds): The average time it takes a hook to enqueue a call. It should be as low as possible.\n\n" + "Dequeue Avg (microseconds): The average time it takes for the ImGui thread to dequeue a single call. This should be as low " + "as possible, though it's likely to increase over time.\n\n" + "Pending Avg (calls): The average amount of calls that are left in the queue after the ImGui thread finishes an iteration of" + "dequeuing. Should be 0 or very close to it.\n\n" + "Time Slot Exceeded Count: Amount of times the ImGui thread exceeded the time set in \"Max MS Read Time\"." + "Should be 0, or at least rarely moving."; + + inline static constexpr auto HELP_TEXT_VIRTUALIZATION_COUNT = "The total amount of calls that can be streamed into ImGui while running.\n\n" + "Changing this value will clear all threads.\n\n" + "When scrolling up while paused, you'll eventually run into a \"Load More\" button, " + "where the number of calls loaded doubles every time from this number.\n\n" + "This doesn't affect filtering, nor does it erase calls, it just controls how much is in " + "ImGui at a time (apart from clearing the threads when changing this value).\n\n" + "Higher values means making the \"Load More\" button appear later, but can" + "come in at a hefty performance cost."; + + inline static constexpr auto HELP_ADD_CALLER_AND_FUNC_NAME_WARNING = "WARNING: Adding Caller or Caller + Function name to a filter may block output completely in " + "Frequency Mode, since the Frequency Mode is only aware of the function name!"; + }; +} + +#undef FILTER_NOTE + +// clang-format on \ No newline at end of file diff --git a/cppmods/EventViewerMod/include/Middleware.hpp b/cppmods/EventViewerMod/include/Middleware.hpp new file mode 100644 index 000000000..9682f5f5b --- /dev/null +++ b/cppmods/EventViewerMod/include/Middleware.hpp @@ -0,0 +1,195 @@ +#pragma once + +// EventViewerMod: Capture backend (UE hook installation + queueing). +// +// Hooks multiple Unreal call sites and enqueues CallStackEntry objects into a lock-free +// moodycamel::ConcurrentQueue. The queue is drained on the ImGui thread by Client. +// +// Design constraints: +// - Hooks are extremely hot (ProcessEvent/ProcessInternal/ProcessLocalScriptFunction), so this code +// avoids allocations where possible and uses thread_local producer tokens. +// - Depth is unified across the hooked functions so nested / recursive call flows keep consistent +// indentation regardless of which hook produced a given entry. + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace RC::EventViewerMod +{ + class Middleware + { + public: + // By-reference singleton. + static auto GetInstance() -> Middleware&; + + // [Thread-Any] Enqueues info on a call. The hook_target is captured at enqueue-time. + auto enqueue(EMiddlewareHookTarget hook_target, RC::Unreal::UObject* context, RC::Unreal::UFunction* function) -> void; + + // [Thread-ImGui] Dequeues call info. + // max_ms - maximum wall time (ms) to spend dequeuing. + // max_count_per_iteration - max items pulled per bulk-dequeue. + // on_dequeue - receives the dequeued entry by rvalue-ref (move). + auto dequeue(uint16_t max_ms, uint16_t max_count_per_iteration, const std::function& on_dequeue) -> void; + + // [Thread-ImGui] Pauses stream, removes hooks. Also drains queue. + auto stop() -> bool; + + // [Thread-ImGui] + [[nodiscard]] auto is_paused() const -> bool; + + // [Thread-ImGui] Resumes stream by installing hooks. + auto start() -> bool; + + // [Thread-ImGui] + auto set_imgui_thread_id(std::thread::id id) -> void; + + // [Thread-ImGui] + [[nodiscard]] auto get_imgui_thread_id() const -> std::thread::id; + + // [Thread-ImGui] + [[nodiscard]] auto get_average_enqueue_time() const -> double; + + // [Thread-ImGui] + [[nodiscard]] auto get_average_dequeue_time() const -> double; + + ~Middleware(); + + private: + Middleware(); + + auto assert_on_imgui_thread() const -> void; + [[nodiscard]] auto is_tick_fn(const RC::Unreal::UFunction* fn) const -> bool; + auto stop_impl(bool do_assert) -> bool; + + template + struct HookController + { + Unreal::Hook::GlobalCallbackId (*register_prehook_fn)(CallbackType, Unreal::Hook::FCallbackOptions){}; + Unreal::Hook::GlobalCallbackId (*register_posthook_fn)(CallbackType, Unreal::Hook::FCallbackOptions){}; + CallbackType m_pre_callback{}; + CallbackType m_post_callback{}; + Unreal::Hook::GlobalCallbackId m_prehook_id = Unreal::Hook::ERROR_ID; + Unreal::Hook::GlobalCallbackId m_posthook_id = Unreal::Hook::ERROR_ID; + + auto install_prehook() -> bool; + auto install_posthook() -> bool; + auto unhook() -> void; + auto is_hooked() const -> bool; + + inline static Unreal::Hook::FCallbackOptions m_cb_options{false, true, STR("EventViewer"), STR("CallStackMonitor")}; + }; + + private: + HookController m_pe_controller{}; + HookController m_pi_controller{}; + HookController m_plsf_controller{}; + + bool m_paused = true; + + std::thread::id m_imgui_id{}; + + // Queue + moodycamel::ConcurrentQueue m_queue{}; + moodycamel::ConsumerToken m_imgui_consumer_token{m_queue}; + std::vector m_buffer{}; + + // Detected tick functions + std::unordered_set m_tick_fns{}; + + inline static thread_local uint32_t m_depth = 0; + std::atomic_uint64_t m_depth_reset_counter = 0; + + std::atomic_flag m_allow_queue; + }; + + template + auto Middleware::HookController::install_prehook() -> bool + { + if (m_prehook_id != RC::Unreal::Hook::ERROR_ID) + { + Output::send(STR("[EventViewerMod] Failed to install prehook because it's already installed!")); + return false; + } + + if (!register_prehook_fn) + { + Output::send(STR("[EventViewerMod] Failed to install prehook because register function is unknown! Is FName.toString known?")); + return false; + } + + m_prehook_id = register_prehook_fn(m_pre_callback, m_cb_options); + + if (m_prehook_id == RC::Unreal::Hook::ERROR_ID) + { + Output::send(STR("[EventViewerMod] Failed to install prehook!")); + return false; + } + + return true; + } + + template + auto Middleware::HookController::install_posthook() -> bool + { + if (m_posthook_id != RC::Unreal::Hook::ERROR_ID) + { + Output::send(STR("[EventViewerMod] Failed to install posthook because it's already installed!")); + return false; + } + + if (!register_posthook_fn) + { + Output::send(STR("[EventViewerMod] Failed to install posthook because register function is unknown! Is FName.toString known?")); + return false; + } + + m_posthook_id = register_posthook_fn(m_post_callback, m_cb_options); + + if (m_posthook_id == RC::Unreal::Hook::ERROR_ID) + { + Output::send(STR("[EventViewerMod] Failed to install posthook!")); + return false; + } + + return true; + } + + template + auto Middleware::HookController::unhook() -> void + { + if (m_prehook_id == RC::Unreal::Hook::ERROR_ID && m_posthook_id == RC::Unreal::Hook::ERROR_ID) + { + Output::send(STR("[EventViewerMod] Failed to remove hooks because it's not active!")); + return; + } + + if (!RC::Unreal::Hook::UnregisterCallback(m_prehook_id)) + { + Output::send(STR("[EventViewerMod] Failed to unregister prehook!")); + } + + if (!RC::Unreal::Hook::UnregisterCallback(m_posthook_id)) + { + Output::send(STR("[EventViewerMod] Failed to unregister posthook!")); + } + + m_prehook_id = m_posthook_id = RC::Unreal::Hook::ERROR_ID; + } + + template + auto Middleware::HookController::is_hooked() const -> bool + { + return (m_prehook_id != RC::Unreal::Hook::ERROR_ID && m_posthook_id != RC::Unreal::Hook::ERROR_ID); + } +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/include/QueueProfiler.hpp b/cppmods/EventViewerMod/include/QueueProfiler.hpp new file mode 100644 index 000000000..33e1337d4 --- /dev/null +++ b/cppmods/EventViewerMod/include/QueueProfiler.hpp @@ -0,0 +1,92 @@ +#pragma once + +// EventViewerMod: Lightweight queue timing instrumentation. +// +// This is not a general-purpose profiler; it's a small helper to measure enqueue/dequeue costs +// and queue backlog under real game load. Numbers are aggregated and displayed in the UI. + +#include +#include + +class QueueProfiler +{ + public: + static void BeginEnqueue() + { + enqueue_count.fetch_add(1, std::memory_order_relaxed); + enqueue_start = std::chrono::high_resolution_clock::now(); + } + + static void EndEnqueue() + { + enqueue_total.fetch_add(std::chrono::duration_cast(std::chrono::high_resolution_clock::now() - enqueue_start).count(), + std::memory_order_relaxed); + } + + static double GetEnqueueAverage() + { + return (enqueue_total.load(std::memory_order_relaxed) / static_cast(enqueue_count.load(std::memory_order_relaxed))); + } + + static void BeginDequeue() + { + dequeue_count.fetch_add(1, std::memory_order_relaxed); + dequeue_start = std::chrono::high_resolution_clock::now(); + } + + static void EndDequeue() + { + const auto now = std::chrono::high_resolution_clock::now(); + dequeue_total.fetch_add(std::chrono::duration_cast(now - dequeue_start).count(), std::memory_order_relaxed); + } + + static double GetDequeueAverage() + { + return (dequeue_total.load(std::memory_order_relaxed) / static_cast(dequeue_count.load(std::memory_order_relaxed))); + } + + static void AddPendingCount(const int64_t pending) + { + pending_count.fetch_add(1, std::memory_order_relaxed); + pending_total.fetch_add(pending, std::memory_order_relaxed); + } + + static void AddTimeExceededCount() + { + time_exceeded_total.fetch_add(1, std::memory_order_relaxed); + } + + static uint64_t GetTimeExceededCount() + { + return time_exceeded_total.load(std::memory_order_relaxed); + } + + static double GetPendingAverage() + { + return (pending_total.load(std::memory_order_relaxed) / static_cast(pending_count.load(std::memory_order_relaxed))); + } + + static void Reset() + { + enqueue_count.store(0, std::memory_order_release); + enqueue_total.store(0, std::memory_order_release); + dequeue_count.store(0, std::memory_order_release); + dequeue_total.store(0, std::memory_order_release); + pending_total.store(0, std::memory_order_release); + pending_count.store(0, std::memory_order_release); + time_exceeded_total.store(0, std::memory_order_release); + } + + private: + inline static std::atomic_int64_t enqueue_total = 0; + inline static std::atomic_int64_t dequeue_total = 0; + inline static std::atomic_int64_t pending_total = 0; + inline static std::atomic_int64_t time_exceeded_total = 0; + + inline static std::atomic_int64_t enqueue_count = 0; + inline static std::atomic_int64_t dequeue_count = 0; + inline static std::atomic_int64_t pending_count = 0; + + inline static thread_local std::chrono::time_point enqueue_start; + inline static thread_local std::chrono::time_point dequeue_start; +}; \ No newline at end of file diff --git a/cppmods/EventViewerMod/include/StringPool.hpp b/cppmods/EventViewerMod/include/StringPool.hpp new file mode 100644 index 000000000..7d5509749 --- /dev/null +++ b/cppmods/EventViewerMod/include/StringPool.hpp @@ -0,0 +1,65 @@ +#pragma once + +// EventViewerMod: String interning + hashing. +// +// Unreal provides stable numeric identifiers for names (ComparisonIndex). StringPool uses those to: +// - Deduplicate storage for function/caller strings. +// - Provide fast comparisons via hashes (avoid expensive string comparisons in hot paths). +// - Cache both original and lowercased strings for case-insensitive filtering. +// +// The pool is designed for “grow-only” lifetime during a session; string_views returned from the pool +// stay valid as long as the underlying storage isn't cleared. + +#include +#include +#include +#include +#include + +#include + +#include + +namespace RC::EventViewerMod +{ + class StringPool + { + public: + // Returns string_views owned by the pool: + // - full_name / function_name for display + // - lower_cased_* for case-insensitive filtering + // - function_hash for fast equality on function identity + auto get_strings(RC::Unreal::UObject* caller, RC::Unreal::UFunction* function) -> AllNameStringViews; + + // Returns path name of a function, same as calling GetPathName but with a string view instead. + auto get_path_name(uint32_t function_hash) -> std::string_view; + + // Clears the string pool. Currently unused, but may be useful if games ever start recycling FName indices. + // Note that this will invalidate any string_views acquired through the getters, so the ImGui views should be cleared before doing this + // in the same frame. + auto clear() -> void; + + static auto GetInstance() -> StringPool&; + + StringPool(const StringPool& Other) = delete; + StringPool(StringPool&& Other) noexcept = delete; + StringPool& operator=(const StringPool& Other) = delete; + StringPool& operator=(StringPool&& Other) noexcept = delete; + + private: + StringPool() = default; + + struct StringInfo + { + size_t function_begin; + std::string full_name; + std::string lower_cased_full_name; + }; + + // TODO replace with better concurrent hash map solution, though not too important for now + std::unordered_map m_main_pool; + // hashed by function->GetComparisonIndex and caller->GetComparisonIndex + std::unordered_map m_path_pool; // hashed by function->GetComparisonIndex + std::shared_mutex m_mutex; + }; +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/include/Structs.hpp b/cppmods/EventViewerMod/include/Structs.hpp new file mode 100644 index 000000000..0c3958220 --- /dev/null +++ b/cppmods/EventViewerMod/include/Structs.hpp @@ -0,0 +1,156 @@ +#pragma once + +// EventViewerMod: Data model + UI state. +// +// This header defines: +// - String-view “bundles” (FunctionNameStringViews / AllNameStringViews) returned by StringPool. +// - Capture entries (CallStackEntry, CallFrequencyEntry). +// - Per-thread buffers and persistent UI state. +// +// Many fields are intentionally plain and public: these objects are moved through the queue and +// stored in large vectors/lists, so keeping them trivially movable matters. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +namespace RC::EventViewerMod +{ + static_assert(std::is_same_v, + "EventViewerMod expects StringType to be std::wstring for ImGui encoding; needs refactor if that changes."); + + struct FunctionNameStringViews + { + // Stable identifier for the UFunction across frames. + // Used for fast frequency aggregation (no string compares in the hot path). + uint32_t function_hash; // FName.ComparisonIndex of UFunction + std::string_view function_name; + std::string_view lower_cased_function_name; + }; + + struct AllNameStringViews : FunctionNameStringViews + { + std::string_view full_name; + std::string_view lower_cased_full_name; + // Composite key for (caller, function). This avoids per-frame string concatenation. + // Layout: UFunction ComparisonIndex in upper 32 bits, caller UObject ComparisonIndex in lower 32 bits. + uint64_t full_hash; + }; + + // Note: these types are intentionally cheap-to-move so they can be passed through + // moodycamel::ConcurrentQueue by value (high throughput, minimal allocator churn). + struct EntryBase + { + EntryBase() = default; + explicit EntryBase(bool is_tick); + + bool is_tick = false; + + // Cached visibility bit (filters + tick toggle). + // Important: depth/indent is NOT recomputed when entries are hidden; callers may be hidden while + // deeper frames remain visible, to preserve the true call depth. + bool is_disabled = false; + }; + + // Stores both original-case and lower-cased string views (for case-insensitive filtering). + struct CallStackEntry : EntryBase, AllNameStringViews + { + CallStackEntry() = default; + CallStackEntry(EMiddlewareHookTarget hook_target, const AllNameStringViews& strings, uint32_t depth, std::thread::id thread_id, bool is_tick); + + auto render(int indent_delta, ECallStackEntryRenderFlags_ flags = ECallStackEntryRenderFlags_None) const -> void; + + // Provides a copy of the entry's key string fields. Don't use with ImGui, only use for logging/saving. + // Use the string_views for ImGui since they utilize the string pool. + auto to_string_with_prefix() const -> std::wstring; + + // Which hook produced this entry. This is captured at enqueue-time and later used as a *view filter* + // ("All" shows everything; other targets show only matching entries). + EMiddlewareHookTarget hook_target = EMiddlewareHookTarget::All; + + // Unified depth counter shared by all hooks; PE can call PI which can call PLSF, etc. + uint32_t depth = 0; + std::thread::id thread_id{}; + + private: + auto render_indents(int indent_delta) const -> void; + auto render_support_menus(ECallStackEntryRenderFlags_ flags) const -> void; + }; + + // Stores both original-case and lower-cased function name views (for case-insensitive filtering). + struct CallFrequencyEntry : EntryBase, FunctionNameStringViews + { + CallFrequencyEntry() = default; + CallFrequencyEntry(const FunctionNameStringViews& strings, bool is_tick); + auto render(ECallFrequencyEntryRenderFlags_ flags) const -> void; + uint64_t frequency = 1; + + // OR'd EMiddlewareHookTarget values that have invoked this function so far. + // This is used for filtering when a specific hook target is selected. + uint32_t source_flags = 0; + + private: + auto render_support_menus() const -> void; + }; + + struct ThreadInfo + { + explicit ThreadInfo(std::thread::id thread_id); + + const std::thread::id thread_id; + const bool is_game_thread; + + // High-throughput capture history (fast filtering/search due to contiguous storage). + std::vector call_stack; + + // Set of entries that should be rendered, respecting the client's text_temp_virtualization_count + std::set call_stack_render_set{}; + + // Aggregated frequency view; list allows O(1) reordering via splice without shifting elements. + std::list call_frequencies; + + auto id_string() -> const char*; + auto clear() -> void; + + private: + std::string m_id_string; + }; + + struct UIState + { + bool enabled = false; // [Savable] [Thread-ImGui] + bool started = false; // [Thread-ImGui] + bool show_tick = true; // [Savable] [Thread-ImGui] + bool disable_indent_colors = false; // [Savable] [Thread-ImGui] + bool thread_explicitly_chosen = false; // [Thread-ImGui] User selected a thread + bool thread_implicitly_set = false; // [Thread-ImGui] System set thread to game thread if not explicit + bool show_filter_counts = true; // [Savable] [Thread-ImGui] + EMiddlewareHookTarget hook_target = EMiddlewareHookTarget::All; // [Savable] [Thread-ImGui] + EMode mode = EMode::Stack; // [Savable] [Thread-ImGui] + uint16_t dequeue_max_ms = 10; // [Savable] [Thread-ImGui] + uint16_t text_virtualization_count = 100; // [Savable] [Thread-ImGui] + uint64_t text_temp_virtualization_count = text_virtualization_count; // [Thread-Imgui] + uint32_t dequeue_max_count = 100000; // [Savable] [Thread-ImGui] + std::string blacklist; // [Savable] [Thread-ImGui] + std::vector blacklist_tokens; // [Thread-ImGui] (lower-cased tokens) + std::string whitelist; // [Savable] [Thread-ImGui] + std::vector whitelist_tokens; // [Thread-ImGui] (lower-cased tokens) + std::vector threads{}; // [Thread-ImGui] + int current_thread = 0; // [Thread-ImGui] + std::atomic_flag needs_save = ATOMIC_FLAG_INIT; // [Thread-Any] + std::string last_save_path; // [Thread-ImGui] + }; +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/src/Client.cpp b/cppmods/EventViewerMod/src/Client.cpp new file mode 100644 index 000000000..380f634f2 --- /dev/null +++ b/cppmods/EventViewerMod/src/Client.cpp @@ -0,0 +1,1099 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include + +// EventViewerMod: UI renderer and history consumer. +// +// This file drives the ImGui tab. High-level flow: +// 1) Draw config + view controls. +// 2) Dequeue a bounded amount of entries from Middleware each frame. +// 3) Merge new entries into per-thread histories (stack + frequency). +// 4) Apply view filters (hook target selection, whitelist/blacklist, tick toggle). +// +// Important: +// - The hook target combo is a *display filter only*. Depth is always computed by Middleware and +// remains unchanged even if callers are hidden. +// - Filtering is case-insensitive by comparing lower-cased strings (see to_lower_ascii_copy()). +// + +// Returns lower-cased tokens (copied strings). +static std::vector split_string_by_comma(const std::string& string) +{ + std::vector result; + if (string.empty()) + { + return result; + } + + const std::string_view sv{string}; + size_t start = 0; + + auto trim = [](std::string_view v) -> std::string_view { + const auto leading = v.find_first_not_of(" \t\n\r\f\v"); + if (leading == std::string_view::npos) + { + return {}; + } + v.remove_prefix(leading); + + const auto trailing = v.find_last_not_of(" \t\n\r\f\v"); + if (trailing == std::string_view::npos) + { + return {}; + } + v = v.substr(0, trailing + 1); + return v; + }; + + while (start <= sv.size()) + { + size_t end = sv.find(',', start); + if (end == std::string_view::npos) + { + end = sv.size(); + } + + auto token = trim(sv.substr(start, end - start)); + if (!token.empty()) + { + result.emplace_back(to_lower_case(token)); + } + + if (end == sv.size()) + { + break; + } + start = end + 1; + } + + return result; +} + +namespace RC::EventViewerMod +{ + using namespace std::literals::string_literals; + + Client::Client() : m_middleware(Middleware::GetInstance()) + { + const auto wd = std::filesystem::path{StringType{UE4SSProgram::get_program().get_working_directory()}}; + const auto mod_root = wd / "Mods" / "EventViewerMod"; + + m_cfg_path = mod_root / "config" / "settings.json"; + m_dump_dir = mod_root / "captures"; + + std::error_code ec; + std::filesystem::create_directories(m_cfg_path.parent_path(), ec); + std::filesystem::create_directories(m_dump_dir, ec); + + load_state(); + } + + auto Client::render() -> void + { + // Ensure middleware knows the correct ImGui thread + if (!m_imgui_thread_id_set) + { + m_middleware.set_imgui_thread_id(std::this_thread::get_id()); + m_imgui_thread_id_set = true; + } + + const auto saved = check_save_request(); + + if (ImGui::Checkbox("Enable", &m_state.enabled)) + { + request_save_state(); + + if (m_state.enabled) + { + // enabling + m_middleware.set_imgui_thread_id(std::this_thread::get_id()); + m_imgui_thread_id_set = true; + QueueProfiler::Reset(); + } + else + { + // disabling + m_middleware.stop(); + m_state.started = false; + m_state.needs_save.clear(std::memory_order_release); + clear_threads(); + if (!saved) + { + save_state(); + } + QueueProfiler::Reset(); + return; + } + } + + if (!m_state.enabled) return; + + render_cfg(); + + if (ImGui::TreeNode("Performance Options")) + { + render_perf_opts(); + ImGui::TreePop(); + } + + dequeue(); + + render_view(); + + if (ImGui::BeginPopupModal("Saved File", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::Text("Saved file to %s", m_state.last_save_path.c_str()); + ImGui::PopTextWrapPos(); + if (ImGui::Button("OK")) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + } + + auto Client::render_cfg() -> void + { + int hook_target_idx = 0; + for (int i = 0; i < EMiddlewareHookTarget_Size; ++i) + { + if (EMiddlewareHookTarget_ValueArray[i] == m_state.hook_target) + { + hook_target_idx = i; + break; + } + } + + if (combo_with_flags("Target", &hook_target_idx, EMiddlewareHookTarget_NameArray, EMiddlewareHookTarget_Size, ImGuiComboFlags_WidthFitPreview)) + { + request_save_state(); + m_state.hook_target = EMiddlewareHookTarget_ValueArray[hook_target_idx]; + + // Hook target is an implicit filter for the stack view. + // Rebuild per-thread render sets immediately so the UI reflects the new selection. + // (Disabled state is controlled by whitelist/blacklist/tick and is handled elsewhere.) + for (auto& thread : m_state.threads) + { + thread.call_stack_render_set.clear(); + resize_render_set(thread, m_state.text_temp_virtualization_count); + } + } + HelpMarker(HelpStrings::HELP_TARGET); + ImGui::SameLine(); + + if (combo_with_flags("Mode", reinterpret_cast(&m_state.mode), EMode_NameArray, EMode_Size, ImGuiComboFlags_WidthFitPreview)) + { + request_save_state(); + } + HelpMarker(HelpStrings::HELP_MODE); + + bool whitelist_changed = false; + bool blacklist_changed = false; + bool tick_changed = false; + + // whitelist + const auto wl_input_changed = ImGui::InputText("Whitelist", &m_state.whitelist, ImGuiInputTextFlags_ElideLeft | ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + if (wl_input_changed || ImGui::Button("Apply##Whitelist")) + { + whitelist_changed = true; + request_save_state(); + } + ImGui::SameLine(); + if (ImGui::Button("Clear##Whitelist")) + { + if (!m_state.whitelist.empty()) + { + m_state.whitelist.clear(); + whitelist_changed = true; + request_save_state(); + } + } + HelpMarker(HelpStrings::HELP_LIST_FILTER); + + // blacklist + const auto bl_input_changed = ImGui::InputText("Blacklist", &m_state.blacklist, ImGuiInputTextFlags_ElideLeft | ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + if (bl_input_changed || ImGui::Button("Apply##Blacklist")) + { + blacklist_changed = true; + request_save_state(); + } + ImGui::SameLine(); + if (ImGui::Button("Clear##Blacklist")) + { + if (!m_state.blacklist.empty()) + { + m_state.blacklist.clear(); + blacklist_changed = true; + request_save_state(); + } + } + auto& threads = m_state.threads; + + if (!threads.empty()) + { + if (m_state.current_thread < 0) + { + m_state.current_thread = 0; + } + if (static_cast(m_state.current_thread) >= threads.size()) + { + m_state.current_thread = static_cast(threads.size() - 1); + } + + ThreadInfo* game_thread = nullptr; + if (ImGui::BeginCombo("Thread", threads[m_state.current_thread].id_string(), ImGuiComboFlags_WidthFitPreview)) + { + for (size_t idx = 0; idx < threads.size(); ++idx) + { + const bool selected = static_cast(idx) == m_state.current_thread; + auto& thread = threads[idx]; + if (ImGui::Selectable(thread.id_string(), selected)) + { + m_state.thread_explicitly_chosen = true; + m_state.current_thread = static_cast(idx); + } + if (selected) + { + ImGui::SetItemDefaultFocus(); + } + if (thread.is_game_thread) + { + game_thread = &thread; + } + } + ImGui::EndCombo(); + } + else if (!m_state.thread_implicitly_set) + { + for (auto& thread : threads) + { + if (thread.is_game_thread) game_thread = &thread; + } + + if (game_thread && !m_state.thread_explicitly_chosen) + { + m_state.current_thread = static_cast(game_thread - threads.data()); + ImGui::SetItemDefaultFocus(); + m_state.thread_implicitly_set = true; + } + } + HelpMarker(HelpStrings::HELP_THREAD); + } + + auto save_mode = ESaveMode::none; + + // controls + if (ImGui::Button(m_state.started ? "Stop" : "Start")) + { + m_state.started = !m_state.started; + if (m_state.started) + { + m_middleware.start(); + m_state.text_temp_virtualization_count = m_state.text_virtualization_count; + // shrink render sets + for (auto& thread : threads) + { + resize_render_set(thread, m_state.text_temp_virtualization_count); + } + } + else + { + m_middleware.stop(); + } + QueueProfiler::Reset(); + } + + ImGui::SameLine(); + if (ImGui::Button("Clear##CurrentThread") && !threads.empty()) + { + auto& thread = threads[m_state.current_thread]; + thread.clear(); + } + HelpMarker(HelpStrings::HELP_CLEAR); + ImGui::SameLine(); + if (ImGui::Button("Clear All##AllThreads") && !threads.empty()) + { + clear_threads(); + } + HelpMarker(HelpStrings::HELP_CLEAR_ALL); + ImGui::SameLine(); + if (ImGui::Button("Save##Current")) + { + save_mode = ESaveMode::current; + } + HelpMarker(HelpStrings::HELP_SAVE); + ImGui::SameLine(); + if (ImGui::Button("Save All##All")) + { + save_mode = ESaveMode::all; + } + HelpMarker(HelpStrings::HELP_SAVE_ALL); + ImGui::SameLine(); + if (ImGui::Checkbox("Show Builtin Tick Functions", &m_state.show_tick)) + { + tick_changed = true; + request_save_state(); + } + HelpMarker(HelpStrings::HELP_SHOW_BUILTIN_TICK); + ImGui::SameLine(); + if (ImGui::Checkbox("Disable Indent Colors", &m_state.disable_indent_colors)) + { + request_save_state(); + } + ImGui::SameLine(); + if (ImGui::Checkbox("Show Filter Counts", &m_state.show_filter_counts)) + { + request_save_state(); + } + + apply_filters_to_history(whitelist_changed, blacklist_changed, tick_changed); + save(save_mode); + } + + auto Client::render_perf_opts() -> void + { + static uint16_t step = 1; + if (ImGui::InputScalar("Max MS Read Time", ImGuiDataType_U16, &m_state.dequeue_max_ms, &step, 0, 0)) + { + request_save_state(); + if (!m_state.dequeue_max_ms) + { + m_state.dequeue_max_ms = 1; + } + } + HelpMarker(HelpStrings::HELP_MAX_MS_READ_TIME); + + if (ImGui::InputScalar("Max Count Per Iteration", ImGuiDataType_U32, &m_state.dequeue_max_count, &step)) + { + request_save_state(); + if (!m_state.dequeue_max_count) + { + m_state.dequeue_max_count = 1; + } + } + HelpMarker(HelpStrings::HELP_MAX_COUNT_PER_ITERATION); + + if (ImGui::InputScalar("Text Virtualization Count", ImGuiDataType_U16, &m_state.text_virtualization_count, &step)) + { + request_save_state(); + if (!m_state.text_virtualization_count) + { + m_state.text_virtualization_count = 1; + } + m_state.text_temp_virtualization_count = m_state.text_virtualization_count; + clear_threads(); + } + HelpMarker(HelpStrings::HELP_TEXT_VIRTUALIZATION_COUNT); + + ImGui::Text("Enqueue Avg: %f Dequeue Avg: %f Pending Avg: %f Time Slot Exceeded Count: %llu", + QueueProfiler::GetEnqueueAverage(), + QueueProfiler::GetDequeueAverage(), + QueueProfiler::GetPendingAverage(), + QueueProfiler::GetTimeExceededCount()); + HelpMarker(HelpStrings::HELP_QUEUE_PROFILE_VALUES); + } + + auto Client::render_view() -> void + { + auto& threads = m_state.threads; + if (threads.empty()) + { + return; + } + + if (m_state.current_thread < 0) + { + m_state.current_thread = 0; + } + if (static_cast(m_state.current_thread) >= threads.size()) + { + m_state.current_thread = static_cast(threads.size() - 1); + } + + auto& thread = threads[m_state.current_thread]; + + const auto selected_flags = static_cast(m_state.hook_target); + + auto area = ImGui::GetContentRegionAvail(); + auto& padding = ImGui::GetStyle().WindowPadding; + auto& scroll_size = ImGui::GetStyle().ScrollbarSize; + area.y -= ((padding.y + scroll_size) * 2); + area.x -= (padding.x + scroll_size); + ImGui::BeginChild("##view", area, ImGuiChildFlags_Borders | ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AutoResizeY, ImGuiWindowFlags_HorizontalScrollbar); + if (m_state.mode == EMode::Stack) + { + if (thread.call_stack.empty()) + { + ImGui::EndChild(); + return; + } + + int prev_depth = 0; + bool have_prev = false; + int current_indent = 0; + int id = 0; + uint8_t entry_flags = 0; + if (!m_state.disable_indent_colors) entry_flags |= ECallStackEntryRenderFlags_IndentColors; + if (!m_state.started) entry_flags |= ECallStackEntryRenderFlags_WithSupportMenusCallStackModal; + + bool needs_scroll_here = false; + if (!m_state.started && thread.call_stack_render_set.size() == m_state.text_temp_virtualization_count) + { + if (ImGui::Button("Load More...")) + { + m_state.text_temp_virtualization_count *= 2; + // expand set + resize_render_set(thread, m_state.text_temp_virtualization_count); + needs_scroll_here = true; + } + } + + const bool show_filter_counts = m_state.show_filter_counts; + const auto set_begin = thread.call_stack_render_set.begin(); + for (auto entry_it = set_begin; entry_it != thread.call_stack_render_set.end(); ++entry_it) + { + auto& entry = thread.call_stack[*entry_it]; + if (show_filter_counts && entry_it != set_begin) [[likely]] + { + auto before_entry_it = entry_it; + --before_entry_it; + const auto gap = (*entry_it) - (*before_entry_it); + if (gap > 1) + { + FilterCountRenderer::render(gap - 1, !m_state.started); + } + } + + const int depth = static_cast(entry.depth); + const int delta = have_prev ? (depth - prev_depth) : depth; + ImGui::PushID(id++); + entry.render(delta, static_cast(entry_flags)); + ImGui::PopID(); + current_indent += delta; + prev_depth = depth; + have_prev = true; + } + + // Reset indent state for safety. + while (current_indent > 0) + { + ImGui::Unindent(); + --current_indent; + } + + // if (show_filter_counts) m_filter_count_renderer.render_and_reset(!m_state.show_filter_counts); + + if (m_state.started || needs_scroll_here) ImGui::SetScrollHereY(1.0f); + } + + else + { + if (ImGui::BeginTable("##frequency", 2, ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersV)) + { + int id = 0; + for (const auto& entry : thread.call_frequencies) + { + if ((entry.source_flags & selected_flags) == 0) + { + continue; + } + + if (entry.is_disabled) + { + continue; + } + ImGui::PushID(id++); + entry.render(m_state.started ? ECallFrequencyEntryRenderFlags_None : ECallFrequencyEntryRenderFlags_WithSupportMenus); + ImGui::PopID(); + } + + ImGui::EndTable(); + } + } + + ImGui::EndChild(); + + if (m_entry_call_stack_renderer) + { + if (!m_entry_call_stack_renderer->render()) + { + m_entry_call_stack_renderer = nullptr; + } + } + } + + auto Client::combo_with_flags(const char* label, int* current_item, const char* const items[], const int items_count, const ImGuiComboFlags_ flags) -> bool + { + bool changed = false; + if (ImGui::BeginCombo(label, items[*current_item], flags)) + { + for (int i = 0; i < items_count; ++i) + { + const auto is_selected = i == *current_item; + if (ImGui::Selectable(items[i], is_selected)) + { + *current_item = i; + changed = true; + } + if (is_selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + return changed; + } + + auto Client::save_state() -> void + { + std::error_code ec; + std::filesystem::create_directories(m_cfg_path.parent_path(), ec); + + std::unordered_map state_map; + state_map.emplace("Enabled", std::to_string(m_state.enabled)); + state_map.emplace("ShowTick", std::to_string(m_state.show_tick)); + int hook_target_idx = 0; + for (int i = 0; i < EMiddlewareHookTarget_Size; ++i) + { + if (EMiddlewareHookTarget_ValueArray[i] == m_state.hook_target) + { + hook_target_idx = i; + break; + } + } + state_map.emplace("HookTarget", std::to_string(hook_target_idx)); + state_map.emplace("Mode", std::to_string(static_cast(m_state.mode))); + state_map.emplace("DequeueMaxMs", std::to_string(m_state.dequeue_max_ms)); + state_map.emplace("DequeueMaxCount", std::to_string(m_state.dequeue_max_count)); + state_map.emplace("Whitelist", m_state.whitelist); + state_map.emplace("Blacklist", m_state.blacklist); + state_map.emplace("DisableIndentColors", std::to_string(m_state.disable_indent_colors)); + state_map.emplace("TextVirtualizationCount", std::to_string(m_state.text_virtualization_count)); + state_map.emplace("ShowFilterCounts", std::to_string(m_state.show_filter_counts)); + (void)glz::write_file_json(state_map, m_cfg_path.string(), std::string{}); + } + + auto Client::load_state() -> void + { + if (!std::filesystem::exists(m_cfg_path) || !std::filesystem::is_regular_file(m_cfg_path)) + { + return; + } + + std::unordered_map state_map{}; + auto ec = glz::read_file_json(state_map, m_cfg_path.string(), std::string{}); + if (ec.ec != glz::error_code::none) + { + return; + } + + try + { + m_state.enabled = state_map.at("Enabled") != "0"; + m_state.show_tick = state_map.at("ShowTick") != "0"; + m_state.disable_indent_colors = state_map.at("DisableIndentColors") != "0"; + const int hook_target_idx = std::stoi(state_map.at("HookTarget")); + if (hook_target_idx >= 0 && hook_target_idx < EMiddlewareHookTarget_Size) + { + m_state.hook_target = EMiddlewareHookTarget_ValueArray[hook_target_idx]; + } + else + { + m_state.hook_target = EMiddlewareHookTarget::All; + } + m_state.mode = static_cast(std::stoi(state_map.at("Mode"))); + m_state.dequeue_max_ms = static_cast(std::stoi(state_map.at("DequeueMaxMs"))); + m_state.dequeue_max_count = static_cast(std::stoul(state_map.at("DequeueMaxCount"))); + m_state.whitelist = state_map.at("Whitelist"); + m_state.blacklist = state_map.at("Blacklist"); + m_state.whitelist_tokens = split_string_by_comma(m_state.whitelist); + m_state.blacklist_tokens = split_string_by_comma(m_state.blacklist); + m_state.text_virtualization_count = static_cast(std::stoi(state_map.at("TextVirtualizationCount"))); + m_state.text_temp_virtualization_count = m_state.text_virtualization_count; + m_state.show_filter_counts = state_map.at("ShowFilterCounts") != "0"; + clear_threads(); // just to be safe, since if text_virtualization_count changes while scrolling it could cause problems + } + catch (...) + { + Output::send(STR("[EventViewerMod] Failed to load state from file due to exception!")); + } + } + + auto Client::check_save_request() -> bool + { + if (m_state.needs_save.test(std::memory_order_acquire)) + { + save_state(); + m_state.needs_save.clear(std::memory_order_release); + return true; + } + return false; + } + + auto Client::apply_filters_to_history(const bool whitelist_changed, const bool blacklist_changed, const bool tick_changed) -> void + { + if (!(whitelist_changed || blacklist_changed || tick_changed)) + { + return; + } + if (whitelist_changed) + { + m_state.whitelist_tokens = split_string_by_comma(m_state.whitelist); + } + if (blacklist_changed) + { + m_state.blacklist_tokens = split_string_by_comma(m_state.blacklist); + } + + // Recompute disabled state for all history. This only runs when the user changes filters/tick setting. + const bool show_tick = m_state.show_tick; + + for (auto& thread : m_state.threads) + { + // Stack history can get very large; leverage parallel execution on random-access iterators. + std::for_each(std::execution::par_unseq, thread.call_stack.begin(), thread.call_stack.end(), [this, show_tick](CallStackEntry& entry) { + entry.is_disabled = (entry.is_tick && !show_tick) || !passes_filters(entry.lower_cased_full_name); + }); + + // Frequency view is smaller and is a list (non-random-access). + for (auto& entry : thread.call_frequencies) + { + entry.is_disabled = (entry.is_tick && !show_tick) || !passes_filters(entry.lower_cased_function_name); + } + + // Recalculate render set + thread.call_stack_render_set.clear(); + resize_render_set(thread, m_state.text_temp_virtualization_count); + } + } + + auto Client::dequeue() -> void + { + if (!m_state.enabled || !m_state.started) + { + return; + } + + m_middleware.dequeue(m_state.dequeue_max_ms, m_state.dequeue_max_count, [this](CallStackEntry&& entry) { + // Thread lookup/creation (unified across hook targets). + auto& threads = m_state.threads; + + const auto entry_thread = entry.thread_id; + auto thread_it = std::ranges::find_if(threads, [&entry_thread](const ThreadInfo& info) { + return info.thread_id == entry_thread; + }); + + ThreadInfo* thread_ptr = nullptr; + if (thread_it != threads.end()) + { + thread_ptr = &(*thread_it); + } + else + { + thread_ptr = &threads.emplace_back(entry_thread); + if (m_state.current_thread < 0) + { + m_state.current_thread = 0; + } + } + + auto& thread = *thread_ptr; + + // Determine disabled state under current filters. + // If it doesn't pass freq, it won't pass stack + const auto freq_disabled = (entry.is_tick && !m_state.show_tick) || !passes_filters(entry.lower_cased_function_name); + const auto stack_disabled = freq_disabled || ((entry.is_tick && !m_state.show_tick) || !passes_filters(entry.lower_cased_full_name)); + + // Frequency tracking: bump existing, or add. + auto freq_it = std::ranges::find_if(thread.call_frequencies, [&entry](const CallFrequencyEntry& freq_entry) -> bool { + return entry.function_hash == freq_entry.function_hash; + }); + + const auto entry_source_flags = static_cast(entry.hook_target); + + if (freq_it != thread.call_frequencies.end()) [[likely]] + { + auto& freq = *freq_it; + ++freq.frequency; + freq.is_disabled = freq_disabled; + freq.source_flags |= entry_source_flags; + + // Maintain descending order by frequency using list::splice (fast, no alloc). + auto new_pos = freq_it; + while (new_pos != thread.call_frequencies.begin()) + { + auto prev = std::prev(new_pos); + if (prev->frequency > freq.frequency) + { + break; + } + new_pos = prev; + } + if (new_pos != freq_it) + { + thread.call_frequencies.splice(new_pos, thread.call_frequencies, freq_it); + } + } + else + { + thread.call_frequencies.emplace_back(static_cast(entry), entry.is_tick); + auto& freq = thread.call_frequencies.back(); + freq.is_disabled = freq_disabled; + freq.source_flags = entry_source_flags; + } + + // Call stack history. + entry.is_disabled = stack_disabled; + if (can_render_entry(thread.call_stack.emplace_back(std::move(entry)))) + { + if (thread.call_stack_render_set.size() == m_state.text_temp_virtualization_count) [[likely]] + { + auto extracted = thread.call_stack_render_set.extract(thread.call_stack_render_set.begin()); + extracted.value() = thread.call_stack.size() - 1; + thread.call_stack_render_set.insert(thread.call_stack_render_set.end(), std::move(extracted)); + } + else [[unlikely]] + { + thread.call_stack_render_set.insert(thread.call_stack_render_set.end(), thread.call_stack.size() - 1); + } + } + }); + } + + // todo there's definitely room for improvement, like diffing white/blacklists to tell if test_str needs to be checked + // (would be done by callers probably) or keeping track of an 'enabled' and 'disabled' unordered_set of + // hashes (that the StringPool could be altered to provide) to skip string parsing, but this is good enough for now. + auto Client::passes_filters(const std::string_view test_str) const -> bool + { + bool passes_whitelist = m_state.whitelist_tokens.empty(); + for (const auto& token : m_state.whitelist_tokens) // any whitelist token present => pass + { + if (test_str.contains(token)) + { + passes_whitelist = true; + break; + } + } + if (!passes_whitelist) + { + return false; + } + + for (const auto& token : m_state.blacklist_tokens) // any blacklist token present => fail + { + if (test_str.contains(token)) + { + return false; + } + } + return true; + } + + auto Client::save(ESaveMode mode) -> void + { + if (mode == ESaveMode::none) + { + return; + } + + std::error_code ec; + std::filesystem::create_directories(m_dump_dir, ec); + + const auto now = std::chrono::system_clock::now(); + const std::time_t now_t = std::chrono::system_clock::to_time_t(now); + std::tm local_tm{}; + localtime_s(&local_tm, &now_t); + + std::ostringstream oss; + // Windows filenames cannot contain ':'. + oss << std::put_time(&local_tm, "%Y-%m-%d %H-%M-%S"); + + if (mode == ESaveMode::current) + { + auto& threads = m_state.threads; + if (threads.empty()) + { + return; + } + + if (m_state.current_thread < 0) + { + m_state.current_thread = 0; + } + if (static_cast(m_state.current_thread) >= threads.size()) + { + m_state.current_thread = static_cast(threads.size() - 1); + } + + const auto filename = "EventViewerMod Capture-"s + to_string(m_state.hook_target) + "-" + EMode_NameArray[static_cast(m_state.mode)] + " " + + oss.str() + ".txt"; + const auto path = m_dump_dir / filename; + std::ofstream file{path}; + if (!file.is_open()) + { + return; + } + + file << to_string(m_state.hook_target) << " "; + serialize_view(threads[m_state.current_thread], m_state.mode, m_state.hook_target, file); + file.close(); + m_state.last_save_path = path.string(); + ImGui::OpenPopup("Saved File"); + return; + } + + if (mode == ESaveMode::all) + { + if (m_state.threads.empty()) + { + return; + } + + const auto filename = "EventViewerMod Capture-All "s + oss.str() + ".txt"; + const auto path = m_dump_dir / filename; + std::ofstream file{path}; + if (!file.is_open()) + { + return; + } + + serialize_all_views(file); + file.close(); + m_state.last_save_path = path.string(); + ImGui::OpenPopup("Saved File"); + } + } + + auto Client::serialize_view(ThreadInfo& info, const EMode mode, const EMiddlewareHookTarget hook_target, std::ofstream& out) const -> void + { + out << fmt::format("Thread {} {}\n\n", info.id_string(), EMode_NameArray[static_cast(mode)]); + + const uint32_t selected_flags = static_cast(hook_target); + + if (mode == EMode::Stack) + { + if (info.call_stack.empty()) + { + out << "No captures.\n\n\n"; + return; + } + + for (const auto& entry : info.call_stack) + { + if ((static_cast(entry.hook_target) & selected_flags) == 0) + { + continue; + } + + if (entry.is_disabled) + { + continue; + } + for (auto i = 0u; i < entry.depth; ++i) + out << "\t"; + out << entry.full_name << '\n'; + } + + out << "\n\n\n"; + return; + } + + if (info.call_frequencies.empty()) + { + out << "No captures.\n\n\n"; + return; + } + + for (const auto& entry : info.call_frequencies) + { + if ((entry.source_flags & selected_flags) == 0) + { + continue; + } + + if (entry.is_disabled) + { + continue; + } + out << entry.function_name << '\t' << entry.frequency << '\n'; + } + + out << "\n\n\n"; + } + + auto Client::serialize_all_views(std::ofstream& out) -> void + { + for (auto& thread : m_state.threads) + { + out << to_string(EMiddlewareHookTarget::All) << " "; + serialize_view(thread, EMode::Stack, EMiddlewareHookTarget::All, out); + out << to_string(EMiddlewareHookTarget::All) << " "; + serialize_view(thread, EMode::Frequency, EMiddlewareHookTarget::All, out); + } + } + + auto Client::clear_threads() -> void + { + m_state.current_thread = 0; + m_state.threads.clear(); + m_state.thread_explicitly_chosen = false; + m_state.thread_implicitly_set = false; + } + + auto Client::can_render_entry(const CallStackEntry& entry) const -> bool + { + return !(entry.is_disabled || ((static_cast(entry.hook_target) & static_cast(m_state.hook_target)) == 0)); + } + + auto Client::resize_render_set(ThreadInfo& thread, const size_t max_size) const -> void + { + auto& stack = thread.call_stack; + auto& set = thread.call_stack_render_set; + + if (!max_size) return set.clear(); + if (set.size() == max_size) return; + + if (set.size() < max_size) + { + const auto cs_begin = stack.begin(); + for (auto entry_it = set.empty() ? stack.rbegin() : std::make_reverse_iterator(stack.begin() + *set.begin()); entry_it != stack.rend(); ++entry_it) + { + if (can_render_entry(*entry_it)) + { + set.insert(set.begin(), static_cast((entry_it.base() - cs_begin) - 1)); + if (set.size() == max_size) break; + } + } + + return; + } + + // set.size() > max_size + while (set.size() != max_size) + set.erase(set.begin()); + } + + auto Client::request_save_state() -> void + { + m_state.needs_save.test_and_set(std::memory_order_release); + } + + auto Client::add_to_white_list(const std::string_view item) -> void + { + if (m_state.whitelist.empty()) + { + m_state.whitelist += item; + } + else + { + m_state.whitelist += ", "; + m_state.whitelist += item; + } + request_save_state(); + apply_filters_to_history(true, false, false); + } + + auto Client::add_to_black_list(std::string_view item) -> void + { + if (m_state.blacklist.empty()) + { + m_state.blacklist += item; + } + else + { + m_state.blacklist += ", "; + m_state.blacklist += item; + } + request_save_state(); + apply_filters_to_history(false, true, false); + } + + auto Client::render_entry_stack_modal(const CallStackEntry* entry) -> void + { + if (m_entry_call_stack_renderer) return; + // find root entry and next root entry + // finding the root entry also reveals all callers, so go ahead and bookkeep it + const auto& stack = m_state.threads[m_state.current_thread].call_stack; + const size_t target_abs_idx = entry - stack.data(); + size_t root_abs_idx = target_abs_idx; + std::vector idxs_relevant_to_target{}; // added in reverse-view order + if (entry->depth) + { + uint32_t last_lowest_depth = entry->depth; + for (auto idx = target_abs_idx - 1; idx >= 0; --idx) + { + auto this_depth = stack[idx].depth; + if (this_depth < last_lowest_depth) + { + idxs_relevant_to_target.push_back(idx); + last_lowest_depth = this_depth; + if (this_depth == 0) + { + root_abs_idx = idx; + break; + } + } + } + } + + size_t next_root_abs_idx = target_abs_idx + 1; + bool out_of_target_scope = false; + for (; next_root_abs_idx < stack.size(); ++next_root_abs_idx) // find callers and callees of target + { + const auto this_entry_depth = stack[next_root_abs_idx].depth; + if (this_entry_depth == 0) break; + + if (this_entry_depth <= entry->depth) out_of_target_scope = true; + if (!out_of_target_scope) idxs_relevant_to_target.push_back(next_root_abs_idx); + } + + std::vector context{stack.begin() + root_abs_idx, stack.begin() + next_root_abs_idx}; // copy entries + for (auto& abs_idx : idxs_relevant_to_target) // make idxs_relevant_to_target relative to context + { + abs_idx -= root_abs_idx; + } + const auto target_rel_idx = target_abs_idx - root_abs_idx; // find context index for target + idxs_relevant_to_target.push_back(target_rel_idx); + + // make any irrelevant entry disabled by default, and assert that any relevant entry is enabled. the modal won't change them, but + // will use is_disabled as a flag to indicate its relevance for the checkbox. + for (size_t context_idx = 0; context_idx < context.size(); ++context_idx) + { + if (std::ranges::find(idxs_relevant_to_target, static_cast(context_idx)) != idxs_relevant_to_target.end()) + { + context[context_idx].is_disabled = false; + continue; + } + context[context_idx].is_disabled = true; + } + + m_entry_call_stack_renderer = std::make_unique(target_rel_idx, std::move(context)); + } + + auto Client::GetInstance() -> Client& + { + static Client client{}; + return client; + } +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/src/EntryCallStackRenderer.cpp b/cppmods/EventViewerMod/src/EntryCallStackRenderer.cpp new file mode 100644 index 000000000..f7473ddf1 --- /dev/null +++ b/cppmods/EventViewerMod/src/EntryCallStackRenderer.cpp @@ -0,0 +1,144 @@ +#include + +#include +#include +#include +#include + +// EventViewerMod: rendering for the call stack/context modal. +// +// The context vector is prepared by Client (based on the selected history entry). This renderer +// is intentionally simple: it only worries about ImGui state management and printing rows. +namespace RC::EventViewerMod +{ + using namespace std::literals::string_literals; + + EntryCallStackRenderer::EntryCallStackRenderer(const size_t target_idx, std::vector context) + : m_target_idx(target_idx), m_context(std::move(context)) + { + m_target_ptr = &m_context[m_target_idx]; + } + + auto EntryCallStackRenderer::render() -> bool + { + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + + // BeginPopupModal() will *never* return true unless the popup has been opened. + // Because the renderer is created from a context-menu click (possibly in a different + // frame / popup), we request opening once here in the stable render path. + if (!m_requested_open) + { + ImGui::OpenPopup("Entry Call Stack##entrycallstackmodal"); + m_requested_open = true; + } + + if (ImGui::BeginPopupModal("Entry Call Stack##entrycallstackmodal", nullptr, ImGuiWindowFlags_HorizontalScrollbar)) + { + ImGui::Checkbox("Show Full Context", &m_show_full_context); + HelpMarker(HelpStrings::HELP_CEMODAL_SHOW_FULL_CONTEXT); + ImGui::Checkbox("Disable Indent Colors", &m_disable_indent_colors); + + const auto btn_height = ImGui::GetFrameHeightWithSpacing(); + auto view_area = ImGui::GetContentRegionAvail(); + view_area.y -= btn_height; + ImGui::BeginChild("##entrycallstackview", view_area, ImGuiChildFlags_Borders | ImGuiChildFlags_FrameStyle, ImGuiWindowFlags_HorizontalScrollbar); + + int prev_depth = 0; + bool have_prev = false; + int current_indent = 0; + int id = 0; + uint8_t flags = m_disable_indent_colors ? ECallStackEntryRenderFlags_None : ECallStackEntryRenderFlags_IndentColors; + flags |= ECallStackEntryRenderFlags_WithSupportMenus; + for (const auto& entry : m_context) + { + if (entry.is_disabled && !m_show_full_context) continue; + const int depth = static_cast(entry.depth); + const int delta = have_prev ? (depth - prev_depth) : depth; + ImGui::PushID(id++); + &entry != m_target_ptr ? entry.render(delta, static_cast(flags)) + : entry.render(delta, static_cast(flags | ECallStackEntryRenderFlags_Highlight)); + ImGui::PopID(); + current_indent += delta; + prev_depth = depth; + have_prev = true; + } + + // Reset indent state so the rest of the popup doesn't inherit the last depth. + while (current_indent > 0) + { + ImGui::Unindent(); + --current_indent; + } + + ImGui::EndChild(); + + if (ImGui::Button("Save##entrycallstacksave")) + { + save(); + } + HelpMarker(HelpStrings::HELP_CEMODAL_SAVE); + ImGui::SameLine(); + // NOTE: + // Do *not* early-return before EndPopup(). + // ImGui maintains multiple internal stacks (window stack, ID stack, etc.). + // Skipping EndPopup() will eventually trip assertions like "Calling PopId() too many times". + bool keep_open = true; + if (ImGui::Button("Close##entrycallstackclose")) + { + ImGui::CloseCurrentPopup(); + keep_open = false; + } + + if (ImGui::BeginPopupModal("Saved Entry File", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::Text("Saved file to %s", m_last_save_path.c_str()); + ImGui::PopTextWrapPos(); + if (ImGui::Button("OK")) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + + ImGui::EndPopup(); + + if (!keep_open) + { + return false; + } + } + + return true; + } + + auto EntryCallStackRenderer::save() -> void + { + static const auto wd = std::filesystem::path{StringType{UE4SSProgram::get_program().get_working_directory()}}; + static const auto captures_root = wd / "Mods" / "EventViewerMod" / "captures"; + std::error_code ec; + std::filesystem::create_directories(captures_root, ec); + + const auto now = std::chrono::system_clock::now(); + const std::time_t now_t = std::chrono::system_clock::to_time_t(now); + std::tm local_tm{}; + localtime_s(&local_tm, &now_t); + + std::ostringstream oss; + // Windows filenames cannot contain ':'. + oss << std::put_time(&local_tm, "%Y-%m-%d %H-%M-%S"); + + const auto filename = "EventViewerMod Capture-Entry "s + std::string(m_context[m_target_idx].function_name) + " " + oss.str() + ".txt"; + const auto path = captures_root / filename; + std::wofstream out{path}; + + for (const auto& entry : m_context) + { + if (entry.is_disabled && !m_show_full_context) continue; + auto str = entry.to_string_with_prefix(); + out << str; + } + + out.close(); + m_last_save_path = path.string(); + ImGui::OpenPopup("Saved Entry File"); + } +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/src/EventViewer.cpp b/cppmods/EventViewerMod/src/EventViewer.cpp new file mode 100644 index 000000000..62b1077fc --- /dev/null +++ b/cppmods/EventViewerMod/src/EventViewer.cpp @@ -0,0 +1,44 @@ +#include + +// EventViewerMod: UE4SS mod entry point and ImGui tab wiring. +// +// This is the glue between UE4SS' mod lifecycle (start_mod/on_unreal_init) and our +// ImGui renderer + middleware. + +#include +#include +#include +#include + +namespace RC::EventViewerMod +{ + EventViewerMod::EventViewerMod() + { + ModName = STR("EventViewerMod"); + ModAuthors = STR("wildcherry"); + ModDescription = STR("Lets you view a live call stack of ProcessEvent and ProcessInternal, with additional call frequency tracking."); + ModVersion = STR("1.0.0"); + + register_tab(STR("EventViewer"), [](CppUserModBase* mod) { + UE4SS_ENABLE_IMGUI(); + + // Avoid unnecessary expensive dynamic_cast + ImGui::BeginDisabled(!static_cast(mod)->m_unreal_loaded.test()); // NOLINT(*-pro-type-static-cast-downcast) + auto& style = ImGui::GetStyle(); + const auto old_size = style.GrabMinSize; + style.GrabMinSize = 30.0f; + Client::GetInstance().render(); + style.GrabMinSize = old_size; + ImGui::EndDisabled(); + }); + } + + void EventViewerMod::on_unreal_init() + { + Unreal::Hook::RegisterEngineTickPreCallback( + [this](auto&, Unreal::UEngine*, float, bool) { + m_unreal_loaded.test_and_set(); + }, + {true, true, STR("EventViewerMod"), STR("InstallHook")}); + } +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/src/FilterCountRenderer.cpp b/cppmods/EventViewerMod/src/FilterCountRenderer.cpp new file mode 100644 index 000000000..5c280cb89 --- /dev/null +++ b/cppmods/EventViewerMod/src/FilterCountRenderer.cpp @@ -0,0 +1,29 @@ +#include +#include + +namespace RC::EventViewerMod +{ + auto FilterCountRenderer::add() -> void + { + ++m_count; + } + + auto FilterCountRenderer::render_and_reset(const bool show_tooltip) -> void + { + if (!m_count) return; + static ImVec4 text_color{1.0f, 1.0f, 0.0f, 1.0f}; + ImGui::TextColored(text_color, "<%llu Calls Filtered>", m_count); + if (show_tooltip) + ImGui::SetItemTooltip("The call above and the call below may not be related. Right click an entry -> Show Call Stack to see related calls."); + m_count = 0; + } + + auto FilterCountRenderer::render(const size_t count, const bool show_tooltip) -> void + { + if (!count) return; + static ImVec4 text_color{1.0f, 1.0f, 0.0f, 1.0f}; + ImGui::TextColored(text_color, "<%llu Calls Filtered>", count); + if (show_tooltip) + ImGui::SetItemTooltip("The call above and the call below may not be related. Right click an entry -> Show Call Stack to see related calls."); + } +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/src/Middleware.cpp b/cppmods/EventViewerMod/src/Middleware.cpp new file mode 100644 index 000000000..670944d61 --- /dev/null +++ b/cppmods/EventViewerMod/src/Middleware.cpp @@ -0,0 +1,279 @@ +#include + +#include +#include + +#include +#include + +#include + +// note if using fname index as a hash, games that implement name recycling can be problematic/inaccurate for context objects, and right clicking entries +// FIXME trailing spaces on copy? +// EventViewerMod: hook interception + enqueue backend. +// +// Middleware installs the UE hooks and turns each callback into a lightweight CallStackEntry +// that can be consumed by the UI thread later. +// +// Key design points: +// - All supported hooks are installed and intercepted concurrently. +// - Depth is computed with a single counter so nested PE/PI/PLSF chains share indentation. +// - Enqueue is guarded by m_allow_queue to avoid capturing half-installed state. +// - The queue is moodycamel::ConcurrentQueue with per-thread ProducerTokens. +// + +namespace RC::EventViewerMod +{ + using RC::Unreal::FFrame; + using RC::Unreal::UFunction; + using RC::Unreal::UObject; + + using RC::Unreal::Hook::ERROR_ID; + using RC::Unreal::Hook::GlobalCallbackId; + using RC::Unreal::Hook::RegisterProcessEventPostCallback; + using RC::Unreal::Hook::RegisterProcessEventPreCallback; + using RC::Unreal::Hook::RegisterProcessInternalPostCallback; + using RC::Unreal::Hook::RegisterProcessInternalPreCallback; + using RC::Unreal::Hook::UnregisterCallback; + + using moodycamel::ConsumerToken; + using moodycamel::ProducerToken; + + auto Middleware::GetInstance() -> Middleware& + { + static Middleware s_instance; + return s_instance; + } + + Middleware::Middleware() + { + Unreal::UObjectGlobals::ForEachUObject([this](UObject* object, ...) -> LoopAction { + if (object && Unreal::Cast(object) && object->GetName().contains(STR("Tick"))) + { + m_tick_fns.insert(object); + } + return LoopAction::Continue; + }); + + Output::send(L"[EventViewerMod] Found {} engine tick functions!", m_tick_fns.size()); + + m_pe_controller = {.register_prehook_fn = &RC::Unreal::Hook::RegisterProcessEventPreCallback, + .register_posthook_fn = &RC::Unreal::Hook::RegisterProcessEventPostCallback, + .m_pre_callback = + [this](auto&, UObject* context, UFunction* function, void*) { + return enqueue(EMiddlewareHookTarget::ProcessEvent, context, function); + }, + .m_post_callback = + [](auto&, UObject*, UFunction*, void*) { + m_depth = (m_depth == 0) ? 0 : (m_depth - 1); + }}; + + m_pi_controller = {.register_prehook_fn = &RC::Unreal::Hook::RegisterProcessInternalPreCallback, + .register_posthook_fn = &RC::Unreal::Hook::RegisterProcessInternalPostCallback, + .m_pre_callback = + [this](auto&, UObject* context, FFrame& stack, void*) { + auto fn = stack.Node(); + if (!fn) fn = stack.CurrentNativeFunction(); + return enqueue(EMiddlewareHookTarget::ProcessInternal, context, fn); + }, + .m_post_callback = + [](auto&, UObject*, FFrame&, void*) { + m_depth = (m_depth == 0) ? 0 : (m_depth - 1); + }}; + + m_plsf_controller = {.register_prehook_fn = &RC::Unreal::Hook::RegisterProcessLocalScriptFunctionPreCallback, + .register_posthook_fn = &RC::Unreal::Hook::RegisterProcessLocalScriptFunctionPostCallback, + .m_pre_callback = + [this](auto&, UObject* context, FFrame& stack, void*) { + auto fn = stack.Node(); + if (!fn) fn = stack.CurrentNativeFunction(); + return enqueue(EMiddlewareHookTarget::ProcessLocalScriptFunction, context, fn); + }, + .m_post_callback = + [](auto&, UObject*, FFrame&, void*) { + m_depth = (m_depth == 0) ? 0 : (m_depth - 1); + }}; + QueueProfiler::Reset(); + } + + Middleware::~Middleware() + { + stop_impl(false); + } + + auto Middleware::assert_on_imgui_thread() const -> void + { + if (std::this_thread::get_id() != m_imgui_id) + { + throw std::runtime_error("EventViewerMod middleware: must be called from ImGui thread"); + } + } + + auto Middleware::is_tick_fn(const UFunction* fn) const -> bool + { + return fn && m_tick_fns.contains(const_cast(fn)); + } + + auto Middleware::set_imgui_thread_id(std::thread::id id) -> void + { + m_imgui_id = id; + } + + auto Middleware::get_imgui_thread_id() const -> std::thread::id + { + return m_imgui_id; + } + + auto Middleware::get_average_enqueue_time() const -> double + { + return QueueProfiler::GetEnqueueAverage(); + } + + auto Middleware::get_average_dequeue_time() const -> double + { + return QueueProfiler::GetDequeueAverage(); + } + + auto Middleware::stop_impl(const bool do_assert) -> bool + { + if (do_assert) + { + assert_on_imgui_thread(); + } + if (m_paused) + { + return true; + } + + m_pe_controller.unhook(); + m_pi_controller.unhook(); + m_plsf_controller.unhook(); + + // Causes all thread_local depths to be reset the next time the prehook runs. + m_depth_reset_counter.fetch_add(1, std::memory_order_release); + m_allow_queue.clear(std::memory_order_release); + m_paused = true; + return true; + } + + auto Middleware::stop() -> bool + { + if (!stop_impl(true)) + { + return false; + } + + // Drain remaining items (discard). + if (m_buffer.empty()) + { + m_buffer.resize(256); + } + + for (;;) + { + const auto count = m_queue.try_dequeue_bulk(m_imgui_consumer_token, m_buffer.data(), m_buffer.size()); + if (count == 0) + { + break; + } + } + + return true; + } + + auto Middleware::is_paused() const -> bool + { + assert_on_imgui_thread(); + return m_paused; + } + + auto Middleware::start() -> bool + { + assert_on_imgui_thread(); + if (!m_paused) + { + return true; + } + + if (!Unreal::FName::ToStringInternal.is_ready()) + { + Output::send(L"[EventViewerMod] Mod requires FName.toString to be known!"); + } + + if (!(m_plsf_controller.install_posthook() && m_pi_controller.install_posthook() && m_pe_controller.install_posthook() && + m_plsf_controller.install_prehook() && m_pi_controller.install_prehook() && m_pe_controller.install_prehook())) + { + m_pe_controller.unhook(); + m_pi_controller.unhook(); + m_plsf_controller.unhook(); + return false; + } + + m_paused = false; + m_allow_queue.test_and_set(std::memory_order_acq_rel); + return true; + } + + auto Middleware::enqueue(const EMiddlewareHookTarget hook_target, UObject* context, UFunction* function) -> void + { + thread_local std::thread::id thread_id = std::this_thread::get_id(); + thread_local uint64_t local_reset_counter = m_depth_reset_counter.load(std::memory_order_acquire); + if (!m_allow_queue.test(std::memory_order_acquire)) return; + const auto current_counter = m_depth_reset_counter.load(std::memory_order_acquire); + if (current_counter != local_reset_counter) + { + m_depth = 0; + local_reset_counter = current_counter; + } + + const auto is_tick = is_tick_fn(function); + + // Middleware is a singleton and lives for the mod lifetime, so a simple thread_local token is safe here. + thread_local ProducerToken tls_token{m_queue}; + + auto strings = StringPool::GetInstance().get_strings(context, function); + + QueueProfiler::BeginEnqueue(); + m_queue.enqueue(tls_token, CallStackEntry{hook_target, strings, m_depth++, thread_id, is_tick}); + QueueProfiler::EndEnqueue(); + } + + auto Middleware::dequeue(const uint16_t max_ms, const uint16_t max_count_per_iteration, const std::function& on_dequeue) -> void + { + assert_on_imgui_thread(); + + const auto start_time = std::chrono::steady_clock::now(); + if (m_buffer.size() < max_count_per_iteration) + { + m_buffer.resize(max_count_per_iteration); + } + auto now = std::chrono::steady_clock::now(); + for (; std::chrono::duration_cast(now - start_time).count() < max_ms; now = std::chrono::steady_clock::now()) + { + QueueProfiler::BeginDequeue(); + const auto amount = m_queue.try_dequeue_bulk(m_imgui_consumer_token, m_buffer.data(), max_count_per_iteration); + QueueProfiler::EndDequeue(); + + if (amount == 0) + { + break; + } + + for (size_t i = 0; i < amount; ++i) + { + on_dequeue(std::move(m_buffer[i])); + } + + if (m_queue.size_approx() == 0) + { + break; + } + } + + QueueProfiler::AddPendingCount(m_queue.size_approx()); + if (std::chrono::duration_cast(now - start_time).count() >= max_ms) + { + QueueProfiler::AddTimeExceededCount(); + } + } +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/src/StringPool.cpp b/cppmods/EventViewerMod/src/StringPool.cpp new file mode 100644 index 000000000..aa0dfa4f0 --- /dev/null +++ b/cppmods/EventViewerMod/src/StringPool.cpp @@ -0,0 +1,87 @@ +#include + +#include + +#include + +// EventViewerMod: string interning and cached lowercase views. +// +// Hooks run on game threads and must be cheap. Instead of allocating/formatting strings for +// every callback, we intern names and cache their lowercase forms once. +// +// The returned std::string_views remain valid until StringPool::clear() is called. + +auto RC::EventViewerMod::StringPool::get_strings(RC::Unreal::UObject* caller, RC::Unreal::UFunction* function) -> AllNameStringViews +{ + if (!caller || !function) return AllNameStringViews{}; + // StringPool combines the caller's ComparisonIndex and the function's ComparisonIndex + // to generate a unique hash for the full name string. + const uint32_t function_hash = function->GetNamePrivate().GetComparisonIndex(); + uint64_t hash = function_hash; + hash = hash << 32; + hash |= caller->GetNamePrivate().GetComparisonIndex(); + + { + std::shared_lock lock(m_mutex); + auto string_info_it = m_main_pool.find(hash); + if (string_info_it != m_main_pool.end()) + { + auto& string_info = string_info_it->second; + std::string_view full_name = string_info.full_name; + std::string_view lower_full = string_info.lower_cased_full_name; + + std::string_view func_name = full_name; + func_name.remove_prefix(string_info.function_begin); + + std::string_view lower_func_name = lower_full; + lower_func_name.remove_prefix(string_info.function_begin); + + return AllNameStringViews{FunctionNameStringViews{function_hash, func_name, lower_func_name}, full_name, lower_full, hash}; + } + } + + const auto caller_str = RC::to_string(caller->GetName()); + const auto func_str = RC::to_string(function->GetName()); + + // Build once, store both original-case and lower-cased versions. + auto full = caller_str + "." + func_str; + auto lower_full = to_lower_case(full); + auto path_str = RC::to_string(function->GetPathName()); + + { + std::unique_lock lock(m_mutex); + auto& string_info = m_main_pool.emplace(hash, StringInfo{caller_str.size() + 1, std::move(full), std::move(lower_full)}).first->second; + m_path_pool.emplace(function_hash, std::move(path_str)); + + std::string_view full_name = string_info.full_name; + std::string_view lower_full_name = string_info.lower_cased_full_name; + + std::string_view func_name = full_name; + func_name.remove_prefix(string_info.function_begin); + + std::string_view lower_func_name = lower_full_name; + lower_func_name.remove_prefix(string_info.function_begin); + + return AllNameStringViews{FunctionNameStringViews{function_hash, func_name, lower_func_name}, full_name, lower_full_name, hash}; + } +} + +auto RC::EventViewerMod::StringPool::get_path_name(const uint32_t function_hash) -> std::string_view +{ + std::shared_lock lock(m_mutex); + auto path_it = m_path_pool.find(function_hash); + if (path_it == m_path_pool.end()) return ""; + return path_it->second; +} + +auto RC::EventViewerMod::StringPool::clear() -> void +{ + std::unique_lock lock(m_mutex); + m_path_pool.clear(); +} + +auto RC::EventViewerMod::StringPool::GetInstance() -> StringPool& +{ + static StringPool string_pool; + return string_pool; +} diff --git a/cppmods/EventViewerMod/src/Structs.cpp b/cppmods/EventViewerMod/src/Structs.cpp new file mode 100644 index 000000000..b27b98b07 --- /dev/null +++ b/cppmods/EventViewerMod/src/Structs.cpp @@ -0,0 +1,203 @@ +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include + +inline constexpr static unsigned ALPHA = 200; +inline constexpr static std::array COLORS = { + IM_COL32(255, 0, 0, ALPHA), // red + IM_COL32(0, 0, 255, ALPHA), // blue + IM_COL32(0, 255, 0, ALPHA), // green + IM_COL32(255, 176, 0, ALPHA), // orange + IM_COL32(176, 255, 0, ALPHA), // lime + IM_COL32(0, 255, 255, ALPHA), // cyan + IM_COL32(255, 255, 0, ALPHA), // yellow +}; +inline constexpr static auto SELECTED_COLOR = ImVec4{1.0f, 1.0f, 0.0f, 1.0f}; + +// EventViewerMod: small UI-facing helpers for entries and context menus. +// +// The heavy lifting (hooks, queueing, string pooling) happens elsewhere. This file is mostly +// convenience methods used by the ImGui layer: context menu attachment management, clipboard +// helpers, and per-entry "support menu" behavior. +// +namespace RC::EventViewerMod +{ + auto copy_to_clipboard(const std::string_view& string) -> void + { + ImGui::LogToClipboard(); + ImVec2 dummy{0, 0}; + ImGui::LogRenderedText(&dummy, string.data(), string.data() + string.size()); + ImGui::LogFinish(); + }; + + EntryBase::EntryBase(const bool is_tick) : is_tick(is_tick) + { + } + + CallStackEntry::CallStackEntry( + const EMiddlewareHookTarget hook_target, const AllNameStringViews& strings, const uint32_t depth, const std::thread::id thread_id, const bool is_tick) + : EntryBase(is_tick), hook_target(hook_target), depth(depth), thread_id(thread_id) + { + // Inheritance is used to avoid extra indirection/caches misses. + // (StringPool owns the backing storage for these views.) + function_hash = strings.function_hash; + function_name = strings.function_name; + lower_cased_function_name = strings.lower_cased_function_name; + full_name = strings.full_name; + lower_cased_full_name = strings.lower_cased_full_name; + full_hash = strings.full_hash; + } + + auto CallStackEntry::render(const int indent_delta, const ECallStackEntryRenderFlags_ flags) const -> void + { + render_indents(indent_delta); + if (flags & ECallStackEntryRenderFlags_IndentColors) + { + const auto indent_width = ImGui::GetStyle().IndentSpacing * static_cast(depth); + auto min = ImGui::GetCursorScreenPos(); + auto max = min; + min.x -= indent_width; + max.y += ImGui::GetTextLineHeight(); + ImGui::GetWindowDrawList()->AddRectFilled(min, max, COLORS[depth % COLORS.size()]); + } + if (flags & ECallStackEntryRenderFlags_Highlight) [[unlikely]] + { + ImGui::TextColored(SELECTED_COLOR, to_prefix_string(hook_target)); + ImGui::SameLine(); + ImGui::TextColored(SELECTED_COLOR, full_name.data()); + } + else [[likely]] + { + ImGui::TextUnformatted(to_prefix_string(hook_target)); + ImGui::SameLine(); + ImGui::TextUnformatted(full_name.data()); + } + + if (flags & ECallStackEntryRenderFlags_WithSupportMenus) + { + render_support_menus(flags); + } + } + + auto CallStackEntry::to_string_with_prefix() const -> std::wstring + { + std::wstring out; + for (uint32_t i = 0; i < depth; ++i) + out += L"\t"; + out += ensure_str(to_prefix_string(hook_target)) + ensure_str(full_name) + L"\n"; + return out; + } + + auto CallStackEntry::render_indents(const int indent_delta) const -> void + { + if (indent_delta > 0) + { + for (int i = 0; i < indent_delta; ++i) + { + ImGui::Indent(); + } + } + else if (indent_delta < 0) + { + for (int i = indent_delta; i != 0; ++i) + { + ImGui::Unindent(); + } + } + } + + auto CallStackEntry::render_support_menus(const ECallStackEntryRenderFlags_ flags) const -> void + { + ImGui::SetItemTooltip("Right click for options"); + if (ImGui::BeginPopupContextItem("EntryPopup##ep", ImGuiPopupFlags_MouseButtonRight)) + { + if (flags & ECallStackEntryRenderFlags_WithSupportMenusCallStackModal & ~ECallStackEntryRenderFlags_WithSupportMenus) + { + if (ImGui::MenuItem("Show Call Stack")) Client::GetInstance().render_entry_stack_modal(this); // need to do this outside + ImGui::Separator(); + } + if (ImGui::MenuItem("Copy Function Full Name")) copy_to_clipboard(StringPool::GetInstance().get_path_name(function_hash)); + if (ImGui::MenuItem("Copy Function Name##cfn")) copy_to_clipboard(function_name); + if (ImGui::MenuItem("Add Function to Whitelist##fwl")) Client::GetInstance().add_to_white_list(function_name); + if (ImGui::MenuItem("Add Function to Blacklist##fbl")) Client::GetInstance().add_to_black_list(function_name); + ImGui::Separator(); + if (ImGui::MenuItem("Copy Caller Name##ccn")) copy_to_clipboard({full_name.begin(), function_name.begin() - 1}); + if (ImGui::MenuItem("Add Caller to Whitelist##cwl")) Client::GetInstance().add_to_white_list({full_name.begin(), function_name.begin() - 1}); + HelpMarker(HelpStrings::HELP_ADD_CALLER_AND_FUNC_NAME_WARNING); + if (ImGui::MenuItem("Add Caller to Blacklist##cbl")) Client::GetInstance().add_to_black_list({full_name.begin(), function_name.begin() - 1}); + HelpMarker(HelpStrings::HELP_ADD_CALLER_AND_FUNC_NAME_WARNING); + ImGui::Separator(); + if (ImGui::MenuItem("Copy Caller + Function Name##cfln")) copy_to_clipboard(full_name); + if (ImGui::MenuItem("Add Caller + Function to Whitelist##fnwl")) Client::GetInstance().add_to_white_list(full_name); + HelpMarker(HelpStrings::HELP_ADD_CALLER_AND_FUNC_NAME_WARNING); + if (ImGui::MenuItem("Add Caller + Function to Blacklist##fnb")) Client::GetInstance().add_to_black_list(full_name); + HelpMarker(HelpStrings::HELP_ADD_CALLER_AND_FUNC_NAME_WARNING); + ImGui::EndPopup(); + } + } + + CallFrequencyEntry::CallFrequencyEntry(const FunctionNameStringViews& strings, const bool is_tick) : EntryBase(is_tick) + { + function_hash = strings.function_hash; + function_name = strings.function_name; + lower_cased_function_name = strings.lower_cased_function_name; + } + + auto CallFrequencyEntry::render(const ECallFrequencyEntryRenderFlags_ flags) const -> void + { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(function_name.data()); + if (flags & ECallFrequencyEntryRenderFlags_WithSupportMenus) render_support_menus(); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%llu", static_cast(frequency)); + } + + auto CallFrequencyEntry::render_support_menus() const -> void + { + ImGui::SetItemTooltip("Right click for options"); + if (ImGui::BeginPopupContextItem("EntryPopup##fep", ImGuiPopupFlags_MouseButtonRight)) + { + if (ImGui::MenuItem("Copy Function Full Name")) copy_to_clipboard(StringPool::GetInstance().get_path_name(function_hash)); + if (ImGui::MenuItem("Copy Function Name##cfn")) copy_to_clipboard(function_name); + if (ImGui::MenuItem("Add Function to Whitelist##fwl")) Client::GetInstance().add_to_white_list(function_name); + if (ImGui::MenuItem("Add Function to Blacklist##fbl")) Client::GetInstance().add_to_black_list(function_name); + ImGui::EndPopup(); + } + } + + ThreadInfo::ThreadInfo(const std::thread::id thread_id) : thread_id(thread_id), is_game_thread(RC::Unreal::GetGameThreadId() == thread_id) + { + } + + auto ThreadInfo::id_string() -> const char* + { + if (m_id_string.empty()) + { + std::stringstream ss; + ss << thread_id; + m_id_string = ss.str(); + if (is_game_thread) + { + m_id_string += " (Game)"; + } + } + return m_id_string.c_str(); + } + + auto ThreadInfo::clear() -> void + { + call_frequencies.clear(); + call_stack.clear(); + call_stack_render_set.clear(); + } +} // namespace RC::EventViewerMod diff --git a/cppmods/EventViewerMod/src/dllmain.cpp b/cppmods/EventViewerMod/src/dllmain.cpp new file mode 100644 index 000000000..c86a94c02 --- /dev/null +++ b/cppmods/EventViewerMod/src/dllmain.cpp @@ -0,0 +1,19 @@ +// EventViewerMod: Windows DLL entry point (UE4SS loads this module). +// +// The actual mod logic lives in EventViewerMod (see EventViewer.cpp). This file exists to +// satisfy the DLL entry requirements on Windows. + +#include + +extern "C" +{ + __declspec(dllexport) RC::CppUserModBase* start_mod() + { + return new EventViewerMod::EventViewerMod(); + } + + __declspec(dllexport) void uninstall_mod(RC::CppUserModBase* mod) + { + delete mod; + } +} \ No newline at end of file diff --git a/deps/first/Helpers/include/Helpers/String.hpp b/deps/first/Helpers/include/Helpers/String.hpp index ca1e7342c..d54554755 100644 --- a/deps/first/Helpers/include/Helpers/String.hpp +++ b/deps/first/Helpers/include/Helpers/String.hpp @@ -518,6 +518,25 @@ namespace RC return ensure_str_as(std::forward(arg)); } + template + auto inline to_lower_case(std::basic_string_view input) -> std::basic_string + { + static auto locale = std::locale(); + std::basic_string out; + out.reserve(input.size()); + for (const auto& c : input) + { + out.push_back(std::tolower(c, locale)); + } + return out; + } + + template + auto inline to_lower_case(const std::basic_string& input) -> std::basic_string + { + return to_lower_case(std::basic_string_view(input)); + } + // You can add more to_* function if needed // Auto Type Conversion Done diff --git a/tools/buildscripts/release.py b/tools/buildscripts/release.py index 7e4386429..e3013849c 100644 --- a/tools/buildscripts/release.py +++ b/tools/buildscripts/release.py @@ -21,6 +21,7 @@ def __init__(self, is_dev_release, is_experimental, release_output='release'): # List of CPP Mods with flags indicating if they need a config folder and if they should be included in release builds self.cpp_mods = { 'KismetDebuggerMod': {'create_config': True, 'include_in_release': False}, + 'EventViewerMod': {'create_config': False, 'include_in_release': False}, } # Lua mods to exclude from the non-dev/release version of the zip