diff --git a/.github/workflows/msbuild.yml b/.github/workflows/msbuild.yml index fe9bd3d..0fd7979 100644 --- a/.github/workflows/msbuild.yml +++ b/.github/workflows/msbuild.yml @@ -2,16 +2,6 @@ name: MSBuild on: workflow_dispatch: - push: - branches: [ "master" ] - paths: - - 'Internal/**' - - 'Launcher/**' - pull_request: - branches: [ "master" ] - paths: - - 'Internal/**' - - 'Launcher/**' env: SOLUTION_FILE_PATH: . @@ -24,16 +14,16 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: submodules: recursive - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 id: cpy with: python-version: '3.14' - name: Add MSBuild to PATH - uses: microsoft/setup-msbuild@v2 + uses: microsoft/setup-msbuild@v3 - name: Install vcpkg run: | @@ -60,7 +50,7 @@ jobs: run: Tools/build.bat - name: Upload Release Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Release path: ${{env.SOLUTION_FILE_PATH}}/x64/Release diff --git a/Internal/Internal.vcxproj b/Internal/Internal.vcxproj index def500d..ef1aa2f 100644 --- a/Internal/Internal.vcxproj +++ b/Internal/Internal.vcxproj @@ -69,6 +69,7 @@ + @@ -242,6 +243,7 @@ + @@ -264,6 +266,28 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Internal/cache/FontCache.h b/Internal/cache/FontCache.h new file mode 100644 index 0000000..60b7ec6 --- /dev/null +++ b/Internal/cache/FontCache.h @@ -0,0 +1,60 @@ +#pragma once + +/// @file +/// @brief Lazily-built, shared list of every UFont in GObjects (short name + pointer), so the UE-canvas +/// renderer can draw text in a chosen game font instead of the default Roboto. Mirrors ClassCache: fonts +/// load once and rarely change, so the walk is done on demand and reused; Rebuild() (a Refresh button) +/// picks up newly-loaded fonts. + +#include +#include + +#include "../ue/Engine.h" + +namespace FontCache +{ + /// One cached font: its short name (for display / the setting) and the object pointer. + struct Entry + { + std::string name; + UFont* font; + }; + + inline std::vector fonts; + inline bool built = false; + + /// Walk GObjects once and collect every UFont instance (skipping the class default object). + inline void Rebuild() + { + fonts.clear(); + built = true; + if (!Engine::GObjects) return; + + UObject* fontClass = Engine::GObjects->FindObject("Class Engine.Font"); + if (!fontClass) return; + + const auto count = Engine::GObjects->NumElements; + for (auto i = 0u; i < count; i++) + { + auto* obj = Engine::GObjects->GetObjectPtr(i); + if (!obj || obj->IsDefaultObject() || !obj->IsA(fontClass)) continue; + fonts.push_back({obj->GetName(), reinterpret_cast(obj)}); + } + } + + /// The cached fonts, building them on first use. + inline const std::vector& Get() + { + if (!built) Rebuild(); + return fonts; + } + + /// Resolve a font by its short name; nullptr for an empty/unknown name (→ the renderer's default). + inline UFont* Find(const std::string& name) + { + if (name.empty()) return nullptr; + for (const Entry& e : Get()) + if (e.name == name) return e.font; + return nullptr; + } +} // namespace FontCache diff --git a/Internal/features/AnnounceToggles.h b/Internal/features/AnnounceToggles.h index c7d4970..7910a18 100644 --- a/Internal/features/AnnounceToggles.h +++ b/Internal/features/AnnounceToggles.h @@ -48,7 +48,7 @@ class AnnounceToggles : public Feature AnnounceToggles() { Name = "AnnounceToggles"; - Event = Events::Type::SettingsChanged; // driven by menu changes, not per-frame + Triggers = {Events::Type::SettingsChanged}; // driven by menu changes, not per-frame UpdateEnabled(); Log("Created"); }; diff --git a/Internal/features/DiscordPresence.h b/Internal/features/DiscordPresence.h index 5a56d44..1142695 100644 --- a/Internal/features/DiscordPresence.h +++ b/Internal/features/DiscordPresence.h @@ -2,23 +2,22 @@ /// @file /// The DiscordPresence feature: refreshes the Discord Rich Presence with live game state (map + -/// K/D) on a throttle, so the presence stays current without spamming Discord every frame. +/// K/D). Event-driven rather than per-frame — it runs on the in-game/in-lobby transitions +/// (EnteredGame / EnteredLobby) and on each kill (PlayerKilled) so the K/D stays current. +/// UpdateGameState only pushes to Discord when the string actually changes, so PlayerKilled +/// (which fires for every kill in the match) effectively updates only on the local player's +/// own kills/deaths. #include "Feature.h" #include "../discord/rpc.h" -#include - class DiscordPresence : public Feature { - private: - std::chrono::steady_clock::time_point lastUpdate{}; - static constexpr int IntervalSeconds = 5; - public: DiscordPresence() { Name = "DiscordPresence"; + Triggers = {Events::Type::EnteredGame, Events::Type::EnteredLobby, Events::Type::PlayerKilled}; UpdateEnabled(); Log("Created"); }; @@ -42,12 +41,10 @@ class DiscordPresence : public Feature void Destroy() { }; - void Run() + /// Push the current game state to Discord on the event that fired us (a state transition or a + /// kill). UpdateGameState no-ops when the resulting presence string is unchanged. + void Run() override { - const auto now = std::chrono::steady_clock::now(); - if (std::chrono::duration_cast(now - lastUpdate).count() < IntervalSeconds) return; - - lastUpdate = now; DiscordRPC::UpdateGameState(); }; }; diff --git a/Internal/features/Feature.h b/Internal/features/Feature.h index 54d21ab..095ad86 100644 --- a/Internal/features/Feature.h +++ b/Internal/features/Feature.h @@ -6,6 +6,9 @@ /// drive it. See the class comment below for the full Init/Check/Run/Destroy /// contract. +#include +#include + #include "../settings/Settings.h" #include "../utils/Logger.h" #include "../scripting/Events.h" @@ -34,19 +37,33 @@ class Feature bool Enabled = false; // toggled from settings via UpdateEnabled() bool Initialized = false; ///< set true by Init() once setup succeeds; features guard on this bool OneTime = false; // Run() fires once per enable instead of every frame + int ThrottleMs = 0; ///< minimum gap between Run() calls in ms; 0 = every tick/event std::string Name = "BaseFeature"; ///< human-readable id, used in Log() output - // Which event drives this feature. Render runs every frame (the fast loop in - // Features::Execute); any other event subscribes it to the event bus and runs - // it when that event is dispatched (e.g. Events::Type::PlayerDeath). - Events::Type Event = Events::Type::Render; + // Which events drive this feature. Render runs every frame (the fast loop in + // Features::Execute); every other trigger subscribes it to the event bus and runs it when that + // event is dispatched (e.g. Events::Type::PlayerDeath). A feature may list several triggers, and + // may mix Render with bus events. + std::vector Triggers{Events::Type::Render}; // Bookkeeping owned by Features::Execute; subclasses should not touch these. - bool applied = false; // Run() has been applied and not yet reverted by Destroy() - bool hasRun = false; // a OneTime feature has already run this enable cycle + bool applied = false; // Run() has been applied and not yet reverted by Destroy() + bool hasRun = false; // a OneTime feature has already run this enable cycle + std::chrono::steady_clock::time_point lastRun{}; // when Run() last fired, for ThrottleMs Feature() {}; + /// Whether the throttle interval has elapsed since the last Run() (and, if so, stamps it now). + /// Always true when ThrottleMs <= 0. Owned by the runner; features don't call this. + bool ThrottleReady() + { + if (ThrottleMs <= 0) return true; + const auto now = std::chrono::steady_clock::now(); + if (now - lastRun < std::chrono::milliseconds(ThrottleMs)) return false; + lastRun = now; + return true; + } + /// One-time setup (resolve game objects, cache originals). Must set /// Initialized to reflect success; the runner calls it on first use. virtual void Init() = 0; @@ -58,8 +75,14 @@ class Feature virtual bool Check() = 0; /// Revert whatever Run() applied. Called once on the enabled -> disabled edge. virtual void Destroy() = 0; - /// Apply the effect. Called every frame while enabled (or once per enable if OneTime). - virtual void Run() = 0; + + /// Apply the effect. Called every frame while enabled (or once per enable if OneTime) for a + /// Render trigger, and on each dispatch of any other trigger event. The runner always calls the + /// (event, payload) form; the default chain lets a subclass override whichever arity it needs — + /// Run() to ignore the trigger, Run(event) for the event only, or Run(event, payload) for both. + virtual void Run() {} + virtual void Run(Events::Type) { Run(); } + virtual void Run(Events::Type event, const Events::Payload&) { Run(event); } /// Emit a "[Name] message" line, but only when Settings.DEBUG.FeaturesLogging is on. void Log(std::string message) diff --git a/Internal/features/FeatureRunner.h b/Internal/features/FeatureRunner.h index 7ba0878..6050814 100644 --- a/Internal/features/FeatureRunner.h +++ b/Internal/features/FeatureRunner.h @@ -7,6 +7,7 @@ #include #include +#include #include "Feature.h" @@ -24,10 +25,11 @@ namespace Features // Drives a single feature once: init on first use, refresh Enabled, skip if // idle-disabled, then Run() while enabled (once, if OneTime) or Destroy() // exactly once on the enabled -> disabled edge. Used both by the per-frame - // render loop and by the event bus (for event-driven features). Features - // whose Check() still returns Enabled keep their previous behavior (they just - // skip on disable and never reach Destroy). - inline void RunFeature(Feature& feature) + // render loop and by the event bus (for event-driven features). The triggering + // @p event and @p payload are forwarded to Run() (defaulting to the Render loop's + // empty payload). Features whose Check() still returns Enabled keep their previous + // behavior (they just skip on disable and never reach Destroy). + inline void RunFeature(Feature& feature, Events::Type event = Events::Type::Render, const Events::Payload& payload = {}) { try { @@ -53,9 +55,9 @@ namespace Features if (feature.Enabled) { - if (!feature.OneTime || !feature.hasRun) + if ((!feature.OneTime || !feature.hasRun) && feature.ThrottleReady()) { - feature.Run(); + feature.Run(event, payload); feature.hasRun = true; } feature.applied = true; @@ -77,13 +79,14 @@ namespace Features } // Runs every render-driven feature. Called once per rendered frame from - // PostRender. Event-driven features (Event != "render") are skipped here and - // run from the event bus instead. + // PostRender. Features without a Render trigger are skipped here and run from + // the event bus instead (a feature may have both). inline void Execute() { for (const auto& feature : Features) { - if (feature->Event == Events::Type::Render) + const auto& triggers = feature->Triggers; + if (std::find(triggers.begin(), triggers.end(), Events::Type::Render) != triggers.end()) { RunFeature(*feature); } diff --git a/Internal/features/Features.h b/Internal/features/Features.h index 0c06c20..4641bb2 100644 --- a/Internal/features/Features.h +++ b/Internal/features/Features.h @@ -88,14 +88,16 @@ namespace Features // Autosave: persist on every change when enabled. if (Settings.MISC.AutoSave) SettingsHelper::File().Save(); }); - // Subscribe event-driven features (Event != "render") to the event bus; - // render features run from Features::Execute each frame instead. + // Subscribe each non-Render trigger to the event bus, forwarding the event + payload to the + // feature's Run(); Render triggers run from Features::Execute each frame instead. A feature + // may list several triggers (e.g. DiscordPresence on EnteredGame + EnteredLobby). for (auto& feature : Features) { - if (feature->Event != Events::Type::Render) + for (Events::Type trigger : feature->Triggers) { - Events::Register(feature->Event, [ptr = feature.get()] - { RunFeature(*ptr); }); + if (trigger == Events::Type::Render) continue; + Events::Register(trigger, [ptr = feature.get(), trigger](const Events::Payload& payload) + { RunFeature(*ptr, trigger, payload); }); } } diff --git a/Internal/hook/GuardHook.h b/Internal/hook/GuardHook.h new file mode 100644 index 0000000..8d3be31 --- /dev/null +++ b/Internal/hook/GuardHook.h @@ -0,0 +1,173 @@ +#pragma once + +/// @file +/// @brief GuardHook — a code-patch-free hooking primitive (our own implementation), an alternative to +/// the MinHook trampoline used elsewhere. MinHook rewrites the target's prologue bytes, which a +/// code-integrity check can spot; a guard-page hook modifies **no** target bytes. It marks the page +/// containing the target `PAGE_GUARD` and installs a vectored exception handler: when execution reaches +/// the target the guard fault fires, the handler redirects RIP to the detour and single-steps once to +/// re-arm the guard (the CPU auto-clears PAGE_GUARD on the fault). +/// +/// Trade-offs (see docs/hooking.md): one exception per call — fine for a rarely-called function, far +/// too slow for a per-frame path like PostRender. It hooks by resolved address (no trampoline back to +/// the original — the detour is responsible for continuing execution), and there is an inherent race: +/// while the guard is cleared between the fault and the single-step re-arm, a concurrent call on +/// another thread runs the real target un-redirected. Ship it opt-in (e.g. behind a Debug toggle) for +/// a low-frequency target, not as a drop-in for the MinHook path. + +#include + +#include +#include + +namespace Hook +{ + /// Guard-page (PAGE_GUARD + VEH) hooking. Install(target, detour) / Remove(target). + namespace GuardHook + { + /// x86 EFlags trap flag: set it to single-step the next instruction (raises EXCEPTION_SINGLE_STEP). + inline constexpr DWORD TrapFlag = 0x100; + /// STATUS_GUARD_PAGE_VIOLATION — raised when code touches a PAGE_GUARD page (winnt has no macro). + inline constexpr DWORD GuardPageViolation = 0x80000001; + + /// One installed hook: redirect @ref target to @ref detour. + struct Entry + { + void* target; + void* detour; + }; + + inline std::vector entries; ///< installed hooks; small, mutated only by Install/Remove + inline PVOID vehHandle = nullptr; ///< the one registered vectored handler (installed lazily) + /// The target page to re-arm on this thread's next single-step (thread-local so concurrent + /// faults don't clobber each other's pending re-arm). + inline thread_local void* pendingRearm = nullptr; + + /// Whether @p a and @p b sit in the same 4 KB page. + inline bool SamePage(const void* a, const void* b) + { + return (reinterpret_cast(a) & ~static_cast(0xFFF)) == + (reinterpret_cast(b) & ~static_cast(0xFFF)); + } + + /// (Re)mark the page containing @p target as PAGE_GUARD, preserving its base protection. + inline bool Arm(void* target) + { + MEMORY_BASIC_INFORMATION mbi{}; + if (!VirtualQuery(target, &mbi, sizeof(mbi))) return false; + DWORD old = 0; + return VirtualProtect(target, 1, mbi.Protect | PAGE_GUARD, &old) != 0; + } + + /// The vectored handler: redirect target faults to the detour and re-arm the guard on the step. + inline LONG CALLBACK Handler(PEXCEPTION_POINTERS ex) + { + CONTEXT* ctx = ex->ContextRecord; + const DWORD code = ex->ExceptionRecord->ExceptionCode; + + if (code == GuardPageViolation) + { + // Execution fault → RIP is the faulting address. The OS has already cleared PAGE_GUARD + // on this page, so we must re-arm it (deferred to the single-step below). + const void* rip = reinterpret_cast(ctx->Rip); + for (const Entry& e : entries) + { + if (rip == e.target) // our target: redirect to the detour + { + ctx->Rip = reinterpret_cast(e.detour); + ctx->EFlags |= TrapFlag; + pendingRearm = e.target; + return EXCEPTION_CONTINUE_EXECUTION; + } + if (SamePage(rip, e.target)) // same page, different code: run it, just re-arm + { + ctx->EFlags |= TrapFlag; + pendingRearm = e.target; + return EXCEPTION_CONTINUE_EXECUTION; + } + } + return EXCEPTION_CONTINUE_SEARCH; // not one of ours (e.g. a stack guard page) + } + + if (code == EXCEPTION_SINGLE_STEP && pendingRearm) + { + Arm(pendingRearm); + pendingRearm = nullptr; + ctx->EFlags &= ~TrapFlag; // one step only (the CPU also clears TF after the trap) + return EXCEPTION_CONTINUE_EXECUTION; + } + + return EXCEPTION_CONTINUE_SEARCH; + } + + /// Redirect calls to @p target into @p detour without patching any bytes. + /// @return false on a null argument or if the guard/VEH couldn't be installed. + /// @note The detour receives control in place of the target; there is no trampoline back to the + /// original, so the detour owns continuing/emulating the target's work. + inline bool Install(void* target, void* detour) + { + if (!target || !detour) return false; + + if (!vehHandle) + { + // First in the chain so we intercept our own guard/step faults before the crash handler's + // last-chance SetUnhandledExceptionFilter sees them. + vehHandle = AddVectoredExceptionHandler(1, Handler); + if (!vehHandle) return false; + } + + entries.push_back({target, detour}); + if (Arm(target)) return true; + + entries.pop_back(); + return false; + } + + /// Remove a hook installed by Install: clear the page guard and drop the entry. Removes the + /// vectored handler once the last hook is gone. + inline void Remove(void* target) + { + MEMORY_BASIC_INFORMATION mbi{}; + DWORD old = 0; + if (VirtualQuery(target, &mbi, sizeof(mbi))) + VirtualProtect(target, 1, mbi.Protect & ~static_cast(PAGE_GUARD), &old); + + std::erase_if(entries, [&](const Entry& e) { return e.target == target; }); + + if (entries.empty() && vehHandle) + { + RemoveVectoredExceptionHandler(vehHandle); + vehHandle = nullptr; + } + } + + inline bool demoDetourRan = false; ///< set by SelfTest's detour to prove it ran + inline void SelfTestDetour() { demoDetourRan = true; } ///< the SelfTest detour (a normal, un-guarded function) + + /// In-process proof that the primitive works, safe to run in the game (touches no game code): + /// guard-hook a scratch page holding a lone `ret`, call it, and confirm the detour ran instead. + /// The target lives on its own VirtualAlloc'd page so guarding it can never fault our own handler + /// or this function. @return true if the detour ran (hook worked). + inline bool SelfTest() + { + demoDetourRan = false; + + auto* page = static_cast(VirtualAlloc(nullptr, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE)); + if (!page) return false; + page[0] = 0xC3; // x64 `ret` — the "original" we expect to be redirected away from + + bool ok = false; + if (Install(page, reinterpret_cast(&SelfTestDetour))) + { + // Call through a volatile pointer so the compiler can't inline past the guarded page. + void(*volatile call)() = reinterpret_cast(page); + call(); + Remove(page); + ok = demoDetourRan; + } + + VirtualFree(page, 0, MEM_RELEASE); + return ok; + } + } // namespace GuardHook +} // namespace Hook diff --git a/Internal/hook/Hook.h b/Internal/hook/Hook.h index ff65074..774742a 100644 --- a/Internal/hook/Hook.h +++ b/Internal/hook/Hook.h @@ -3,6 +3,7 @@ #include "../features/Features.h" #include "functions/ProcessEvent.h" #include "functions/PostRender.h" +#include "GuardHook.h" #include "../menu/gui/Gui.h" #include diff --git a/Internal/hook/functions/PostRender.h b/Internal/hook/functions/PostRender.h index bfc8005..3883029 100644 --- a/Internal/hook/functions/PostRender.h +++ b/Internal/hook/functions/PostRender.h @@ -13,6 +13,7 @@ #include "../../cache/ActorCache.h" #include "../../render/Render.h" #include "../../features/Features.h" +#include "../../menu/Menu.h" #include "../../utils/Input.h" /// @brief Hook code for UGameViewportClient::PostRender. @@ -36,23 +37,33 @@ namespace PostRender // off-thread fault (a null check can't detect a freed object). IsInGame is evaluated here on the // game thread, where the controller is valid. Engine::PlayerController = PlayerController; - Engine::IsInGame = PlayerController && PlayerController->IsInGame(); + + // Dispatch the in-game / in-lobby transition (event-driven features like DiscordPresence run + // off this instead of polling every frame). Compared against the previous frame's cached value. + const bool inGame = PlayerController && PlayerController->IsInGame(); + if (inGame != Engine::IsInGame) + Events::Dispatch(inGame ? Events::Type::EnteredGame : Events::Type::EnteredLobby); + Engine::IsInGame = inGame; + + Engine::World = World; + Engine::Canvas = Canvas; + + // Point the drawing backend at the selected renderer for this frame — the menu and the features + // both draw through Render::*. Selected unconditionally so the Canvas menu renders correctly even + // at the main menu (no player controller). ImGui-recorded commands are replayed in the Present hook. + Render::Select(Settings.MENU.Renderer); + + // Draw the menu for the PostRender-driven backend (the Canvas backend); a no-op if ImGui is active. + Menu::Frame(Menu::Phase::PostRender); if (PlayerController) { - Engine::World = World; - Engine::Canvas = Canvas; - // Edge-detect hotkeys once per frame → Events::HotKeyPressed (press-once actions subscribe). Input::DispatchHotKeys(); // One shared actor pass per frame, consumed by the visual features below. ActorCache::Update(); - // Point the drawing backend at the selected renderer for this frame (features draw - // through Render::*). ImGui-recorded commands are replayed in the Present hook. - Render::Select(Settings.MENU.Renderer); - Features::Execute(); } diff --git a/Internal/menu/Menu.h b/Internal/menu/Menu.h index 28040ca..75e0126 100644 --- a/Internal/menu/Menu.h +++ b/Internal/menu/Menu.h @@ -1,12 +1,15 @@ #pragma once /// @file -/// @brief Top-level in-game GUI: owns the main ImGui window and dispatches to each tab section. +/// @brief Top-level menu driver: owns the tab list and the per-frame frame loop, and drives whichever +/// backend is active through the abstract Menu::Backend interface. It names no concrete backend and +/// includes neither ImGui nor UCanvasGUI — all backend-specific chrome/widgets live in menu/backend/. #include "../settings/Settings.h" #include "../scripting/Events.h" #include "../utils/Input.h" -#include "../utils/Rgb.h" +#include "backend/Backend.h" +#include "backend/Backends.h" #include "sections/Misc.h" #include "sections/Exploits.h" #include "sections/Visuals.h" @@ -18,145 +21,63 @@ #include "sections/Discord.h" #include "sections/Debug.h" -#include - -#include - -/// @brief Top-level menu: owns the main window and routes each frame to the tab sections. namespace Menu { - static ImGuiWindowFlags windowFlags = ImGuiWindowFlags_NoCollapse; - static int tab = 0; - static ImGuiTabBarFlags tabFlags = ImGuiTabBarFlags_Reorderable | ImGuiTabBarFlags_NoCloseWithMiddleMouseButton; + /// One menu tab: its label and the section function that draws it (through Menu::UI). + struct Tab + { + const char* label; + void (*fn)(); + }; + + /// The tab order, shared by every backend (each renders it through its own window/tab chrome). + inline const Tab Tabs[] = { + {"Misc", Sections::MiscTab}, + {"Exploits", Sections::ExploitsTab}, + {"Visuals", Sections::VisualsTab}, + {"Aim", Sections::AimTab}, + {"Network", Sections::NetworkTab}, + {"Config", Sections::ConfigTab}, + {"Scripts", Sections::ScriptsTab}, + {"SDK", Sections::SdkTab}, + {"Discord", Sections::DiscordTab}, + {"Debug", Sections::DebugTab}, + }; - /// @brief Renders one full frame of the GUI. - /// Always draws the watermark; toggles menu visibility on the configured hotkey / gamepad Start - /// (dispatching MenuOpened/MenuClosed), and when the menu is visible draws the main window with its - /// tab bar (Misc, Exploits, Visuals, Settings, Debug) plus the optional ImGui demo/style-editor windows. - void Draw() + /// Flip menu visibility on the show/hotkey (or @p extra — a backend's own signal, e.g. gamepad + /// Start), dispatching MenuOpened/MenuClosed. The two backends are mutually exclusive per frame, so + /// this never double-fires. + inline void HandleToggle(bool extra) { - // The watermark is its own Watermark feature now (drawn through the Render backend), so it - // follows the active renderer — including the streamproof external window — and isn't drawn here. - if (Input::Pressed(Settings.MENU.ShowHotkey) || ImGui::IsKeyPressed(ImGuiKey_GamepadStart)) + if (Input::Pressed(Settings.MENU.ShowHotkey) || extra) { Settings.MENU.ShowMenu = !Settings.MENU.ShowMenu; Events::Dispatch(Settings.MENU.ShowMenu ? Events::Type::MenuOpened : Events::Type::MenuClosed); } - if (!Settings.MENU.ShowMenu) return; - - if (Settings.DEBUG.ShowDemoWindow) - { - ImGui::ShowDemoWindow(&Settings.DEBUG.ShowDemoWindow); - } - if (Settings.DEBUG.ShowStyleEditor) - { - ImGui::ShowStyleEditor(); - } - - // Tint the interactive accent slots each frame: the cycling RGB color when enabled, else the - // theme's default reds. The defaults are snapshotted once (before the first override) so - // toggling RGB back off restores the original per-slot shades instead of freezing on the last - // rainbow frame. - { - static constexpr ImGuiCol accentSlots[] = { - ImGuiCol_CheckMark, ImGuiCol_SliderGrab, ImGuiCol_SliderGrabActive, - ImGuiCol_Header, ImGuiCol_HeaderHovered, ImGuiCol_HeaderActive, - ImGuiCol_Tab, ImGuiCol_TabHovered, ImGuiCol_TabActive, ImGuiCol_TitleBgActive}; - - ImVec4* colors = ImGui::GetStyle().Colors; - - static const std::array defaults = [&] - { - std::array saved{}; - for (size_t i = 0; i < saved.size(); ++i) saved[i] = colors[accentSlots[i]]; - return saved; - }(); - - if (Settings.MENU.Rgb) - { - const Color rgb = Rgb::Current(); - const ImVec4 accent(rgb.R, rgb.G, rgb.B, rgb.A); - for (ImGuiCol slot : accentSlots) colors[slot] = accent; - } - else - { - for (size_t i = 0; i < defaults.size(); ++i) colors[accentSlots[i]] = defaults[i]; - } - } - - if (!ImGui::Begin("Splitgate Internal", &Settings.MENU.ShowMenu, windowFlags)) - { - ImGui::End(); - return; - }; - - if (!ImGui::BeginTabBar("MainTabBar", tabFlags)) - { - ImGui::End(); - return; - }; - - if (ImGui::BeginTabItem("Misc")) - { - Menu::Sections::MiscTab(); - ImGui::EndTabItem(); - } + } - if (ImGui::BeginTabItem("Exploits")) - { - Menu::Sections::ExploitsTab(); - ImGui::EndTabItem(); - } - - if (ImGui::BeginTabItem("Visuals")) - { - Menu::Sections::VisualsTab(); - ImGui::EndTabItem(); - } - - if (ImGui::BeginTabItem("Aim")) - { - Menu::Sections::AimTab(); - ImGui::EndTabItem(); - } - - if (ImGui::BeginTabItem("Network")) - { - Menu::Sections::NetworkTab(); - ImGui::EndTabItem(); - } - - if (ImGui::BeginTabItem("Config")) - { - Menu::Sections::ConfigTab(); - ImGui::EndTabItem(); - } - - if (ImGui::BeginTabItem("Scripts")) - { - Menu::Sections::ScriptsTab(); - ImGui::EndTabItem(); - } - - if (ImGui::BeginTabItem("SDK")) - { - Menu::Sections::SdkTab(); - ImGui::EndTabItem(); - } + /// Drive one frame of the menu for a given hook @p phase. Selects the configured backend and, if it + /// is driven by this phase, runs its frame: setup, toggle, then the window + tab loop. This is the + /// only shared code that touches a backend, and it does so purely through the interface. + inline void Frame(Phase phase) + { + Select(Settings.MENU.Backend); + if (active->phase() != phase) return; // the backend picks its own hook - if (ImGui::BeginTabItem("Discord")) - { - Menu::Sections::DiscordTab(); - ImGui::EndTabItem(); - } + active->BeginFrame(); + HandleToggle(active->ExtraToggle()); - if (ImGui::BeginTabItem("Debug")) + if (Settings.MENU.ShowMenu && active->BeginWindow("Splitgate Internal")) { - Menu::Sections::DebugTab(); - ImGui::EndTabItem(); + for (const Tab& tab : Tabs) + if (active->BeginTab(tab.label)) + { + tab.fn(); + active->EndTab(); + } + active->EndWindow(); } - ImGui::EndTabBar(); - ImGui::End(); - }; -}; // namespace Menu \ No newline at end of file + active->EndFrame(); + } +} // namespace Menu diff --git a/Internal/menu/Widgets.h b/Internal/menu/Widgets.h deleted file mode 100644 index 3238f72..0000000 --- a/Internal/menu/Widgets.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -/// @file -/// @brief Menu helpers that tie ImGui controls to the event bus. - -#include "gui/Custom.h" // ImGui::ToggleButton -#include "../scripting/Events.h" - -namespace Menu -{ - /// A toggle that, when flipped, dispatches SettingsChanged tagged with its label (so handlers - /// and scripts know which setting/feature changed — payload.name = label, payload.value = 0/1). - /// @return true if the value changed this frame. - inline bool ToggleSetting(const char* label, bool* value) - { - if (!ImGui::ToggleButton(label, value)) return false; - Events::Dispatch(Events::Type::SettingsChanged, Events::Payload{.value = *value ? 1.f : 0.f, .name = label}); - return true; - } -} // namespace Menu diff --git a/Internal/menu/backend/Backend.h b/Internal/menu/backend/Backend.h new file mode 100644 index 0000000..01f5613 --- /dev/null +++ b/Internal/menu/backend/Backend.h @@ -0,0 +1,95 @@ +#pragma once + +/// @file +/// @brief Menu::Backend — the abstract menu GUI backend. The shared menu layer (Menu::UI facade, +/// the Menu::Frame driver, the tab sections) draws through this interface and never names a concrete +/// backend, exactly like the render layer's Renderer. Concrete backends (ImGuiBackend, CanvasBackend) +/// live in this folder and are the ONLY place ImGui / UCanvasGUI are named. Adding a third backend is a +/// new implementation + one registry entry — no shared-code change. +/// +/// This header is a pure interface: it depends only on the settings color type and the standard +/// library — no ImGui, no UCanvasGUI, no UE. + +#include +#include +#include +#include + +#include "../../settings/Settings.h" // ::Color + +namespace Menu +{ + /// Which per-frame hook drives a backend. ImGui draws in the Present hook; the UE canvas draws in + /// PostRender. The shared driver asks the active backend for its phase — that is the only place + /// backend selection happens, and it is a single comparison, not a per-widget branch. + enum class Phase + { + Present, + PostRender, + }; + + /// The menu GUI backend interface: window/tab chrome, every widget, and per-frame lifecycle. + class Backend + { + public: + virtual ~Backend() = default; + + // --- Lifecycle (each backend owns its input sampling, theming/accent, cursor, debug windows). --- + virtual Phase phase() const = 0; + virtual bool ExtraToggle() = 0; ///< extra "open menu" signal (e.g. ImGui gamepad Start); false if none + virtual void BeginFrame() = 0; ///< once per frame before the window (sample input, apply accent, ...) + virtual void EndFrame() = 0; ///< once per frame after the window + + // --- Window + tab chrome. --- + virtual bool BeginWindow(const char* title) = 0; ///< false => don't draw the menu this frame + virtual void EndWindow() = 0; + virtual bool BeginTab(const char* label) = 0; ///< true => this tab is active; draw its section + virtual void EndTab() = 0; + + // --- Core widgets (return `changed` where a value can change this frame). --- + virtual bool Toggle(const char* label, bool* v) = 0; + virtual bool Button(const char* label) = 0; + virtual bool SmallButton(const char* label) = 0; + virtual bool Checkbox(const char* label, bool* v) = 0; + virtual bool RadioBool(const char* label, bool active) = 0; + virtual bool RadioInt(const char* label, int* v, int value) = 0; + virtual bool SliderFloat(const char* label, float* v, float min, float max, const char* fmt) = 0; + virtual bool SliderInt(const char* label, int* v, int min, int max) = 0; + virtual bool Combo(const char* label, int* v, const char* const* items, int count) = 0; + virtual bool ColorEdit(const char* label, ::Color* c) = 0; + virtual bool HotKey(const char* label, int* key) = 0; + virtual void SameLine() = 0; + virtual void SeparatorText(const char* label) = 0; + virtual void Tooltip(const char* text) = 0; + virtual void TextV(const char* fmt, va_list args) = 0; + virtual void TextDisabledV(const char* fmt, va_list args) = 0; + virtual void BulletTextV(const char* fmt, va_list args) = 0; + + // --- Layout / scope helpers. --- + virtual void SetNextItemWidth(float w) = 0; + virtual void BeginDisabled(bool disabled) = 0; + virtual void EndDisabled() = 0; + virtual void PushID(const char* id) = 0; + virtual void PopID() = 0; + + // --- Rich widgets (the advanced panels use only these; both backends implement them). --- + virtual bool InputText(const char* label, char* buf, size_t size) = 0; + virtual bool InputTextHint(const char* label, const char* hint, char* buf, size_t size) = 0; + virtual bool InputTextMultiline(const char* label, char* buf, size_t size, float height) = 0; + virtual bool BeginChild(const char* id, float w, float h) = 0; + virtual void EndChild() = 0; + virtual bool BeginCombo(const char* label, const char* preview) = 0; + virtual void EndCombo() = 0; + virtual bool Selectable(const char* label, bool selected) = 0; + /// Draw a (potentially long) list of @p count rows; @p drawRow(i) draws row i. The backend + /// decides how to clip/scroll (ImGui uses ImGuiListClipper; the canvas culls to a visible window). + virtual void ClippedList(int count, const std::function& drawRow) = 0; + virtual bool CollapsingHeader(const char* label) = 0; + virtual bool TreeNode(const char* label) = 0; + virtual void TreePop() = 0; + + // --- Clipboard. --- + virtual void SetClipboardText(const char* text) = 0; + virtual const char* GetClipboardText() = 0; + }; +} // namespace Menu diff --git a/Internal/menu/backend/Backends.h b/Internal/menu/backend/Backends.h new file mode 100644 index 0000000..1e3ef32 --- /dev/null +++ b/Internal/menu/backend/Backends.h @@ -0,0 +1,33 @@ +#pragma once + +/// @file +/// @brief The menu backend registry: the concrete backend instances, the table indexed by the +/// MenuBackend setting, the active pointer, and Select — mirroring render/Render.h. Adding a backend +/// is a new instance + one table entry here (and one MenuBackend enum value); no shared-code change. + +#include "Backend.h" +#include "ImGuiBackend.h" +#include "CanvasBackend.h" +#include "../../settings/Settings.h" + +namespace Menu +{ + inline ImGuiBackend imguiBackend; + inline CanvasBackend canvasBackend; + + /// Indexed by MenuBackend (order must match the enum). Adding a backend = a new entry, no branching. + inline Backend* const backends[] = { + &imguiBackend, // MenuBackend::ImGui + &canvasBackend, // MenuBackend::Canvas + }; + + /// The backend the shared menu layer draws through this frame. + inline Backend* active = backends[0]; + + /// Point @ref active at the backend the setting selects (called once per frame per hook). + inline void Select(MenuBackend backend) + { + const size_t index = static_cast(backend); + active = (index < (sizeof(backends) / sizeof(*backends))) ? backends[index] : backends[0]; + } +} // namespace Menu diff --git a/Internal/menu/backend/CanvasBackend.h b/Internal/menu/backend/CanvasBackend.h new file mode 100644 index 0000000..7654271 --- /dev/null +++ b/Internal/menu/backend/CanvasBackend.h @@ -0,0 +1,175 @@ +#pragma once + +/// @file +/// @brief The UE-canvas menu backend: draws in the PostRender hook through UCanvasGUI (which draws via +/// the active Render backend). Owns everything UCanvasGUI — the window/tab chrome (built over UCanvasGUI's Window + +/// tab-column primitives), every widget, the accent theming, per-frame input sampling, and its own +/// software cursor. Works everywhere the UE canvas is valid (main menu / loading included). Nothing +/// here leaks into the shared menu layer. + +#include +#include + +#include "Backend.h" +#include "../canvas/UCanvasGUI.h" +#include "../../../shared/Utilities.h" +#include "../../settings/Settings.h" +#include "../../utils/Rgb.h" + +namespace Menu +{ + class CanvasBackend : public Backend + { + public: + Phase phase() const override { return Phase::PostRender; } + bool ExtraToggle() override { return false; } + + void BeginFrame() override + { + Input::Handle(); // sample mouse/keyboard once per frame + + // Accent: retint from the rainbow while the RGB feature is on; otherwise reset to the palette. + UCanvasGUI::Colors::Accent = Settings.MENU.Rgb + ? [] { const Color c = Rgb::Current(); return FLinearColor{c.R, c.G, c.B, c.A}; }() + : Render::Palette::Primary.To(); + } + void EndFrame() override {} + + bool BeginWindow(const char* title) override + { + if (!UCanvasGUI::Window(title, &pos, FVector2D{700.f, 500.f}, Settings.MENU.ShowMenu)) return false; + tabIndex = 0; + return true; + } + void EndWindow() override + { + UCanvasGUI::Render(); // drain deferred pop-ups (combo/color swatches) on top + UCanvasGUI::DrawCursor(); // the Canvas menu's own cursor + } + bool BeginTab(const char* label) override + { + // Place this tab's button at an absolute slot in the left column (deterministic regardless of + // content drawn for other tabs), then, if it is the active tab, move the cursor to the content + // column so the section draws there. + const float headerH = 25.f, tabH = 32.f, contentX = 130.f; + UCanvasGUI::offsetX = 0.f; + UCanvasGUI::PushNextElementY(UCanvasGUI::menuPos.Y + headerH + tabIndex * tabH, false); + if (UCanvasGUI::ButtonTab(label, FVector2D{112.f, 30.f}, activeTab == tabIndex)) + activeTab = tabIndex; + + const bool isActive = (activeTab == tabIndex); + tabIndex++; + if (isActive) + { + UCanvasGUI::offsetX = contentX; + UCanvasGUI::PushNextElementY(UCanvasGUI::menuPos.Y + headerH + 5.f, false); + } + return isActive; + } + void EndTab() override {} + + // --- Core widgets. --- + bool Toggle(const char* label, bool* v) override { return UCanvasGUI::Checkbox(Vis(label), v); } + bool Button(const char* label) override { return UCanvasGUI::Button(Vis(label), FVector2D{150.f, 25.f}); } + bool SmallButton(const char* label) override { return UCanvasGUI::Button(Vis(label), FVector2D{90.f, 20.f}); } + bool Checkbox(const char* label, bool* v) override { return UCanvasGUI::Checkbox(Vis(label), v); } + bool RadioBool(const char* label, bool active) override { return UCanvasGUI::ButtonTab(Vis(label), FVector2D{110.f, 24.f}, active); } + bool RadioInt(const char* label, int* v, int value) override + { + if (UCanvasGUI::ButtonTab(Vis(label), FVector2D{110.f, 24.f}, *v == value)) + { + *v = value; + return true; + } + return false; + } + bool SliderFloat(const char* label, float* v, float min, float max, const char* fmt) override + { + const float old = *v; + UCanvasGUI::SliderFloat(Vis(label), v, min, max, fmt); + return *v != old; + } + bool SliderInt(const char* label, int* v, int min, int max) override + { + const int old = *v; + UCanvasGUI::SliderInt(Vis(label), v, min, max); + return *v != old; + } + bool Combo(const char* label, int* v, const char* const* items, int count) override { return UCanvasGUI::Combobox(Vis(label), FVector2D{150.f, 25.f}, v, items, count); } + bool ColorEdit(const char* label, ::Color* c) override { return UCanvasGUI::ColorPicker(Vis(label), reinterpret_cast(c)); } + bool HotKey(const char* label, int* key) override { return UCanvasGUI::Hotkey(Vis(label), FVector2D{90.f, 22.f}, key); } + void SameLine() override { UCanvasGUI::SameLine(); } + void SeparatorText(const char* label) override { UCanvasGUI::Text(Vis(label)); } + void Tooltip(const char*) override {} // no hover tooltip on the canvas backend + void TextV(const char* fmt, va_list args) override + { + char buf[512]; + vsnprintf(buf, sizeof(buf), fmt, args); + UCanvasGUI::Text(buf); + } + void TextDisabledV(const char* fmt, va_list args) override { TextV(fmt, args); } + void BulletTextV(const char* fmt, va_list args) override { TextV(fmt, args); } + + // --- Layout / scope (no-ops on the canvas: UCanvasGUI auto-sizes and ids are per-call). --- + void SetNextItemWidth(float) override {} + void BeginDisabled(bool) override {} + void EndDisabled() override {} + void PushID(const char*) override {} + void PopID() override {} + + // --- Rich widgets. --- + bool InputText(const char* label, char* buf, size_t size) override { return UCanvasGUI::TextField(Vis(label), buf, size, 220.f, 22.f, nullptr); } + bool InputTextHint(const char* label, const char* hint, char* buf, size_t size) override { return UCanvasGUI::TextField(Vis(label), buf, size, 220.f, 22.f, hint); } + bool InputTextMultiline(const char* label, char* buf, size_t size, float height) override { return UCanvasGUI::TextField(Vis(label), buf, size, 320.f, height, nullptr); } + bool BeginChild(const char* id, float w, float h) override + { + lastChildH = h; + return UCanvasGUI::BeginChild(id, w, h); + } + void EndChild() override { UCanvasGUI::EndChild(lastChildH); } + bool BeginCombo(const char* label, const char* preview) override { return UCanvasGUI::BeginCombo(Vis(label), preview, 220.f); } + void EndCombo() override { UCanvasGUI::EndCombo(); } + bool Selectable(const char* label, bool selected) override { return UCanvasGUI::Selectable(Vis(label), selected, 300.f); } + void ClippedList(int count, const std::function& drawRow) override + { + // No mouse-wheel on the canvas: draw a bounded window of rows and prompt to filter for more. + constexpr int maxVisible = 14; + const int shown = count < maxVisible ? count : maxVisible; + for (int i = 0; i < shown; i++) + drawRow(i); + if (count > shown) + { + char note[64]; + snprintf(note, sizeof(note), "... %d more (filter to narrow)", count - shown); + UCanvasGUI::Text(note); + } + } + bool CollapsingHeader(const char* label) override { return UCanvasGUI::CollapsingHeader(Vis(label)); } + bool TreeNode(const char* label) override { return UCanvasGUI::CollapsingHeader(Vis(label)); } + void TreePop() override {} + + void SetClipboardText(const char* text) override { Shared::Utilities::CopyToClipboard(text ? text : ""); } + const char* GetClipboardText() override + { + static std::string buffer; + buffer = Shared::Utilities::PasteFromClipboard(); + return buffer.c_str(); + } + + private: + FVector2D pos{400.f, 200.f}; ///< draggable window position + int tabIndex = 0; ///< reset each frame in BeginWindow + int activeTab = 0; ///< persists across frames + float lastChildH = 0.f; ///< height of the open BeginChild, for EndChild + + /// The visible part of a label: everything before an ImGui "##id" suffix (which the canvas would + /// otherwise draw literally). Returns a thread-local buffer valid for the widget call. + static const char* Vis(const char* label) + { + static std::string buf; + const char* p = std::strstr(label, "##"); + buf.assign(label, p ? static_cast(p - label) : std::strlen(label)); + return buf.c_str(); + } + }; +} // namespace Menu diff --git a/Internal/menu/backend/ImGuiBackend.h b/Internal/menu/backend/ImGuiBackend.h new file mode 100644 index 0000000..42a5e57 --- /dev/null +++ b/Internal/menu/backend/ImGuiBackend.h @@ -0,0 +1,172 @@ +#pragma once + +/// @file +/// @brief The ImGui menu backend: draws in the Present hook. Owns everything ImGui — the window/tab +/// chrome, every widget (native ImGui / Custom.h), the accent tinting, the demo/style-editor windows, +/// the software cursor, and the gamepad open-toggle. Nothing here leaks into the shared menu layer. + +#include +#include + +#include + +#include "Backend.h" +#include "../gui/Custom.h" // ImGui::ToggleButton / HotKey / Tooltip +#include "../../settings/Settings.h" +#include "../../utils/Rgb.h" + +namespace Menu +{ + class ImGuiBackend : public Backend + { + public: + Phase phase() const override { return Phase::Present; } + + bool ExtraToggle() override { return ImGui::IsKeyPressed(ImGuiKey_GamepadStart); } + + void BeginFrame() override + { + // ImGui paints its own software cursor while the menu is open (the Canvas backend draws its + // own, so this is scoped to the ImGui backend). + ImGui::GetIO().MouseDrawCursor = Settings.MENU.ShowMenu; + + if (Settings.DEBUG.ShowDemoWindow) ImGui::ShowDemoWindow(&Settings.DEBUG.ShowDemoWindow); + if (Settings.DEBUG.ShowStyleEditor) ImGui::ShowStyleEditor(); + + ApplyAccent(); + } + + void EndFrame() override {} + + bool BeginWindow(const char* title) override + { + const ImGuiViewport* vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + 550, vp->WorkPos.y + 20), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(550, 350), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin(title, &Settings.MENU.ShowMenu, ImGuiWindowFlags_NoCollapse)) + { + ImGui::End(); + return false; + } + if (!ImGui::BeginTabBar("MainTabBar", ImGuiTabBarFlags_Reorderable | ImGuiTabBarFlags_NoCloseWithMiddleMouseButton)) + { + ImGui::End(); + return false; + } + return true; + } + void EndWindow() override + { + ImGui::EndTabBar(); + ImGui::End(); + } + bool BeginTab(const char* label) override { return ImGui::BeginTabItem(label); } + void EndTab() override { ImGui::EndTabItem(); } + + // --- Core widgets. --- + bool Toggle(const char* label, bool* v) override { return ImGui::ToggleButton(label, v); } + bool Button(const char* label) override { return ImGui::Button(label); } + bool SmallButton(const char* label) override { return ImGui::SmallButton(label); } + bool Checkbox(const char* label, bool* v) override { return ImGui::Checkbox(label, v); } + bool RadioBool(const char* label, bool active) override { return ImGui::RadioButton(label, active); } + bool RadioInt(const char* label, int* v, int value) override { return ImGui::RadioButton(label, v, value); } + bool SliderFloat(const char* label, float* v, float min, float max, const char* fmt) override { return ImGui::SliderFloat(label, v, min, max, fmt); } + bool SliderInt(const char* label, int* v, int min, int max) override { return ImGui::SliderInt(label, v, min, max); } + bool Combo(const char* label, int* v, const char* const* items, int count) override { return ImGui::Combo(label, v, items, count); } + bool ColorEdit(const char* label, ::Color* c) override { return ImGui::ColorEdit4(label, &c->R); } + bool HotKey(const char* label, int* key) override + { + const int old = *key; + ImGui::HotKey(label, key); + return *key != old; + } + void SameLine() override { ImGui::SameLine(); } + void SeparatorText(const char* label) override { ImGui::SeparatorText(label); } + void Tooltip(const char* text) override { ImGui::Tooltip(text); } + void TextV(const char* fmt, va_list args) override + { + char buf[512]; + vsnprintf(buf, sizeof(buf), fmt, args); + ImGui::TextUnformatted(buf); + } + void TextDisabledV(const char* fmt, va_list args) override + { + char buf[512]; + vsnprintf(buf, sizeof(buf), fmt, args); + ImGui::TextDisabled("%s", buf); + } + void BulletTextV(const char* fmt, va_list args) override + { + char buf[512]; + vsnprintf(buf, sizeof(buf), fmt, args); + ImGui::BulletText("%s", buf); + } + + // --- Layout / scope. --- + void SetNextItemWidth(float w) override { ImGui::SetNextItemWidth(w); } + void BeginDisabled(bool disabled) override { ImGui::BeginDisabled(disabled); } + void EndDisabled() override { ImGui::EndDisabled(); } + void PushID(const char* id) override { ImGui::PushID(id); } + void PopID() override { ImGui::PopID(); } + + // --- Rich widgets. --- + bool InputText(const char* label, char* buf, size_t size) override { return ImGui::InputText(label, buf, size); } + bool InputTextHint(const char* label, const char* hint, char* buf, size_t size) override { return ImGui::InputTextWithHint(label, hint, buf, size); } + bool InputTextMultiline(const char* label, char* buf, size_t size, float height) override { return ImGui::InputTextMultiline(label, buf, size, ImVec2(0, height)); } + bool BeginChild(const char* id, float w, float h) override { return ImGui::BeginChild(id, ImVec2(w, h), true, ImGuiWindowFlags_HorizontalScrollbar); } + void EndChild() override { ImGui::EndChild(); } + bool BeginCombo(const char* label, const char* preview) override { return ImGui::BeginCombo(label, preview); } + void EndCombo() override { ImGui::EndCombo(); } + bool Selectable(const char* label, bool selected) override { return ImGui::Selectable(label, selected); } + void ClippedList(int count, const std::function& drawRow) override + { + ImGuiListClipper clipper; + clipper.Begin(count); + while (clipper.Step()) + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) + drawRow(i); + } + bool CollapsingHeader(const char* label) override { return ImGui::CollapsingHeader(label); } + bool TreeNode(const char* label) override { return ImGui::TreeNode(label); } + void TreePop() override { ImGui::TreePop(); } + + void SetClipboardText(const char* text) override { ImGui::SetClipboardText(text); } + const char* GetClipboardText() override { return ImGui::GetClipboardText(); } + + private: + /// Tint the interactive accent slots each frame: the cycling RGB color when enabled, else the + /// theme's default reds. Defaults are snapshotted once (before the first override) so toggling RGB + /// off restores the original shades instead of freezing on the last rainbow frame. + static void ApplyAccent() + { + static constexpr ImGuiCol accentSlots[] = { + ImGuiCol_CheckMark, ImGuiCol_SliderGrab, ImGuiCol_SliderGrabActive, + ImGuiCol_Header, ImGuiCol_HeaderHovered, ImGuiCol_HeaderActive, + ImGuiCol_Tab, ImGuiCol_TabHovered, ImGuiCol_TabActive, ImGuiCol_TitleBgActive}; + + ImVec4* colors = ImGui::GetStyle().Colors; + + static const std::array defaults = [&] + { + std::array saved{}; + for (size_t i = 0; i < saved.size(); ++i) + saved[i] = colors[accentSlots[i]]; + return saved; + }(); + + if (Settings.MENU.Rgb) + { + const Color rgb = Rgb::Current(); + const ImVec4 accent(rgb.R, rgb.G, rgb.B, rgb.A); + for (ImGuiCol slot : accentSlots) + colors[slot] = accent; + } + else + { + for (size_t i = 0; i < defaults.size(); ++i) + colors[accentSlots[i]] = defaults[i]; + } + } + }; +} // namespace Menu diff --git a/Internal/menu/canvas/Colors.h b/Internal/menu/canvas/Colors.h new file mode 100644 index 0000000..78fa93d --- /dev/null +++ b/Internal/menu/canvas/Colors.h @@ -0,0 +1,27 @@ +#pragma once + +/// @file +/// @brief UCanvasGUI::Colors — the canvas menu's theme colors (mutable FLinearColor). Each member is +/// seeded at startup from a Render::Palette entry (bridged to FLinearColor by the UE adapter). They +/// stay mutable so the canvas backend can retint the accent per frame for the RGB feature and reset +/// it to the palette default when that feature is off. + +#include "../../ue/sdk/FLinearColor.h" +#include "../../render/Colors.h" +#include "../../render/adapters/Ue.h" + +namespace UCanvasGUI +{ + /// The canvas menu theme (0-1 float RGBA), defaulting to the shared Render::Palette. Only `Accent` + /// changes at runtime (RGB retint); the rest are effectively constants sourced from the palette. + namespace Colors + { + inline FLinearColor Accent = Render::Palette::Primary.To(); ///< titles, active tabs, buttons, slider fill (retinted by the RGB feature) + inline FLinearColor Text = Render::Palette::Text.To(); ///< body and label text + inline FLinearColor Background = Render::Palette::Background.To(); ///< window, popup, and child backgrounds + inline FLinearColor Frame = Render::Palette::Border.To(); ///< neutral element backgrounds (tab strip, sliders, combos, headers, fields) + inline FLinearColor Selection = Render::Palette::Blue.To(); ///< highlighted / selected row + inline FLinearColor Enabled = Render::Palette::Success.To(); ///< checked-toggle mark + inline FLinearColor Knob = Render::Palette::Muted.To(); ///< slider knob + } // namespace Colors +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/UCanvasGUI.h b/Internal/menu/canvas/UCanvasGUI.h new file mode 100644 index 0000000..299cf59 --- /dev/null +++ b/Internal/menu/canvas/UCanvasGUI.h @@ -0,0 +1,25 @@ +#pragma once + +/// @file +/// @brief UCanvasGUI — the immediate-mode UE-canvas menu backend. Recovered from the project's original +/// GUI and adapted to the neutral Render API: every primitive draws through the active `Render` +/// backend (canvas or the ImGui recorder), so there is no direct K2_Draw* here and no per-scanline +/// fill loops. Positions stay FVector2D and colors FLinearColor (they convert implicitly to Render::Vec2 / +/// Render::Color). This is the umbrella header: it pulls in the shared core plus every widget +/// component from `components/`; include it to use the GUI. Everything is `inline`. + +#include "components/Core.h" +#include "components/Window.h" +#include "components/Text.h" +#include "components/Tab.h" +#include "components/Button.h" +#include "components/Checkbox.h" +#include "components/Slider.h" +#include "components/Combobox.h" +#include "components/Hotkey.h" +#include "components/ColorPicker.h" +#include "components/TextField.h" +#include "components/Selectable.h" +#include "components/CollapsingHeader.h" +#include "components/Combo.h" +#include "components/Child.h" diff --git a/Internal/menu/canvas/components/Button.h b/Internal/menu/canvas/components/Button.h new file mode 100644 index 0000000..7dcd98f --- /dev/null +++ b/Internal/menu/canvas/components/Button.h @@ -0,0 +1,26 @@ +#pragma once + +/// @file +/// @brief Button widget: a clickable labelled button; returns true on the click frame. + +#include "Core.h" + +namespace UCanvasGUI +{ + inline bool Button(const char* name, FVector2D size) + { + elementCount++; + + const FVector2D pos = NextPos({5, 10}, size.Y + 10.0f); + const bool isHovered = MouseInZone(pos, size); + + DrawRect(pos, size.X, size.Y, Colors::Accent); + if (isHovered) + elementHovered = true; + + TextCenter(name, FVector2D{pos.X + size.X / 2, pos.Y + size.Y / 2}, Colors::Text, false); + + EndElement(pos, size); + return isHovered && Input::IsMouseClicked(0, elementCount, false); + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/Checkbox.h b/Internal/menu/canvas/components/Checkbox.h new file mode 100644 index 0000000..9e28e20 --- /dev/null +++ b/Internal/menu/canvas/components/Checkbox.h @@ -0,0 +1,35 @@ +#pragma once + +/// @file +/// @brief Checkbox widget: a labelled boolean toggle with a filled mark when enabled. + +#include "Core.h" + +namespace UCanvasGUI +{ + inline bool Checkbox(const char* name, bool* value) + { + elementCount++; + + constexpr float box = 18.0f; + const FVector2D pos = NextPos({10, 10}, box + 10.0f); + const bool isHovered = MouseInZone(pos, FVector2D{box, box}); + + DrawRect(pos, box, box, Colors::Accent); + if (isHovered) + elementHovered = true; + if (*value) + DrawRect(FVector2D{pos.X + 3, pos.Y + 3}, box - 6, box - 6, Colors::Enabled); + + TextLeft(name, FVector2D{pos.X + box + 5.0f, pos.Y + box / 2}, Colors::Text, false); + + EndElement(pos); + + if (isHovered && Input::IsMouseClicked(0, elementCount, false)) + { + *value = !*value; + return true; + } + return false; + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/Child.h b/Internal/menu/canvas/components/Child.h new file mode 100644 index 0000000..cabe736 --- /dev/null +++ b/Internal/menu/canvas/components/Child.h @@ -0,0 +1,25 @@ +#pragma once + +/// @file +/// @brief Child rich widget: BeginChild / EndChild — a bordered region marker (no hard clip). + +#include "Core.h" + +namespace UCanvasGUI +{ + /// Bordered region marker: draws a frame of height @p h; content flows inside (no hard clip). + inline float childStartY = 0.0f; + inline bool BeginChild(const char* /*id*/, float w, float h) + { + const FVector2D pos{menuPos.X + 10.0f + offsetX, menuPos.Y + 10.0f + offsetY}; + const float width = w > 0.0f ? w : 320.0f; + DrawRect(pos, width, h, Colors::Background); + childStartY = offsetY; + return true; + } + inline void EndChild(float h) + { + const float end = childStartY + h + 10.0f; + if (offsetY < end) offsetY = end; + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/CollapsingHeader.h b/Internal/menu/canvas/components/CollapsingHeader.h new file mode 100644 index 0000000..d38f510 --- /dev/null +++ b/Internal/menu/canvas/components/CollapsingHeader.h @@ -0,0 +1,33 @@ +#pragma once + +/// @file +/// @brief CollapsingHeader rich widget: a persistent collapsible section header (also backs TreeNode). + +#include "Core.h" + +namespace UCanvasGUI +{ + inline bool headerOpen[256]; ///< per-id open state for CollapsingHeader / TreeNode + /// Collapsible section header; returns whether it is open (persistent per id). + inline bool CollapsingHeader(const char* name) + { + elementCount++; + const int id = elementCount; + + const FVector2D size{260.0f, 22.0f}; + const FVector2D pos = NextPos(FVector2D{10, 10}, size.Y + 8.0f); + const bool isHovered = MouseInZone(pos, size); + + DrawRect(pos, size.X, size.Y, Colors::Frame); + if (isHovered) elementHovered = true; + + std::string label = std::string(headerOpen[id] ? "- " : "+ ") + name; + TextLeft(label.c_str(), FVector2D{pos.X + 4.0f, pos.Y + size.Y / 2}, Colors::Text, false); + + if (isHovered && Input::IsMouseClicked(0, elementCount, false)) + headerOpen[id] = !headerOpen[id]; + + EndElement(pos, size); + return headerOpen[id]; + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/ColorPicker.h b/Internal/menu/canvas/components/ColorPicker.h new file mode 100644 index 0000000..4bd34e7 --- /dev/null +++ b/Internal/menu/canvas/components/ColorPicker.h @@ -0,0 +1,221 @@ +#pragma once + +/// @file +/// @brief ColorPicker widget: a swatch that opens a 12x9 palette grid (ColorPixel cells), drawn via PostRenderer. + +#include "Core.h" + +namespace UCanvasGUI +{ + inline int activePicker = -1; + inline FLinearColor savedColor; + /// One swatch of the open palette grid: draws @p color, outlines it while it's the current pick, + /// and writes it into @p original when clicked. + inline void ColorPixel(FVector2D pos, FVector2D size, FLinearColor* original, FLinearColor color) + { + PostRenderer::DrawRect(pos, size.X, size.Y, color); + + if (original->R == color.R && original->G == color.G && original->B == color.B) + { + constexpr FLinearColor outline{0.0f, 0.0f, 0.0f, 1.0f}; + PostRenderer::DrawLine(FVector2D{pos.X, pos.Y}, FVector2D{pos.X + size.X - 1, pos.Y}, 1, outline); + PostRenderer::DrawLine(FVector2D{pos.X, pos.Y + size.Y - 1}, FVector2D{pos.X + size.X - 1, pos.Y + size.Y - 1}, 1, outline); + PostRenderer::DrawLine(FVector2D{pos.X, pos.Y}, FVector2D{pos.X, pos.Y + size.Y - 1}, 1, outline); + PostRenderer::DrawLine(FVector2D{pos.X + size.X - 1, pos.Y}, FVector2D{pos.X + size.X - 1, pos.Y + size.Y - 1}, 1, outline); + } + + if (MouseInZone(pos, size) && Input::IsMouseClicked(0, elementCount, false)) + *original = color; + } + inline bool ColorPicker(const char* name, FLinearColor* color) + { + elementCount++; + + constexpr float box = 25.0f; + const FVector2D pos = NextPos({10, 10}, box + 10.0f); + const bool isHovered = MouseInZone(pos, FVector2D{box, box}); + + if (activePicker == elementCount) + { + elementHovered = true; + + float sizePickerX = 250; + float sizePickerY = 250; + bool isHoveredPicker = MouseInZone(FVector2D{pos.X, pos.Y}, FVector2D{sizePickerX, sizePickerY - 60}); + + // Background + PostRenderer::DrawRect(FVector2D{pos.X, pos.Y}, sizePickerX, sizePickerY - 65, Colors::Background); + + FVector2D pixelSize = FVector2D{sizePickerX / 12, sizePickerY / 12}; + + // 0 + { + ColorPixel(FVector2D{pos.X + pixelSize.X * 0, pos.Y + pixelSize.Y * 0}, pixelSize, color, FLinearColor{174 / 255.f, 235 / 255.f, 253 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 0, pos.Y + pixelSize.Y * 1}, pixelSize, color, FLinearColor{136 / 255.f, 225 / 255.f, 251 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 0, pos.Y + pixelSize.Y * 2}, pixelSize, color, FLinearColor{108 / 255.f, 213 / 255.f, 250 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 0, pos.Y + pixelSize.Y * 3}, pixelSize, color, FLinearColor{89 / 255.f, 175 / 255.f, 213 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 0, pos.Y + pixelSize.Y * 4}, pixelSize, color, FLinearColor{76 / 255.f, 151 / 255.f, 177 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 0, pos.Y + pixelSize.Y * 5}, pixelSize, color, FLinearColor{60 / 255.f, 118 / 255.f, 140 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 0, pos.Y + pixelSize.Y * 6}, pixelSize, color, FLinearColor{43 / 255.f, 85 / 255.f, 100 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 0, pos.Y + pixelSize.Y * 7}, pixelSize, color, FLinearColor{32 / 255.f, 62 / 255.f, 74 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 0, pos.Y + pixelSize.Y * 8}, pixelSize, color, FLinearColor{255 / 255.f, 255 / 255.f, 255 / 255.f, 1.0f}); + } + // 1 + { + ColorPixel(FVector2D{pos.X + pixelSize.X * 1, pos.Y + pixelSize.Y * 0}, pixelSize, color, FLinearColor{175 / 255.f, 205 / 255.f, 252 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 1, pos.Y + pixelSize.Y * 1}, pixelSize, color, FLinearColor{132 / 255.f, 179 / 255.f, 252 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 1, pos.Y + pixelSize.Y * 2}, pixelSize, color, FLinearColor{90 / 255.f, 152 / 255.f, 250 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 1, pos.Y + pixelSize.Y * 3}, pixelSize, color, FLinearColor{55 / 255.f, 120 / 255.f, 250 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 1, pos.Y + pixelSize.Y * 4}, pixelSize, color, FLinearColor{49 / 255.f, 105 / 255.f, 209 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 1, pos.Y + pixelSize.Y * 5}, pixelSize, color, FLinearColor{38 / 255.f, 83 / 255.f, 165 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 1, pos.Y + pixelSize.Y * 6}, pixelSize, color, FLinearColor{28 / 255.f, 61 / 255.f, 120 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 1, pos.Y + pixelSize.Y * 7}, pixelSize, color, FLinearColor{20 / 255.f, 43 / 255.f, 86 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 1, pos.Y + pixelSize.Y * 8}, pixelSize, color, FLinearColor{247 / 255.f, 247 / 255.f, 247 / 255.f, 1.0f}); + } + // 2 + { + ColorPixel(FVector2D{pos.X + pixelSize.X * 2, pos.Y + pixelSize.Y * 0}, pixelSize, color, FLinearColor{153 / 255.f, 139 / 255.f, 250 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 2, pos.Y + pixelSize.Y * 1}, pixelSize, color, FLinearColor{101 / 255.f, 79 / 255.f, 249 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 2, pos.Y + pixelSize.Y * 2}, pixelSize, color, FLinearColor{64 / 255.f, 50 / 255.f, 230 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 2, pos.Y + pixelSize.Y * 3}, pixelSize, color, FLinearColor{54 / 255.f, 38 / 255.f, 175 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 2, pos.Y + pixelSize.Y * 4}, pixelSize, color, FLinearColor{39 / 255.f, 31 / 255.f, 144 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 2, pos.Y + pixelSize.Y * 5}, pixelSize, color, FLinearColor{32 / 255.f, 25 / 255.f, 116 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 2, pos.Y + pixelSize.Y * 6}, pixelSize, color, FLinearColor{21 / 255.f, 18 / 255.f, 82 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 2, pos.Y + pixelSize.Y * 7}, pixelSize, color, FLinearColor{16 / 255.f, 13 / 255.f, 61 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 2, pos.Y + pixelSize.Y * 8}, pixelSize, color, FLinearColor{228 / 255.f, 228 / 255.f, 228 / 255.f, 1.0f}); + } + // 3 + { + ColorPixel(FVector2D{pos.X + pixelSize.X * 3, pos.Y + pixelSize.Y * 0}, pixelSize, color, FLinearColor{194 / 255.f, 144 / 255.f, 251 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 3, pos.Y + pixelSize.Y * 1}, pixelSize, color, FLinearColor{165 / 255.f, 87 / 255.f, 249 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 3, pos.Y + pixelSize.Y * 2}, pixelSize, color, FLinearColor{142 / 255.f, 57 / 255.f, 239 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 3, pos.Y + pixelSize.Y * 3}, pixelSize, color, FLinearColor{116 / 255.f, 45 / 255.f, 184 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 3, pos.Y + pixelSize.Y * 4}, pixelSize, color, FLinearColor{92 / 255.f, 37 / 255.f, 154 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 3, pos.Y + pixelSize.Y * 5}, pixelSize, color, FLinearColor{73 / 255.f, 29 / 255.f, 121 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 3, pos.Y + pixelSize.Y * 6}, pixelSize, color, FLinearColor{53 / 255.f, 21 / 255.f, 88 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 3, pos.Y + pixelSize.Y * 7}, pixelSize, color, FLinearColor{37 / 255.f, 15 / 255.f, 63 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 3, pos.Y + pixelSize.Y * 8}, pixelSize, color, FLinearColor{203 / 255.f, 203 / 255.f, 203 / 255.f, 1.0f}); + } + // 4 + { + ColorPixel(FVector2D{pos.X + pixelSize.X * 4, pos.Y + pixelSize.Y * 0}, pixelSize, color, FLinearColor{224 / 255.f, 162 / 255.f, 197 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 4, pos.Y + pixelSize.Y * 1}, pixelSize, color, FLinearColor{210 / 255.f, 112 / 255.f, 166 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 4, pos.Y + pixelSize.Y * 2}, pixelSize, color, FLinearColor{199 / 255.f, 62 / 255.f, 135 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 4, pos.Y + pixelSize.Y * 3}, pixelSize, color, FLinearColor{159 / 255.f, 49 / 255.f, 105 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 4, pos.Y + pixelSize.Y * 4}, pixelSize, color, FLinearColor{132 / 255.f, 41 / 255.f, 89 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 4, pos.Y + pixelSize.Y * 5}, pixelSize, color, FLinearColor{104 / 255.f, 32 / 255.f, 71 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 4, pos.Y + pixelSize.Y * 6}, pixelSize, color, FLinearColor{75 / 255.f, 24 / 255.f, 51 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 4, pos.Y + pixelSize.Y * 7}, pixelSize, color, FLinearColor{54 / 255.f, 14 / 255.f, 36 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 4, pos.Y + pixelSize.Y * 8}, pixelSize, color, FLinearColor{175 / 255.f, 175 / 255.f, 175 / 255.f, 1.0f}); + } + // 5 + { + ColorPixel(FVector2D{pos.X + pixelSize.X * 5, pos.Y + pixelSize.Y * 0}, pixelSize, color, FLinearColor{235 / 255.f, 175 / 255.f, 176 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 5, pos.Y + pixelSize.Y * 1}, pixelSize, color, FLinearColor{227 / 255.f, 133 / 255.f, 135 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 5, pos.Y + pixelSize.Y * 2}, pixelSize, color, FLinearColor{219 / 255.f, 87 / 255.f, 88 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 5, pos.Y + pixelSize.Y * 3}, pixelSize, color, FLinearColor{215 / 255.f, 50 / 255.f, 36 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 5, pos.Y + pixelSize.Y * 4}, pixelSize, color, FLinearColor{187 / 255.f, 25 / 255.f, 7 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 5, pos.Y + pixelSize.Y * 5}, pixelSize, color, FLinearColor{149 / 255.f, 20 / 255.f, 6 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 5, pos.Y + pixelSize.Y * 6}, pixelSize, color, FLinearColor{107 / 255.f, 14 / 255.f, 4 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 5, pos.Y + pixelSize.Y * 7}, pixelSize, color, FLinearColor{77 / 255.f, 9 / 255.f, 3 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 5, pos.Y + pixelSize.Y * 8}, pixelSize, color, FLinearColor{144 / 255.f, 144 / 255.f, 144 / 255.f, 1.0f}); + } + // 6 + { + ColorPixel(FVector2D{pos.X + pixelSize.X * 6, pos.Y + pixelSize.Y * 0}, pixelSize, color, FLinearColor{241 / 255.f, 187 / 255.f, 171 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 6, pos.Y + pixelSize.Y * 1}, pixelSize, color, FLinearColor{234 / 255.f, 151 / 255.f, 126 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 6, pos.Y + pixelSize.Y * 2}, pixelSize, color, FLinearColor{229 / 255.f, 115 / 255.f, 76 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 6, pos.Y + pixelSize.Y * 3}, pixelSize, color, FLinearColor{227 / 255.f, 82 / 255.f, 24 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 6, pos.Y + pixelSize.Y * 4}, pixelSize, color, FLinearColor{190 / 255.f, 61 / 255.f, 15 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 6, pos.Y + pixelSize.Y * 5}, pixelSize, color, FLinearColor{150 / 255.f, 48 / 255.f, 12 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 6, pos.Y + pixelSize.Y * 6}, pixelSize, color, FLinearColor{107 / 255.f, 34 / 255.f, 8 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 6, pos.Y + pixelSize.Y * 7}, pixelSize, color, FLinearColor{79 / 255.f, 25 / 255.f, 6 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 6, pos.Y + pixelSize.Y * 8}, pixelSize, color, FLinearColor{113 / 255.f, 113 / 255.f, 113 / 255.f, 1.0f}); + } + // 7 + { + ColorPixel(FVector2D{pos.X + pixelSize.X * 7, pos.Y + pixelSize.Y * 0}, pixelSize, color, FLinearColor{245 / 255.f, 207 / 255.f, 169 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 7, pos.Y + pixelSize.Y * 1}, pixelSize, color, FLinearColor{240 / 255.f, 183 / 255.f, 122 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 7, pos.Y + pixelSize.Y * 2}, pixelSize, color, FLinearColor{236 / 255.f, 159 / 255.f, 74 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 7, pos.Y + pixelSize.Y * 3}, pixelSize, color, FLinearColor{234 / 255.f, 146 / 255.f, 37 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 7, pos.Y + pixelSize.Y * 4}, pixelSize, color, FLinearColor{193 / 255.f, 111 / 255.f, 28 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 7, pos.Y + pixelSize.Y * 5}, pixelSize, color, FLinearColor{152 / 255.f, 89 / 255.f, 22 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 7, pos.Y + pixelSize.Y * 6}, pixelSize, color, FLinearColor{110 / 255.f, 64 / 255.f, 16 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 7, pos.Y + pixelSize.Y * 7}, pixelSize, color, FLinearColor{80 / 255.f, 47 / 255.f, 12 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 7, pos.Y + pixelSize.Y * 8}, pixelSize, color, FLinearColor{82 / 255.f, 82 / 255.f, 82 / 255.f, 1.0f}); + } + // 8 + { + ColorPixel(FVector2D{pos.X + pixelSize.X * 8, pos.Y + pixelSize.Y * 0}, pixelSize, color, FLinearColor{247 / 255.f, 218 / 255.f, 170 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 8, pos.Y + pixelSize.Y * 1}, pixelSize, color, FLinearColor{244 / 255.f, 200 / 255.f, 124 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 8, pos.Y + pixelSize.Y * 2}, pixelSize, color, FLinearColor{241 / 255.f, 182 / 255.f, 77 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 8, pos.Y + pixelSize.Y * 3}, pixelSize, color, FLinearColor{239 / 255.f, 174 / 255.f, 44 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 8, pos.Y + pixelSize.Y * 4}, pixelSize, color, FLinearColor{196 / 255.f, 137 / 255.f, 34 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 8, pos.Y + pixelSize.Y * 5}, pixelSize, color, FLinearColor{154 / 255.f, 108 / 255.f, 27 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 8, pos.Y + pixelSize.Y * 6}, pixelSize, color, FLinearColor{111 / 255.f, 77 / 255.f, 19 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 8, pos.Y + pixelSize.Y * 7}, pixelSize, color, FLinearColor{80 / 255.f, 56 / 255.f, 14 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 8, pos.Y + pixelSize.Y * 8}, pixelSize, color, FLinearColor{54 / 255.f, 54 / 255.f, 54 / 255.f, 1.0f}); + } + // 9 + { + ColorPixel(FVector2D{pos.X + pixelSize.X * 9, pos.Y + pixelSize.Y * 0}, pixelSize, color, FLinearColor{254 / 255.f, 243 / 255.f, 187 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 9, pos.Y + pixelSize.Y * 1}, pixelSize, color, FLinearColor{253 / 255.f, 237 / 255.f, 153 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 9, pos.Y + pixelSize.Y * 2}, pixelSize, color, FLinearColor{253 / 255.f, 231 / 255.f, 117 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 9, pos.Y + pixelSize.Y * 3}, pixelSize, color, FLinearColor{254 / 255.f, 232 / 255.f, 85 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 9, pos.Y + pixelSize.Y * 4}, pixelSize, color, FLinearColor{242 / 255.f, 212 / 255.f, 53 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 9, pos.Y + pixelSize.Y * 5}, pixelSize, color, FLinearColor{192 / 255.f, 169 / 255.f, 42 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 9, pos.Y + pixelSize.Y * 6}, pixelSize, color, FLinearColor{138 / 255.f, 120 / 255.f, 30 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 9, pos.Y + pixelSize.Y * 7}, pixelSize, color, FLinearColor{101 / 255.f, 87 / 255.f, 22 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 9, pos.Y + pixelSize.Y * 8}, pixelSize, color, FLinearColor{29 / 255.f, 29 / 255.f, 29 / 255.f, 1.0f}); + } + // 10 + { + ColorPixel(FVector2D{pos.X + pixelSize.X * 10, pos.Y + pixelSize.Y * 0}, pixelSize, color, FLinearColor{247 / 255.f, 243 / 255.f, 185 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 10, pos.Y + pixelSize.Y * 1}, pixelSize, color, FLinearColor{243 / 255.f, 239 / 255.f, 148 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 10, pos.Y + pixelSize.Y * 2}, pixelSize, color, FLinearColor{239 / 255.f, 232 / 255.f, 111 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 10, pos.Y + pixelSize.Y * 3}, pixelSize, color, FLinearColor{235 / 255.f, 229 / 255.f, 76 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 10, pos.Y + pixelSize.Y * 4}, pixelSize, color, FLinearColor{208 / 255.f, 200 / 255.f, 55 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 10, pos.Y + pixelSize.Y * 5}, pixelSize, color, FLinearColor{164 / 255.f, 157 / 255.f, 43 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 10, pos.Y + pixelSize.Y * 6}, pixelSize, color, FLinearColor{118 / 255.f, 114 / 255.f, 31 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 10, pos.Y + pixelSize.Y * 7}, pixelSize, color, FLinearColor{86 / 255.f, 82 / 255.f, 21 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 10, pos.Y + pixelSize.Y * 8}, pixelSize, color, FLinearColor{9 / 255.f, 9 / 255.f, 9 / 255.f, 1.0f}); + } + // 11 + { + ColorPixel(FVector2D{pos.X + pixelSize.X * 11, pos.Y + pixelSize.Y * 0}, pixelSize, color, FLinearColor{218 / 255.f, 232 / 255.f, 182 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 11, pos.Y + pixelSize.Y * 1}, pixelSize, color, FLinearColor{198 / 255.f, 221 / 255.f, 143 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 11, pos.Y + pixelSize.Y * 2}, pixelSize, color, FLinearColor{181 / 255.f, 210 / 255.f, 103 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 11, pos.Y + pixelSize.Y * 3}, pixelSize, color, FLinearColor{154 / 255.f, 186 / 255.f, 76 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 11, pos.Y + pixelSize.Y * 4}, pixelSize, color, FLinearColor{130 / 255.f, 155 / 255.f, 64 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 11, pos.Y + pixelSize.Y * 5}, pixelSize, color, FLinearColor{102 / 255.f, 121 / 255.f, 50 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 11, pos.Y + pixelSize.Y * 6}, pixelSize, color, FLinearColor{74 / 255.f, 88 / 255.f, 36 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 11, pos.Y + pixelSize.Y * 7}, pixelSize, color, FLinearColor{54 / 255.f, 64 / 255.f, 26 / 255.f, 1.0f}); + ColorPixel(FVector2D{pos.X + pixelSize.X * 11, pos.Y + pixelSize.Y * 8}, pixelSize, color, FLinearColor{0 / 255.f, 0 / 255.f, 0 / 255.f, 1.0f}); + } + + // Consume the click; only one outside the grid closes the picker. + if (Input::IsMouseClicked(0, elementCount, false) && !isHoveredPicker) + activePicker = -1; + } + else + { + // Closed swatch: accent border, the current color inset, and the label. + DrawRect(pos, box, box, Colors::Accent); + if (isHovered) + elementHovered = true; + DrawRect(FVector2D{pos.X + 4, pos.Y + 4}, box - 8, box - 8, *color); + TextLeft(name, FVector2D{pos.X + box + 5.0f, pos.Y + box / 2}, Colors::Text, false); + + if (isHovered && Input::IsMouseClicked(0, elementCount, false)) + { + savedColor = *color; + activePicker = elementCount; + } + } + + EndElement(pos); + + // Report a change when the live color differs from the value saved when the picker opened. + return activePicker != elementCount && (color->R != savedColor.R || color->G != savedColor.G || color->B != savedColor.B); + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/Combo.h b/Internal/menu/canvas/components/Combo.h new file mode 100644 index 0000000..597cd24 --- /dev/null +++ b/Internal/menu/canvas/components/Combo.h @@ -0,0 +1,35 @@ +#pragma once + +/// @file +/// @brief Combo rich widgets: BeginCombo / EndCombo — a combo whose item list the caller draws inline. + +#include "Core.h" + +namespace UCanvasGUI +{ + inline bool comboOpen[256]; ///< per-id open state for BeginCombo + /// Combo box that opens an inline item list (drawn by the caller via Selectable); returns open. + inline bool BeginCombo(const char* name, const char* preview, float width) + { + elementCount++; + const int id = elementCount; + + const FVector2D size{width, 22.0f}; + const FVector2D pos = NextPos(FVector2D{10, 10}, size.Y + 10.0f); + const bool isHovered = MouseInZone(pos, size); + + DrawRect(pos, size.X, size.Y, Colors::Frame); + if (isHovered) elementHovered = true; + + if (preview && preview[0]) TextLeft(preview, FVector2D{pos.X + 4.0f, pos.Y + size.Y / 2}, Colors::Text, false); + if (name && name[0] && name[0] != '#') + TextLeft(name, FVector2D{pos.X + size.X + 5.0f, pos.Y + size.Y / 2}, Colors::Text, false); + + if (isHovered && Input::IsMouseClicked(0, elementCount, false)) + comboOpen[id] = !comboOpen[id]; + + EndElement(pos, size); + return comboOpen[id]; + } + inline void EndCombo() {} ///< items drew inline; nothing to close +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/Combobox.h b/Internal/menu/canvas/components/Combobox.h new file mode 100644 index 0000000..f16c533 --- /dev/null +++ b/Internal/menu/canvas/components/Combobox.h @@ -0,0 +1,73 @@ +#pragma once + +/// @file +/// @brief Combobox widget: a dropdown over a string array. The open item list is queued through +/// PostRenderer so it replays on top of the widgets drawn below it. + +#include "Core.h" + +namespace UCanvasGUI +{ + inline bool comboboxOpen[256]; ///< per-id open state for the array Combobox + + /// Dropdown combo over @p count option strings; writes the picked index to @p value. + /// @return true on the frame the selection changes. + inline bool Combobox(const char* name, FVector2D size, int* value, const char* const* items, int count) + { + elementCount++; + const int id = elementCount; + bool changed = false; + + const FVector2D pos = NextPos({5, 10}, size.Y + 10.0f); + const bool isHovered = MouseInZone(pos, size); + + DrawRect(pos, size.X, size.Y, Colors::Frame); + if (isHovered || comboboxOpen[id]) + elementHovered = true; + + TextLeft(name, FVector2D{pos.X + size.X + 5.0f, pos.Y + size.Y / 2}, Colors::Text, false); + + // The selected label sits on the closed box; the full list draws (on top) only when open. + bool overItem = false; + FVector2D itemPos = pos; + for (int num = 0; num < count; num++) + { + const char* item = items[num]; + if (num == *value) + TextCenter(item, FVector2D{pos.X + size.X / 2, pos.Y + size.Y / 2}, Colors::Text, false); + + if (!comboboxOpen[id]) + continue; + + itemPos.Y += 25.0f; + const bool overThis = MouseInZone(itemPos, FVector2D{size.X, 25.0f}); + overItem = overItem || overThis; + + PostRenderer::DrawRect(itemPos, size.X, 25.0f, Colors::Frame); + if (overThis) + { + elementHovered = true; + if (Input::IsMouseClicked(0, elementCount, false)) + { + *value = num; + changed = true; + comboboxOpen[id] = false; + } + } + PostRenderer::TextLeft(item, FVector2D{itemPos.X + 5.0f, itemPos.Y + 15.0f}, Colors::Text, false); + } + + EndElement(pos, size); + + // Toggle open on the box; close when clicking away from both the box and the list. + if (Input::IsMouseClicked(0, elementCount, false)) + { + if (isHovered) + comboboxOpen[id] = !comboboxOpen[id]; + else if (!overItem) + comboboxOpen[id] = false; + } + + return changed; + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/Core.h b/Internal/menu/canvas/components/Core.h new file mode 100644 index 0000000..edfbb5a --- /dev/null +++ b/Internal/menu/canvas/components/Core.h @@ -0,0 +1,245 @@ +#pragma once + +/// @file +/// @brief UCanvasGUI core: the shared immediate-mode foundation for the UE-canvas menu backend — the +/// deferred draw queue (PostRenderer), the single-window layout state, the Render:: draw primitives +/// (routed to whichever backend is active — canvas or the ImGui recorder), the software cursor, the +/// layout-cursor helpers (SameLine/PushNextElementY/NextPos/ +/// EndElement), and the frame-end Render() drain. Every widget component in this folder includes it. +/// Everything is `inline` — this header is pulled into multiple translation units. + +#include +#include +#include +#include +#include + +#include "../../../utils/Input.h" +#include "../../../ue/Engine.h" +#include "../../../render/Render.h" +#include "../Colors.h" + +namespace UCanvasGUI +{ + /// Deferred draw queue: pop-ups (combo dropdowns, color-picker swatches) enqueue here so they + /// replay last, on top of the widgets drawn earlier in the frame. Render() drains it. + namespace PostRenderer + { + /// One queued primitive. @ref kind selects which fields are meaningful. + struct Command + { + enum Kind + { + None = -1, + Rect, + TextLeft, + TextCenter, + Line + }; + + Kind kind = None; + FVector2D pos; + FVector2D size; + FLinearColor color; + const char* text; + bool outline; + + FVector2D from; + FVector2D to; + int thickness; + }; + + inline constexpr int Capacity = 128; + inline Command queue[Capacity]; + + /// Reserve the next free slot, or nullptr when the queue is full. + inline Command* Next() + { + for (Command& cmd : queue) + if (cmd.kind == Command::None) + return &cmd; + return nullptr; + } + + inline void DrawRect(FVector2D pos, float w, float h, FLinearColor color) + { + if (Command* cmd = Next()) + *cmd = {Command::Rect, pos, FVector2D{w, h}, color}; + } + inline void TextLeft(const char* text, FVector2D pos, FLinearColor color, bool outline) + { + if (Command* cmd = Next()) + { + cmd->kind = Command::TextLeft; + cmd->text = text; + cmd->pos = pos; + cmd->outline = outline; + cmd->color = color; + } + } + inline void TextCenter(const char* text, FVector2D pos, FLinearColor color, bool outline) + { + if (Command* cmd = Next()) + { + cmd->kind = Command::TextCenter; + cmd->text = text; + cmd->pos = pos; + cmd->outline = outline; + cmd->color = color; + } + } + inline void DrawLine(FVector2D from, FVector2D to, int thickness, FLinearColor color) + { + if (Command* cmd = Next()) + { + cmd->kind = Command::Line; + cmd->from = from; + cmd->to = to; + cmd->thickness = thickness; + cmd->color = color; + } + } + } // namespace PostRenderer + + // --- Immediate-mode layout state (single window, rebuilt every frame). --- + inline bool elementHovered = false; ///< set when the cursor is over a widget (suppresses window drag) + inline FVector2D menuPos = {0, 0}; ///< top-left of the window this frame + inline float offsetX = 0.0f; ///< running layout cursor, relative to menuPos + inline float offsetY = 0.0f; ///< running layout cursor, relative to menuPos + + inline FVector2D lastElementPos = {0, 0}; ///< last widget's position (for SameLine) + inline FVector2D lastElementSize = {0, 0}; ///< last widget's size (for SameLine) + + inline int activeElement = -1; ///< elementCount of the widget currently being dragged (-1 = none) + inline int elementCount = 0; ///< per-frame widget counter, doubles as each widget's id + + inline bool sameLine = false; ///< when set, the next widget is placed to the right of the last + + inline bool pushY = false; ///< when set, the next widget's Y is forced to pushYValue + inline float pushYValue = 0.0f; ///< absolute Y forced by PushNextElementY + + /// The cursor position in client (window) pixels. + inline FVector2D CursorPos() + { + POINT cursor; + GetCursorPos(&cursor); + ScreenToClient(GetActiveWindow(), &cursor); + return FVector2D{(float)cursor.x, (float)cursor.y}; + } + + /// Whether the cursor is inside the [pos, pos + size] rectangle. + inline bool MouseInZone(FVector2D pos, FVector2D size) + { + FVector2D cursor = CursorPos(); + return cursor.X > pos.X && cursor.Y > pos.Y && cursor.X < pos.X + size.X && cursor.Y < pos.Y + size.Y; + } + + // --- Draw primitives (draw through the active Render backend, not the canvas directly). --- + inline void DrawLine(FVector2D from, FVector2D to, int thickness, FLinearColor color) + { + Render::Line(from, to, (float)thickness, color); + } + inline void DrawRect(FVector2D pos, float w, float h, FLinearColor color) + { + Render::RectFilled(pos, FVector2D{pos.X + w, pos.Y + h}, color); + } + inline void DrawCircle(FVector2D pos, float radius, FLinearColor color) + { + Render::CircleFilled(pos, radius, color); + } + inline void TextLeft(const char* text, FVector2D pos, FLinearColor color, bool /*outline*/) + { + Render::Text(pos, std::string(text), 0.97f, color, false); + } + inline void TextCenter(const char* text, FVector2D pos, FLinearColor color, bool /*outline*/) + { + Render::Text(pos, std::string(text), 0.97f, color, true); + } + + /// Software arrow cursor built from line segments (the Canvas menu owns its cursor; it does not + /// borrow ImGui's software cursor, keeping the two backends fully decoupled). + inline void DrawCursor() + { + constexpr FLinearColor tint{0.30f, 0.30f, 0.80f, 1.0f}; + const FVector2D origin = CursorPos(); + + // Fan of segments from the tip, sweeping the arrow's inner edge. + for (int x = 35, y = 10; y <= 30; x = x > 15 ? x - 1 : 15, y++) + DrawLine(origin, FVector2D{origin.X + x, origin.Y + y}, 1, tint); + + DrawLine(FVector2D{origin.X + 35, origin.Y + 10}, FVector2D{origin.X + 15, origin.Y + 30}, 1, tint); + } + + // --- Layout cursor helpers. --- + inline void SameLine() + { + sameLine = true; + } + inline void PushNextElementY(float y, bool fromLastElement = true) + { + pushY = true; + pushYValue = fromLastElement ? lastElementPos.Y + lastElementSize.Y + y : y; + } + + /// Resolve the next widget's top-left, honoring SameLine / PushNextElementY, and (unless SameLine) + /// advance the vertical cursor by @p advance. @p sameLineY offsets the Y when placed on the same + /// line (used by widgets that vertically center against the previous element). + inline FVector2D NextPos(FVector2D padding, float advance, float sameLineY = 0.0f) + { + FVector2D pos{menuPos.X + padding.X + offsetX, menuPos.Y + padding.Y + offsetY}; + if (sameLine) + { + pos.X = lastElementPos.X + lastElementSize.X + padding.X; + pos.Y = lastElementPos.Y + sameLineY; + } + if (pushY) + { + pos.Y = pushYValue; + pushY = false; + pushYValue = 0.0f; + offsetY = pos.Y - menuPos.Y; + } + if (!sameLine) + offsetY += advance; + return pos; + } + + /// Close the current widget, recording its position (and, in the two-arg form, its size) for the + /// next SameLine, and clearing the SameLine request. + inline void EndElement(FVector2D pos) + { + sameLine = false; + lastElementPos = pos; + } + inline void EndElement(FVector2D pos, FVector2D size) + { + lastElementSize = size; + EndElement(pos); + } + + /// Drain the deferred draw queue (dropdowns / swatches) so they land on top. Call at frame end. + inline void Render() + { + for (PostRenderer::Command& cmd : PostRenderer::queue) + { + switch (cmd.kind) + { + case PostRenderer::Command::Rect: + DrawRect(cmd.pos, cmd.size.X, cmd.size.Y, cmd.color); + break; + case PostRenderer::Command::TextLeft: + TextLeft(cmd.text, cmd.pos, cmd.color, cmd.outline); + break; + case PostRenderer::Command::TextCenter: + TextCenter(cmd.text, cmd.pos, cmd.color, cmd.outline); + break; + case PostRenderer::Command::Line: + DrawLine(cmd.from, cmd.to, cmd.thickness, cmd.color); + break; + default: + continue; + } + cmd.kind = PostRenderer::Command::None; + } + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/Hotkey.h b/Internal/menu/canvas/components/Hotkey.h new file mode 100644 index 0000000..fa96dd3 --- /dev/null +++ b/Internal/menu/canvas/components/Hotkey.h @@ -0,0 +1,109 @@ +#pragma once + +/// @file +/// @brief Hotkey widget: a key-capture button that, once armed, binds the next pressed key. Includes +/// VirtualKeyCodeToString, which renders a virtual-key code as its display name. + +#include "Core.h" + +namespace UCanvasGUI +{ + inline int activeHotkey = -1; ///< elementCount of the hotkey currently capturing a key (-1 = none) + inline bool alreadyPressed = false; ///< guards against binding the mouse click that armed capture + + /// Human-readable name for a virtual-key code (mouse buttons and the extended-key set handled). + inline std::string VirtualKeyCodeToString(UCHAR virtualKey) + { + switch (virtualKey) + { + case VK_LBUTTON: return "MOUSE0"; + case VK_RBUTTON: return "MOUSE1"; + case VK_MBUTTON: return "MBUTTON"; + case VK_XBUTTON1: return "XBUTTON1"; + case VK_XBUTTON2: return "XBUTTON2"; + } + + UINT scanCode = MapVirtualKey(virtualKey, MAPVK_VK_TO_VSC); + switch (virtualKey) + { + case VK_LEFT: + case VK_UP: + case VK_RIGHT: + case VK_DOWN: + case VK_RCONTROL: + case VK_RMENU: + case VK_LWIN: + case VK_RWIN: + case VK_APPS: + case VK_PRIOR: + case VK_NEXT: + case VK_END: + case VK_HOME: + case VK_INSERT: + case VK_DELETE: + case VK_DIVIDE: + case VK_NUMLOCK: + scanCode |= KF_EXTENDED; + } + + char name[128]; + GetKeyNameTextA(scanCode << 16, name, sizeof(name)); + return name; + } + + inline bool Hotkey(const char* name, FVector2D size, int* key) + { + elementCount++; + bool changed = false; + + const FVector2D pos = NextPos({5, 10}, size.Y + 10.0f, lastElementSize.Y / 2 - size.Y / 2); + const bool isHovered = MouseInZone(pos, size); + + DrawRect(pos, size.X, size.Y, Colors::Accent); + if (isHovered) + elementHovered = true; + + const FVector2D textPos{pos.X + size.X / 2, pos.Y + size.Y / 2}; + if (activeHotkey == elementCount) + { + TextCenter("[Press Key]", textPos, Colors::Text, false); + + if (!Input::IsAnyMouseDown()) + alreadyPressed = false; + + // Bind the highest key currently held (once the arming click has been released). + if (!alreadyPressed) + for (int code = 0; code < 255; code++) + if (GetAsyncKeyState(code) & 0x8000) + { + *key = code; + changed = true; + activeHotkey = -1; + } + } + else + { + TextCenter(VirtualKeyCodeToString(*key).c_str(), textPos, Colors::Text, false); + + if (Input::IsMouseClicked(0, elementCount, false)) + { + if (isHovered) + { + alreadyPressed = true; + activeHotkey = elementCount; + + // Drain the keys down right now so the arming click/keys aren't captured as the bind. + for (int code = 0; code < 255; code++) + GetAsyncKeyState(code); + } + else + { + activeHotkey = -1; + } + } + } + + EndElement(pos, size); + return changed; + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/Selectable.h b/Internal/menu/canvas/components/Selectable.h new file mode 100644 index 0000000..57cc0a7 --- /dev/null +++ b/Internal/menu/canvas/components/Selectable.h @@ -0,0 +1,29 @@ +#pragma once + +/// @file +/// @brief Selectable rich widget: a clickable text row highlighted when hovered/selected. + +#include "Core.h" + +namespace UCanvasGUI +{ + /// Clickable text row (no background unless hovered/selected); returns true on click. + inline bool Selectable(const char* name, bool selected, float width) + { + elementCount++; + + const FVector2D size{width, 20.0f}; + const FVector2D pos = NextPos(FVector2D{10, 2}, size.Y + 2.0f); + const bool isHovered = MouseInZone(pos, size); + + if (selected || isHovered) + { + DrawRect(pos, size.X, size.Y, isHovered ? Colors::Frame : Colors::Selection); + if (isHovered) elementHovered = true; + } + TextLeft(name, FVector2D{pos.X + 4.0f, pos.Y + size.Y / 2}, Colors::Text, false); + + EndElement(pos, size); + return isHovered && Input::IsMouseClicked(0, elementCount, false); + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/Slider.h b/Internal/menu/canvas/components/Slider.h new file mode 100644 index 0000000..f59ee0a --- /dev/null +++ b/Internal/menu/canvas/components/Slider.h @@ -0,0 +1,96 @@ +#pragma once + +/// @file +/// @brief Slider widgets: SliderInt / SliderFloat — a draggable track with a knob and a live value +/// readout. Dragging is captured by widget id in `activeElement` so it continues off the track. + +#include "Core.h" + +namespace UCanvasGUI +{ + inline void SliderInt(const char* name, int* value, int min, int max) + { + elementCount++; + + const FVector2D size{240, 50}; + const FVector2D track{200, 10}; + const FVector2D pos = NextPos({10, 15}, size.Y + 15.0f); + + const FVector2D trackPos{pos.X, pos.Y + track.Y + 15.0f}; + const bool isHovered = MouseInZone(trackPos, track); + + if (isHovered || activeElement == elementCount) + { + elementHovered = true; + if (Input::IsMouseClicked(0, elementCount, true)) + { + activeElement = elementCount; + const FVector2D cursor = CursorPos(); + *value = (int)(((cursor.X - pos.X) * ((max - min) / track.X)) + min); + if (*value < min) *value = min; + if (*value > max) *value = max; + } + } + + // Track + progress fill + knob. + DrawRect(trackPos, track.X, track.Y, Colors::Frame); + DrawRect(FVector2D{trackPos.X, trackPos.Y + 5.0f}, 5.0f, 5.0f, Colors::Accent); + + const float step = track.X / (max - min); + DrawRect(trackPos, step * (*value - min), track.Y, Colors::Accent); + DrawCircle(FVector2D{pos.X + step * (*value - min), trackPos.Y + 3.3f}, 10.0f, Colors::Knob); + DrawCircle(FVector2D{pos.X + step * (*value - min), trackPos.Y + 3.3f}, 5.0f, Colors::Accent); + + char buffer[32]; + sprintf_s(buffer, "%i", *value); + TextCenter(buffer, FVector2D{pos.X + step * (*value - min), trackPos.Y + 25.0f}, Colors::Text, false); + TextLeft(name, FVector2D{pos.X + 5, pos.Y + 10}, Colors::Text, false); + + EndElement(pos, size); + } + + inline void SliderFloat(const char* name, float* value, float min, float max, const char* format = "%.0f") + { + elementCount++; + + const FVector2D size{210, 40}; + const FVector2D track{170, 7}; + const FVector2D pos = NextPos({10, 15}, size.Y + 15.0f); + + const FVector2D trackPos{pos.X, pos.Y + track.Y + 15.0f}; + // The hit zone is taller than the track so the thin bar is easy to grab. + const bool isHovered = MouseInZone(FVector2D{pos.X, trackPos.Y - 20.0f}, FVector2D{track.X, track.Y + 30.0f}); + + if (isHovered || activeElement == elementCount) + { + elementHovered = true; + if (Input::IsMouseClicked(0, elementCount, true)) + { + activeElement = elementCount; + const FVector2D cursor = CursorPos(); + *value = ((cursor.X - pos.X) * ((max - min) / track.X)) + min; + if (*value < min) *value = min; + if (*value > max) *value = max; + } + } + + // Track + end caps. + DrawRect(trackPos, track.X, track.Y, Colors::Frame); + DrawCircle(FVector2D{pos.X, pos.Y + 24.3f}, 3.1f, Colors::Accent); + DrawCircle(FVector2D{pos.X + track.X, pos.Y + 24.3f}, 3.1f, Colors::Frame); + + TextLeft(name, FVector2D{pos.X, pos.Y + 5}, Colors::Text, false); + + // Progress fill + knob. + const float step = track.X / (max - min); + DrawRect(trackPos, step * (*value - min), track.Y, Colors::Accent); + DrawCircle(FVector2D{pos.X + step * (*value - min), trackPos.Y + 2.66f}, 8.0f, Colors::Knob); + DrawCircle(FVector2D{pos.X + step * (*value - min), trackPos.Y + 2.66f}, 4.0f, Colors::Accent); + + char buffer[32]; + sprintf_s(buffer, format, *value); + TextCenter(buffer, FVector2D{pos.X + step * (*value - min), trackPos.Y + 20.0f}, Colors::Text, false); + + EndElement(pos, size); + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/Tab.h b/Internal/menu/canvas/components/Tab.h new file mode 100644 index 0000000..9e38f35 --- /dev/null +++ b/Internal/menu/canvas/components/Tab.h @@ -0,0 +1,26 @@ +#pragma once + +/// @file +/// @brief Tab button: a selectable tab-strip entry (ButtonTab), highlighted while active/hovered. + +#include "Core.h" + +namespace UCanvasGUI +{ + inline bool ButtonTab(const char* name, FVector2D size, bool active) + { + elementCount++; + + const FVector2D pos = NextPos({5, 10}, size.Y + 10.0f); + const bool isHovered = MouseInZone(pos, size); + + DrawRect(pos, size.X, size.Y, Colors::Accent); + if (isHovered && !active) + elementHovered = true; + + TextCenter(name, FVector2D{pos.X + size.X / 2, pos.Y + size.Y / 2}, Colors::Text, false); + + EndElement(pos, size); + return isHovered && Input::IsMouseClicked(0, elementCount, false); + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/Text.h b/Internal/menu/canvas/components/Text.h new file mode 100644 index 0000000..fae0123 --- /dev/null +++ b/Internal/menu/canvas/components/Text.h @@ -0,0 +1,25 @@ +#pragma once + +/// @file +/// @brief Text widget: a laid-out (left- or center-aligned) text row that advances the layout cursor. + +#include "Core.h" + +namespace UCanvasGUI +{ + inline void Text(const char* text, bool center = false, bool outline = false) + { + elementCount++; + + constexpr float height = 25.0f; + const FVector2D pos = NextPos({10, 10}, height + 10.0f); + const FVector2D textPos{pos.X + 5.0f, pos.Y + height / 2}; + + if (center) + TextCenter(text, textPos, Colors::Text, outline); + else + TextLeft(text, textPos, Colors::Text, outline); + + EndElement(pos); + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/TextField.h b/Internal/menu/canvas/components/TextField.h new file mode 100644 index 0000000..9fd88f1 --- /dev/null +++ b/Internal/menu/canvas/components/TextField.h @@ -0,0 +1,90 @@ +#pragma once + +/// @file +/// @brief TextField rich widget: an editable single-line field (plus CharFromVK) with hint and trailing label. + +#include "Core.h" + +namespace UCanvasGUI +{ + inline int activeField = -1; ///< elementCount id of the focused text field + /// Translate a virtual-key code to a printable character (basic US layout), honoring @p shift. + inline char CharFromVK(int vk, bool shift) + { + if (vk >= 'A' && vk <= 'Z') return shift ? (char)vk : (char)(vk - 'A' + 'a'); + if (vk >= '0' && vk <= '9') + { + if (!shift) return (char)vk; + static const char* sym = ")!@#$%^&*("; + return sym[vk - '0']; + } + switch (vk) + { + case VK_SPACE: + return ' '; + case VK_OEM_MINUS: + return shift ? '_' : '-'; + case VK_OEM_PLUS: + return shift ? '+' : '='; + case VK_OEM_PERIOD: + return shift ? '>' : '.'; + case VK_OEM_COMMA: + return shift ? '<' : ','; + case VK_OEM_1: + return shift ? ':' : ';'; + case VK_OEM_2: + return shift ? '?' : '/'; + case VK_OEM_5: + return shift ? '|' : '\\'; + } + return 0; + } + + /// Editable single-line text field with a trailing label; @p hint shows when empty and focused. + inline bool TextField(const char* name, char* buf, size_t size, float width, float height, const char* hint) + { + elementCount++; + bool changed = false; + + const FVector2D fieldSize{width, height}; + const FVector2D pos = NextPos(FVector2D{10, 10}, height + 10.0f); + const bool isHovered = MouseInZone(pos, fieldSize); + + DrawRect(pos, fieldSize.X, fieldSize.Y, Colors::Frame); + if (isHovered) elementHovered = true; + + if (Input::IsMouseClicked(0, elementCount, false)) + activeField = isHovered ? elementCount : (activeField == elementCount ? -1 : activeField); + + if (activeField == elementCount) + { + const bool shift = (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; + size_t len = strlen(buf); + if (Input::IsKeyPressed(VK_BACK, false) && len > 0) + { + buf[len - 1] = '\0'; + changed = true; + } + for (int vk = 0x20; vk < 256; vk++) + { + if (!Input::IsKeyPressed(vk, false)) continue; + char ch = CharFromVK(vk, shift); + if (ch && len < size - 1) + { + buf[len++] = ch; + buf[len] = '\0'; + changed = true; + } + } + } + + const bool empty = (buf[0] == '\0'); + const char* shown = empty ? (hint ? hint : "") : buf; + if (shown[0]) TextLeft(shown, FVector2D{pos.X + 4.0f, pos.Y + fieldSize.Y / 2}, Colors::Text, false); + if (name && name[0] && name[0] != '#') + TextLeft(name, FVector2D{pos.X + fieldSize.X + 5.0f, pos.Y + fieldSize.Y / 2}, Colors::Text, false); + + EndElement(pos, fieldSize); + return changed; + } +} // namespace UCanvasGUI diff --git a/Internal/menu/canvas/components/Window.h b/Internal/menu/canvas/components/Window.h new file mode 100644 index 0000000..e176c36 --- /dev/null +++ b/Internal/menu/canvas/components/Window.h @@ -0,0 +1,70 @@ +#pragma once + +/// @file +/// @brief The draggable menu window: chrome (background, tab column, header bar, title) and the +/// pointer-driven drag. Opens the frame — resets the layout cursor and widget counter — and draws the +/// chrome; returns false (drawing nothing) while closed or while the game window isn't focused. + +#include "Core.h" + +namespace UCanvasGUI +{ + inline FVector2D dragPos; ///< cursor-to-window offset captured at drag start (zero while not dragging) + + /// @param pos in/out window top-left; updated while dragged. + /// @param isOpen whether the menu is shown. + /// @return true if the window is drawn (callers should draw their widgets only then). + inline bool Window(const char* name, FVector2D* pos, FVector2D size, bool isOpen) + { + elementCount = 0; + static HWND window = FindWindow(L"UnrealWindow", L"PortalWars "); + if (!isOpen || GetActiveWindow() != window) + return false; + + const bool isHovered = MouseInZone(*pos, size); + const bool lmb = GetAsyncKeyState(VK_LBUTTON); + + // Release the dragged slider once the button is up. + if (activeElement != -1 && !lmb) + activeElement = -1; + + if (elementHovered && lmb) + { + // A widget is capturing this click — don't start a window drag. + } + else if ((isHovered || dragPos.X != 0) && !elementHovered) + { + if (Input::IsMouseClicked(0, elementCount, true)) + { + FVector2D cursor = CursorPos(); + cursor.X -= size.X; + cursor.Y -= size.Y; + if (dragPos.X == 0) + dragPos = FVector2D{cursor.X - pos->X, cursor.Y - pos->Y}; + pos->X = cursor.X - dragPos.X; + pos->Y = cursor.Y - dragPos.Y; + } + else + { + dragPos = FVector2D{0, 0}; + } + } + else + { + elementHovered = false; + } + + offsetX = 0.0f; + offsetY = 0.0f; + menuPos = *pos; + + // Chrome: window background, left tab column, header bar. + DrawRect(*pos, size.X, size.Y, Colors::Background); + DrawRect(*pos, 122, size.Y, Colors::Frame); + DrawRect(*pos, size.X, 25.0f, Colors::Accent); + offsetY += 25.0f; + + TextCenter(name, FVector2D{pos->X + size.X / 2, pos->Y + 25 / 2}, Colors::Text, false); + return true; + } +} // namespace UCanvasGUI diff --git a/Internal/menu/gui/ExternalWindow.h b/Internal/menu/gui/ExternalWindow.h index 0f4541a..e93abf9 100644 --- a/Internal/menu/gui/ExternalWindow.h +++ b/Internal/menu/gui/ExternalWindow.h @@ -42,6 +42,8 @@ #pragma comment(lib, "dxgi.lib") #pragma comment(lib, "dcomp.lib") +#include + #include #include "imgui_Impl_dx11.h" #include "imgui_Impl_Win32.h" @@ -348,8 +350,19 @@ namespace ExternalWindow const float transparent[4] = {0.f, 0.f, 0.f, 0.f}; Context->OMSetRenderTargets(1, &Rtv, nullptr); Context->ClearRenderTargetView(Rtv, transparent); - ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData()); - - SwapChain->Present(0, 0); // no vsync: we're on the game thread and must not block on it + ImDrawData* drawData = ImGui::GetDrawData(); + ImGui_ImplDX11_RenderDrawData(drawData); + + const HRESULT present = SwapChain->Present(0, 0); // no vsync: we're on the game thread, must not block + + // Diagnostic (first frames + periodic): why nothing shows. vtx==0 => the recorded commands + // aren't reaching this draw list (recording/drain problem); vtx>0 but nothing on screen => + // the DirectComposition swap chain isn't compositing; present!=0 => the Present itself failed. + static int diagFrames = 0; + if (diagFrames < 5 || (diagFrames % 600) == 0) + Logger::Log(SUCCEEDED(present) ? "INFO" : "ERROR", + std::format("[Overlay] render: focused={} size={}x{} vtx={} present=0x{:08X}", + focused, Width, Height, drawData ? drawData->TotalVtxCount : -1, static_cast(present))); + diagFrames++; } } // namespace ExternalWindow diff --git a/Internal/menu/gui/Gui.h b/Internal/menu/gui/Gui.h index cf1b668..a844243 100644 --- a/Internal/menu/gui/Gui.h +++ b/Internal/menu/gui/Gui.h @@ -78,6 +78,12 @@ namespace GUI ImGui_ImplWin32_Init(Window::WindowHandle); ImGui_ImplDX11_Init(Window::Device, Window::DeviceContext); ImGui_ImplDX11_CreateDeviceObjects(); + + // Cache this context's default font for Render::Measure, so features measure text without ever + // reading ImGui's global current-context (which the external overlay switches on another thread). + // Fonts[0] is valid now that the atlas has been built above. + if (ImFontAtlas* fonts = ImGui::GetIO().Fonts; fonts && fonts->Fonts.Size > 0) + Render::imgui.measureFont = fonts->Fonts[0]; ImGui::GetMainViewport()->PlatformHandleRaw = Window::WindowHandle; Window::OldWindowProcess = (WNDPROC)SetWindowLongPtr(Window::WindowHandle, GWLP_WNDPROC, (__int3264)(LONG_PTR)Window::WndProc); @@ -140,18 +146,18 @@ namespace GUI if (!external) Render::Flush(); - const ImGuiViewport* mainViewport = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(ImVec2(mainViewport->WorkPos.x + 550, mainViewport->WorkPos.y + 20), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(550, 350), ImGuiCond_FirstUseEver); - + // Game-input capture is keyed on ShowMenu (backend-agnostic) so input is blocked whenever the + // menu is open, regardless of which backend draws it. The menu cursor is a backend concern: + // default it off; the ImGui backend re-enables ImGui's software cursor itself when it draws. ImGuiIO& io = ImGui::GetIO(); (void)io; - io.MouseDrawCursor = Settings.MENU.ShowMenu; + io.MouseDrawCursor = false; io.WantCaptureMouse = Settings.MENU.ShowMenu; io.WantTextInput = Settings.MENU.ShowMenu; io.WantCaptureKeyboard = Settings.MENU.ShowMenu; - Menu::Draw(); + // Draw the menu for the Present-driven backend (the ImGui backend); a no-op if Canvas is active. + Menu::Frame(Menu::Phase::Present); ImGui::EndFrame(); ImGui::Render(); diff --git a/Internal/menu/gui/Styles.h b/Internal/menu/gui/Styles.h index 84ac297..46f7605 100644 --- a/Internal/menu/gui/Styles.h +++ b/Internal/menu/gui/Styles.h @@ -37,11 +37,11 @@ namespace GUI colors[ImGuiCol_Text] = Palette::Text.To(); colors[ImGuiCol_TextDisabled] = Palette::Text.Alpha(0.77f).To(); - colors[ImGuiCol_WindowBg] = Palette::Cream.Alpha(0.82f).To(); + colors[ImGuiCol_WindowBg] = Palette::Background.Alpha(0.82f).To(); colors[ImGuiCol_ChildBg] = Palette::Frame.Alpha(0.58f).To(); - colors[ImGuiCol_PopupBg] = Palette::Cream.Alpha(0.92f).To(); + colors[ImGuiCol_PopupBg] = Palette::Background.Alpha(0.92f).To(); colors[ImGuiCol_Border] = Palette::Border.Alpha(0.65f).To(); - colors[ImGuiCol_BorderShadow] = Palette::Cream.Alpha(0.00f).To(); + colors[ImGuiCol_BorderShadow] = Palette::Background.Alpha(0.00f).To(); colors[ImGuiCol_FrameBg] = Palette::Frame.To(); colors[ImGuiCol_FrameBgHovered] = ImVec4(1.00f, 0.40f, 0.40f, 0.78f); // one-off light red colors[ImGuiCol_FrameBgActive] = Palette::Primary.To(); diff --git a/Internal/menu/sections/Aim.h b/Internal/menu/sections/Aim.h index dea3e14..4cbd950 100644 --- a/Internal/menu/sections/Aim.h +++ b/Internal/menu/sections/Aim.h @@ -5,6 +5,7 @@ #include "../../settings/Settings.h" #include "../../scripting/Events.h" +#include "../ui/UI.h" namespace Menu { @@ -16,40 +17,40 @@ namespace Menu bool changed = false; auto& a = Settings.AIM; - ImGui::SeparatorText("Aimbot"); - changed |= ImGui::ToggleButton("Enable##aim", &a.Aimbot); - ImGui::HotKey("Aim key", &a.AimKey); - changed |= ImGui::SliderFloat("FOV (px)", &a.AimFov, 10.f, 500.f, "%.0f"); - changed |= ImGui::SliderFloat("Smoothing", &a.AimSmooth, 0.05f, 1.f, "%.2f"); - ImGui::Tooltip("1.0 snaps instantly; lower is smoother."); + UI::SeparatorText("Aimbot"); + UI::ToggleSetting("Enable##aim", &a.Aimbot); + UI::HotKey("Aim key", &a.AimKey); + changed |= UI::SliderFloat("FOV (px)", &a.AimFov, 10.f, 500.f, "%.0f"); + changed |= UI::SliderFloat("Smoothing", &a.AimSmooth, 0.05f, 1.f, "%.2f"); + UI::Tooltip("1.0 snaps instantly; lower is smoother."); const char* bones[] = {"Head", "Chest", "Pelvis"}; - changed |= ImGui::Combo("Bone", &a.AimBone, bones, IM_ARRAYSIZE(bones)); - changed |= ImGui::ToggleButton("Team check##aim", &a.AimTeamCheck); - changed |= ImGui::ToggleButton("Ignore bots", &a.IgnoreBots); - ImGui::Tooltip("Aimbot and triggerbot target only real players, never AI bots."); - changed |= ImGui::ToggleButton("Silent aim", &a.SilentAim); - ImGui::Tooltip("Snap to the target only while firing (left click), ignoring the aim key."); - changed |= ImGui::ToggleButton("Visibility check", &a.AimVisibleCheck); - ImGui::Tooltip("Only lock onto targets that were recently rendered (visible). Also applies to the triggerbot."); + changed |= UI::Combo("Bone", &a.AimBone, bones, UI::Count(bones)); + UI::ToggleSetting("Team check##aim", &a.AimTeamCheck); + UI::ToggleSetting("Ignore bots", &a.IgnoreBots); + UI::Tooltip("Aimbot and triggerbot target only real players, never AI bots."); + UI::ToggleSetting("Silent aim", &a.SilentAim); + UI::Tooltip("Snap to the target only while firing (left click), ignoring the aim key."); + UI::ToggleSetting("Visibility check", &a.AimVisibleCheck); + UI::Tooltip("Only lock onto targets that were recently rendered (visible). Also applies to the triggerbot."); if (a.AimVisibleCheck) { - changed |= ImGui::ToggleButton("Per-bone visibility", &a.AimVisiblePerBone); - ImGui::Tooltip("Stricter: line-trace each bone and aim at the first one in line of sight\n(e.g. skip the head when only the legs are exposed). Skips targets with no visible bone."); + UI::ToggleSetting("Per-bone visibility", &a.AimVisiblePerBone); + UI::Tooltip("Stricter: line-trace each bone and aim at the first one in line of sight\n(e.g. skip the head when only the legs are exposed). Skips targets with no visible bone."); } - changed |= ImGui::ToggleButton("Aim assist", &a.AimAssist); - ImGui::Tooltip("Amplify the weapon's built-in aim-assist/magnetism (soft aim, view isn't moved).\nMay only take effect on controller input - verify in-game."); + UI::ToggleSetting("Aim assist", &a.AimAssist); + UI::Tooltip("Amplify the weapon's built-in aim-assist/magnetism (soft aim, view isn't moved).\nMay only take effect on controller input - verify in-game."); if (a.AimAssist) - changed |= ImGui::SliderFloat("Aim assist strength", &a.AimAssistStrength, 1.f, 8.f, "%.1fx"); + changed |= UI::SliderFloat("Aim assist strength", &a.AimAssistStrength, 1.f, 8.f, "%.1fx"); - changed |= ImGui::ToggleButton("Draw FOV circle", &a.DrawAimFov); - ImGui::ColorEdit4("FOV circle color", &a.AimFovColor.R); + UI::ToggleSetting("Draw FOV circle", &a.DrawAimFov); + UI::ColorEdit("FOV circle color", &a.AimFovColor); - ImGui::SeparatorText("Triggerbot"); - changed |= ImGui::ToggleButton("Enable##trig", &a.Triggerbot); - ImGui::HotKey("Trigger key", &a.TriggerKey); - changed |= ImGui::SliderFloat("Trigger FOV (px)", &a.TriggerFov, 1.f, 30.f, "%.0f"); - changed |= ImGui::SliderInt("Delay (ms)", &a.TriggerDelay, 0, 500); - changed |= ImGui::ToggleButton("Team check##trig", &a.TriggerTeamCheck); + UI::SeparatorText("Triggerbot"); + UI::ToggleSetting("Enable##trig", &a.Triggerbot); + UI::HotKey("Trigger key", &a.TriggerKey); + changed |= UI::SliderFloat("Trigger FOV (px)", &a.TriggerFov, 1.f, 30.f, "%.0f"); + changed |= UI::SliderInt("Delay (ms)", &a.TriggerDelay, 0, 500); + UI::ToggleSetting("Team check##trig", &a.TriggerTeamCheck); if (changed) Events::Dispatch(Events::Type::SettingsChanged); } diff --git a/Internal/menu/sections/Config.h b/Internal/menu/sections/Config.h index 547e9cd..fa2634f 100644 --- a/Internal/menu/sections/Config.h +++ b/Internal/menu/sections/Config.h @@ -8,6 +8,7 @@ #include "../../settings/Profiles.h" #include "../../../shared/Utilities.h" +#include "../ui/UI.h" namespace Menu { @@ -16,39 +17,39 @@ namespace Menu /// @brief Renders the Config tab. void ConfigTab() { - ImGui::SeparatorText("Config"); - if (ImGui::Button("Save")) SettingsHelper::File().Save(); - ImGui::SameLine(); - if (ImGui::Button("Reload")) + UI::SeparatorText("Config"); + if (UI::Button("Save")) SettingsHelper::File().Save(); + UI::SameLine(); + if (UI::Button("Reload")) { SettingsHelper::File().Load(); Events::Dispatch(Events::Type::SettingsChanged); } - ImGui::SameLine(); - if (ImGui::Button("Reset defaults")) + UI::SameLine(); + if (UI::Button("Reset defaults")) { SettingsHelper::File().Reset(); Events::Dispatch(Events::Type::SettingsChanged); } - ImGui::SameLine(); - if (ImGui::Button("Open folder")) + UI::SameLine(); + if (UI::Button("Open folder")) Shared::Utilities::OpenFolder(SettingsHelper::File().Path().parent_path()); - ImGui::Tooltip("Open the folder holding the settings file."); + UI::Tooltip("Open the folder holding the settings file."); - if (ImGui::ToggleButton("Autosave on change", &Settings.MISC.AutoSave)) + if (UI::Toggle("Autosave on change", &Settings.MISC.AutoSave)) Events::Dispatch(Events::Type::SettingsChanged); - if (ImGui::ToggleButton("Delete config on crash", &Settings.DEBUG.DeleteSettingsOnCrash)) + if (UI::Toggle("Delete config on crash", &Settings.DEBUG.DeleteSettingsOnCrash)) Events::Dispatch(Events::Type::SettingsChanged); - ImGui::SeparatorText("Menu"); - ImGui::HotKey("Open Menu", &Settings.MENU.ShowHotkey); - ImGui::ToggleButton("Watermark", &Settings.MENU.ShowWatermark); - ImGui::ToggleButton("RGB", &Settings.MENU.Rgb); - ImGui::Tooltip("Cycle the watermark, menu accent, and radar self-icon through a rainbow. Off = the defaults (red / white)."); + UI::SeparatorText("Menu"); + UI::HotKey("Open Menu", &Settings.MENU.ShowHotkey); + UI::Toggle("Watermark", &Settings.MENU.ShowWatermark); + UI::Toggle("RGB", &Settings.MENU.Rgb); + UI::Tooltip("Cycle the watermark, menu accent, and radar self-icon through a rainbow. Off = the defaults (red / white)."); // Restore every hotkey (menu, no-clip, aim, trigger) to its struct default, leaving the // rest of the config untouched. - if (ImGui::Button("Reset keybinds")) + if (UI::Button("Reset keybinds")) { Settings.MENU.ShowHotkey = MenuSettings{}.ShowHotkey; Settings.EXPLOITS.NoClip = ExploitsSettings{}.NoClip; @@ -56,48 +57,48 @@ namespace Menu Settings.AIM.TriggerKey = AimSettings{}.TriggerKey; Events::Dispatch(Events::Type::SettingsChanged); } - ImGui::Tooltip("Reset all hotkeys (menu, no-clip, aim, trigger) to their defaults."); + UI::Tooltip("Reset all hotkeys (menu, no-clip, aim, trigger) to their defaults."); - ImGui::SeparatorText("Save current config"); + UI::SeparatorText("Save current config"); static char nameBuffer[64] = ""; - ImGui::SetNextItemWidth(200.f); - ImGui::InputText("##name", nameBuffer, sizeof(nameBuffer)); - ImGui::SameLine(); - if (ImGui::Button("Save as") && nameBuffer[0]) + UI::SetNextItemWidth(200.f); + UI::InputText("##name", nameBuffer, sizeof(nameBuffer)); + UI::SameLine(); + if (UI::Button("Save as") && nameBuffer[0]) { Profiles::Save(nameBuffer); nameBuffer[0] = '\0'; } - ImGui::SeparatorText("Profiles"); + UI::SeparatorText("Profiles"); std::string toLoad, toDelete; for (const auto& name : Profiles::List()) { - ImGui::PushID(name.c_str()); - if (ImGui::SmallButton("Load")) toLoad = name; - ImGui::SameLine(); - if (ImGui::SmallButton("Delete")) toDelete = name; - ImGui::SameLine(); - ImGui::TextUnformatted(name.c_str()); - ImGui::PopID(); + UI::PushID(name.c_str()); + if (UI::SmallButton("Load")) toLoad = name; + UI::SameLine(); + if (UI::SmallButton("Delete")) toDelete = name; + UI::SameLine(); + UI::Text("%s", name.c_str()); + UI::PopID(); } if (!toLoad.empty()) Profiles::Load(toLoad); if (!toDelete.empty()) Profiles::Delete(toDelete); - ImGui::SeparatorText("Share code"); - ImGui::Tooltip("A portable code for the current config. Copy to share; paste + import to apply."); - if (ImGui::Button("Copy current config")) ImGui::SetClipboardText(Profiles::Export().c_str()); + UI::SeparatorText("Share code"); + UI::Tooltip("A portable code for the current config. Copy to share; paste + import to apply."); + if (UI::Button("Copy current config")) UI::SetClipboardText(Profiles::Export().c_str()); static char codeBuffer[8192] = ""; - ImGui::InputTextMultiline("##code", codeBuffer, sizeof(codeBuffer), ImVec2(0, 80)); + UI::InputTextMultiline("##code", codeBuffer, sizeof(codeBuffer), 80); - if (ImGui::Button("Paste")) + if (UI::Button("Paste")) { - const char* clip = ImGui::GetClipboardText(); + const char* clip = UI::GetClipboardText(); if (clip) strncpy_s(codeBuffer, clip, _TRUNCATE); } - ImGui::SameLine(); - if (ImGui::Button("Import") && codeBuffer[0]) + UI::SameLine(); + if (UI::Button("Import") && codeBuffer[0]) { if (Profiles::Import(codeBuffer)) codeBuffer[0] = '\0'; } diff --git a/Internal/menu/sections/Debug.h b/Internal/menu/sections/Debug.h index d680a29..07cdf74 100644 --- a/Internal/menu/sections/Debug.h +++ b/Internal/menu/sections/Debug.h @@ -6,7 +6,9 @@ #include "../../settings/Settings.h" #include "../../scripting/Events.h" #include "../../hook/Hook.h" +#include "../../hook/GuardHook.h" #include "../../../shared/Utilities.h" +#include "../ui/UI.h" namespace Menu { @@ -20,72 +22,76 @@ namespace Menu /// GObjects to Dumps/GObjects.txt, and a tree listing loaded features with their init/enabled state. void DebugTab() { - bool changed = false; + UI::SeparatorText("Logging"); + UI::ToggleSetting("Log ProcessEvent", &Settings.DEBUG.LogProcessEvent); + UI::ToggleSetting("Features Logging", &Settings.DEBUG.FeaturesLogging); - ImGui::SeparatorText("Logging"); - changed |= ImGui::ToggleButton("Log ProcessEvent", &Settings.DEBUG.LogProcessEvent); - changed |= ImGui::ToggleButton("Features Logging", &Settings.DEBUG.FeaturesLogging); + UI::SeparatorText("GUI"); + UI::ToggleSetting("Show demo window", &Settings.DEBUG.ShowDemoWindow); + UI::ToggleSetting("Show style editor", &Settings.DEBUG.ShowStyleEditor); - ImGui::SeparatorText("GUI"); - changed |= ImGui::ToggleButton("Show demo window", &Settings.DEBUG.ShowDemoWindow); - changed |= ImGui::ToggleButton("Show style editor", &Settings.DEBUG.ShowStyleEditor); - - ImGui::SeparatorText("Performance"); - changed |= ImGui::ToggleButton("Native WorldToScreen", &Settings.DEBUG.NativeWorldToScreen); - ImGui::Tooltip("Project overlays with math instead of the game's ProjectWorldLocationToScreen UFunction. Turn off if boxes/names are misplaced."); + UI::SeparatorText("Performance"); + UI::ToggleSetting("Native WorldToScreen", &Settings.DEBUG.NativeWorldToScreen); + UI::Tooltip("Project overlays with math instead of the game's ProjectWorldLocationToScreen UFunction. Turn off if boxes/names are misplaced."); if (!Settings.DEBUG.NativeWorldToScreen) { - changed |= ImGui::ToggleButton("Custom projection", &Settings.DEBUG.CustomProjection); - ImGui::Tooltip("With native off: use PortalWars' ProjectWorldLocationToScreenCustom instead of the stock UFunction."); + UI::ToggleSetting("Custom projection", &Settings.DEBUG.CustomProjection); + UI::Tooltip("With native off: use PortalWars' ProjectWorldLocationToScreenCustom instead of the stock UFunction."); } - changed |= ImGui::ToggleButton("Native bones", &Settings.DEBUG.NativeBones); - ImGui::Tooltip("Project the ESP skeleton via native GetBoneMatrix + WorldToScreen. Off falls back to the game's bone projection."); - changed |= ImGui::ToggleButton("Native actor location", &Settings.DEBUG.NativeActorLocation); - ImGui::Tooltip("Read actor location from RootComponent->RelativeLocation (no ProcessEvent). Off uses K2_GetActorLocation."); - - if (changed) Events::Dispatch(Events::Type::SettingsChanged); + UI::ToggleSetting("Native bones", &Settings.DEBUG.NativeBones); + UI::Tooltip("Project the ESP skeleton via native GetBoneMatrix + WorldToScreen. Off falls back to the game's bone projection."); + UI::ToggleSetting("Native actor location", &Settings.DEBUG.NativeActorLocation); + UI::Tooltip("Read actor location from RootComponent->RelativeLocation (no ProcessEvent). Off uses K2_GetActorLocation."); - ImGui::SeparatorText("Files"); - if (ImGui::Button("Open app folder")) + UI::SeparatorText("Files"); + if (UI::Button("Open app folder")) Shared::Utilities::OpenFolder(Shared::AppDataPath(SettingsHelper::AppFolder)); - ImGui::Tooltip("Open the SplitgateInternal data folder (settings, logs, dumps)."); + UI::Tooltip("Open the SplitgateInternal data folder (settings, logs, dumps)."); + + UI::SeparatorText("Hooking"); + if (UI::Button("Test GuardHook")) + { + const bool ok = Hook::GuardHook::SelfTest(); + Logger::Log(ok ? "SUCCESS" : "ERROR", ok ? "[GuardHook] self-test passed (detour ran)" : "[GuardHook] self-test failed"); + } + UI::Tooltip("Run an in-process self-test of the guard-page hook primitive: it hooks a scratch\nfunction and confirms the detour ran instead. Touches no game code; result goes to the log."); - ImGui::SeparatorText("Console command"); + UI::SeparatorText("Console command"); static char consoleBuffer[256] = ""; - ImGui::SetNextItemWidth(260.f); - ImGui::InputText("##console", consoleBuffer, sizeof(consoleBuffer)); - ImGui::SameLine(); - if (ImGui::Button("Run") && consoleBuffer[0] && Engine::PlayerController) + UI::SetNextItemWidth(260.f); + UI::InputText("##console", consoleBuffer, sizeof(consoleBuffer)); + UI::SameLine(); + if (UI::Button("Run") && consoleBuffer[0] && Engine::PlayerController) { Engine::PlayerController->SendToConsole(FString(std::string(consoleBuffer))); } - if (ImGui::TreeNode("Loaded Features")) + if (UI::TreeNode("Loaded Features")) { for (const auto& Feature : Features::Features) { - ImGui::BulletText(Feature->Name.c_str()); - ImGui::Tooltip(std::format("Init [{}], Enabled [{}]", Feature->Initialized, Feature->Enabled).c_str()); + UI::BulletText("%s", Feature->Name.c_str()); + UI::Tooltip(std::format("Init [{}], Enabled [{}]", Feature->Initialized, Feature->Enabled).c_str()); } - ImGui::TreePop(); + UI::TreePop(); } - if (ImGui::CollapsingHeader("Recent logs")) + if (UI::CollapsingHeader("Recent logs")) { - if (ImGui::Button("Copy##logs")) + if (UI::Button("Copy##logs")) { std::string out; - for (const auto& line : Logger::Recent()) out += line + "\n"; - ImGui::SetClipboardText(out.c_str()); + for (const auto& line : Logger::Recent()) + out += line + "\n"; + UI::SetClipboardText(out.c_str()); } - ImGui::Tooltip("Copy the recent log lines to the clipboard."); + UI::Tooltip("Copy the recent log lines to the clipboard."); - ImGui::BeginChild("Logs", ImVec2(0, 200), true, ImGuiWindowFlags_HorizontalScrollbar); + UI::BeginChild("Logs", 0, 200); for (const auto& line : Logger::Recent()) - ImGui::TextUnformatted(line.c_str()); - if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) ImGui::SetScrollHereY(1.0f); // stick to bottom - ImGui::EndChild(); + UI::Text("%s", line.c_str()); + UI::EndChild(); } } } // namespace Sections diff --git a/Internal/menu/sections/Discord.h b/Internal/menu/sections/Discord.h index 4d86dfd..ac6e46b 100644 --- a/Internal/menu/sections/Discord.h +++ b/Internal/menu/sections/Discord.h @@ -8,6 +8,7 @@ #include "../../settings/Settings.h" #include "../../scripting/Events.h" #include "../../discord/rpc.h" +#include "../ui/UI.h" namespace Menu { @@ -16,9 +17,9 @@ namespace Menu /// @brief Renders the Discord tab. void DiscordTab() { - ImGui::SeparatorText("Rich Presence"); + UI::SeparatorText("Rich Presence"); - if (ImGui::ToggleButton("Enable", &Settings.MISC.DiscordRPCEnabled)) + if (UI::Toggle("Enable", &Settings.MISC.DiscordRPCEnabled)) { Events::Dispatch(Events::Type::SettingsChanged); // features (incl. DiscordPresence) refresh if (Settings.MISC.DiscordRPCEnabled) @@ -26,23 +27,25 @@ namespace Menu else Discord_ClearPresence(); // hide it from your profile while off } - ImGui::Tooltip("Show a Rich Presence on your Discord profile. Updates with the live game state\n(map + K/D) every few seconds."); + UI::Tooltip("Show a Rich Presence on your Discord profile. Updates with the live game state\n(map + K/D) every few seconds."); - if (!Settings.MISC.DiscordRPCEnabled) ImGui::BeginDisabled(); + // Grey out the live/info block while RPC is off (a no-op on backends with no disabled scope). + const bool disabled = !Settings.MISC.DiscordRPCEnabled; + UI::BeginDisabled(disabled); - ImGui::SeparatorText("Live"); + UI::SeparatorText("Live"); const char* state = DiscordRPC::GetState(); - ImGui::Text("State: %s", (state && state[0]) ? state : "(none)"); - ImGui::Text("Details: %s", Settings.MENU.Watermark.c_str()); - if (ImGui::Button("Refresh now")) DiscordRPC::UpdateGameState(); - ImGui::Tooltip("Push the current map + K/D to Discord immediately (also happens automatically ~every 5s)."); + UI::Text("State: %s", (state && state[0]) ? state : "(none)"); + UI::Text("Details: %s", Settings.MENU.Watermark.c_str()); + if (UI::Button("Refresh now")) DiscordRPC::UpdateGameState(); + UI::Tooltip("Push the current map + K/D to Discord immediately (also happens automatically ~every 5s)."); - ImGui::SeparatorText("Info"); - ImGui::Text("App ID: %s", Settings.MISC.DiscordAppID.c_str()); - ImGui::Text("Image: %s", "icon"); - ImGui::TextDisabled("App ID / Steam app id are runtime-only and set at startup."); + UI::SeparatorText("Info"); + UI::Text("App ID: %s", Settings.MISC.DiscordAppID.c_str()); + UI::Text("Image: %s", "icon"); + UI::TextDisabled("App ID / Steam app id are runtime-only and set at startup."); - if (!Settings.MISC.DiscordRPCEnabled) ImGui::EndDisabled(); + UI::EndDisabled(); } } // namespace Sections } // namespace Menu diff --git a/Internal/menu/sections/Exploits.h b/Internal/menu/sections/Exploits.h index 3e00e99..558b518 100644 --- a/Internal/menu/sections/Exploits.h +++ b/Internal/menu/sections/Exploits.h @@ -5,69 +5,69 @@ #include "../../settings/Settings.h" #include "../../scripting/Events.h" -#include "../Widgets.h" +#include "../ui/UI.h" namespace Menu { namespace Sections { /// @brief Renders the Exploits tab. Each toggle dispatches SettingsChanged tagged with its - /// own label (via ToggleSetting), plus a No Clip hotkey binding. + /// own label (via UI::ToggleSetting), plus a No Clip hotkey binding. void ExploitsTab() { - ImGui::SeparatorText("Misc"); - ToggleSetting("God Mode", &Settings.EXPLOITS.GodMode); - ToggleSetting("Spin Bot", &Settings.EXPLOITS.SpinBot); - ToggleSetting("Enable all input", &Settings.EXPLOITS.EnableAllInput); - ImGui::Tooltip("Forces UI input actions enabled (e.g. a greyed-out Play button)"); + UI::SeparatorText("Misc"); + UI::ToggleSetting("God Mode", &Settings.EXPLOITS.GodMode); + UI::ToggleSetting("Spin Bot", &Settings.EXPLOITS.SpinBot); + UI::ToggleSetting("Enable all input", &Settings.EXPLOITS.EnableAllInput); + UI::Tooltip("Forces UI input actions enabled (e.g. a greyed-out Play button)"); - ImGui::SeparatorText("Camera"); + UI::SeparatorText("Camera"); const char* cameras[] = {"First person", "Third person", "Free cam"}; int camera = static_cast(Settings.EXPLOITS.Camera); - if (ImGui::Combo("Camera", &camera, cameras, IM_ARRAYSIZE(cameras))) + if (UI::Combo("Camera", &camera, cameras, UI::Count(cameras))) { Settings.EXPLOITS.Camera = static_cast(camera); Events::Dispatch(Events::Type::SettingsChanged); } - ImGui::Tooltip("First person (default), a custom over-the-shoulder third person, or the game's debug free-fly cam."); + UI::Tooltip("First person (default), a custom over-the-shoulder third person, or the game's debug free-fly cam."); if (Settings.EXPLOITS.Camera == CameraMode::ThirdPerson) { - ImGui::SliderFloat("TP distance", &Settings.EXPLOITS.ThirdPersonDistance, 50.f, 600.f, "%.0f"); - ImGui::SliderFloat("TP height", &Settings.EXPLOITS.ThirdPersonHeight, 0.f, 200.f, "%.0f"); + UI::SliderFloat("TP distance", &Settings.EXPLOITS.ThirdPersonDistance, 50.f, 600.f, "%.0f"); + UI::SliderFloat("TP height", &Settings.EXPLOITS.ThirdPersonHeight, 0.f, 200.f, "%.0f"); } - ImGui::SeparatorText("Movement"); - ImGui::HotKey("No Clip", &Settings.EXPLOITS.NoClip); - ToggleSetting("Infinite Jetpack", &Settings.EXPLOITS.InfinteJetpack); + UI::SeparatorText("Movement"); + UI::HotKey("No Clip", &Settings.EXPLOITS.NoClip); + UI::ToggleSetting("Infinite Jetpack", &Settings.EXPLOITS.InfinteJetpack); - ToggleSetting("Super Jump", &Settings.EXPLOITS.SuperJump); + UI::ToggleSetting("Super Jump", &Settings.EXPLOITS.SuperJump); if (Settings.EXPLOITS.SuperJump) { - ImGui::HotKey("Super Jump key", &Settings.EXPLOITS.SuperJumpKey); - ImGui::SliderFloat("Jump force", &Settings.EXPLOITS.SuperJumpForce, 500.f, 5000.f, "%.0f"); + UI::HotKey("Super Jump key", &Settings.EXPLOITS.SuperJumpKey); + UI::SliderFloat("Jump force", &Settings.EXPLOITS.SuperJumpForce, 500.f, 5000.f, "%.0f"); } - ToggleSetting("Teleport", &Settings.EXPLOITS.Teleport); - ImGui::Tooltip("Teleport forward, toward where you're looking, on the hotkey."); + UI::ToggleSetting("Teleport", &Settings.EXPLOITS.Teleport); + UI::Tooltip("Teleport forward, toward where you're looking, on the hotkey."); if (Settings.EXPLOITS.Teleport) { - ImGui::HotKey("Teleport key", &Settings.EXPLOITS.TeleportKey); - ImGui::SliderFloat("Teleport distance", &Settings.EXPLOITS.TeleportDistance, 200.f, 5000.f, "%.0f"); + UI::HotKey("Teleport key", &Settings.EXPLOITS.TeleportKey); + UI::SliderFloat("Teleport distance", &Settings.EXPLOITS.TeleportDistance, 200.f, 5000.f, "%.0f"); } - ImGui::SeparatorText("Weapon"); - ToggleSetting("God Melee", &Settings.EXPLOITS.GodMelee); - ToggleSetting("No Recoil", &Settings.EXPLOITS.NoRecoil); - ToggleSetting("Infinite Ammo", &Settings.EXPLOITS.InfiniteAmmo); - ToggleSetting("No Reload", &Settings.EXPLOITS.NoReload); - ToggleSetting("Phasing bullets", &Settings.EXPLOITS.PhasingBullets); - ImGui::Tooltip("Disable collision on cover (CullableActor) so shots pass through it.\nLeaves the floor intact."); - ToggleSetting("Bullet TP", &Settings.EXPLOITS.BulletTp); - ImGui::Tooltip("Teleport your own projectiles onto the aim bone of the enemy nearest the crosshair.\nUses the Aim tab's Bone selection and Team-check / Ignore-bots filters."); - ToggleSetting("Bullet speed", &Settings.EXPLOITS.BulletSpeed); - ImGui::Tooltip("Push your own projectiles further along their velocity each frame (faster bullets)."); + UI::SeparatorText("Weapon"); + UI::ToggleSetting("God Melee", &Settings.EXPLOITS.GodMelee); + UI::ToggleSetting("No Recoil", &Settings.EXPLOITS.NoRecoil); + UI::ToggleSetting("Infinite Ammo", &Settings.EXPLOITS.InfiniteAmmo); + UI::ToggleSetting("No Reload", &Settings.EXPLOITS.NoReload); + UI::ToggleSetting("Phasing bullets", &Settings.EXPLOITS.PhasingBullets); + UI::Tooltip("Disable collision on cover (CullableActor) so shots pass through it.\nLeaves the floor intact."); + UI::ToggleSetting("Bullet TP", &Settings.EXPLOITS.BulletTp); + UI::Tooltip("Teleport your own projectiles onto the aim bone of the enemy nearest the crosshair.\nUses the Aim tab's Bone selection and Team-check / Ignore-bots filters."); + UI::ToggleSetting("Bullet speed", &Settings.EXPLOITS.BulletSpeed); + UI::Tooltip("Push your own projectiles further along their velocity each frame (faster bullets)."); if (Settings.EXPLOITS.BulletSpeed) - ImGui::SliderFloat("Bullet boost", &Settings.EXPLOITS.BulletSpeedBoost, 100.f, 3000.f, "%.0f"); + UI::SliderFloat("Bullet boost", &Settings.EXPLOITS.BulletSpeedBoost, 100.f, 3000.f, "%.0f"); } } // namespace Sections } // namespace Menu diff --git a/Internal/menu/sections/Misc.h b/Internal/menu/sections/Misc.h index 4822933..b94a7fd 100644 --- a/Internal/menu/sections/Misc.h +++ b/Internal/menu/sections/Misc.h @@ -11,6 +11,7 @@ #include "../../scripting/Events.h" #include "../../cache/ClassCache.h" // shared class list for the spawn picker #include "../../utils/Logger.h" // Logger::SetConsoleVisibility +#include "../ui/UI.h" // Forward-declared instead of including hook/Hook.h: that header transitively includes this menu // (via Features -> GUI -> Menu), so including it here would be circular. The inline definition in @@ -36,29 +37,29 @@ namespace Menu // can't catch. Everything below already gates on isInGame. bool isInGame = Engine::IsInGame; - ImGui::SeparatorText("Player"); + UI::SeparatorText("Player"); - ImGui::SetNextItemWidth(180.f); - ImGui::SliderFloat("##fov", &Settings.EXPLOITS.FOV, 80.0f, 160.0f, "FOV %.0f"); - ImGui::SameLine(); - if (ImGui::SmallButton("Reset##fov")) + UI::SetNextItemWidth(180.f); + UI::SliderFloat("##fov", &Settings.EXPLOITS.FOV, 80.0f, 160.0f, "FOV %.0f"); + UI::SameLine(); + if (UI::SmallButton("Reset##fov")) { Settings.EXPLOITS.FOV = ExploitsSettings{}.FOV; Events::Dispatch(Events::Type::SettingsChanged); } - if (!isInGame) ImGui::BeginDisabled(); - ImGui::SetNextItemWidth(180.f); - ImGui::SliderFloat("##speed", &Settings.EXPLOITS.PlayerSpeed, 0.2f, 4.f, "Speed %.2f"); - ImGui::SameLine(); - if (ImGui::SmallButton("Reset##speed")) + UI::BeginDisabled(!isInGame); + UI::SetNextItemWidth(180.f); + UI::SliderFloat("##speed", &Settings.EXPLOITS.PlayerSpeed, 0.2f, 4.f, "Speed %.2f"); + UI::SameLine(); + if (UI::SmallButton("Reset##speed")) { Settings.EXPLOITS.PlayerSpeed = ExploitsSettings{}.PlayerSpeed; Events::Dispatch(Events::Type::SettingsChanged); } - if (!isInGame) ImGui::EndDisabled(); + UI::EndDisabled(); - ImGui::SeparatorText("Game"); + UI::SeparatorText("Game"); // "Load into map" is usable whenever you're out of a game (e.g. back in the menu after a // match), disabled only while already in one. The dropdown beside it picks the target level @@ -119,12 +120,12 @@ namespace Menu static std::string levelLastKey = "\x01"; // sentinel: forces the first filter build static const char* selectedLevel = levels[0]; - if (isInGame) ImGui::BeginDisabled(); - ImGui::SetNextItemWidth(220.f); - if (ImGui::BeginCombo("##level", selectedLevel)) + UI::BeginDisabled(isInGame); + UI::SetNextItemWidth(220.f); + if (UI::BeginCombo("##level", selectedLevel)) { - ImGui::SetNextItemWidth(-1.f); - ImGui::InputTextWithHint("##levelsearch", "filter maps...", levelSearch, sizeof(levelSearch)); + UI::SetNextItemWidth(-1.f); + UI::InputTextHint("##levelsearch", "filter maps...", levelSearch, sizeof(levelSearch)); // Rebuild the filtered index list only when the search text changes. if (levelSearch != levelLastKey) @@ -132,57 +133,53 @@ namespace Menu levelLastKey = levelSearch; levelFiltered.clear(); const std::string needle = levelSearch; - for (int i = 0; i < IM_ARRAYSIZE(levels); i++) + for (int i = 0; i < UI::Count(levels); i++) if (needle.empty() || std::string(levels[i]).find(needle) != std::string::npos) levelFiltered.push_back(i); } - ImGui::BeginChild("##levellist", ImVec2(240, 200)); - ImGuiListClipper clipper; - clipper.Begin(static_cast(levelFiltered.size())); - while (clipper.Step()) - for (int r = clipper.DisplayStart; r < clipper.DisplayEnd; r++) - { + UI::BeginChild("##levellist", 240, 200); + UI::ClippedList(static_cast(levelFiltered.size()), [&](int r) { const char* name = levels[levelFiltered[r]]; - if (ImGui::Selectable(name, name == selectedLevel)) selectedLevel = name; - } - ImGui::EndChild(); - ImGui::EndCombo(); + if (UI::Selectable(name, name == selectedLevel)) selectedLevel = name; + }); + UI::EndChild(); + UI::EndCombo(); } - ImGui::SameLine(); - if (ImGui::Button("Load into map")) + UI::SameLine(); + if (UI::Button("Load into map")) { Events::Payload payload; payload.name = selectedLevel; Events::Dispatch(Events::Type::LoadIntoMap, payload); } - if (isInGame) ImGui::EndDisabled(); + UI::EndDisabled(); - ImGui::SameLine(); - if (!isInGame) ImGui::BeginDisabled(); - if (ImGui::Button("Respawn")) + UI::SameLine(); + UI::BeginDisabled(!isInGame); + if (UI::Button("Respawn")) if (auto* character = reinterpret_cast(Engine::PlayerController->Character)) character->RequestSuicide(); - if (!isInGame) ImGui::EndDisabled(); - ImGui::Tooltip("Kill your character so it respawns (RequestSuicide)."); + UI::EndDisabled(); + UI::Tooltip("Kill your character so it respawns (RequestSuicide)."); // Spawn picker: a searchable dropdown of spawnable actor classes (bots, pawns, guns, ...) // scanned from GObjects, plus a Spawn button that spawns the selection in front of you. { - static std::vector filtered; // indices into the shared ClassCache + static std::vector filtered; // indices into the shared ClassCache static char search[128] = ""; - static std::string lastKey = "\x01"; // sentinel: forces the first filter build + static std::string lastKey = "\x01"; // sentinel: forces the first filter build static size_t lastCacheSize = SIZE_MAX; // re-filter when the cache is rebuilt static std::string selected; static const char* keywords[] = {"Bot", "Pawn", "Gun", "Weapon", "Character", "Projectile", "Grenade", "Vehicle"}; - ImGui::SetNextItemWidth(240.f); - if (ImGui::BeginCombo("##spawnclass", selected.empty() ? "Spawn class..." : selected.c_str())) + UI::SetNextItemWidth(240.f); + if (UI::BeginCombo("##spawnclass", selected.empty() ? "Spawn class..." : selected.c_str())) { const auto& classes = ClassCache::Get(); // shared, built once - ImGui::SetNextItemWidth(-1.f); - ImGui::InputTextWithHint("##spawnsearch", "filter: bot, gun, pawn...", search, sizeof(search)); + UI::SetNextItemWidth(-1.f); + UI::InputTextHint("##spawnsearch", "filter: bot, gun, pawn...", search, sizeof(search)); // Rebuild the filtered index list only when the search or the underlying cache changes. if (search != lastKey || classes.size() != lastCacheSize) @@ -196,7 +193,11 @@ namespace Menu const std::string& name = classes[i].name; bool spawnable = false; // narrow to bots/pawns/guns/... so it's a spawn list, not every class for (const char* kw : keywords) - if (name.find(kw) != std::string::npos) { spawnable = true; break; } + if (name.find(kw) != std::string::npos) + { + spawnable = true; + break; + } if (!spawnable) continue; if (!needle.empty() && name.find(needle) == std::string::npos) continue; filtered.push_back(i); @@ -204,24 +205,20 @@ namespace Menu } // Clip to the visible rows so a few-thousand-class list isn't laid out in full each frame. - ImGui::BeginChild("##spawnlist", ImVec2(340, 220)); - ImGuiListClipper clipper; - clipper.Begin(static_cast(filtered.size())); - while (clipper.Step()) - for (int r = clipper.DisplayStart; r < clipper.DisplayEnd; r++) - { + UI::BeginChild("##spawnlist", 340, 220); + UI::ClippedList(static_cast(filtered.size()), [&](int r) { const std::string& name = classes[filtered[r]].name; - if (ImGui::Selectable(name.c_str(), name == selected)) selected = name; - } - ImGui::EndChild(); - ImGui::EndCombo(); + if (UI::Selectable(name.c_str(), name == selected)) selected = name; + }); + UI::EndChild(); + UI::EndCombo(); } - ImGui::SameLine(); - if (ImGui::SmallButton("Refresh##spawn")) ClassCache::Rebuild(); + UI::SameLine(); + if (UI::SmallButton("Refresh##spawn")) ClassCache::Rebuild(); - ImGui::SameLine(); - if (!isInGame || selected.empty()) ImGui::BeginDisabled(); - if (ImGui::Button("Spawn") && isInGame && !selected.empty() && Engine::PlayerController) + UI::SameLine(); + UI::BeginDisabled(!isInGame || selected.empty()); + if (UI::Button("Spawn") && isInGame && !selected.empty() && Engine::PlayerController) { UObject* cls = Engine::GObjects->FindObject(selected.c_str()); auto* pawn = Engine::PlayerController->AcknowledgedPawn; @@ -236,11 +233,11 @@ namespace Menu else Logger::Log("ERROR", "Spawn: class not found: " + selected); } - if (!isInGame || selected.empty()) ImGui::EndDisabled(); - ImGui::Tooltip("Pick a spawnable class (searchable — bots, pawns, guns, ...) and Spawn it in front\nof you. Refresh rescans GObjects. Uses the deferred SpawnActor."); + UI::EndDisabled(); + UI::Tooltip("Pick a spawnable class (searchable — bots, pawns, guns, ...) and Spawn it in front\nof you. Refresh rescans GObjects. Uses the deferred SpawnActor."); } - ImGui::SeparatorText("Cosmetics"); + UI::SeparatorText("Cosmetics"); { static std::vector skinFiltered; // indices into the shared ClassCache static char skinSearch[128] = ""; @@ -248,13 +245,13 @@ namespace Menu static size_t skinLastCacheSize = SIZE_MAX; static std::string skinSelected; - ImGui::SetNextItemWidth(240.f); - if (ImGui::BeginCombo("##skinclass", skinSelected.empty() ? "Skin class..." : skinSelected.c_str())) + UI::SetNextItemWidth(240.f); + if (UI::BeginCombo("##skinclass", skinSelected.empty() ? "Skin class..." : skinSelected.c_str())) { const auto& classes = ClassCache::Get(); - ImGui::SetNextItemWidth(-1.f); - ImGui::InputTextWithHint("##skinsearch", "filter: skin name...", skinSearch, sizeof(skinSearch)); + UI::SetNextItemWidth(-1.f); + UI::InputTextHint("##skinsearch", "filter: skin name...", skinSearch, sizeof(skinSearch)); if (skinSearch != skinLastKey || classes.size() != skinLastCacheSize) { @@ -271,24 +268,20 @@ namespace Menu } } - ImGui::BeginChild("##skinlist", ImVec2(340, 220)); - ImGuiListClipper clipper; - clipper.Begin(static_cast(skinFiltered.size())); - while (clipper.Step()) - for (int r = clipper.DisplayStart; r < clipper.DisplayEnd; r++) - { + UI::BeginChild("##skinlist", 340, 220); + UI::ClippedList(static_cast(skinFiltered.size()), [&](int r) { const std::string& name = classes[skinFiltered[r]].name; - if (ImGui::Selectable(name.c_str(), name == skinSelected)) skinSelected = name; - } - ImGui::EndChild(); - ImGui::EndCombo(); + if (UI::Selectable(name.c_str(), name == skinSelected)) skinSelected = name; + }); + UI::EndChild(); + UI::EndCombo(); } - ImGui::SameLine(); - if (ImGui::SmallButton("Refresh##skin")) ClassCache::Rebuild(); + UI::SameLine(); + if (UI::SmallButton("Refresh##skin")) ClassCache::Rebuild(); - ImGui::SameLine(); - if (!isInGame || skinSelected.empty()) ImGui::BeginDisabled(); - if (ImGui::Button("Apply skin") && isInGame && !skinSelected.empty() && Engine::PlayerController) + UI::SameLine(); + UI::BeginDisabled(!isInGame || skinSelected.empty()); + if (UI::Button("Apply skin") && isInGame && !skinSelected.empty() && Engine::PlayerController) { UObject* cls = Engine::GObjects->FindObject(skinSelected.c_str()); auto* character = reinterpret_cast(Engine::PlayerController->Character); @@ -301,12 +294,12 @@ namespace Menu else Logger::Log("ERROR", "Apply skin: class not found: " + skinSelected); } - if (!isInGame || skinSelected.empty()) ImGui::EndDisabled(); - ImGui::Tooltip("Pick a skin class, then apply it to your character, gun or jetpack.\nClient-side (sets the *SkinClass + UpdateSkins); the server may re-assert your real skins. Refresh rescans classes."); + UI::EndDisabled(); + UI::Tooltip("Pick a skin class, then apply it to your character, gun or jetpack.\nClient-side (sets the *SkinClass + UpdateSkins); the server may re-assert your real skins. Refresh rescans classes."); // Apply the selected class to the gun / jetpack too (they use their own skin types). - if (!isInGame || skinSelected.empty()) ImGui::BeginDisabled(); - if (ImGui::Button("Apply gun skin") && isInGame && !skinSelected.empty() && Engine::PlayerController) + UI::BeginDisabled(!isInGame || skinSelected.empty()); + if (UI::Button("Apply gun skin") && isInGame && !skinSelected.empty() && Engine::PlayerController) { UObject* cls = Engine::GObjects->FindObject(skinSelected.c_str()); auto* character = reinterpret_cast(Engine::PlayerController->Character); @@ -317,8 +310,8 @@ namespace Menu Logger::Log("SUCCESS", "Applied gun skin: " + skinSelected); } } - ImGui::SameLine(); - if (ImGui::Button("Apply jetpack skin") && isInGame && !skinSelected.empty() && Engine::PlayerController) + UI::SameLine(); + if (UI::Button("Apply jetpack skin") && isInGame && !skinSelected.empty() && Engine::PlayerController) { UObject* cls = Engine::GObjects->FindObject(skinSelected.c_str()); auto* character = reinterpret_cast(Engine::PlayerController->Character); @@ -329,22 +322,22 @@ namespace Menu Logger::Log("SUCCESS", "Applied jetpack skin: " + skinSelected); } } - if (!isInGame || skinSelected.empty()) ImGui::EndDisabled(); + UI::EndDisabled(); } - ImGui::SeparatorText("Program"); - if (ImGui::Button("Toggle Console")) + + UI::SeparatorText("Program"); + if (UI::Button("Toggle Console")) { Settings.MISC.ShowConsole = !Settings.MISC.ShowConsole; Logger::SetConsoleVisibility(Settings.MISC.ShowConsole); } - ImGui::SameLine(); - if (ImGui::Button("Unload")) Hook::RequestUnload(); + UI::SameLine(); + if (UI::Button("Unload")) Hook::RequestUnload(); - if (ImGui::ToggleButton("Announce toggles in chat", &Settings.MISC.AnnounceToggles)) + if (UI::Toggle("Announce toggles in chat", &Settings.MISC.AnnounceToggles)) Events::Dispatch(Events::Type::SettingsChanged); - ImGui::Tooltip("Post a local (client-only) chat line when you toggle a feature, e.g. \"[ESP] Enabled\".\nShown only to you, not sent to the server."); - + UI::Tooltip("Post a local (client-only) chat line when you toggle a feature, e.g. \"[ESP] Enabled\".\nShown only to you, not sent to the server."); } } // namespace Sections } // namespace Menu \ No newline at end of file diff --git a/Internal/menu/sections/Network.h b/Internal/menu/sections/Network.h index c08d8ca..1166f44 100644 --- a/Internal/menu/sections/Network.h +++ b/Internal/menu/sections/Network.h @@ -11,6 +11,7 @@ #include "../../settings/Settings.h" #include "../../scripting/Events.h" #include "../../../shared/LauncherSettings.h" // Shared::LauncherSettings (launcher.settings) +#include "../ui/UI.h" namespace Menu { @@ -38,26 +39,27 @@ namespace Menu /// SettingsChanged on any change. void NetworkTab() { + bool changed = false; - ImGui::SeparatorText("Backend redirect"); - ImGui::TextUnformatted("Proxy mode"); - ImGui::Tooltip("Internal: the DLL redirects in-process. Mitmproxy: the launcher spawns\nmitmproxy at startup (takes effect next launch). Manual: do nothing."); + UI::SeparatorText("Backend redirect"); + UI::Text("Proxy mode"); + UI::Tooltip("Internal: the DLL redirects in-process. Mitmproxy: the launcher spawns\nmitmproxy at startup (takes effect next launch). Manual: do nothing."); int mode = static_cast(Settings.NETWORK.Proxy); - changed |= ImGui::RadioButton("Manual", &mode, static_cast(ProxyMode::Manual)); - ImGui::SameLine(); - changed |= ImGui::RadioButton("Internal", &mode, static_cast(ProxyMode::Internal)); - ImGui::SameLine(); - changed |= ImGui::RadioButton("Mitmproxy", &mode, static_cast(ProxyMode::Mitmproxy)); + changed |= UI::RadioButton("Manual", &mode, static_cast(ProxyMode::Manual)); + UI::SameLine(); + changed |= UI::RadioButton("Internal", &mode, static_cast(ProxyMode::Internal)); + UI::SameLine(); + changed |= UI::RadioButton("Mitmproxy", &mode, static_cast(ProxyMode::Mitmproxy)); Settings.NETWORK.Proxy = static_cast(mode); // Existing redirects, each with a remove button. std::string toRemove; for (const auto& [original, target] : Settings.NETWORK.Redirects) { - ImGui::BulletText("%s -> %s", original.c_str(), target.c_str()); - ImGui::SameLine(); - if (ImGui::SmallButton(("X##" + original).c_str())) toRemove = original; + UI::BulletText("%s -> %s", original.c_str(), target.c_str()); + UI::SameLine(); + if (UI::SmallButton(("X##" + original).c_str())) toRemove = original; } if (!toRemove.empty()) { @@ -68,9 +70,9 @@ namespace Menu // Add a new original -> target ("host" or "host:port") mapping. static char originalBuffer[128] = ""; static char targetBuffer[128] = ""; - ImGui::InputText("Original host", originalBuffer, sizeof(originalBuffer)); - ImGui::InputText("Target host[:port]", targetBuffer, sizeof(targetBuffer)); - if (ImGui::Button("Add redirect") && originalBuffer[0] && targetBuffer[0]) + UI::InputText("Original host", originalBuffer, sizeof(originalBuffer)); + UI::InputText("Target host[:port]", targetBuffer, sizeof(targetBuffer)); + if (UI::Button("Add redirect") && originalBuffer[0] && targetBuffer[0]) { Settings.NETWORK.Redirects[originalBuffer] = targetBuffer; originalBuffer[0] = '\0'; @@ -78,15 +80,15 @@ namespace Menu changed = true; } - changed |= ImGui::ToggleButton("Bypass SSL verification", &Settings.NETWORK.BypassSslVerify); - ImGui::Tooltip("Force curl's cert/host verification off so a redirected host can serve a self-signed cert.\nDisables TLS verification for ALL curl traffic while on."); + UI::ToggleSetting("Bypass SSL verification", &Settings.NETWORK.BypassSslVerify); + UI::Tooltip("Force curl's cert/host verification off so a redirected host can serve a self-signed cert.\nDisables TLS verification for ALL curl traffic while on."); - // Mitmproxy script — a launcher-only setting (launcher.settings), so it lives outside - // the DLL's SETTINGS. Only relevant when the launcher will spawn mitmproxy. + // Mitmproxy script — a launcher-only setting (launcher.settings), so it lives outside + // the DLL's SETTINGS. Only relevant when the launcher will spawn mitmproxy. if (Settings.NETWORK.Proxy == ProxyMode::Mitmproxy) { - ImGui::SeparatorText("Mitmproxy script"); - ImGui::Tooltip("How the launcher starts mitmdump (saved to launcher.settings, applied next launch).\n" + UI::SeparatorText("Mitmproxy script"); + UI::Tooltip("How the launcher starts mitmdump (saved to launcher.settings, applied next launch).\n" "Default: a generated addon (redirects above + TLS passthrough).\n" "Path: mitmdump -s . Inline: your python, run as the addon."); @@ -113,16 +115,16 @@ namespace Menu bool launcherChanged = false; int scriptMode = static_cast(mitm.ScriptMode); - launcherChanged |= ImGui::RadioButton("Default##mitm", &scriptMode, static_cast(Shared::MitmScriptMode::Default)); - ImGui::SameLine(); - launcherChanged |= ImGui::RadioButton("Path##mitm", &scriptMode, static_cast(Shared::MitmScriptMode::Path)); - ImGui::SameLine(); - launcherChanged |= ImGui::RadioButton("Inline##mitm", &scriptMode, static_cast(Shared::MitmScriptMode::Inline)); + launcherChanged |= UI::RadioButton("Default##mitm", &scriptMode, static_cast(Shared::MitmScriptMode::Default)); + UI::SameLine(); + launcherChanged |= UI::RadioButton("Path##mitm", &scriptMode, static_cast(Shared::MitmScriptMode::Path)); + UI::SameLine(); + launcherChanged |= UI::RadioButton("Inline##mitm", &scriptMode, static_cast(Shared::MitmScriptMode::Inline)); mitm.ScriptMode = static_cast(scriptMode); if (mitm.ScriptMode == Shared::MitmScriptMode::Path) { - if (ImGui::InputText("Script path", pathBuffer, sizeof(pathBuffer))) + if (UI::InputText("Script path", pathBuffer, sizeof(pathBuffer))) { mitm.ScriptPath = pathBuffer; launcherChanged = true; @@ -130,7 +132,7 @@ namespace Menu } else if (mitm.ScriptMode == Shared::MitmScriptMode::Inline) { - if (ImGui::InputTextMultiline("Inline python", inlineBuffer, sizeof(inlineBuffer), ImVec2(0, 160))) + if (UI::InputTextMultiline("Inline python", inlineBuffer, sizeof(inlineBuffer), 160)) { mitm.InlineScript = inlineBuffer; launcherChanged = true; @@ -138,31 +140,31 @@ namespace Menu } else { - ImGui::TextDisabled("Runs the bundled scripts/default_proxy.py (redirects above + TLS passthrough)."); + UI::TextDisabled("Runs the bundled scripts/default_proxy.py (redirects above + TLS passthrough)."); } - launcherChanged |= ImGui::ToggleButton("Show mitmproxy window", &mitm.ShowConsole); + launcherChanged |= UI::Toggle("Show mitmproxy window", &mitm.ShowConsole); if (launcherChanged) LauncherConfigFile().Save(); } - ImGui::SeparatorText("HTTP logging"); - changed |= ImGui::ToggleButton("Log HTTP calls", &Settings.NETWORK.HttpLogging); - changed |= ImGui::ToggleButton("Also log to http.log", &Settings.NETWORK.HttpLogToFile); - changed |= ImGui::ToggleButton("Redirected hosts only", &Settings.NETWORK.HttpLogRedirectedOnly); + UI::SeparatorText("HTTP logging"); + UI::ToggleSetting("Log HTTP calls", &Settings.NETWORK.HttpLogging); + UI::ToggleSetting("Also log to http.log", &Settings.NETWORK.HttpLogToFile); + UI::ToggleSetting("Redirected hosts only", &Settings.NETWORK.HttpLogRedirectedOnly); // Live request flow — populated while HTTP logging is on. - if (ImGui::CollapsingHeader("Request flow")) + if (UI::CollapsingHeader("Request flow")) { - if (ImGui::SmallButton("Clear")) Network::Http::Clear(); + if (UI::SmallButton("Clear")) Network::Http::Clear(); const auto requests = Network::Http::Recent(); - ImGui::BeginChild("RequestFlow", ImVec2(0, 200), true, ImGuiWindowFlags_HorizontalScrollbar); + UI::BeginChild("RequestFlow", 0, 200); if (requests.empty() && !Settings.NETWORK.HttpLogging) - ImGui::TextDisabled("Enable \"Log HTTP calls\" to capture requests."); + UI::TextDisabled("Enable \"Log HTTP calls\" to capture requests."); for (const auto& request : requests) - ImGui::TextUnformatted(request.c_str()); - ImGui::EndChild(); + UI::Text("%s", request.c_str()); + UI::EndChild(); } if (changed) Events::Dispatch(Events::Type::SettingsChanged); diff --git a/Internal/menu/sections/Scripts.h b/Internal/menu/sections/Scripts.h index f3c4883..5f6b6ba 100644 --- a/Internal/menu/sections/Scripts.h +++ b/Internal/menu/sections/Scripts.h @@ -9,6 +9,7 @@ #include "../../settings/Settings.h" #include "../../scripting/Scripts.h" #include "../../scripting/Events.h" +#include "../ui/UI.h" namespace Menu { @@ -17,35 +18,36 @@ namespace Menu /// @brief Renders the Scripts tab. void ScriptsTab() { - ImGui::SeparatorText("User Scripts"); - ImGui::Tooltip("Python scripts from Documents\\SplitgateInternal\\UserScripts. See docs/scripting.md."); + UI::SeparatorText("User Scripts"); + UI::Tooltip("Python scripts from Documents\\SplitgateInternal\\UserScripts. See docs/scripting.md."); - if (ImGui::ToggleButton("Enable", &Settings.MISC.UserScriptsEnabled)) + if (UI::Toggle("Enable", &Settings.MISC.UserScriptsEnabled)) Events::Dispatch(Events::Type::SettingsChanged); - ImGui::SameLine(); - if (ImGui::Button("Reload")) Scripts::Reload(); - ImGui::Tooltip("Re-scan the UserScripts folder and re-import every script (edits take effect\n" - "without a relaunch). Previously-registered script event handlers are cleared\n" - "first, so reloading doesn't stack duplicates."); + UI::SameLine(); + if (UI::Button("Reload")) Scripts::Reload(); + UI::Tooltip("Re-scan the UserScripts folder and re-import every script (edits take effect\n" + "without a relaunch). Previously-registered script event handlers are cleared\n" + "first, so reloading doesn't stack duplicates."); + + UI::SeparatorText("Loaded Scripts"); - ImGui::SeparatorText("Loaded Scripts"); if (Scripts::scriptList.empty()) { - ImGui::TextDisabled("No scripts found. Drop a .py with a main() into the UserScripts folder, then Reload."); + UI::TextDisabled("No scripts found. Drop a .py with a main() into the UserScripts folder, then Reload."); return; } std::string toRun; - ImGui::BeginChild("ScriptList", ImVec2(0, 200), true); + UI::BeginChild("ScriptList", 0, 200); for (const auto& script : Scripts::scriptList) { - ImGui::PushID(script.c_str()); - if (ImGui::SmallButton("Run")) toRun = script; - ImGui::SameLine(); - ImGui::TextUnformatted(script.c_str()); - ImGui::PopID(); + UI::PushID(script.c_str()); + if (UI::SmallButton("Run")) toRun = script; + UI::SameLine(); + UI::Text("%s", script.c_str()); + UI::PopID(); } - ImGui::EndChild(); + UI::EndChild(); if (!toRun.empty()) Scripts::ExecuteUnloaded(toRun); } } // namespace Sections diff --git a/Internal/menu/sections/Sdk.h b/Internal/menu/sections/Sdk.h index 4706e13..d40ee68 100644 --- a/Internal/menu/sections/Sdk.h +++ b/Internal/menu/sections/Sdk.h @@ -24,6 +24,7 @@ #include "../../cache/NameCache.h" #include "../../ue/Engine.h" #include "../../utils/Logger.h" +#include "../ui/UI.h" namespace Menu { @@ -40,16 +41,17 @@ namespace Menu /// @brief Renders the SDK tab. void SdkTab() { - ImGui::SeparatorText("Object search"); - ImGui::Tooltip("Scan every GObject and list those whose full name contains the text.\nOn demand (a full walk, like Dump GObjects)."); + + UI::SeparatorText("Object search"); + UI::Tooltip("Scan every GObject and list those whose full name contains the text.\nOn demand (a full walk, like Dump GObjects)."); static char nameFilter[128] = ""; static std::vector nameResults; static int nameTotal = 0; - ImGui::SetNextItemWidth(260.f); - ImGui::InputText("##namefilter", nameFilter, sizeof(nameFilter)); - ImGui::SameLine(); - if (ImGui::Button("Search names") && nameFilter[0]) + UI::SetNextItemWidth(260.f); + UI::InputText("##namefilter", nameFilter, sizeof(nameFilter)); + UI::SameLine(); + if (UI::Button("Search names") && nameFilter[0]) { nameResults.clear(); nameTotal = 0; @@ -67,50 +69,46 @@ namespace Menu } Logger::Log("INFO", std::format("[SDK] {} objects match \"{}\" ({} shown)", nameTotal, needle, nameResults.size())); } - ImGui::SameLine(); - if (ImGui::Button("Copy##names")) + UI::SameLine(); + if (UI::Button("Copy##names")) { std::string out; - for (const auto& row : nameResults) out += std::format("[{}] {}\n", row.index, row.name); - ImGui::SetClipboardText(out.c_str()); + for (const auto& row : nameResults) + out += std::format("[{}] {}\n", row.index, row.name); + UI::SetClipboardText(out.c_str()); } - ImGui::Tooltip("Copy the listed results to the clipboard."); + UI::Tooltip("Copy the listed results to the clipboard."); if (!nameResults.empty()) { - ImGui::Text("%d match(es)%s", nameTotal, nameTotal > (int)nameResults.size() ? " (first 1000)" : ""); - ImGui::BeginChild("NameResults", ImVec2(0, 180), true, ImGuiWindowFlags_HorizontalScrollbar); - // Only lay out the rows actually on screen (a fixed-height Text list), so a 1000-row - // result doesn't cost 1000 widgets every frame. - ImGuiListClipper clipper; - clipper.Begin(static_cast(nameResults.size())); - while (clipper.Step()) - for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) - ImGui::Text("[%d] %s", nameResults[i].index, nameResults[i].name.c_str()); - ImGui::EndChild(); + UI::Text("%d match(es)%s", nameTotal, nameTotal > (int)nameResults.size() ? " (first 1000)" : ""); + UI::BeginChild("NameResults", 0, 180); + UI::ClippedList(static_cast(nameResults.size()), [&](int i) + { UI::Text("[%d] %s", nameResults[i].index, nameResults[i].name.c_str()); }); + UI::EndChild(); } - ImGui::SeparatorText("Class search"); - ImGui::Tooltip("Filter the cached list of every class (shared with the Misc spawn picker; built\nonce, Refresh to rebuild). Click a row to copy the exact name."); + UI::SeparatorText("Class search"); + UI::Tooltip("Filter the cached list of every class (shared with the Misc spawn picker; built\nonce, Refresh to rebuild). Click a row to copy the exact name."); static char classFilter[128] = ""; static std::vector classFiltered; static std::string lastClassKey = "\x01"; // sentinel: forces the first filter build static size_t lastClassCacheSize = SIZE_MAX; // re-filter when the cache is rebuilt - ImGui::SetNextItemWidth(260.f); - ImGui::InputText("##classfilter", classFilter, sizeof(classFilter)); - ImGui::SameLine(); - if (ImGui::Button("Refresh##classes")) ClassCache::Rebuild(); - ImGui::SameLine(); - if (ImGui::Button("Copy##classes")) + UI::SetNextItemWidth(260.f); + UI::InputText("##classfilter", classFilter, sizeof(classFilter)); + UI::SameLine(); + if (UI::Button("Refresh##classes")) ClassCache::Rebuild(); + UI::SameLine(); + if (UI::Button("Copy##classes")) { std::string out; const std::string needle = classFilter; for (const auto& entry : ClassCache::Get()) if (needle.empty() || entry.name.find(needle) != std::string::npos) out += entry.name + "\n"; - ImGui::SetClipboardText(out.c_str()); + UI::SetClipboardText(out.c_str()); } - ImGui::Tooltip("Copy the filtered class names to the clipboard."); + UI::Tooltip("Copy the filtered class names to the clipboard."); { const auto& classes = ClassCache::Get(); if (classFilter != lastClassKey || classes.size() != lastClassCacheSize) @@ -124,40 +122,37 @@ namespace Menu classFiltered.push_back(i); } - ImGui::Text("%d / %d classes", static_cast(classFiltered.size()), static_cast(classes.size())); - ImGui::BeginChild("ClassResults", ImVec2(0, 160), true, ImGuiWindowFlags_HorizontalScrollbar); - ImGuiListClipper clipper; - clipper.Begin(static_cast(classFiltered.size())); - while (clipper.Step()) - for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) - { + UI::Text("%d / %d classes", static_cast(classFiltered.size()), static_cast(classes.size())); + UI::BeginChild("ClassResults", 0, 160); + UI::ClippedList(static_cast(classFiltered.size()), [&](int i) + { const std::string& name = classes[classFiltered[i]].name; - if (ImGui::Selectable(name.c_str())) ImGui::SetClipboardText(name.c_str()); - } - ImGui::EndChild(); + if (UI::Selectable(name.c_str())) UI::SetClipboardText(name.c_str()); + }); + UI::EndChild(); } - ImGui::SeparatorText("Name pool (FNames)"); - ImGui::Tooltip("Search every interned FName, including names for content that isn't loaded\n(e.g. map/level names to travel to). Built once, Refresh to rebuild. Click a row to copy."); + UI::SeparatorText("Name pool (FNames)"); + UI::Tooltip("Search every interned FName, including names for content that isn't loaded\n(e.g. map/level names to travel to). Built once, Refresh to rebuild. Click a row to copy."); { static char nameFilter2[128] = ""; static bool caseSensitive = false; // default: case-insensitive static bool useRegex = false; // plain substring by default static std::vector nameFiltered; - static std::string regexError; // last regex compile error, shown when useRegex - static std::string lastNameKey = "\x01"; // sentinel: forces the first filter build + static std::string regexError; // last regex compile error, shown when useRegex + static std::string lastNameKey = "\x01"; // sentinel: forces the first filter build static size_t lastNameCacheSize = SIZE_MAX; // re-filter when the cache is rebuilt static bool lastCase = false, lastRegex = false; - ImGui::SetNextItemWidth(260.f); - ImGui::InputText("##namepoolfilter", nameFilter2, sizeof(nameFilter2)); - ImGui::SameLine(); - if (ImGui::Button("Refresh##names")) NameCache::Rebuild(); + UI::SetNextItemWidth(260.f); + UI::InputText("##namepoolfilter", nameFilter2, sizeof(nameFilter2)); + UI::SameLine(); + if (UI::Button("Refresh##names")) NameCache::Rebuild(); - ImGui::Checkbox("Case sensitive##names", &caseSensitive); - ImGui::SameLine(); - ImGui::Checkbox("Regex##names", &useRegex); - ImGui::Tooltip("ECMAScript regex, matched as a search (unanchored, so a bare pattern behaves like\n\"contains\"). Anchor with ^ and $ to constrain: ^/Game/Maps/[^/]+$ matches a map\npackage but not the assets nested under it."); + UI::Checkbox("Case sensitive##names", &caseSensitive); + UI::SameLine(); + UI::Checkbox("Regex##names", &useRegex); + UI::Tooltip("ECMAScript regex, matched as a search (unanchored, so a bare pattern behaves like\n\"contains\"). Anchor with ^ and $ to constrain: ^/Game/Maps/[^/]+$ matches a map\npackage but not the assets nested under it."); // Re-filter only when an input changes (text, cache, or a toggle), not every frame. const auto& allNames = NameCache::Get(); @@ -173,7 +168,8 @@ namespace Menu const std::string needle = nameFilter2; if (needle.empty()) { - for (int i = 0; i < static_cast(allNames.size()); i++) nameFiltered.push_back(i); + for (int i = 0; i < static_cast(allNames.size()); i++) + nameFiltered.push_back(i); } else if (useRegex) { @@ -207,41 +203,39 @@ namespace Menu } if (useRegex && !regexError.empty()) - ImGui::TextColored(ImVec4(1.f, 0.4f, 0.4f, 1.f), "regex error: %s", regexError.c_str()); + UI::Text("regex error: %s", regexError.c_str()); - if (ImGui::Button("Copy##namepool")) + if (UI::Button("Copy##namepool")) { std::string out; - for (int idx : nameFiltered) out += allNames[idx] + "\n"; - ImGui::SetClipboardText(out.c_str()); + for (int idx : nameFiltered) + out += allNames[idx] + "\n"; + UI::SetClipboardText(out.c_str()); } - ImGui::Tooltip("Copy the filtered names to the clipboard."); - ImGui::SameLine(); - ImGui::Text("%d / %d names", static_cast(nameFiltered.size()), static_cast(allNames.size())); - - ImGui::BeginChild("NamePoolResults", ImVec2(0, 160), true, ImGuiWindowFlags_HorizontalScrollbar); - ImGuiListClipper clipper; - clipper.Begin(static_cast(nameFiltered.size())); - while (clipper.Step()) - for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) - { + UI::Tooltip("Copy the filtered names to the clipboard."); + UI::SameLine(); + UI::Text("%d / %d names", static_cast(nameFiltered.size()), static_cast(allNames.size())); + + UI::BeginChild("NamePoolResults", 0, 160); + UI::ClippedList(static_cast(nameFiltered.size()), [&](int i) + { const std::string& name = allNames[nameFiltered[i]]; - if (ImGui::Selectable(name.c_str())) ImGui::SetClipboardText(name.c_str()); - } - ImGui::EndChild(); + if (UI::Selectable(name.c_str())) UI::SetClipboardText(name.c_str()); + }); + UI::EndChild(); } - ImGui::SeparatorText("Class instances"); - ImGui::Tooltip("Resolve a class by name, then list its live instances (IsA).\nAccepts a full name (\"Class PortalWars.PortalWarsCharacter\") or a bare class name."); + UI::SeparatorText("Class instances"); + UI::Tooltip("Resolve a class by name, then list its live instances (IsA).\nAccepts a full name (\"Class PortalWars.PortalWarsCharacter\") or a bare class name."); static char className[128] = ""; static std::vector instanceResults; static int instanceTotal = 0; static std::string classStatus; - ImGui::SetNextItemWidth(260.f); - ImGui::InputText("##classname", className, sizeof(className)); - ImGui::SameLine(); - if (ImGui::Button("List instances") && className[0]) + UI::SetNextItemWidth(260.f); + UI::InputText("##classname", className, sizeof(className)); + UI::SameLine(); + if (UI::Button("List instances") && className[0]) { instanceResults.clear(); instanceTotal = 0; @@ -270,28 +264,26 @@ namespace Menu } Logger::Log("INFO", "[SDK] " + classStatus); } - ImGui::SameLine(); - if (ImGui::Button("Copy##instances")) + UI::SameLine(); + if (UI::Button("Copy##instances")) { std::string out; - for (const auto& row : instanceResults) out += std::format("[{}] 0x{:x} {}\n", row.index, row.address, row.name); - ImGui::SetClipboardText(out.c_str()); + for (const auto& row : instanceResults) + out += std::format("[{}] 0x{:x} {}\n", row.index, row.address, row.name); + UI::SetClipboardText(out.c_str()); } - ImGui::Tooltip("Copy the listed instances (index, address, name) to the clipboard."); - if (!classStatus.empty()) ImGui::TextUnformatted(classStatus.c_str()); + UI::Tooltip("Copy the listed instances (index, address, name) to the clipboard."); + if (!classStatus.empty()) UI::Text("%s", classStatus.c_str()); if (!instanceResults.empty()) { - ImGui::BeginChild("InstanceResults", ImVec2(0, 180), true, ImGuiWindowFlags_HorizontalScrollbar); - ImGuiListClipper clipper; - clipper.Begin(static_cast(instanceResults.size())); - while (clipper.Step()) - for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) - ImGui::Text("[%d] 0x%llx %s", instanceResults[i].index, static_cast(instanceResults[i].address), instanceResults[i].name.c_str()); - ImGui::EndChild(); + UI::BeginChild("InstanceResults", 0, 180); + UI::ClippedList(static_cast(instanceResults.size()), [&](int i) + { UI::Text("[%d] 0x%llx %s", instanceResults[i].index, static_cast(instanceResults[i].address), instanceResults[i].name.c_str()); }); + UI::EndChild(); } - ImGui::SeparatorText("Dump"); - if (ImGui::Button("Dump GObjects")) + UI::SeparatorText("Dump"); + if (UI::Button("Dump GObjects")) { fs::path dumpsDir = Shared::AppDataPath(SettingsHelper::AppFolder) / "Dumps"; if (!fs::exists(dumpsDir)) fs::create_directories(dumpsDir); @@ -320,7 +312,34 @@ namespace Menu Logger::Log("SUCCESS", msg); if (Engine::PlayerController) Engine::PlayerController->SendChatMessage(FString(msg)); } - ImGui::Tooltip("Write every GObject (index + full name) to Dumps/GObjects.txt."); + UI::Tooltip("Write every GObject (index + full name) to Dumps/GObjects.txt."); + + UI::SameLine(); + if (UI::Button("Dump FName pool")) + { + fs::path dumpsDir = Shared::AppDataPath(SettingsHelper::AppFolder) / "Dumps"; + if (!fs::exists(dumpsDir)) fs::create_directories(dumpsDir); + + fs::path filePath = dumpsDir / "FNames.txt"; + std::ofstream file(filePath, std::ios::out | std::ios::trunc); + if (!file.is_open()) + { + char errorMsg[256]; + strerror_s(errorMsg, sizeof(errorMsg), errno); + Logger::Log("ERROR", std::format("Failed to open {} for writing: {}", filePath.string(), errorMsg)); + return; + } + + const auto& names = NameCache::Get(); + for (const auto& name : names) + file << name << '\n'; + file.close(); + + std::string msg = std::format("Dumped {} FNames to {}", names.size(), filePath.string()); + Logger::Log("SUCCESS", msg); + if (Engine::PlayerController) Engine::PlayerController->SendChatMessage(FString(msg)); + } + UI::Tooltip("Write the whole FName pool (every interned name, incl. not-yet-loaded content) to\nDumps/FNames.txt. Uses the cached pool — Refresh the Name pool list above to rescan first."); } } // namespace Sections } // namespace Menu diff --git a/Internal/menu/sections/Visuals.h b/Internal/menu/sections/Visuals.h index 9e59cb9..3367421 100644 --- a/Internal/menu/sections/Visuals.h +++ b/Internal/menu/sections/Visuals.h @@ -3,8 +3,12 @@ /// @file /// @brief Visuals tab: player ESP element toggles, radar, and per-element colors. +#include + #include "../../settings/Settings.h" #include "../../scripting/Events.h" +#include "../../cache/FontCache.h" +#include "../ui/UI.h" namespace Menu { @@ -19,114 +23,148 @@ namespace Menu bool changed = false; auto& v = Settings.VISUALS; - ImGui::SeparatorText("Renderer"); + UI::SeparatorText("Renderer"); const char* renderers[] = {"UE Canvas", "ImGui (faster)", "None", "External (streamproof)"}; int renderer = static_cast(Settings.MENU.Renderer); - if (ImGui::Combo("Draw with", &renderer, renderers, IM_ARRAYSIZE(renderers))) + if (UI::Combo("Draw with", &renderer, renderers, UI::Count(renderers))) { Settings.MENU.Renderer = static_cast(renderer); changed = true; } - ImGui::Tooltip("ImGui draws the overlay without a ProcessEvent per line/text - much faster for a busy ESP.\n" - "External draws the ESP, watermark, and everything the renderer produces into a separate\n" - "window hidden from screen capture (OBS, Game Bar); the menu stays on the game window."); - - ImGui::SeparatorText("Player ESP"); - changed |= ImGui::ToggleButton("Enable", &v.Esp); - - ImGui::SeparatorText("Elements"); - changed |= ImGui::ToggleButton("Name", &v.Name); - changed |= ImGui::ToggleButton("Box", &v.Box); - changed |= ImGui::ToggleButton("3D Box", &v.Box3D); - changed |= ImGui::ToggleButton("Bones", &v.Bones); - changed |= ImGui::ToggleButton("Snaplines", &v.Snaplines); - changed |= ImGui::ToggleButton("Health", &v.Health); - changed |= ImGui::ToggleButton("Distance", &v.Distance); - changed |= ImGui::ToggleButton("K/D", &v.KD); - ImGui::Tooltip("Draw each player's kills/deaths, and [killstreak] for the current life."); - changed |= ImGui::ToggleButton("Rank", &v.Rank); - ImGui::Tooltip("Draw each player's rank/level (from their player state)."); - - ImGui::SeparatorText("Range"); - changed |= ImGui::SliderFloat("Max distance (m)", &v.MaxDistance, 0.f, 300.f, v.MaxDistance <= 0.f ? "unlimited" : "%.0f"); - ImGui::Tooltip("Only draw enemies within this many metres. 0 = unlimited."); - - ImGui::SeparatorText("Visibility"); - changed |= ImGui::ToggleButton("Visibility check", &v.EspVisibleCheck); - ImGui::Tooltip("Recolor visible (recently-rendered) enemies in the Visible color below;\noccluded enemies keep the normal box/bone/snapline colors."); - changed |= ImGui::ToggleButton("Hide bots", &v.HideBots); - ImGui::Tooltip("Don't draw AI bots in the ESP at all."); - changed |= ImGui::ToggleButton("Bot tag", &v.BotTag); - ImGui::Tooltip("Prefix an AI bot's name with a colored \"[BOT]\" tag."); + UI::Tooltip("ImGui draws the overlay without a ProcessEvent per line/text - much faster for a busy ESP.\n" + "External draws the ESP, watermark, and everything the renderer produces into a separate\n" + "window hidden from screen capture (OBS, Game Bar); the menu stays on the game window."); + + // Font picker — only the UE-canvas renderer draws text through a UFont (ImGui uses its own + // atlas), so this applies to canvas-mode text (ESP + the canvas menu). Index 0 = Roboto default. + if (Settings.MENU.Renderer == RendererMode::Canvas) + { + const auto& faces = FontCache::Get(); + std::vector items{"Default (Roboto)"}; + int fontIdx = 0; + for (int i = 0; i < static_cast(faces.size()); i++) + { + items.push_back(faces[i].name.c_str()); + if (faces[i].name == Settings.VISUALS.CanvasFont) fontIdx = i + 1; + } + + UI::SetNextItemWidth(220.f); + if (UI::Combo("Canvas font", &fontIdx, items.data(), static_cast(items.size()))) + { + Settings.VISUALS.CanvasFont = fontIdx == 0 ? "" : faces[fontIdx - 1].name; + changed = true; + } + UI::SameLine(); + if (UI::SmallButton("Refresh##fonts")) FontCache::Rebuild(); + UI::Tooltip("Text font for the UE-canvas renderer (ESP + the canvas menu). Default is the engine Roboto.\nRefresh rescans GObjects for newly-loaded fonts."); + } + + const char* backends[] = {"ImGui", "UE Canvas"}; + int backend = static_cast(Settings.MENU.Backend); + if (UI::Combo("Menu backend", &backend, backends, UI::Count(backends))) + { + Settings.MENU.Backend = static_cast(backend); + changed = true; + } + UI::Tooltip("Which GUI engine draws THIS menu (independent of the ESP renderer above).\n" + "ImGui = the Present overlay; UE Canvas = drawn on the game canvas (works at the main menu too)."); + + UI::SeparatorText("Player ESP"); + UI::ToggleSetting("Enable", &v.Esp); + + UI::SeparatorText("Elements"); + UI::ToggleSetting("Name", &v.Name); + UI::ToggleSetting("Box", &v.Box); + UI::ToggleSetting("3D Box", &v.Box3D); + UI::ToggleSetting("Bones", &v.Bones); + UI::ToggleSetting("Snaplines", &v.Snaplines); + UI::ToggleSetting("Health", &v.Health); + UI::ToggleSetting("Distance", &v.Distance); + UI::ToggleSetting("K/D", &v.KD); + UI::Tooltip("Draw each player's kills/deaths, and [killstreak] for the current life."); + UI::ToggleSetting("Rank", &v.Rank); + UI::Tooltip("Draw each player's rank/level (from their player state)."); + + UI::SeparatorText("Range"); + changed |= UI::SliderFloat("Max distance (m)", &v.MaxDistance, 0.f, 300.f, v.MaxDistance <= 0.f ? "unlimited" : "%.0f"); + UI::Tooltip("Only draw enemies within this many metres. 0 = unlimited."); + + UI::SeparatorText("Visibility"); + UI::ToggleSetting("Visibility check", &v.EspVisibleCheck); + UI::Tooltip("Recolor visible (recently-rendered) enemies in the Visible color below;\noccluded enemies keep the normal box/bone/snapline colors."); + UI::ToggleSetting("Hide bots", &v.HideBots); + UI::Tooltip("Don't draw AI bots in the ESP at all."); + UI::ToggleSetting("Bot tag", &v.BotTag); + UI::Tooltip("Prefix an AI bot's name with a colored \"[BOT]\" tag."); if (v.BotTag) - ImGui::ColorEdit4("Bot tag color", &v.BotTagColor.R); + UI::ColorEdit("Bot tag color", &v.BotTagColor); - ImGui::SeparatorText("Teams"); - changed |= ImGui::ToggleButton("Show teammates", &v.ShowFriendly); - ImGui::Tooltip("Also draw teammates (ESP + radar), in the friendly color below."); + UI::SeparatorText("Teams"); + UI::ToggleSetting("Show teammates", &v.ShowFriendly); + UI::Tooltip("Also draw teammates (ESP + radar), in the friendly color below."); - ImGui::SeparatorText("Radar"); - changed |= ImGui::ToggleButton("Enable Radar", &v.Radar); - changed |= ImGui::ToggleButton("Radar teammates", &v.RadarShowFriendly); + UI::SeparatorText("Radar"); + UI::ToggleSetting("Enable Radar", &v.Radar); + UI::ToggleSetting("Radar teammates", &v.RadarShowFriendly); - ImGui::SeparatorText("Debug"); - changed |= ImGui::ToggleButton("Draw all object names", &v.DrawAllNames); - ImGui::Tooltip("Draws the UObject name of every actor in the world (not just players)."); + UI::SeparatorText("Debug"); + UI::ToggleSetting("Draw all object names", &v.DrawAllNames); + UI::Tooltip("Draws the UObject name of every actor in the world (not just players)."); - ImGui::SeparatorText("Text"); - changed |= ImGui::SliderFloat("Font size", &v.FontScale, 0.5f, 3.f, "%.2f"); + UI::SeparatorText("Text"); + changed |= UI::SliderFloat("Font size", &v.FontScale, 0.5f, 3.f, "%.2f"); - ImGui::SeparatorText("Crosshair"); - changed |= ImGui::ToggleButton("Crosshair", &v.Crosshair); + UI::SeparatorText("Crosshair"); + UI::ToggleSetting("Crosshair", &v.Crosshair); if (v.Crosshair) { - changed |= ImGui::SliderFloat("Size", &v.CrosshairSize, 1.f, 30.f, "%.0f"); - changed |= ImGui::SliderFloat("Gap", &v.CrosshairGap, 0.f, 20.f, "%.0f"); - changed |= ImGui::SliderFloat("Thickness", &v.CrosshairThickness, 1.f, 6.f, "%.0f"); - ImGui::ColorEdit4("Crosshair color", &v.CrosshairColor.R); - ImGui::Tooltip("Overridden by the RGB rainbow when RGB is on."); + changed |= UI::SliderFloat("Size", &v.CrosshairSize, 1.f, 30.f, "%.0f"); + changed |= UI::SliderFloat("Gap", &v.CrosshairGap, 0.f, 20.f, "%.0f"); + changed |= UI::SliderFloat("Thickness", &v.CrosshairThickness, 1.f, 6.f, "%.0f"); + UI::ColorEdit("Crosshair color", &v.CrosshairColor); + UI::Tooltip("Overridden by the RGB rainbow when RGB is on."); } - ImGui::SeparatorText("Bullet traces"); - changed |= ImGui::ToggleButton("Bullet traces", &v.BulletTraces); - ImGui::Tooltip("Draw a fading trail behind each projectile (PortalWars.Projectile and subclasses)."); + UI::SeparatorText("Bullet traces"); + UI::ToggleSetting("Bullet traces", &v.BulletTraces); + UI::Tooltip("Draw a fading trail behind each projectile (PortalWars.Projectile and subclasses)."); if (v.BulletTraces) { - changed |= ImGui::SliderFloat("Trail duration", &v.BulletTraceDuration, 0.5f, 6.f, "%.1fs"); - ImGui::ColorEdit4("Trail color", &v.BulletTraceColor.R); - ImGui::Tooltip("Overridden by the RGB rainbow when RGB is on."); + changed |= UI::SliderFloat("Trail duration", &v.BulletTraceDuration, 0.5f, 6.f, "%.1fs"); + UI::ColorEdit("Trail color", &v.BulletTraceColor); + UI::Tooltip("Overridden by the RGB rainbow when RGB is on."); } - ImGui::SeparatorText("Glow / chams"); - changed |= ImGui::ToggleButton("Glow enemies", &v.GlowEnemy); - ImGui::Tooltip("Force a custom-depth outline on enemies, visible through walls.\nRides on the game's team-outline post-process (verify color mapping in-game)."); + UI::SeparatorText("Glow / chams"); + UI::ToggleSetting("Glow enemies", &v.GlowEnemy); + UI::Tooltip("Force a custom-depth outline on enemies, visible through walls.\nRides on the game's team-outline post-process (verify color mapping in-game)."); if (v.GlowEnemy) { - ImGui::ColorEdit4("Enemy glow", &v.GlowEnemyColor.R); - ImGui::Tooltip("Overridden by the RGB rainbow when RGB is on."); + UI::ColorEdit("Enemy glow", &v.GlowEnemyColor); + UI::Tooltip("Overridden by the RGB rainbow when RGB is on."); } - changed |= ImGui::ToggleButton("Glow teammates", &v.GlowFriendly); + UI::ToggleSetting("Glow teammates", &v.GlowFriendly); if (v.GlowFriendly) { - ImGui::ColorEdit4("Teammate glow", &v.GlowFriendlyColor.R); - ImGui::Tooltip("Overridden by the RGB rainbow when RGB is on."); + UI::ColorEdit("Teammate glow", &v.GlowFriendlyColor); + UI::Tooltip("Overridden by the RGB rainbow when RGB is on."); } - changed |= ImGui::ToggleButton("Glow self", &v.GlowSelf); - ImGui::Tooltip("Outline your own pawn - only visible in third person."); + UI::ToggleSetting("Glow self", &v.GlowSelf); + UI::Tooltip("Outline your own pawn - only visible in third person."); if (v.GlowSelf) { - ImGui::ColorEdit4("Self glow", &v.GlowSelfColor.R); - ImGui::Tooltip("Overridden by the RGB rainbow when RGB is on."); + UI::ColorEdit("Self glow", &v.GlowSelfColor); + UI::Tooltip("Overridden by the RGB rainbow when RGB is on."); } - ImGui::SeparatorText("Colors"); - ImGui::ColorEdit4("Name", &v.NameColor.R); - ImGui::ColorEdit4("Box", &v.BoxColor.R); - ImGui::ColorEdit4("Bones", &v.BonesColor.R); - ImGui::ColorEdit4("Snaplines", &v.SnaplineColor.R); - ImGui::ColorEdit4("Friendly", &v.FriendColor.R); - ImGui::ColorEdit4("Visible", &v.VisibleColor.R); - ImGui::Tooltip("Color for visible enemies when the visibility check is on."); + UI::SeparatorText("Colors"); + UI::ColorEdit("Name", &v.NameColor); + UI::ColorEdit("Box", &v.BoxColor); + UI::ColorEdit("Bones", &v.BonesColor); + UI::ColorEdit("Snaplines", &v.SnaplineColor); + UI::ColorEdit("Friendly", &v.FriendColor); + UI::ColorEdit("Visible", &v.VisibleColor); + UI::Tooltip("Color for visible enemies when the visibility check is on."); if (changed) Events::Dispatch(Events::Type::SettingsChanged); } diff --git a/Internal/menu/ui/UI.h b/Internal/menu/ui/UI.h new file mode 100644 index 0000000..02ae3d7 --- /dev/null +++ b/Internal/menu/ui/UI.h @@ -0,0 +1,98 @@ +#pragma once + +/// @file +/// @brief Menu::UI — the backend-neutral widget facade the tab sections call. Every function is a +/// one-line forward to the active backend (Menu::active->*); this layer names no concrete backend and +/// includes neither ImGui nor UCanvasGUI. Switching or adding a backend never touches this file or the +/// sections. Value widgets return `bool changed` (true on the change frame). + +#include + +#include "../backend/Backend.h" +#include "../backend/Backends.h" +#include "../../scripting/Events.h" + +namespace Menu +{ + namespace UI + { + /// Element count of a C array (replaces ImGui's IM_ARRAYSIZE without depending on ImGui). + template + constexpr int Count(const T (&)[N]) + { + return static_cast(N); + } + + // --- Core widgets. --- + inline bool Toggle(const char* label, bool* v) { return active->Toggle(label, v); } + + /// Toggle that dispatches SettingsChanged (payload name=label, value=0/1) when flipped. + inline bool ToggleSetting(const char* label, bool* v) + { + if (!active->Toggle(label, v)) return false; + Events::Dispatch(Events::Type::SettingsChanged, Events::Payload{.value = *v ? 1.f : 0.f, .name = label}); + return true; + } + + inline bool Checkbox(const char* label, bool* v) { return active->Checkbox(label, v); } + inline bool Button(const char* label) { return active->Button(label); } + inline bool SmallButton(const char* label) { return active->SmallButton(label); } + inline bool RadioButton(const char* label, int* v, int value) { return active->RadioInt(label, v, value); } + inline bool SliderFloat(const char* label, float* v, float min, float max, const char* fmt = "%.0f") { return active->SliderFloat(label, v, min, max, fmt); } + inline bool SliderInt(const char* label, int* v, int min, int max) { return active->SliderInt(label, v, min, max); } + inline bool Combo(const char* label, int* v, const char* const* items, int count) { return active->Combo(label, v, items, count); } + inline bool ColorEdit(const char* label, ::Color* c) { return active->ColorEdit(label, c); } + inline bool HotKey(const char* label, int* key) { return active->HotKey(label, key); } + inline void SameLine() { active->SameLine(); } + inline void SeparatorText(const char* label) { active->SeparatorText(label); } + inline void Tooltip(const char* text) { active->Tooltip(text); } + + // --- Text (printf-style; the va_list is packed here and lowered to the backend's TextV). --- + inline void Text(const char* fmt, ...) + { + va_list args; + va_start(args, fmt); + active->TextV(fmt, args); + va_end(args); + } + inline void TextDisabled(const char* fmt, ...) + { + va_list args; + va_start(args, fmt); + active->TextDisabledV(fmt, args); + va_end(args); + } + inline void BulletText(const char* fmt, ...) + { + va_list args; + va_start(args, fmt); + active->BulletTextV(fmt, args); + va_end(args); + } + + // --- Layout / scope. --- + inline void SetNextItemWidth(float w) { active->SetNextItemWidth(w); } + inline void BeginDisabled(bool disabled) { active->BeginDisabled(disabled); } + inline void EndDisabled() { active->EndDisabled(); } + inline void PushID(const char* id) { active->PushID(id); } + inline void PopID() { active->PopID(); } + + // --- Rich widgets. --- + inline bool InputText(const char* label, char* buf, size_t size) { return active->InputText(label, buf, size); } + inline bool InputTextHint(const char* label, const char* hint, char* buf, size_t size) { return active->InputTextHint(label, hint, buf, size); } + inline bool InputTextMultiline(const char* label, char* buf, size_t size, float height) { return active->InputTextMultiline(label, buf, size, height); } + inline bool BeginChild(const char* id, float w, float h) { return active->BeginChild(id, w, h); } + inline void EndChild() { active->EndChild(); } + inline bool BeginCombo(const char* label, const char* preview) { return active->BeginCombo(label, preview); } + inline void EndCombo() { active->EndCombo(); } + inline bool Selectable(const char* label, bool selected = false) { return active->Selectable(label, selected); } + inline void ClippedList(int count, const std::function& drawRow) { active->ClippedList(count, drawRow); } + inline bool CollapsingHeader(const char* label) { return active->CollapsingHeader(label); } + inline bool TreeNode(const char* label) { return active->TreeNode(label); } + inline void TreePop() { active->TreePop(); } + + // --- Clipboard. --- + inline void SetClipboardText(const char* text) { active->SetClipboardText(text); } + inline const char* GetClipboardText() { return active->GetClipboardText(); } + } // namespace UI +} // namespace Menu diff --git a/Internal/network/WinHttpHook.h b/Internal/network/WinHttpHook.h index 4b41ac2..dc02974 100644 --- a/Internal/network/WinHttpHook.h +++ b/Internal/network/WinHttpHook.h @@ -32,7 +32,11 @@ namespace Network::WinHttp } inline std::string Narrow(const std::wstring& s) { - return std::string(s.begin(), s.end()); + std::string out; + out.reserve(s.size()); + for (wchar_t c : s) + out.push_back(static_cast(c)); // explicit ASCII narrowing (hosts are ASCII) + return out; } using Connect_t = HINTERNET(WINAPI*)(HINTERNET, LPCWSTR, INTERNET_PORT, DWORD); diff --git a/Internal/render/CanvasRenderer.h b/Internal/render/CanvasRenderer.h index 11072a1..7338d61 100644 --- a/Internal/render/CanvasRenderer.h +++ b/Internal/render/CanvasRenderer.h @@ -6,13 +6,31 @@ /// ImGuiRenderer exists. #include +#include #include "Renderer.h" #include "adapters/Ue.h" #include "../ue/Engine.h" +#include "../cache/FontCache.h" +#include "../settings/Settings.h" class CanvasRenderer : public Renderer { + UFont* font = nullptr; ///< resolved render font (null = the engine default / Roboto) + std::string fontName; ///< the Settings.VISUALS.CanvasFont value `font` was resolved from + + /// The UFont to draw/measure with, per Settings.VISUALS.CanvasFont. Re-resolves only when the + /// setting changes (so it's a cheap string compare per call, not a GObjects walk). Null = Roboto. + UFont* CurrentFont() + { + if (Settings.VISUALS.CanvasFont != fontName) + { + fontName = Settings.VISUALS.CanvasFont; + font = FontCache::Find(fontName); + } + return font; + } + public: void Line(const Render::Vec2& a, const Render::Vec2& b, float thickness, const Render::Color& color) override { @@ -22,19 +40,19 @@ class CanvasRenderer : public Renderer void Text(const Render::Vec2& pos, const std::string& text, float scale, const Render::Color& color, bool centered) override { if (Engine::Canvas) - Engine::Canvas->K2_DrawText(0, FString(text), pos.To(), {scale, scale}, color.To(), 1.f, {0.f, 0.f, 0.f, 0.f}, {0.f, 0.f}, centered, false, true, {0.f, 0.f, 0.f, 1.f}); + Engine::Canvas->K2_DrawText(CurrentFont(), FString(text), pos.To(), {scale, scale}, color.To(), 1.f, {0.f, 0.f, 0.f, 0.f}, {0.f, 0.f}, centered, false, true, {0.f, 0.f, 0.f, 1.f}); } Render::Vec2 TextSize(const std::string& text, float scale) override { if (!Engine::Canvas) return {0.f, 0.f}; - return Engine::Canvas->K2_TextSize(nullptr, FString(text), {scale, scale}); // null font -> Roboto fallback + return Engine::Canvas->K2_TextSize(CurrentFont(), FString(text), {scale, scale}); // null font -> Roboto fallback } Render::Vec2 StrLen(const std::string& text) override { if (!Engine::Canvas) return {0.f, 0.f}; - return Engine::Canvas->K2_StrLen(nullptr, FString(text)); + return Engine::Canvas->K2_StrLen(CurrentFont(), FString(text)); } void RectFilled(const Render::Vec2& min, const Render::Vec2& max, const Render::Color& color) override diff --git a/Internal/render/Colors.h b/Internal/render/Colors.h index 5f9f3d2..7fed28f 100644 --- a/Internal/render/Colors.h +++ b/Internal/render/Colors.h @@ -13,14 +13,16 @@ namespace Render /// The menu's cream/red theme palette. Reuse `.Alpha(a)` for a color at a different opacity. namespace Palette { - inline constexpr Color Primary{1.00f, 0.00f, 0.00f, 1.00f}; ///< the accent red (titles, active controls) - inline constexpr Color Text{0.40f, 0.39f, 0.38f, 1.00f}; ///< body text on the cream ground - inline constexpr Color Cream{0.92f, 0.91f, 0.88f, 1.00f}; ///< window background - inline constexpr Color Frame{1.00f, 0.98f, 0.95f, 1.00f}; ///< lighter frame/child/title background - inline constexpr Color Border{0.84f, 0.83f, 0.80f, 1.00f}; ///< border tint + inline constexpr Color Primary{1.00f, 0.00f, 0.00f, 1.00f}; ///< the accent red (titles, active controls) + inline constexpr Color Text{0.40f, 0.39f, 0.38f, 1.00f}; ///< body text on the cream ground + inline constexpr Color Background{0.92f, 0.91f, 0.88f, 1.00f}; ///< window background + inline constexpr Color Frame{1.00f, 0.98f, 0.95f, 1.00f}; ///< lighter frame/child/title background + inline constexpr Color Border{0.84f, 0.83f, 0.80f, 1.00f}; ///< border tint inline constexpr Color White{1.00f, 1.00f, 1.00f, 1.00f}; inline constexpr Color Black{0.00f, 0.00f, 0.00f, 1.00f}; - inline constexpr Color Blue{0.10f, 0.40f, 0.75f, 1.00f}; ///< separator/interaction accent - inline constexpr Color Slate{0.43f, 0.43f, 0.50f, 1.00f}; ///< neutral separator gray + inline constexpr Color Blue{0.10f, 0.40f, 0.75f, 1.00f}; ///< separator/interaction accent + inline constexpr Color Slate{0.43f, 0.43f, 0.50f, 1.00f}; ///< neutral separator gray + inline constexpr Color Success{0.00f, 0.80f, 0.28f, 1.00f}; ///< enabled/on state (e.g. checked toggles) + inline constexpr Color Muted{0.70f, 0.70f, 0.70f, 1.00f}; ///< muted gray (e.g. slider knob) } // namespace Palette } // namespace Render diff --git a/Internal/render/ImGuiRenderer.h b/Internal/render/ImGuiRenderer.h index 39890b0..f7c826a 100644 --- a/Internal/render/ImGuiRenderer.h +++ b/Internal/render/ImGuiRenderer.h @@ -68,6 +68,13 @@ class ImGuiRenderer : public Renderer std::mutex mtx; public: + /// A stable font used only for text measurement (Measure/TextSize/StrLen). Set once from the + /// game-window ImGui context's atlas after init; ImFont::CalcTextSizeA is a const, context-free + /// call, so measuring through this pointer never touches ImGui's global current-context (which the + /// streamproof overlay switches/destroys on another thread). Null until set — falls back to an + /// approximation. + ImFont* measureFont = nullptr; + void Line(const Render::Vec2& a, const Render::Vec2& b, float thickness, const Render::Color& color) override { std::lock_guard guard(mtx); @@ -82,10 +89,11 @@ class ImGuiRenderer : public Renderer Render::Vec2 TextSize(const std::string& text, float scale) override { - // GetFont() dereferences the current ImGui context, which can momentarily be null while the - // external overlay is switching/destroying its context on another thread — guard the pointer - // itself (GetCurrentContext is a plain read) before calling GetFont, or this crashes. - ImFont* font = ImGui::GetCurrentContext() ? ImGui::GetFont() : nullptr; + // Measure through the stable measureFont, never ImGui::GetFont(): features call this from + // PostRender (game thread) while the streamproof overlay switches/destroys ImGui's global + // current-context on the Present thread — reading GetFont() there raced and crashed. + // CalcTextSizeA is a const, context-free method on the font object, so this is race-free. + ImFont* font = measureFont; if (!font) return {text.length() * scale * 7.f, scale * 14.f}; const ImVec2 size = font->CalcTextSizeA(font->FontSize * scale, FLT_MAX, 0.f, text.c_str()); return {size.x, size.y}; diff --git a/Internal/scripting/Events.h b/Internal/scripting/Events.h index 5295cb7..d2dc417 100644 --- a/Internal/scripting/Events.h +++ b/Internal/scripting/Events.h @@ -31,6 +31,8 @@ namespace Events SettingsChanged, ///< a setting was changed in the menu MenuOpened, ///< the GUI was shown MenuClosed, ///< the GUI was hidden + EnteredGame, ///< the local player entered an active match (IsInGame false -> true) + EnteredLobby, ///< the local player left the match, back to lobby/menu (IsInGame true -> false) HotKeyPressed, ///< a key/mouse button went down while the game is focused; payload.value = vk code // Game events dispatched from ProcessEvent (payload.source = the calling UObject). diff --git a/Internal/settings/Settings.h b/Internal/settings/Settings.h index 131cd2b..928e7d6 100644 --- a/Internal/settings/Settings.h +++ b/Internal/settings/Settings.h @@ -52,6 +52,19 @@ NLOHMANN_JSON_SERIALIZE_ENUM(RendererMode, { {RendererMode::External, "external"}, }) +/// Which GUI engine draws the menu itself (independent of RendererMode, which is for the ESP/overlay). +/// ImGui draws in the Present hook; Canvas draws through the UE canvas (UCanvasGUI) in PostRender. +enum class MenuBackend +{ + ImGui, + Canvas, +}; + +NLOHMANN_JSON_SERIALIZE_ENUM(MenuBackend, { + {MenuBackend::ImGui, "imgui"}, + {MenuBackend::Canvas, "canvas"}, + }) + /// Menu appearance, the show/hide hotkey, and how the overlays are rendered. struct MenuSettings { @@ -61,9 +74,10 @@ struct MenuSettings int ShowHotkey = VK_INSERT; ///< virtual-key code toggling the GUI (default Insert) bool Rgb = false; ///< cycle the watermark, menu accent, and radar self-icon through a rainbow; off = their defaults (red / white) RendererMode Renderer = RendererMode::Canvas; ///< how the overlays are drawn (canvas / imgui / null / external) + MenuBackend Backend = MenuBackend::ImGui; ///< which GUI engine draws the menu (imgui / canvas) }; -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(MenuSettings, ShowMenu, ShowWatermark, ShowHotkey, Rgb, Renderer) +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(MenuSettings, ShowMenu, ShowWatermark, ShowHotkey, Rgb, Renderer, Backend) /// Gameplay feature toggles and tunables (the Exploits tab). /// Which camera the Camera feature drives. First person is the game default (no override). @@ -169,7 +183,8 @@ struct VisualsSettings bool RadarShowFriendly = false; ///< radar: also plot teammates, in FriendColor bool DrawAllNames = false; ///< debug: draw the object name of every actor (not just players) - float FontScale = 1.f; ///< text size for ESP names/distance (and the debug names) + float FontScale = 1.f; ///< text size for ESP names/distance (and the debug names) + std::string CanvasFont = ""; ///< UE-canvas renderer text font, by name (empty = the engine default / Roboto) Color NameColor{1.f, 1.f, 1.f, 1.f}; Color BoxColor{1.f, 0.f, 0.f, 1.f}; @@ -197,7 +212,7 @@ struct VisualsSettings Color GlowSelfColor{0.f, 1.f, 0.f, 1.f}; ///< own-pawn glow color (overridden by RGB when on) }; -NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(VisualsSettings, Esp, Name, Box, Box3D, Bones, Snaplines, Health, Distance, KD, Rank, MaxDistance, EspVisibleCheck, BotTag, HideBots, Radar, ShowFriendly, RadarShowFriendly, DrawAllNames, FontScale, NameColor, BoxColor, BonesColor, SnaplineColor, FriendColor, VisibleColor, BotTagColor, Crosshair, CrosshairSize, CrosshairGap, CrosshairThickness, CrosshairColor, BulletTraces, BulletTraceDuration, BulletTraceColor, GlowEnemy, GlowFriendly, GlowSelf, GlowEnemyColor, GlowFriendlyColor, GlowSelfColor) +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(VisualsSettings, Esp, Name, Box, Box3D, Bones, Snaplines, Health, Distance, KD, Rank, MaxDistance, EspVisibleCheck, BotTag, HideBots, Radar, ShowFriendly, RadarShowFriendly, DrawAllNames, FontScale, CanvasFont, NameColor, BoxColor, BonesColor, SnaplineColor, FriendColor, VisibleColor, BotTagColor, Crosshair, CrosshairSize, CrosshairGap, CrosshairThickness, CrosshairColor, BulletTraces, BulletTraceDuration, BulletTraceColor, GlowEnemy, GlowFriendly, GlowSelf, GlowEnemyColor, GlowFriendlyColor, GlowSelfColor) /// Aimbot / triggerbot tunables (the Aim tab). struct AimSettings diff --git a/Internal/utils/Input.h b/Internal/utils/Input.h index 92a60e2..f794feb 100644 --- a/Internal/utils/Input.h +++ b/Internal/utils/Input.h @@ -68,4 +68,79 @@ namespace Input prev[vk] = down; } } + + // --- Immediate-mode GUI input (used by the UE-canvas menu / UCanvasGUI). --- + // Sampled once per frame by Handle() and edge-detected per widget id, independent of the + // game-focus-gated Down()/Pressed() above (the canvas menu gates itself via its active-window + // check). Kept as a distinct per-element scheme so overlapping menu widgets each see their own + // click; not for feature hotkeys. + + inline bool mouseDown[5]; + inline bool mouseDownAlready[256]; + + inline bool keysDown[256]; + inline bool keysDownAlready[256]; + + inline bool IsAnyMouseDown() + { + if (mouseDown[0]) return true; + if (mouseDown[1]) return true; + if (mouseDown[2]) return true; + if (mouseDown[3]) return true; + if (mouseDown[4]) return true; + + return false; + } + + /// Rising-edge (or, with @p repeat, level) detection of button @p button for widget @p element_id. + inline bool IsMouseClicked(int button, int element_id, bool repeat) + { + if (mouseDown[button]) + { + if (!mouseDownAlready[element_id]) + { + mouseDownAlready[element_id] = true; + return true; + } + if (repeat) + return true; + } + else + { + mouseDownAlready[element_id] = false; + } + return false; + } + + inline bool IsKeyPressed(int key, bool repeat) + { + if (keysDown[key]) + { + if (!keysDownAlready[key]) + { + keysDownAlready[key] = true; + return true; + } + if (repeat) + return true; + } + else + { + keysDownAlready[key] = false; + } + return false; + } + + /// Sample every mouse button and key once per frame (high bit = currently down). + inline void Handle() + { + mouseDown[0] = (GetAsyncKeyState(VK_LBUTTON) & 0x8000) != 0; + mouseDown[1] = (GetAsyncKeyState(VK_RBUTTON) & 0x8000) != 0; + mouseDown[2] = (GetAsyncKeyState(VK_MBUTTON) & 0x8000) != 0; + mouseDown[3] = (GetAsyncKeyState(VK_XBUTTON1) & 0x8000) != 0; + mouseDown[4] = (GetAsyncKeyState(VK_XBUTTON2) & 0x8000) != 0; + + for (int i = 0; i < 256; i++) + keysDown[i] = (GetAsyncKeyState(i) & 0x8000) != 0; + } } // namespace Input diff --git a/Tests/FeaturesTests.cpp b/Tests/FeaturesTests.cpp index 5ccb771..3ca5482 100644 --- a/Tests/FeaturesTests.cpp +++ b/Tests/FeaturesTests.cpp @@ -10,6 +10,7 @@ #include +#include #include #include #include @@ -51,6 +52,47 @@ namespace } }; + /// A feature that overrides the event-only Run(event) overload, to test the runner forwards the + /// triggering event and the base delegates the (event, payload) call down to it. + struct EventFake : public Feature + { + int runs = 0; + Events::Type lastEvent = Events::Type::Render; + + EventFake() { Name = "EventFake"; } + void Init() override { Initialized = true; } + void UpdateEnabled() override {} + bool Check() override { return true; } + void Destroy() override {} + void Run(Events::Type event) override + { + runs++; + lastEvent = event; + } + }; + + /// A feature that overrides the full Run(event, payload) overload, to test both are forwarded. + struct PayloadFake : public Feature + { + int runs = 0; + Events::Type lastEvent = Events::Type::Render; + float lastValue = -1.f; + std::string lastName; + + PayloadFake() { Name = "PayloadFake"; } + void Init() override { Initialized = true; } + void UpdateEnabled() override {} + bool Check() override { return true; } + void Destroy() override {} + void Run(Events::Type event, const Events::Payload& payload) override + { + runs++; + lastEvent = event; + lastValue = payload.value; + lastName = payload.name ? payload.name : ""; + } + }; + /// Registers a new FakeFeature in the global registry and returns a non-owning pointer to it. FakeFeature* add(std::string name = "Fake") { @@ -218,7 +260,7 @@ namespace TEST_F(FeaturesTest, ExecuteSkipsEventDrivenFeatures) { FakeFeature* f = add(); - f->Event = Events::Type::Shutdown; + f->Triggers = {Events::Type::Shutdown}; f->Enabled = true; Features::Execute(); EXPECT_EQ(0, f->runCount); @@ -229,9 +271,9 @@ namespace TEST_F(FeaturesTest, EventFeatureRunsWhenDispatched) { FakeFeature f; - f.Event = Events::Type::Shutdown; + f.Triggers = {Events::Type::Shutdown}; f.Enabled = true; - Events::Register(f.Event, [&] + Events::Register(Events::Type::Shutdown, [&] { Features::RunFeature(f); }); Events::Dispatch(Events::Type::Render); @@ -241,4 +283,54 @@ namespace EXPECT_EQ(1, f.runCount); // fires on its event } + // A feature can list several triggers; Execute() drives it as long as Render is one of them. + TEST_F(FeaturesTest, ExecuteRunsWhenRenderIsAmongTriggers) + { + FakeFeature* f = add(); + f->Triggers = {Events::Type::Shutdown, Events::Type::Render}; + f->Enabled = true; + Features::Execute(); + EXPECT_EQ(1, f->runCount); + } + + // ThrottleMs caps how often Run() fires: a second immediate tick is skipped, and it runs again + // once the interval has elapsed (simulated by back-dating lastRun rather than sleeping). + TEST_F(FeaturesTest, ThrottleGatesRepeatedRuns) + { + FakeFeature* f = add(); + f->Enabled = true; + f->ThrottleMs = 10000; + + Features::Execute(); // first run stamps lastRun + Features::Execute(); // within the interval -> throttled + EXPECT_EQ(1, f->runCount); + + f->lastRun = std::chrono::steady_clock::now() - std::chrono::milliseconds(20000); // interval elapsed + Features::Execute(); + EXPECT_EQ(2, f->runCount); + } + + // The runner forwards the triggering event to a feature that overrides Run(event) (the base + // delegates its Run(event, payload) down to it). + TEST_F(FeaturesTest, ForwardsEventToRunOverload) + { + EventFake f; + f.Enabled = true; + Features::RunFeature(f, Events::Type::PlayerDeath); + EXPECT_EQ(1, f.runs); + EXPECT_EQ(Events::Type::PlayerDeath, f.lastEvent); + } + + // The runner forwards both the event and the payload to a feature that overrides the full overload. + TEST_F(FeaturesTest, ForwardsEventAndPayloadToRunOverload) + { + PayloadFake f; + f.Enabled = true; + Features::RunFeature(f, Events::Type::HotKeyPressed, Events::Payload{.value = 3.f, .name = "hello"}); + EXPECT_EQ(1, f.runs); + EXPECT_EQ(Events::Type::HotKeyPressed, f.lastEvent); + EXPECT_EQ(3.f, f.lastValue); + EXPECT_EQ("hello", f.lastName); + } + } // namespace diff --git a/docs/features.md b/docs/features.md index 3992208..af8db45 100644 --- a/docs/features.md +++ b/docs/features.md @@ -74,12 +74,21 @@ rather than every frame. It re-arms when disabled. ### Event-driven features -Every feature has an `Event` (an `Events::Type`, default `Render`). `Render` -features run from the per-frame `Features::Execute` loop. A feature with any -other `Event` is instead subscribed to the [event bus](scripting.md#events) in +Every feature has `Triggers` — a `std::vector`, default +`{Render}`. `Render` triggers run from the per-frame `Features::Execute` loop. +Every other trigger is subscribed to the [event bus](scripting.md#events) in `Features::Init` and driven by `Features::RunFeature` when that event is -dispatched — e.g. set `Event = Events::Type::PlayerDeath` to run a feature when -the player dies. Same toggle/Check/Run/Destroy contract, just a different clock. +dispatched. A feature may list **several** triggers (and may mix `Render` with +bus events) — e.g. `DiscordPresence` sets +`Triggers = {Events::Type::EnteredGame, Events::Type::EnteredLobby}` to refresh +only when the in-game/in-lobby state flips. Same toggle/Check/Run/Destroy +contract, just a different clock. + +The triggering event and its `Events::Payload` are forwarded to `Run()`. Override +whichever arity you need: `Run()` to ignore the trigger (the common case), +`Run(Events::Type event)` for the event only, or +`Run(Events::Type event, const Events::Payload& payload)` for both — the base +delegates down the chain, so existing nullary `Run()` overrides keep working. One-shot **actions** that don't need a toggle (like the "Load into map" button) are better as a plain event handler than a feature — see the `LoadIntoMap` diff --git a/docs/roadmap.md b/docs/roadmap.md index f478635..2a2e7fe 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -357,27 +357,36 @@ ESP blending looks right; focus/alt-tab/resize edge cases. ## Aim -### True (trace-redirect) silent aim — BLOCKED (needs the native fire/trace function) +### True (trace-redirect) silent aim — UNBLOCKED (reflected hit RPCs found in the dump) The current silent aim snaps the view on fire. A truly invisible one redirects the *shot*, not the -view: hook the fire/trace path and rewrite the trace start/direction (or the hit result) toward the -selected target before the original runs, leaving `ControlRotation` untouched. - -**Why it isn't built yet.** The SDK dump exposes no clean hook point: the shot's trace is computed in -the `Gun`'s **native** fire code, and the plausible reflected sources (`APlayerController::StartFire`, -`APawn::GetBaseAimRotation`) are called *natively*, so the `ProcessEvent` hook — which only sees -reflected/Blueprint calls (as `EnableAllInput` does) — never observes them. (A dump search confirms -this: the only reflected `Gun` fire-path entry is `Function PortalWars.Gun.ServerGoToState`, no -`Fire`/`HitScan`/`ProcessHit` UFunction.) Redirecting the shot therefore needs a **MinHook on the -native function**, which can't be found/verified blind. - -**Next step (one in-game pass unblocks it).** Enable **Debug → Log ProcessEvent** and fire: if *any* -reflected fire/hit event appears (e.g. a `Server*Fire` / `ProcessHit` / weapon-fire UFunction), hook -it via the event bus / a `ProcessEvent` intercept and rewrite its trace params. If nothing reflected -shows, RE the `Gun` native fire (an AOB like the `curl_easy_setopt` one in -[ue4-cheatsheet.md](ue4-cheatsheet.md)) and MinHook it. Then a `bool TrueSilentAim` gates the rewrite. -**Files.** the fire hook, `settings/Settings.h`, `menu/sections/Aim.h`. **Size:** Medium; **depends -on:** identifying the fire/trace function in-game. +view: rewrite the hit result / shot direction toward the selected target before it reaches the server, +leaving `ControlRotation` untouched. + +**What the dump shows (`FunctionsInfo.json`, hash `d2a5bd8c`).** Contrary to the earlier note (which +saw only `Gun.ServerGoToState`), the hit path **is** reflected. The client reports its hits/shots to +the server through `Server*` UFunctions, and on the client a `Server*` RPC is dispatched through +`UObject::ProcessEvent` (that is how it serializes the params to send) — so the **existing ProcessEvent +funnel already sees them** and can rewrite their params before `Original` runs, exactly like the +`BroadcastDeath` / `ClientUpdateChat` decodes already do. No native AOB hook is needed: +- `ALineTraceGun::ServerNotifyHit` — the hitscan base's per-shot hit report. +- `AMultiKillGun::ServerNotifyHits` / `AShotgun::ServerProcessHits` — multi-pellet hit arrays. +- `AProjectileGun::ServerSpawnProjectile` — projectile weapons' spawn (redirect the direction). +- `APortalWarsCharacter::ServerApplyMeleeDamage` — melee damage. +- `AGunSkin::OnStartFire_BP` / `OnStopFire_BP` — reflected fire start/stop edges (a firing signal). + +**Remaining work.** (1) **Param layouts** — these classes/RPCs aren't in the SDK yet (only `AGun` / +`ABaseGun` are), so add `ALineTraceGun` / `AShotgun` / `AProjectileGun` and decode each RPC's param +block (an `FHitResult` / target + hit location/bone), from `ClassesInfo`/`StructsInfo` or one +`LogProcessEvent` pass while firing (the `BroadcastDeath` decode in +[ProcessEvent.h](../Internal/hook/functions/ProcessEvent.h) is the template). (2) A `bool TrueSilentAim` +gates the rewrite toward the aimbot's selected target (reuse its target pick). (3) Server-side +validation may still reject implausible hits (line-of-sight / angle / range) — best against the private +backend; verify in-game. This same `ServerNotifyHit`/`ServerProcessHits` rewrite also enables the +**wallbang** (Phasing Approach B) without disturbing world physics. +**Files.** `hook/functions/ProcessEvent.h` (the intercept), `ue/sdk/` (the gun classes + param +structs), `settings/Settings.h`, `menu/sections/Aim.h`. **Size:** Medium. **Depends on:** the hit-RPC +param layout + in-game validation (the reflected hook point itself is confirmed). ### Phasing bullets (wallbang) toggle — Approach A DONE diff --git a/shared/Utilities.h b/shared/Utilities.h index cad8d2b..fd9cabe 100644 --- a/shared/Utilities.h +++ b/shared/Utilities.h @@ -1,8 +1,8 @@ #pragma once /// @file -/// @brief Small cross-project helpers shared by the launcher and the DLL: putting text on the -/// clipboard and opening a folder in the system file browser. +/// @brief Small cross-project helpers shared by the launcher and the DLL: putting text on and +/// reading text off the clipboard, and opening a folder in the system file browser. #include #include @@ -47,6 +47,27 @@ namespace Shared::Utilities return ok; } + /** + * Reads CF_TEXT off the clipboard. Best-effort — returns an empty string if the clipboard + * couldn't be opened or holds no text. + * @return the clipboard text (empty on failure). + */ + inline std::string PasteFromClipboard() + { + std::string text; + if (!OpenClipboard(nullptr)) return text; + + if (HANDLE data = GetClipboardData(CF_TEXT)) + if (const char* src = static_cast(GlobalLock(data))) + { + text = src; + GlobalUnlock(data); + } + + CloseClipboard(); + return text; + } + /// Opens @p path in the system file browser (Explorer), creating the folder first so the call /// succeeds even on a fresh install. No-op on an empty path. inline void OpenFolder(const std::filesystem::path& path)