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
35 changes: 35 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,14 @@ set(SWEETLOADER_SOURCES
src/pl/cosmetics/network/CosmeticSyncJni.cpp
src/pl/render/CameraSource.cpp
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 @@ -200,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 @@ -228,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
23 changes: 23 additions & 0 deletions include/pl/render/GlesProbe.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,16 @@ struct GlesFunctions {
void *GenVertexArrays = nullptr;
void *BindVertexArray = nullptr;
void *EnableVertexAttribArray = nullptr;
void *DisableVertexAttribArray = nullptr;
void *VertexAttribPointer = nullptr;
void *UseProgram = nullptr;
void *GetUniformLocation = nullptr;
void *UniformMatrix4fv = nullptr;
void *Uniform4f = nullptr;
void *Uniform3f = nullptr;
void *Uniform1i = nullptr;
void *Uniform1f = nullptr;
void *Uniform2f = nullptr;
void *ActiveTexture = nullptr;
void *BindTexture = nullptr;
void *GenTextures = nullptr;
Expand All @@ -76,9 +79,29 @@ struct GlesFunctions {
void *DrawElements = nullptr;
void *DrawArrays = nullptr;
void *BlendFunc = nullptr;
void *BlendFuncSeparate = nullptr;
void *DeleteBuffers = nullptr;
void *DeleteVertexArrays = nullptr;
void *DeleteTextures = nullptr;
// Present-frame effect path (frame generation / motion blur): the shader program that samples the
// captured frame, and the framebuffer/texture copy used to keep it. Resolved here rather than in
// the effect so the probe stays the single GL symbol table.
void *CreateShader = nullptr;
void *ShaderSource = nullptr;
void *CompileShader = nullptr;
void *GetShaderiv = nullptr;
void *CreateProgram = nullptr;
void *AttachShader = nullptr;
void *LinkProgram = nullptr;
void *GetProgramiv = nullptr;
void *GetAttribLocation = nullptr;
void *GenFramebuffers = nullptr;
void *BindFramebuffer = nullptr;
void *FramebufferTexture2D = nullptr;
void *CopyTexImage2D = nullptr;
void *TexParameterf = nullptr;
void *ClearColor = nullptr;
void *Clear = nullptr;

/** @brief True when the core query path is available (nothing essential is null). */
[[nodiscard]] bool usable() const;
Expand Down
65 changes: 65 additions & 0 deletions include/pl/render/PresentFrameEffect.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#pragma once

/**
* @file PresentFrameEffect.hpp
* @brief The present-frame effect seam: frame generation and motion blur, on the engine's present.
*
* <p>Both effects act on the frame the engine is about to present, so both live on the same seam:
* the {@code eglSwapBuffers} detour (`pl::runtime::InstallSwapFrameBoundary`). This header owns the
* plan the launcher sends, the pure blend/interpolation math, and the install state.
*
* <p><b>Honesty contract.</b> Neither effect raises the game's frame rate. Frame generation blends
* the previous frame into the current one so the presented image sits <em>between</em> two the game
* already drew; motion blur blends the previous frame in, which softens the image by design. Neither
* presents an extra frame: the seam emits one present per rendered frame. {@link ComputeBlendAlpha}
* and {@link ComputeInterpolationFactor} are pure and host-tested, so the numbers the seam uses are
* checkable off-device.
*
* <p><b>Fail-closed.</b> {@link InstallPresentFrameEffect} reports false when the GL path cannot be
* set up (no GLES table, no EGL context, or a build with no present hook). {@link
* IsPresentFrameEffectLive} then reports false and the launcher keeps both modules badged rather
* than toggling to no effect. Nothing here fabricates a frame.
*/

#include <cstdint>

#include "pl/Export.hpp"
#include "pl/render/PresentFrameMath.hpp"

namespace pl::render {

/**
* @brief Installs the present-frame effect seam, once. Safe to call repeatedly.
*
* <p>Fail-closed: returns false when the engine's present hook is not live, or the GL path the
* effects need cannot be resolved. A false result leaves {@link IsPresentFrameEffectLive} false, so
* the launcher keeps both modules badged.
*
* @return true when the seam is installed and can act on presented frames.
*/
PL_EXPORT bool InstallPresentFrameEffect();

/** @brief True once {@link InstallPresentFrameEffect} has installed a working seam. */
PL_EXPORT bool IsPresentFrameEffectLive();

/**
* @brief Stores the plan the effects apply on the next present.
*
* Safe to call from the launcher thread; the seam reads the plan on the render thread. Values are
* clamped so a stored option can never drive an out-of-range blend.
*/
PL_EXPORT void SetPresentFramePlan(const PresentFramePlan &plan);

/** @brief The stored plan, for diagnostics. */
PL_EXPORT PresentFramePlan GetPresentFramePlan();

/**
* @brief Runs the effects at a present boundary.
*
* Called from the {@code eglSwapBuffers} record stub, on the render thread, with the GL context
* current. A no-op when the seam is not live or no effect is enabled. It never blocks and never
* allocates on the steady path.
*/
PL_EXPORT void OnPresentBoundary();

} // namespace pl::render
117 changes: 117 additions & 0 deletions include/pl/render/PresentFrameMath.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#pragma once

/**
* @file PresentFrameMath.hpp
* @brief The pure math behind the present-frame effect modules (frame generation, motion blur).
*
* <p>Kept apart from {@link PresentFrameEffect} on purpose: the effect file pulls in the GL probe
* and the logger (and therefore the Android shims), while these two functions are plain arithmetic
* with no dependencies. That is what lets the host test runner pin them directly, off-device, which
* is the same reason the other pure rules in this tree live in their own headers.
*
* <p><b>Honesty.</b> Neither function measures or promises a frame rate. Frame generation returns
* how far the blended image sits toward the previous real frame; motion blur returns how much of the
* previous frame to mix in. Both are clamped to [0, 1]. Neither inserts a frame: the seam presents
* one frame per rendered frame.
*/

#include <cstdint>

namespace pl::render {

/** @brief The plan the launcher sends from the module options. All values are clamped on receipt. */
struct PresentFramePlan {
/** Whether frame generation is enabled (the module switch and its own toggle). */
bool frameGenerationEnabled = false;
/** 0..1: how far the blended image is pushed toward the previous of the two real frames. */
float interpolationStrength = 0.f;
/**
* 2..3: reserved. The seam blends the previous frame into the current one and presents a single
* frame, so it does not act on this ceiling yet; it is kept for a future true frame-insertion
* seam. Stored and clamped so the option round-trips, but nothing reads it to emit extra presents.
*/
int maxFramesPerRendered = 2;
/** Bias the blend to reuse less of the previous frame near edges. */
bool reduceEdgeArtifacts = false;

/** Whether motion blur is enabled (the module switch and its own toggle). */
bool motionBlurEnabled = false;
/** 0..1: how much of the previous frame is blended in. */
float blurStrength = 0.f;
/** 0..1: the most of the frame time the blend may take. */
float blurBudget = 0.f;
/** Scale the blur by camera speed, so a still view stays sharp. */
bool weightByViewSpeed = false;
};

/**
* @brief The blend alpha for motion blur this frame.
*
* The alpha is the fraction of the previous frame mixed into the current one. It is the configured
* strength, scaled by how fast the view is moving when {@code weightByViewSpeed} is set, and it is
* additionally capped by the budget: a frame interval longer than the budget leaves no room for the
* blend, so the alpha falls proportionally rather than the effect overrunning the frame.
*
* @param strength configured blur strength, 0..1
* @param budget configured blend budget as a fraction of the frame time, 0..1
* @param frameIntervalMs the last measured frame interval, milliseconds (<= 0 means unknown)
* @param viewSpeed01 camera speed normalised to 0..1 (0 when unknown)
* @param weightByViewSpeed whether to scale by {@p viewSpeed01}
* @return the blend alpha in [0, 1]
*/
inline float ComputeBlendAlpha(float strength, float budget, float frameIntervalMs,
float viewSpeed01, bool weightByViewSpeed) {
if (strength <= 0.f) return 0.f;
float alpha = strength;
if (weightByViewSpeed) {
const float speed = viewSpeed01 < 0.f ? 0.f : (viewSpeed01 > 1.f ? 1.f : viewSpeed01);
alpha *= speed;
}
if (budget > 0.f && frameIntervalMs > 0.f) {
const float budgetMs = budget * frameIntervalMs;
if (budgetMs <= 0.f) return 0.f;
if (frameIntervalMs > budgetMs) alpha *= budgetMs / frameIntervalMs;
}
if (alpha < 0.f) return 0.f;
if (alpha > 1.f) return 1.f;
return alpha;
}

/**
* @brief The interpolation factor for frame generation this frame.
*
* The factor is where the blended frame sits between the previous real frame (0) and the current
* one (1): 0.5 is the midpoint. At full strength the factor is the midpoint; as strength falls it
* moves toward the current frame the game actually drew, which is the low-artefact end. With
* {@code reduceEdgeArtifacts} the factor is pulled further toward that real frame.
*
* <p><b>Honesty.</b> This factor is a position between two real frames, not a promise that a third
* frame is presented. The seam uses {@link FrameGenerationWeight} (its complement) as the blend
* weight: it mixes the previous frame into the current one, it does not insert a frame.
*
* @param strength configured interpolation strength, 0..1
* @param reduceEdgeArtifacts bias toward the current real frame near edges
* @return the interpolation factor in [0, 1]
*/
inline float ComputeInterpolationFactor(float strength, bool reduceEdgeArtifacts) {
const float s = strength < 0.f ? 0.f : (strength > 1.f ? 1.f : strength);
float factor = 0.5f + 0.5f * (1.f - s);
if (reduceEdgeArtifacts) {
factor = factor * 0.85f + 0.15f;
}
if (factor < 0.f) return 0.f;
if (factor > 1.f) return 1.f;
return factor;
}

/**
* @brief The blend weight frame generation contributes: its distance from the current real frame.
*
* This is what the seam actually blends. It is derived from {@link ComputeInterpolationFactor} so
* the two never drift, and it is the value the seam compares against the motion-blur alpha.
*/
inline float FrameGenerationWeight(float strength, bool reduceEdgeArtifacts) {
return 1.f - ComputeInterpolationFactor(strength, reduceEdgeArtifacts);
}

} // namespace pl::render
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
Loading
Loading