Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ set(SWEETLOADER_SOURCES
src/pl/render/GlesProbe.cpp
src/pl/render/PresentFrameEffect.cpp
src/pl/render/RenderApi.cpp
src/pl/menu/MenuTheme.cpp
src/pl/menu/MenuWidgets.cpp
src/pl/menu/MenuRender.cpp
src/pl/menu/MenuFilter.cpp
src/pl/menu/MenuHost.cpp
src/pl/menu/MenuJni.cpp
src/pl/hooks/DobbyHookManager.cpp
src/pl/runtime/FeatureAvailability.cpp
src/pl/runtime/GameHookRules.cpp
Expand Down Expand Up @@ -201,6 +207,33 @@ set(BUILD_SHARED_LIBS OFF)
FetchContent_MakeAvailable(nlohmann_json boost_pfr magic_enum fmt lunasvg)
set(BUILD_SHARED_LIBS ${_sweetloader_build_shared_libs})

# --- Dear ImGui (vendored, not fetched) -------------------------------------------------------
# The in-game Mod Menu is drawn with Dear ImGui on the game's own GL context, from inside the
# present hook (pl::render::OnPresentBoundary -> pl::menu). The pack (Lynxifyy/CaramelClient-UI-pack)
# ships the menu as a renderer-agnostic library plus a GLFW desktop backend; GLFW has no Android
# path, so only the renderer-agnostic half is used and the backend is the GLES3 one, which the
# game's context already satisfies. Vendored rather than FetchContent'd because the network is not
# available to a normal Android build and the revision must be pinned for a reproducible binary.
# Licence: MIT (see third_party/imgui/LICENSE.txt and NOTICE).
add_library(imgui STATIC
third_party/imgui/imgui.cpp
third_party/imgui/imgui_draw.cpp
third_party/imgui/imgui_tables.cpp
third_party/imgui/imgui_widgets.cpp
third_party/imgui/backends/imgui_impl_opengl3.cpp)
target_include_directories(imgui PUBLIC
third_party/imgui
third_party/imgui/backends)
# The game's context is GLES3, so the backend compiles against <GLES3/gl3.h> from the NDK and uses
# its own bundled loader. IMGUI_DISABLE_OBSOLETE_FUNCTIONS keeps the surface to the current API.
target_compile_definitions(imgui PUBLIC
IMGUI_IMPL_OPENGL_ES3
IMGUI_DISABLE_OBSOLETE_FUNCTIONS)
# ImGui is third-party code built with the project's strict flags (-Wall -Wextra); silence the
# advisory warnings it would otherwise raise so the build stays clean without touching the source.
target_compile_options(imgui PRIVATE
$<$<COMPILE_LANG_AND_ID:CXX,Clang,GNU>:-w>)

find_library(log-lib log REQUIRED)
find_library(android-lib android REQUIRED)
find_library(egl-lib EGL REQUIRED)
Expand Down Expand Up @@ -229,6 +262,7 @@ target_link_libraries(sweetloader
PUBLIC
fmt::fmt
PRIVATE
imgui
${GLOSSHOOK_PATH}
${log-lib}
${android-lib}
Expand Down
6 changes: 6 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ here so a distributor can honour the attribution requirement.
fmtlib/fmt 40626af88bd7df9a5fb80be7b25ac85b122d6c21 MIT
sammycage/lunasvg 83c58df8103dc7dca423dfd824992af94d49bed6 MIT

The following is vendored rather than fetched, because the build must not need the network:

ocornut/imgui 1.91.5 (third_party/imgui/) MIT
(Dear ImGui, plus its backends/imgui_impl_opengl3 backend; the
in-game menu is drawn through the game's own GL context)

The following is vendored as a prebuilt static library under lib/ARM64/ and is not rebuilt here:

GlossHook (libGlossHook.a) MIT
Expand Down
48 changes: 48 additions & 0 deletions src/pl/menu/MenuFilter.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* @file MenuFilter.cpp
* @brief The Mods tab's filter rule (see MenuTypes.hpp). No ImGui, so it is host-testable.
*/

#include <cctype>
#include <cstring>

#include "pl/menu/MenuTypes.hpp"

namespace pl::menu {
namespace {

bool ContainsIgnoreCase(const std::string &hay, const char *needle) {
if (needle == nullptr || *needle == '\0') return true;
const std::size_t n = std::strlen(needle);
if (n > hay.size()) return false;
for (std::size_t i = 0; i + n <= hay.size(); ++i) {
std::size_t j = 0;
while (j < n && std::tolower(static_cast<unsigned char>(hay[i + j])) ==
std::tolower(static_cast<unsigned char>(needle[j])))
++j;
if (j == n) return true;
}
return false;
}

} // namespace

bool MatchesSearch(const MenuModuleRow &row, const char *search) {
if (search == nullptr || *search == '\0') return true;
return ContainsIgnoreCase(row.name, search) || ContainsIgnoreCase(row.description, search) ||
ContainsIgnoreCase(row.groupName, search);
}

std::vector<int> VisibleModuleIndices(const std::vector<MenuModuleRow> &modules,
const MenuFilter &filter) {
std::vector<int> out;
for (std::size_t i = 0; i < modules.size(); ++i) {
const MenuModuleRow &row = modules[i];
if (filter.favoritesOnly && !row.favorite) continue;
if (!MatchesSearch(row, filter.search.c_str())) continue;
out.push_back(static_cast<int>(i));
}
return out;
}

} // namespace pl::menu
217 changes: 217 additions & 0 deletions src/pl/menu/MenuHost.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* @file MenuHost.cpp
* @brief Owns the in-game Mod Menu's ImGui context, GLES backend and frame loop (see MenuHost.hpp).
*/

#include "pl/menu/MenuHost.hpp"

#include <atomic>
#include <cstring>
#include <mutex>
#include <string>
#include <vector>

#include "imgui.h"
#include "imgui_impl_opengl3.h"

#include "pl/Logger.hpp"
#include "pl/menu/MenuTheme.hpp"

namespace pl::menu {
namespace {

std::atomic_bool g_wanted{false};
std::atomic_bool g_live{false};
std::atomic_bool g_open{false};
std::atomic_bool g_backPressed{false};

/// Guards the launcher-thread setters against the render-thread frame loop.
std::mutex g_mutex;
std::vector<MenuModuleRow> g_catalogue;
std::string g_emptyReason;
std::vector<TouchInput> g_pendingTouches;
MenuState g_state;
/** Events the launcher drains each frame (a toggle, a config change, a close). */
std::vector<MenuEvent> g_events;

bool g_imguiReady = false;

/// Tracks the touch pointer ImGui is following, so a move without a fresh down still maps.
bool g_touchActive = false;
ImVec2 g_lastTouchPos{0, 0};

void PushEvent(MenuEvent event) {
std::lock_guard<std::mutex> lock(g_mutex);
// Bounded: a launcher that stops draining must not grow this without limit.
if (g_events.size() > 256) g_events.erase(g_events.begin());
g_events.push_back(std::move(event));
}

MenuActions BuildActions() {
MenuActions actions;
actions.onToggleModule = [](const std::string &key, bool enabled) {
PushEvent(MenuEvent{MenuEventKind::ToggleModule, key, enabled ? "true" : "false"});
};
actions.onSetConfig = [](const std::string &key, const std::string &configKey,
const std::string &value) {
PushEvent(MenuEvent{MenuEventKind::SetConfig, key, configKey + "=" + value});
};
actions.onToggleFavorite = [](const std::string &key) {
PushEvent(MenuEvent{MenuEventKind::ToggleFavorite, key, ""});
};
actions.onClose = []() { PushEvent(MenuEvent{MenuEventKind::Close, "", ""}); };
return actions;
}

/// Translates the queued Android touches into ImGui mouse state. Called on the render thread.
void ApplyInput(float width, float height) {
ImGuiIO &io = ImGui::GetIO();
io.DisplaySize = ImVec2(width, height);
// The menu skips its whole ImGui frame while closed, so the first frame after it opens can carry
// a delta of seconds. Clamp it, or every ImGui animation snaps to its end on the opening frame.
if (io.DeltaTime <= 0.0f) io.DeltaTime = 1.0f / 60.0f;
if (io.DeltaTime > 0.1f) io.DeltaTime = 0.1f;

std::vector<TouchInput> touches;
{
std::lock_guard<std::mutex> lock(g_mutex);
touches.swap(g_pendingTouches);
}
if (g_backPressed.exchange(false, std::memory_order_relaxed)) {
g_open.store(false, std::memory_order_relaxed);
PushEvent(MenuEvent{MenuEventKind::Close, "", ""});
}
for (const TouchInput &t : touches) {
// The touch arrives in the view's coordinate space; ImGui's display is the same surface, so no
// scaling is needed when the two agree. When the view reports its own size, scale to it so a
// letterboxed or rotated surface still maps correctly.
float x = t.x;
float y = t.y;
if (t.viewportWidth > 0.0f && t.viewportHeight > 0.0f) {
x *= width / t.viewportWidth;
y *= height / t.viewportHeight;
}
g_lastTouchPos = ImVec2(x, y);
io.AddMousePosEvent(x, y);
switch (t.action) {
case 0: // ACTION_DOWN
g_touchActive = true;
io.AddMouseButtonEvent(0, true);
break;
case 1: // ACTION_UP
g_touchActive = false;
io.AddMouseButtonEvent(0, false);
break;
case 2: // ACTION_MOVE
if (g_touchActive) io.AddMouseButtonEvent(0, true);
break;
case 3: // ACTION_CANCEL
g_touchActive = false;
io.AddMouseButtonEvent(0, false);
break;
default:
break;
}
}
}

bool InitImGui() {
if (g_imguiReady) return true;
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO &io = ImGui::GetIO();
io.IniFilename = nullptr; // no imgui.ini on the game's storage
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
theme::ApplyStyle();
if (!ImGui_ImplOpenGL3_Init(nullptr)) {
sweetLoaderLogger.warn("Mod Menu: the GLES3 backend refused to initialise");
ImGui::DestroyContext();
return false;
}
g_imguiReady = true;
sweetLoaderLogger.info("Mod Menu: ImGui context live");
return true;
}

} // namespace

void InstallMenu() {
g_wanted.store(true, std::memory_order_relaxed);
}

bool IsMenuLive() { return g_live.load(std::memory_order_relaxed); }

bool IsOpen() { return g_open.load(std::memory_order_relaxed); }

void SetOpen(bool open) {
g_open.store(open, std::memory_order_relaxed);
if (!open) g_touchActive = false;
}

void SetCatalogue(std::vector<MenuModuleRow> modules, std::string emptyReason) {
std::lock_guard<std::mutex> lock(g_mutex);
g_catalogue = std::move(modules);
g_emptyReason = std::move(emptyReason);
}

void PushTouch(const TouchInput &touch) {
std::lock_guard<std::mutex> lock(g_mutex);
if (g_pendingTouches.size() > 64) return; // a stalled frame must not queue unbounded input
g_pendingTouches.push_back(touch);
}

void PushBack() { g_backPressed.store(true, std::memory_order_relaxed); }

bool RegisterFont(theme::FontRole role, const void *data, int size) {
// Sizes match the pack's roles: body 15, bold 13, title 19, display 17.
const float px = role == theme::FontRole::BodyBold ? 13.0f
: role == theme::FontRole::Title ? 19.0f
: role == theme::FontRole::Display ? 17.0f
: 15.0f;
return theme::AddFont(role, data, size, px);
}

std::vector<MenuEvent> DrainEvents() {
std::lock_guard<std::mutex> lock(g_mutex);
std::vector<MenuEvent> out;
out.swap(g_events);
return out;
}

void Frame(float viewportWidth, float viewportHeight) {
if (!g_wanted.load(std::memory_order_relaxed)) return;
if (viewportWidth <= 0.0f || viewportHeight <= 0.0f) return;
if (!InitImGui()) return;
g_live.store(true, std::memory_order_relaxed);

const bool open = g_open.load(std::memory_order_relaxed);
if (!open) {
// The menu is closed, which is most of a play session. Skip the whole ImGui frame: building a
// frame the player cannot see still costs vertex work and a GLES draw every present, which is
// exactly the overhead an overlay must not add. Still drain the touch queue so a stale touch
// does not replay when the menu next opens.
std::lock_guard<std::mutex> lock(g_mutex);
g_pendingTouches.clear();
g_backPressed.store(false, std::memory_order_relaxed);
return;
}

ApplyInput(viewportWidth, viewportHeight);

// Copy the catalogue under the lock, then render from the copy: the render thread must not hold
// the launcher's mutex for the length of a frame.
{
std::lock_guard<std::mutex> lock(g_mutex);
g_state.modules = g_catalogue;
g_state.emptyReason = g_emptyReason;
}
g_state.open = true;

ImGui_ImplOpenGL3_NewFrame();
ImGui::NewFrame();
RenderMenu(g_state, BuildActions(), ImVec2(viewportWidth, viewportHeight));
ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
}

} // namespace pl::menu
Loading
Loading