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)