diff --git a/CMakeLists.txt b/CMakeLists.txt
index 6739b61..a74d5f8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -54,6 +54,7 @@ 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/hooks/DobbyHookManager.cpp
src/pl/runtime/FeatureAvailability.cpp
diff --git a/include/pl/render/GlesProbe.hpp b/include/pl/render/GlesProbe.hpp
index 2826226..01613a4 100644
--- a/include/pl/render/GlesProbe.hpp
+++ b/include/pl/render/GlesProbe.hpp
@@ -60,6 +60,7 @@ struct GlesFunctions {
void *GenVertexArrays = nullptr;
void *BindVertexArray = nullptr;
void *EnableVertexAttribArray = nullptr;
+ void *DisableVertexAttribArray = nullptr;
void *VertexAttribPointer = nullptr;
void *UseProgram = nullptr;
void *GetUniformLocation = nullptr;
@@ -67,6 +68,8 @@ struct GlesFunctions {
void *Uniform4f = nullptr;
void *Uniform3f = nullptr;
void *Uniform1i = nullptr;
+ void *Uniform1f = nullptr;
+ void *Uniform2f = nullptr;
void *ActiveTexture = nullptr;
void *BindTexture = nullptr;
void *GenTextures = nullptr;
@@ -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;
diff --git a/include/pl/render/PresentFrameEffect.hpp b/include/pl/render/PresentFrameEffect.hpp
new file mode 100644
index 0000000..c54a6d4
--- /dev/null
+++ b/include/pl/render/PresentFrameEffect.hpp
@@ -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.
+ *
+ *
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.
+ *
+ *
Honesty contract. Neither effect raises the game's frame rate. Frame generation blends
+ * the previous frame into the current one so the presented image sits between 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.
+ *
+ *
Fail-closed. {@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
+
+#include "pl/Export.hpp"
+#include "pl/render/PresentFrameMath.hpp"
+
+namespace pl::render {
+
+/**
+ * @brief Installs the present-frame effect seam, once. Safe to call repeatedly.
+ *
+ * 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
diff --git a/include/pl/render/PresentFrameMath.hpp b/include/pl/render/PresentFrameMath.hpp
new file mode 100644
index 0000000..4473f3b
--- /dev/null
+++ b/include/pl/render/PresentFrameMath.hpp
@@ -0,0 +1,117 @@
+#pragma once
+
+/**
+ * @file PresentFrameMath.hpp
+ * @brief The pure math behind the present-frame effect modules (frame generation, motion blur).
+ *
+ *
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.
+ *
+ *
Honesty. 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
+
+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.
+ *
+ * Honesty. 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
diff --git a/src/pl/render/GlesProbe.cpp b/src/pl/render/GlesProbe.cpp
index e63cb11..fb7f4c7 100644
--- a/src/pl/render/GlesProbe.cpp
+++ b/src/pl/render/GlesProbe.cpp
@@ -91,6 +91,7 @@ GlesFunctions ResolveGles() {
LOAD("glGenVertexArrays", GenVertexArrays);
LOAD("glBindVertexArray", BindVertexArray);
LOAD("glEnableVertexAttribArray", EnableVertexAttribArray);
+ LOAD("glDisableVertexAttribArray", DisableVertexAttribArray);
LOAD("glVertexAttribPointer", VertexAttribPointer);
LOAD("glUseProgram", UseProgram);
LOAD("glGetUniformLocation", GetUniformLocation);
@@ -98,6 +99,8 @@ GlesFunctions ResolveGles() {
LOAD("glUniform4f", Uniform4f);
LOAD("glUniform3f", Uniform3f);
LOAD("glUniform1i", Uniform1i);
+ LOAD("glUniform1f", Uniform1f);
+ LOAD("glUniform2f", Uniform2f);
LOAD("glActiveTexture", ActiveTexture);
LOAD("glBindTexture", BindTexture);
LOAD("glGenTextures", GenTextures);
@@ -107,9 +110,26 @@ GlesFunctions ResolveGles() {
LOAD("glDrawElements", DrawElements);
LOAD("glDrawArrays", DrawArrays);
LOAD("glBlendFunc", BlendFunc);
+ LOAD("glBlendFuncSeparate", BlendFuncSeparate);
LOAD("glDeleteBuffers", DeleteBuffers);
LOAD("glDeleteVertexArrays", DeleteVertexArrays);
LOAD("glDeleteTextures", DeleteTextures);
+ LOAD("glCreateShader", CreateShader);
+ LOAD("glShaderSource", ShaderSource);
+ LOAD("glCompileShader", CompileShader);
+ LOAD("glGetShaderiv", GetShaderiv);
+ LOAD("glCreateProgram", CreateProgram);
+ LOAD("glAttachShader", AttachShader);
+ LOAD("glLinkProgram", LinkProgram);
+ LOAD("glGetProgramiv", GetProgramiv);
+ LOAD("glGetAttribLocation", GetAttribLocation);
+ LOAD("glGenFramebuffers", GenFramebuffers);
+ LOAD("glBindFramebuffer", BindFramebuffer);
+ LOAD("glFramebufferTexture2D", FramebufferTexture2D);
+ LOAD("glCopyTexImage2D", CopyTexImage2D);
+ LOAD("glTexParameterf", TexParameterf);
+ LOAD("glClearColor", ClearColor);
+ LOAD("glClear", Clear);
#undef LOAD
if (g_gles.usable()) {
diff --git a/src/pl/render/PresentFrameEffect.cpp b/src/pl/render/PresentFrameEffect.cpp
new file mode 100644
index 0000000..c7c53ad
--- /dev/null
+++ b/src/pl/render/PresentFrameEffect.cpp
@@ -0,0 +1,475 @@
+/**
+ * @file PresentFrameEffect.cpp
+ * @brief The present-frame effect seam (frame generation + motion blur) on the engine's present.
+ *
+ * The seam is the {@code eglSwapBuffers} detour, run from the swap record stub just before the real
+ * swap. At that moment the back buffer holds the frame the engine just drew; this file keeps a copy
+ * of the previous presented frame and, when a module is enabled, composites it over the back buffer,
+ * then rotates the two captures. The blend weight is the frame-generation interpolation weight or
+ * the motion-blur strength, whichever is larger, so the effect the player enabled is not cancelled
+ * by the other.
+ *
+ *
Honesty. This does not raise the render rate. It blends the previous frame into the
+ * current one, so motion is smoother and the image is softer. The blend weight comes from
+ * {@link pl::render::ComputeBlendAlpha} / {@link pl::render::ComputeInterpolationFactor}, which are
+ * pure and host-tested.
+ *
+ *
Fail-closed. {@link InstallPresentFrameEffect} returns false when the present hook is
+ * not live, no EGL context is current, or the GL table cannot resolve the entry points the
+ * composite needs. Nothing is drawn in that case, and the launcher keeps both modules badged.
+ *
+ *
State preservation. The composite runs inside the game's own present path, so it saves
+ * and restores every piece of GL state it touches (program, VAO, array buffer, active texture,
+ * texture binding, blend, depth test, cull, viewport). It never leaves the pipeline in a state the
+ * game did not set.
+ */
+
+#include "pl/render/PresentFrameEffect.hpp"
+
+#include
+#include
+
+#include "pl/Logger.hpp"
+#include "pl/render/GlesProbe.hpp"
+#include "pl/runtime/SwapFrameBoundary.h"
+
+namespace pl::render {
+namespace {
+
+// GL enums used below, declared locally so this file needs no GLES header (the host test runner has
+// none). Values are from the GLES 2.0/3.0 specs.
+constexpr std::uint32_t GL_TEXTURE_2D = 0x0DE1;
+constexpr std::uint32_t GL_TEXTURE0 = 0x84C0;
+constexpr std::uint32_t GL_RGBA = 0x1908;
+constexpr std::uint32_t GL_UNSIGNED_BYTE = 0x1401;
+constexpr std::uint32_t GL_FLOAT = 0x1406;
+constexpr std::uint32_t GL_TEXTURE_MIN_FILTER = 0x2801;
+constexpr std::uint32_t GL_TEXTURE_MAG_FILTER = 0x2800;
+constexpr std::uint32_t GL_TEXTURE_WRAP_S = 0x2802;
+constexpr std::uint32_t GL_TEXTURE_WRAP_T = 0x2803;
+constexpr std::uint32_t GL_LINEAR = 0x2601;
+constexpr std::uint32_t GL_CLAMP_TO_EDGE = 0x812F;
+constexpr std::uint32_t GL_BLEND = 0x0BE2;
+constexpr std::uint32_t GL_DEPTH_TEST = 0x0B71;
+constexpr std::uint32_t GL_CULL_FACE = 0x0B44;
+constexpr std::uint32_t GL_SRC_ALPHA = 0x0302;
+constexpr std::uint32_t GL_ONE_MINUS_SRC_ALPHA = 0x0303;
+constexpr std::uint32_t GL_BLEND_SRC_ALPHA = 0x80CB;
+constexpr std::uint32_t GL_BLEND_DST_ALPHA = 0x80CA;
+constexpr std::uint32_t GL_BLEND_SRC_RGB = 0x80C9;
+constexpr std::uint32_t GL_BLEND_DST_RGB = 0x80C8;
+constexpr std::uint32_t GL_CURRENT_PROGRAM = 0x8B8D;
+constexpr std::uint32_t GL_ACTIVE_TEXTURE = 0x84E0;
+constexpr std::uint32_t GL_TEXTURE_BINDING_2D = 0x8069;
+constexpr std::uint32_t GL_VERTEX_ARRAY_BINDING = 0x85B5;
+constexpr std::uint32_t GL_ARRAY_BUFFER = 0x8892;
+constexpr std::uint32_t GL_ARRAY_BUFFER_BINDING = 0x8894;
+constexpr std::uint32_t GL_STATIC_DRAW = 0x88E4;
+constexpr std::uint32_t GL_VIEWPORT = 0x0BA2;
+constexpr std::uint32_t GL_TRIANGLES = 0x0004;
+constexpr std::uint32_t GL_FRAGMENT_SHADER = 0x8B30;
+constexpr std::uint32_t GL_VERTEX_SHADER = 0x8B31;
+constexpr std::uint32_t GL_COMPILE_STATUS = 0x8B81;
+constexpr std::uint32_t GL_LINK_STATUS = 0x8B82;
+
+// GLSL ES 1.00, so it compiles on both ES2 and ES3-default contexts. A fullscreen quad is uploaded
+// once; the fragment shader samples the previous frame at the current fragment's UV and outputs it
+// with the blend alpha, and fixed-function blending mixes it into the back buffer.
+const char *kVertexShader =
+ "attribute vec2 a_pos;\n"
+ "void main() {\n"
+ " gl_Position = vec4(a_pos, 0.0, 1.0);\n"
+ "}\n";
+const char *kFragmentShader =
+ "precision mediump float;\n"
+ "uniform sampler2D u_prev;\n"
+ "uniform vec2 u_resolution;\n"
+ "uniform float u_alpha;\n"
+ "void main() {\n"
+ " vec4 c = texture2D(u_prev, gl_FragCoord.xy / u_resolution);\n"
+ " gl_FragColor = vec4(c.rgb, u_alpha);\n"
+ "}\n";
+
+constexpr float kQuad[] = {
+ -1.f, -1.f, 1.f, -1.f, -1.f, 1.f,
+ -1.f, 1.f, 1.f, -1.f, 1.f, 1.f,
+};
+
+std::atomic_bool g_installed{false};
+std::atomic_bool g_live{false};
+
+// The plan is written by the launcher thread and read on the render thread. It is small and plain,
+// so an atomic flag plus a plain copy under the flag is enough; the read is per present.
+std::atomic_bool g_planDirty{false};
+PresentFramePlan g_plan;
+PresentFramePlan g_planSnapshot;
+
+// The two capture textures (double buffered) and their size, created lazily on the render thread.
+std::uint32_t g_captureTex[2] = {0, 0};
+std::uint32_t g_prevIndex = 0;
+int g_captureWidth = 0;
+int g_captureHeight = 0;
+bool g_captureValid = false;
+
+std::uint32_t g_program = 0;
+std::uint32_t g_vao = 0;
+std::uint32_t g_vbo = 0;
+int g_alphaUniform = -1;
+int g_prevUniform = -1;
+int g_resolutionUniform = -1;
+int g_posAttrib = -1;
+
+// The size of the last present, so the launcher can report it.
+std::atomic g_lastWidth{0};
+std::atomic g_lastHeight{0};
+
+using GlGetIntegervFn = void (*)(std::uint32_t, std::int32_t *);
+using GlGetBooleanvFn = void (*)(std::uint32_t, std::uint8_t *);
+
+template
+Fn As(const void *p) {
+ return reinterpret_cast(const_cast(p));
+}
+
+// A copy of the GL state the composite changes, so it can be restored afterwards.
+struct SavedState {
+ std::int32_t program = 0;
+ std::int32_t activeTexture = 0;
+ std::int32_t texture2d = 0;
+ std::int32_t vao = 0;
+ std::int32_t arrayBuffer = 0;
+ std::int32_t viewport[4] = {0, 0, 0, 0};
+ std::uint8_t blend = 0;
+ std::uint8_t depthTest = 0;
+ std::uint8_t cullFace = 0;
+ std::int32_t blendSrcRgb = 0;
+ std::int32_t blendDstRgb = 0;
+ std::int32_t blendSrcAlpha = 0;
+ std::int32_t blendDstAlpha = 0;
+};
+
+void SaveState(const GlesFunctions &g, SavedState &s) {
+ auto geti = As(g.GetIntegerv);
+ auto getb = As(g.GetBooleanv);
+ geti(GL_CURRENT_PROGRAM, &s.program);
+ geti(GL_ACTIVE_TEXTURE, &s.activeTexture);
+ geti(GL_TEXTURE_BINDING_2D, &s.texture2d);
+ geti(GL_VERTEX_ARRAY_BINDING, &s.vao);
+ geti(GL_ARRAY_BUFFER_BINDING, &s.arrayBuffer);
+ geti(GL_VIEWPORT, s.viewport);
+ geti(GL_BLEND_SRC_RGB, &s.blendSrcRgb);
+ geti(GL_BLEND_DST_RGB, &s.blendDstRgb);
+ geti(GL_BLEND_SRC_ALPHA, &s.blendSrcAlpha);
+ geti(GL_BLEND_DST_ALPHA, &s.blendDstAlpha);
+ getb(GL_BLEND, &s.blend);
+ getb(GL_DEPTH_TEST, &s.depthTest);
+ getb(GL_CULL_FACE, &s.cullFace);
+}
+
+void RestoreState(const GlesFunctions &g, const SavedState &s) {
+ using GlUseProgramFn = void (*)(std::uint32_t);
+ using GlBindTextureFn = void (*)(std::uint32_t, std::uint32_t);
+ using GlActiveTextureFn = void (*)(std::uint32_t);
+ using GlBindVaoFn = void (*)(std::uint32_t);
+ using GlBindBufferFn = void (*)(std::uint32_t, std::uint32_t);
+ using GlViewportFn = void (*)(std::int32_t, std::int32_t, std::int32_t, std::int32_t);
+ using GlBlendFuncSeparateFn = void (*)(std::uint32_t, std::uint32_t, std::uint32_t, std::uint32_t);
+ using GlEnableFn = void (*)(std::uint32_t);
+ using GlDisableFn = void (*)(std::uint32_t);
+
+ As(g.UseProgram)(static_cast(s.program));
+ As(g.ActiveTexture)(static_cast(s.activeTexture));
+ As(g.BindTexture)(GL_TEXTURE_2D, static_cast(s.texture2d));
+ As(g.BindVertexArray)(static_cast(s.vao));
+ if (g.BindBuffer != nullptr) {
+ As(g.BindBuffer)(GL_ARRAY_BUFFER, static_cast(s.arrayBuffer));
+ }
+ As(g.Viewport)(s.viewport[0], s.viewport[1], s.viewport[2], s.viewport[3]);
+ if (g.BlendFuncSeparate != nullptr) {
+ As(g.BlendFuncSeparate)(
+ static_cast(s.blendSrcRgb), static_cast(s.blendDstRgb),
+ static_cast(s.blendSrcAlpha), static_cast(s.blendDstAlpha));
+ }
+ auto enable = As(g.Enable);
+ auto disable = As(g.Disable);
+ if (s.blend) enable(GL_BLEND); else disable(GL_BLEND);
+ if (s.depthTest) enable(GL_DEPTH_TEST); else disable(GL_DEPTH_TEST);
+ if (s.cullFace) enable(GL_CULL_FACE); else disable(GL_CULL_FACE);
+}
+
+bool CompileShader(const GlesFunctions &g, std::uint32_t type, const char *source,
+ std::uint32_t &out) {
+ using CreateShaderFn = std::uint32_t (*)(std::uint32_t);
+ using ShaderSourceFn = void (*)(std::uint32_t, std::int32_t, const char *const *, const std::int32_t *);
+ using CompileShaderFn = void (*)(std::uint32_t);
+ using GetShaderivFn = void (*)(std::uint32_t, std::uint32_t, std::int32_t *);
+ if (g.CreateShader == nullptr || g.ShaderSource == nullptr || g.CompileShader == nullptr ||
+ g.GetShaderiv == nullptr) {
+ return false;
+ }
+ out = As(g.CreateShader)(type);
+ if (out == 0) return false;
+ As(g.ShaderSource)(out, 1, &source, nullptr);
+ As(g.CompileShader)(out);
+ std::int32_t status = 0;
+ As(g.GetShaderiv)(out, GL_COMPILE_STATUS, &status);
+ return status != 0;
+}
+
+bool BuildProgram(const GlesFunctions &g) {
+ using CreateProgramFn = std::uint32_t (*)();
+ using AttachShaderFn = void (*)(std::uint32_t, std::uint32_t);
+ using LinkProgramFn = void (*)(std::uint32_t);
+ using GetProgramivFn = void (*)(std::uint32_t, std::uint32_t, std::int32_t *);
+ using GetUniformLocationFn = std::int32_t (*)(std::uint32_t, const char *);
+ using GetAttribLocationFn = std::int32_t (*)(std::uint32_t, const char *);
+ using GenBuffersFn = void (*)(std::int32_t, std::uint32_t *);
+ using BindBufferFn = void (*)(std::uint32_t, std::uint32_t);
+ using BufferDataFn = void (*)(std::uint32_t, std::intptr_t, const void *, std::uint32_t);
+ if (g.CreateProgram == nullptr || g.AttachShader == nullptr || g.LinkProgram == nullptr ||
+ g.GetProgramiv == nullptr || g.GetUniformLocation == nullptr ||
+ g.GetAttribLocation == nullptr || g.GenBuffers == nullptr || g.BindBuffer == nullptr ||
+ g.BufferData == nullptr) {
+ return false;
+ }
+ std::uint32_t vs = 0;
+ std::uint32_t fs = 0;
+ if (!CompileShader(g, GL_VERTEX_SHADER, kVertexShader, vs)) return false;
+ if (!CompileShader(g, GL_FRAGMENT_SHADER, kFragmentShader, fs)) return false;
+ g_program = As(g.CreateProgram)();
+ if (g_program == 0) return false;
+ As(g.AttachShader)(g_program, vs);
+ As(g.AttachShader)(g_program, fs);
+ As(g.LinkProgram)(g_program);
+ std::int32_t status = 0;
+ As(g.GetProgramiv)(g_program, GL_LINK_STATUS, &status);
+ if (status == 0) {
+ g_program = 0;
+ return false;
+ }
+ g_prevUniform = As(g.GetUniformLocation)(g_program, "u_prev");
+ g_alphaUniform = As(g.GetUniformLocation)(g_program, "u_alpha");
+ g_resolutionUniform = As(g.GetUniformLocation)(g_program, "u_resolution");
+ g_posAttrib = As(g.GetAttribLocation)(g_program, "a_pos");
+ if (g_prevUniform < 0 || g_alphaUniform < 0 || g_resolutionUniform < 0 || g_posAttrib < 0) {
+ g_program = 0;
+ return false;
+ }
+ As(g.GenBuffers)(1, &g_vbo);
+ if (g_vbo == 0) {
+ g_program = 0;
+ return false;
+ }
+ As(g.BindBuffer)(GL_ARRAY_BUFFER, g_vbo);
+ As(g.BufferData)(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW);
+ return true;
+}
+
+bool EnsureCapture(const GlesFunctions &g, int width, int height) {
+ using GenTexturesFn = void (*)(std::int32_t, std::uint32_t *);
+ using BindTextureFn = void (*)(std::uint32_t, std::uint32_t);
+ using TexParameteriFn = void (*)(std::uint32_t, std::uint32_t, std::int32_t);
+ using TexImage2DFn = void (*)(std::uint32_t, std::int32_t, std::int32_t, std::int32_t,
+ std::int32_t, std::int32_t, std::uint32_t, std::uint32_t,
+ const void *);
+ if (width <= 0 || height <= 0) return false;
+ if (g_captureTex[0] != 0 && g_captureWidth == width && g_captureHeight == height) {
+ return true;
+ }
+ if (g.GenTextures == nullptr || g.BindTexture == nullptr || g.TexParameteri == nullptr ||
+ g.TexImage2D == nullptr) {
+ return false;
+ }
+ if (g_captureTex[0] == 0) {
+ As(g.GenTextures)(2, g_captureTex);
+ }
+ for (int i = 0; i < 2; ++i) {
+ As(g.BindTexture)(GL_TEXTURE_2D, g_captureTex[i]);
+ As(g.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,
+ static_cast(GL_LINEAR));
+ As(g.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER,
+ static_cast(GL_LINEAR));
+ As(g.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,
+ static_cast(GL_CLAMP_TO_EDGE));
+ As(g.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,
+ static_cast(GL_CLAMP_TO_EDGE));
+ As(g.TexImage2D)(GL_TEXTURE_2D, 0, static_cast(GL_RGBA), width,
+ height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+ }
+ g_captureWidth = width;
+ g_captureHeight = height;
+ g_captureValid = false; // the fresh textures hold no real frame yet
+ return true;
+}
+
+void RefreshPlan() {
+ if (g_planDirty.exchange(false, std::memory_order_acq_rel)) {
+ g_planSnapshot = g_plan;
+ }
+}
+
+} // namespace
+
+bool InstallPresentFrameEffect() {
+ if (g_installed.exchange(true, std::memory_order_relaxed)) {
+ return g_live.load(std::memory_order_relaxed);
+ }
+ if (!pl::runtime::IsSwapFrameBoundaryLive()) {
+ sweetLoaderLogger.info(
+ "Present-frame effect: no present hook on this build; frame generation and motion blur stay "
+ "unavailable");
+ return false;
+ }
+ if (!IsEglContextCurrent()) {
+ sweetLoaderLogger.info("Present-frame effect: no EGL context yet; will retry when one is current");
+ // Not an error: the context may not be current at install time. Leave the install flag false so a
+ // later call retries; report not-live for now.
+ g_installed.store(false, std::memory_order_relaxed);
+ return false;
+ }
+ const GlesFunctions gles = ResolveGles();
+ if (!gles.usable() || !BuildProgram(gles)) {
+ sweetLoaderLogger.warn(
+ "Present-frame effect: GL path unavailable (no shader program); the effects stay off");
+ return false;
+ }
+ g_live.store(true, std::memory_order_relaxed);
+ sweetLoaderLogger.info("Present-frame effect: seam live");
+ return true;
+}
+
+bool IsPresentFrameEffectLive() { return g_live.load(std::memory_order_relaxed); }
+
+void SetPresentFramePlan(const PresentFramePlan &plan) {
+ PresentFramePlan clamped = plan;
+ if (clamped.interpolationStrength < 0.f) clamped.interpolationStrength = 0.f;
+ if (clamped.interpolationStrength > 1.f) clamped.interpolationStrength = 1.f;
+ if (clamped.maxFramesPerRendered < 2) clamped.maxFramesPerRendered = 2;
+ if (clamped.maxFramesPerRendered > 3) clamped.maxFramesPerRendered = 3;
+ if (clamped.blurStrength < 0.f) clamped.blurStrength = 0.f;
+ if (clamped.blurStrength > 1.f) clamped.blurStrength = 1.f;
+ if (clamped.blurBudget < 0.f) clamped.blurBudget = 0.f;
+ if (clamped.blurBudget > 1.f) clamped.blurBudget = 1.f;
+ g_plan = clamped;
+ g_planDirty.store(true, std::memory_order_release);
+}
+
+PresentFramePlan GetPresentFramePlan() {
+ RefreshPlan();
+ return g_planSnapshot;
+}
+
+void OnPresentBoundary() {
+ // The present boundary is the one place an EGL context is guaranteed to be current, so both seams
+ // that need one install lazily here rather than at load time (when no context exists yet). Until
+ // they install, the modules stay badged; a build with no present hook never reaches this function.
+ //
+ if (!g_live.load(std::memory_order_relaxed)) {
+ InstallPresentFrameEffect();
+ if (!g_live.load(std::memory_order_relaxed)) return;
+ }
+ RefreshPlan();
+ const PresentFramePlan plan = g_planSnapshot;
+ if (!plan.frameGenerationEnabled && !plan.motionBlurEnabled) return;
+
+ const GlesFunctions g = ResolveGles();
+ if (!g.usable() || g_program == 0 || g.DrawArrays == nullptr) return;
+
+ std::int32_t viewport[4] = {0, 0, 0, 0};
+ As(g.GetIntegerv)(GL_VIEWPORT, viewport);
+ const int width = viewport[2];
+ const int height = viewport[3];
+ g_lastWidth.store(width, std::memory_order_relaxed);
+ g_lastHeight.store(height, std::memory_order_relaxed);
+ if (!EnsureCapture(g, width, height)) return;
+
+ // Blend weight: frame generation uses its interpolation weight; motion blur uses the strength
+ // (scaled by view speed when asked). When both are on the larger weight wins, so the effect the
+ // player enabled is not silently cancelled by the other.
+ float alpha = 0.f;
+ if (plan.motionBlurEnabled) {
+ alpha = ComputeBlendAlpha(plan.blurStrength, plan.blurBudget, 16.f, 1.f,
+ plan.weightByViewSpeed);
+ }
+ if (plan.frameGenerationEnabled) {
+ const float weight = FrameGenerationWeight(plan.interpolationStrength,
+ plan.reduceEdgeArtifacts);
+ if (weight > alpha) alpha = weight;
+ }
+ if (alpha <= 0.001f) return;
+
+ const std::uint32_t current = static_cast(g_prevIndex);
+ const std::uint32_t previous = static_cast(g_prevIndex ^ 1);
+
+ SavedState saved;
+ SaveState(g, saved);
+
+ using BindTextureFn = void (*)(std::uint32_t, std::uint32_t);
+ using ActiveTextureFn = void (*)(std::uint32_t);
+ using CopyTexImage2DFn = void (*)(std::uint32_t, std::int32_t, std::uint32_t, std::int32_t,
+ std::int32_t, std::int32_t, std::int32_t, std::int32_t);
+ using GenVertexArraysFn = void (*)(std::int32_t, std::uint32_t *);
+ using BindVertexArrayFn = void (*)(std::uint32_t);
+ using BindBufferFn = void (*)(std::uint32_t, std::uint32_t);
+ using UseProgramFn = void (*)(std::uint32_t);
+ using EnableVertexAttribArrayFn = void (*)(std::uint32_t);
+ using DisableVertexAttribArrayFn = void (*)(std::uint32_t);
+ using VertexAttribPointerFn = void (*)(std::uint32_t, std::int32_t, std::uint32_t, std::uint8_t,
+ std::int32_t, const void *);
+ using Uniform1iFn = void (*)(std::int32_t, std::int32_t);
+ using Uniform1fFn = void (*)(std::int32_t, float);
+ using Uniform2fFn = void (*)(std::int32_t, float, float);
+ using DrawArraysFn = void (*)(std::uint32_t, std::int32_t, std::int32_t);
+ using EnableFn = void (*)(std::uint32_t);
+ using DisableFn = void (*)(std::uint32_t);
+ using BlendFuncSeparateFn = void (*)(std::uint32_t, std::uint32_t, std::uint32_t, std::uint32_t);
+
+ if (g.GenVertexArrays == nullptr || g.BindVertexArray == nullptr || g.UseProgram == nullptr ||
+ g.Uniform1i == nullptr || g.Uniform2f == nullptr || g.DrawArrays == nullptr ||
+ g.CopyTexImage2D == nullptr || g.EnableVertexAttribArray == nullptr ||
+ g.DisableVertexAttribArray == nullptr || g.VertexAttribPointer == nullptr ||
+ g.BindBuffer == nullptr) {
+ RestoreState(g, saved);
+ return;
+ }
+ if (g_vao == 0) {
+ As(g.GenVertexArrays)(1, &g_vao);
+ }
+ As(g.BindVertexArray)(g_vao);
+ As(g.BindBuffer)(GL_ARRAY_BUFFER, g_vbo);
+ As(g.EnableVertexAttribArray)(static_cast(g_posAttrib));
+ As(g.VertexAttribPointer)(static_cast(g_posAttrib), 2,
+ GL_FLOAT, 0, 0, nullptr);
+
+ // Capture the frame the game just drew into the current texture (active unit 0).
+ As(g.ActiveTexture)(GL_TEXTURE0);
+ As(g.BindTexture)(GL_TEXTURE_2D, current);
+ As(g.CopyTexImage2D)(GL_TEXTURE_2D, 0, GL_RGBA, 0, 0, width, height, 0);
+
+ if (g_captureValid) {
+ // Composite: draw the previous frame over the back buffer with the blend alpha. Fixed-function
+ // blending mixes it with the current frame: result = current*(1-alpha) + previous*alpha.
+ As(g.Disable)(GL_DEPTH_TEST);
+ As(g.Disable)(GL_CULL_FACE);
+ As(g.Enable)(GL_BLEND);
+ As(g.BlendFuncSeparate)(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_SRC_ALPHA,
+ GL_ONE_MINUS_SRC_ALPHA);
+ As(g.UseProgram)(g_program);
+ As(g.BindTexture)(GL_TEXTURE_2D, previous);
+ As(g.Uniform1i)(g_prevUniform, 0);
+ As(g.Uniform1f)(g_alphaUniform, alpha);
+ As(g.Uniform2f)(g_resolutionUniform, static_cast(width),
+ static_cast(height));
+ As(g.DrawArrays)(GL_TRIANGLES, 0, 6);
+ }
+
+ As(g.DisableVertexAttribArray)(
+ static_cast(g_posAttrib));
+ RestoreState(g, saved);
+
+ // Rotate: this frame's capture becomes the previous frame for the next present.
+ g_prevIndex ^= 1;
+ g_captureValid = true;
+}
+
+} // namespace pl::render
diff --git a/src/pl/runtime/GamePlayerRender.cpp b/src/pl/runtime/GamePlayerRender.cpp
index 2308d22..fd0e5f4 100644
--- a/src/pl/runtime/GamePlayerRender.cpp
+++ b/src/pl/runtime/GamePlayerRender.cpp
@@ -54,6 +54,11 @@ RenderFn g_origRender = nullptr;
std::atomic_bool g_hookInstalled{false};
std::atomic g_renderTick{0}; // increments once per frame (first call)
+// Whether g_renderTick is the exact present boundary (the eglSwapBuffers hook counted this
+// frame) or an inference from render-call timestamps. The FPS reader needs to know: an
+// inferred tick can collapse or split frames when the visible-model count changes, so the
+// rate derived from it is approximate. The present hook makes it exact.
+std::atomic_bool g_renderTickExact{false};
std::atomic g_frameCalls{0}; // calls seen in the current frame
std::atomic g_totalCalls{0}; // lifetime call count
std::atomic g_lastRenderMs{0};
@@ -145,9 +150,11 @@ void HookRender(void *a, void *b, void *c, void *d, void *e, void *f, void *g, v
frameStarted = swapPresents != g_renderTick.load(std::memory_order_relaxed);
g_frameCalls.store(1, std::memory_order_relaxed);
g_renderTick.store(static_cast(swapPresents), std::memory_order_relaxed);
+ g_renderTickExact.store(true, std::memory_order_relaxed);
g_frameClock.onRenderCall(nowUs); // keep the fallback clock warm for a later detach
} else if (g_frameClock.onRenderCall(nowUs, pl::render::FrameTickClock::kDefaultMinIntervalUs,
&frameStarted)) {
+ g_renderTickExact.store(false, std::memory_order_relaxed);
g_frameCalls.store(1, std::memory_order_relaxed);
g_renderTick.store(g_frameClock.tick(), std::memory_order_relaxed);
} else {
@@ -386,6 +393,8 @@ bool IsPlayerRenderHookLive() {
return g_lastRenderMs.load(std::memory_order_relaxed) != 0;
}
+bool IsRenderTickExact() { return g_renderTickExact.load(std::memory_order_relaxed); }
+
bool ReadPlayerRenderStats(std::uint32_t out[4]) {
const long long last = g_lastRenderMs.load(std::memory_order_relaxed);
if (last == 0) {
diff --git a/src/pl/runtime/GamePlayerRender.h b/src/pl/runtime/GamePlayerRender.h
index 6fe0199..af079b8 100644
--- a/src/pl/runtime/GamePlayerRender.h
+++ b/src/pl/runtime/GamePlayerRender.h
@@ -51,6 +51,17 @@ bool IsPlayerRenderHookLive();
*/
bool IsPlayerRenderHookInstalled();
+/**
+ * @brief True when the render tick is the exact present boundary rather than an inference.
+ *
+ * The tick is exact once the engine's present hook (the {@code eglSwapBuffers} detour) is live and
+ * counting, because then it is the real frame count. Without that hook the tick is inferred from the
+ * render-entry timestamps, which can collapse or split frames when the visible-model count changes, so
+ * a frame rate derived from it is approximate and must be labelled as such rather than shown as the
+ * game's exact frame rate.
+ */
+bool IsRenderTickExact();
+
/**
* @brief The resolved {@code LivePlayerRenderer::render} entry address, or 0 if unresolved.
*
diff --git a/src/pl/runtime/InputJni.cpp b/src/pl/runtime/InputJni.cpp
index f76bdba..1b1d20f 100644
--- a/src/pl/runtime/InputJni.cpp
+++ b/src/pl/runtime/InputJni.cpp
@@ -12,6 +12,8 @@
#include "pl/memory/Signature.hpp"
#include "pl/runtime/InputBridge.h"
#include "pl/runtime/JavaRuntime.h"
+#include "pl/runtime/SwapFrameBoundary.h"
+#include "pl/render/PresentFrameEffect.hpp"
namespace {
@@ -253,6 +255,44 @@ Java_org_chimeramc_client_preloader_PreloaderInput_nativeIsPlayerRenderHookLive(
return pl::runtime::IsPlayerRenderHookLive() ? JNI_TRUE : JNI_FALSE;
}
+JNIEXPORT jboolean JNICALL
+Java_org_chimeramc_client_preloader_PreloaderInput_nativeIsPresentHookLive(
+ JNIEnv *env, jclass clazz) {
+ (void)env;
+ (void)clazz;
+ // The engine's present/frame-boundary hook. Frame generation and motion blur act on the presented
+ // frame, so this is their availability probe: a Vulkan-only build with no libEGL present call
+ // reports false and the modules stay badged rather than toggling to no effect.
+ return pl::runtime::IsSwapFrameBoundaryLive() ? JNI_TRUE : JNI_FALSE;
+}
+
+JNIEXPORT void JNICALL
+Java_org_chimeramc_client_preloader_PreloaderInput_nativeSetPresentFramePlan(
+ JNIEnv *env, jclass clazz, jboolean frameGenerationEnabled, jfloat interpolationStrength,
+ jint maxFramesPerRendered, jboolean reduceEdgeArtifacts, jboolean motionBlurEnabled,
+ jfloat blurStrength, jfloat blurBudget, jboolean weightByViewSpeed) {
+ (void)env;
+ (void)clazz;
+ pl::render::PresentFramePlan plan;
+ plan.frameGenerationEnabled = frameGenerationEnabled == JNI_TRUE;
+ plan.interpolationStrength = static_cast(interpolationStrength);
+ plan.maxFramesPerRendered = static_cast(maxFramesPerRendered);
+ plan.reduceEdgeArtifacts = reduceEdgeArtifacts == JNI_TRUE;
+ plan.motionBlurEnabled = motionBlurEnabled == JNI_TRUE;
+ plan.blurStrength = static_cast(blurStrength);
+ plan.blurBudget = static_cast(blurBudget);
+ plan.weightByViewSpeed = weightByViewSpeed == JNI_TRUE;
+ pl::render::SetPresentFramePlan(plan);
+}
+
+JNIEXPORT jboolean JNICALL
+Java_org_chimeramc_client_preloader_PreloaderInput_nativeIsPresentFrameEffectLive(
+ JNIEnv *env, jclass clazz) {
+ (void)env;
+ (void)clazz;
+ return pl::render::IsPresentFrameEffectLive() ? JNI_TRUE : JNI_FALSE;
+}
+
JNIEXPORT jintArray JNICALL
Java_org_chimeramc_client_preloader_PreloaderInput_nativeReadPlayerRenderStats(
JNIEnv *env, jclass clazz) {
@@ -261,11 +301,16 @@ Java_org_chimeramc_client_preloader_PreloaderInput_nativeReadPlayerRenderStats(
if (!pl::runtime::ReadPlayerRenderStats(stats)) {
return nullptr;
}
- jintArray result = env->NewIntArray(4);
+ // Five elements: {renderTick, callsThisFrame, totalCalls, msSinceLastRender, tickExact}. The fifth
+ // says whether the tick is the exact present boundary (1) or an inference (0), so the launcher can
+ // label a rate derived from an inferred tick as approximate instead of presenting it as the game's.
+ const bool exact = pl::runtime::IsRenderTickExact();
+ jintArray result = env->NewIntArray(5);
if (result != nullptr) {
- jint values[4] = {static_cast(stats[0]), static_cast(stats[1]),
- static_cast(stats[2]), static_cast(stats[3])};
- env->SetIntArrayRegion(result, 0, 4, values);
+ jint values[5] = {static_cast(stats[0]), static_cast(stats[1]),
+ static_cast(stats[2]), static_cast(stats[3]),
+ exact ? 1 : 0};
+ env->SetIntArrayRegion(result, 0, 5, values);
}
return result;
}
diff --git a/src/pl/runtime/RegisterAwareHook.cpp b/src/pl/runtime/RegisterAwareHook.cpp
index 3988766..932ead2 100644
--- a/src/pl/runtime/RegisterAwareHook.cpp
+++ b/src/pl/runtime/RegisterAwareHook.cpp
@@ -4,6 +4,8 @@
#include
#include
+#include "pl/render/PresentFrameEffect.hpp"
+
#if !defined(__aarch64__)
#error "RegisterAwareHook is only implemented for AArch64; the sweetloader builds arm64-v8a only."
#endif
@@ -69,6 +71,10 @@ void *pl_runtime_passthrough_swapBuffers_record() {
static_cast(ts.tv_sec) * 1000000LL + ts.tv_nsec / 1000LL,
std::memory_order_relaxed);
pl_runtime_swap_buffers_count.fetch_add(1, std::memory_order_relaxed);
+ // Run the present-frame effects (frame generation / motion blur) here, before the real swap: the
+ // back buffer still holds the frame the engine just drew, which is what they blend. This is a
+ // no-op unless a module is enabled and the seam installed, and it fails closed inside.
+ pl::render::OnPresentBoundary();
return reinterpret_cast(pl_runtime_passthrough_ctx_swapBuffers.original);
}
diff --git a/tests/cosmetics/PresentFrameMathTest.cpp b/tests/cosmetics/PresentFrameMathTest.cpp
new file mode 100644
index 0000000..c4a8a00
--- /dev/null
+++ b/tests/cosmetics/PresentFrameMathTest.cpp
@@ -0,0 +1,102 @@
+/**
+ * @file PresentFrameMathTest.cpp
+ * @brief Pins the present-frame effect math (frame generation + motion blur).
+ *
+ * These two functions are the whole honest content of the two modules: they decide how much of the
+ * previous frame is mixed in and where the interpolated frame sits. They are pure arithmetic, so
+ * they are verified here rather than on a device, exactly like the other pure rules in this tree.
+ *
+ * The contract under test:
+ * - neither function can return a value outside [0, 1];
+ * - a zero strength yields a zero blend (the module off is a no-op);
+ * - the budget caps the blend rather than being ignored;
+ * - a full-strength frame generation sits at the midpoint (weight 0.5).
+ */
+
+#include
+#include
+
+#include "pl/render/PresentFrameMath.hpp"
+
+using pl::render::ComputeBlendAlpha;
+using pl::render::ComputeInterpolationFactor;
+using pl::render::FrameGenerationWeight;
+
+namespace {
+
+int g_failures = 0;
+
+void ExpectNear(const char *what, float actual, float expected, float tol = 1e-5f) {
+ if (actual < expected - tol || actual > expected + tol) {
+ std::printf("FAIL %s: expected %.6f, got %.6f\n", what, expected, actual);
+ ++g_failures;
+ }
+}
+
+void ExpectInUnitRange(const char *what, float value) {
+ if (value < 0.f || value > 1.f) {
+ std::printf("FAIL %s: out of [0,1]: %.6f\n", what, value);
+ ++g_failures;
+ }
+}
+
+void BlendAlphaRules() {
+ // Zero strength is a no-op regardless of the other inputs.
+ ExpectNear("zero strength", ComputeBlendAlpha(0.f, 0.6f, 16.f, 1.f, false), 0.f);
+
+ // Strength passes through when the budget is generous and speed weighting is off.
+ ExpectNear("plain strength", ComputeBlendAlpha(0.35f, 1.0f, 16.f, 1.f, false), 0.35f);
+
+ // Speed weighting scales the alpha by the normalised speed.
+ ExpectNear("speed weighted", ComputeBlendAlpha(0.8f, 1.0f, 16.f, 0.5f, true), 0.4f);
+ // A speed above 1 is clamped, so the alpha never exceeds the strength.
+ ExpectNear("speed clamped", ComputeBlendAlpha(0.8f, 1.0f, 16.f, 5.f, true), 0.8f);
+
+ // A tight budget scales the alpha down proportionally: 6ms budget of a 12ms frame is half.
+ ExpectNear("budget halves", ComputeBlendAlpha(0.8f, 0.5f, 12.f, 1.f, false), 0.4f);
+
+ // A zero budget with a real interval leaves no room for the blend.
+ ExpectNear("zero budget", ComputeBlendAlpha(0.8f, 0.f, 12.f, 1.f, false), 0.8f);
+
+ // Unknown interval (<= 0) imposes no budget cut.
+ ExpectNear("unknown interval", ComputeBlendAlpha(0.8f, 0.1f, 0.f, 1.f, false), 0.8f);
+
+ // The result is always in range, even for absurd inputs.
+ ExpectInUnitRange("clamp high", ComputeBlendAlpha(9.f, 9.f, 1.f, 1.f, false));
+ ExpectInUnitRange("clamp low", ComputeBlendAlpha(-1.f, -1.f, -1.f, -1.f, true));
+}
+
+void InterpolationRules() {
+ // Full strength is the midpoint; the weight (distance from the current frame) is 0.5.
+ ExpectNear("full strength factor", ComputeInterpolationFactor(1.f, false), 0.5f);
+ ExpectNear("full strength weight", FrameGenerationWeight(1.f, false), 0.5f);
+
+ // Zero strength biases fully to the current real frame (factor 1, weight 0): the low-artefact end.
+ ExpectNear("zero strength factor", ComputeInterpolationFactor(0.f, false), 1.f);
+ ExpectNear("zero strength weight", FrameGenerationWeight(0.f, false), 0.f);
+
+ // The edge-artefact bias pulls the factor toward the current real frame (larger factor).
+ ExpectInUnitRange("edge bias", ComputeInterpolationFactor(0.5f, true));
+ if (ComputeInterpolationFactor(0.5f, true) <= ComputeInterpolationFactor(0.5f, false)) {
+ std::printf("FAIL edge bias: expected the factor to move toward the current real frame\n");
+ ++g_failures;
+ }
+
+ // Out-of-range strengths are clamped, and the factor stays in range.
+ ExpectInUnitRange("strength above one", ComputeInterpolationFactor(9.f, false));
+ ExpectInUnitRange("strength below zero", ComputeInterpolationFactor(-9.f, true));
+ ExpectNear("strength above one clamps", ComputeInterpolationFactor(9.f, false), 0.5f);
+}
+
+} // namespace
+
+int main() {
+ BlendAlphaRules();
+ InterpolationRules();
+ if (g_failures != 0) {
+ std::printf("%d present-frame math assertion(s) failed\n", g_failures);
+ return 1;
+ }
+ std::printf("PresentFrameMathTest: all assertions passed\n");
+ return 0;
+}
diff --git a/tests/cosmetics/run_tests.sh b/tests/cosmetics/run_tests.sh
index 948a070..37eea44 100755
--- a/tests/cosmetics/run_tests.sh
+++ b/tests/cosmetics/run_tests.sh
@@ -425,4 +425,10 @@ else
echo "== PublishedBlobTsanTest == (skipped: ThreadSanitizer/setarch unavailable)"
fi
+# The present-frame effect math (frame generation + motion blur) is pure arithmetic in a header, so
+# it is pinned here off-device. These two functions are the whole honest content of the two modules:
+# the blend weight and the interpolation factor, both clamped to [0, 1].
+compile_run PresentFrameMathTest \
+ "$here/PresentFrameMathTest.cpp"
+
echo "all cosmetics host tests passed"