From 82503315c6994a21db18a990afdc69dae9fade1c Mon Sep 17 00:00:00 2001 From: shenlei Date: Tue, 29 Sep 2026 18:14:19 +0900 Subject: [PATCH] native: frame-rate setting, idle frame rate and battery power telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MtFrameRate setting (30 / 60 default / display max) as app.Get/SetFrameRateMode, with a "画面帧率" row injected into the system option dialog (mt_framerate); saved in the SDL pref path. - FrameRatePolicy in the live loop: 30 fps after 15 s without input (not while auto hunting), display switched to 60 Hz unless the highest mode, ADPF setPreferPowerEfficiency, vsync pacing when the display already runs at the target. --fps-mode / --idle-fps / --idle-after; --fps-cap / --refresh-rate still pin a test run. - Perf log: battery current/voltage/power, plugged, fps_mode, idle columns; perf_summary groups power by mode. Co-Authored-By: Claude Opus 5.5 --- .../org/metin2port/client/MainActivity.java | 59 ++++++- .../src/platform/EterBase/FrameRateMode.h | 31 ++++ .../ScriptLib/FrameRateOptionScript.inc | 75 +++++++++ .../src/platform/ScriptLib/PythonBoot.cpp | 2 + .../UserInterface/PythonApplicationModule.cpp | 18 ++ native_render/android_perf.h | 66 ++++++++ native_render/main.cpp | 156 +++++++++++++++++- native_render/perf_log.h | 52 +++++- tools/perf_summary.py | 13 ++ 9 files changed, 453 insertions(+), 19 deletions(-) create mode 100644 extension/src/platform/EterBase/FrameRateMode.h create mode 100644 extension/src/platform/ScriptLib/FrameRateOptionScript.inc diff --git a/android-native/app/src/main/java/org/metin2port/client/MainActivity.java b/android-native/app/src/main/java/org/metin2port/client/MainActivity.java index 02b99b54..9a04f823 100644 --- a/android-native/app/src/main/java/org/metin2port/client/MainActivity.java +++ b/android-native/app/src/main/java/org/metin2port/client/MainActivity.java @@ -37,19 +37,20 @@ public class MainActivity extends SDLActivity { super.onCreate(savedInstanceState); setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE); applyImmersiveMode(); - chosenRefreshRate = applyRefreshRate(requestedRefreshRate(launchArgs())); + // --refresh-rate N pins the display mode for a test run; otherwise native_render switches it with + // the frame-rate setting (setDisplayRefreshRate). + final float pinned = requestedRefreshRate(launchArgs()); + if (pinned > 0) + applyRefreshRate(pinned); } - // The display rate picked in onCreate (0: left to the system); handed to native as --refresh-rate. - private int chosenRefreshRate = 0; - private String launchArgs() { Intent intent = getIntent(); String args = intent != null ? intent.getStringExtra("args") : null; return args == null || args.trim().isEmpty() ? DEFAULT_ARGS : args; } - // --refresh-rate N (0 = system default); absent = the display's highest rate. + // --refresh-rate N (0 = system default); -1 when absent. private static float requestedRefreshRate(String args) { final String[] tokens = args.trim().split("\\s+"); for (int i = 0; i + 1 < tokens.length; ++i) { @@ -141,6 +142,52 @@ public class MainActivity extends SDLActivity { return tenths / 10f; } + // Called from native code (android_perf.h): the display mode whose refresh rate is closest to `hz` + // (the highest one when hz < 0), for the frame-rate setting. The panel then need not scan out at 120 Hz + // for a 60 fps game. + public void setDisplayRefreshRate(float hz) { + runOnUiThread(() -> applyRefreshRate(hz)); + } + + // Called from native code (android_perf.h): the highest refresh rate of the current resolution. + public float maxRefreshRate() { + final Display display = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? getDisplay() + : getWindowManager().getDefaultDisplay(); + if (display == null) + return -1f; + float best = display.getRefreshRate(); + final Display.Mode current = display.getMode(); + for (Display.Mode mode : display.getSupportedModes()) + if (mode.getPhysicalWidth() == current.getPhysicalWidth() && + mode.getPhysicalHeight() == current.getPhysicalHeight()) + best = Math.max(best, mode.getRefreshRate()); + return best; + } + + // Called from native code (android_perf.h): BATTERY_PROPERTY_CURRENT_NOW as reported -- uA per the + // docs, mA on some vendors; the sign convention varies too (android_perf::battery_power sorts both + // out). 0 if unknown. + public float batteryCurrentRaw() { + final BatteryManager manager = (BatteryManager) getSystemService(BATTERY_SERVICE); + if (manager == null) + return 0f; + final int raw = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW); + return raw == Integer.MIN_VALUE ? 0f : raw; + } + + // Called from native code (android_perf.h): battery voltage in mV, -1 if unknown. + public float batteryVoltage() { + final Intent battery = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + return battery != null ? battery.getIntExtra(BatteryManager.EXTRA_VOLTAGE, -1) : -1f; + } + + // Called from native code (android_perf.h): 1 while a charger is connected (the current then no + // longer measures the game's draw), 0 otherwise. + public float batteryPlugged() { + final Intent battery = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + return battery != null && battery.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0 ? 1f : 0f; + } + // Called from native code (android_perf.h): the rate the display runs at now. public float currentRefreshRate() { final Display display = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? getDisplay() @@ -204,8 +251,6 @@ public class MainActivity extends SDLActivity { // Test phase: FPS readout in the top-left corner. Drop this line for release builds. if (!args.contains("--show-fps")) args = args.trim() + " --show-fps"; - if (!args.contains("--refresh-rate") && chosenRefreshRate > 0) - args = args.trim() + " --refresh-rate " + chosenRefreshRate; return args.trim().split("\\s+"); } diff --git a/extension/src/platform/EterBase/FrameRateMode.h b/extension/src/platform/EterBase/FrameRateMode.h new file mode 100644 index 00000000..6a8d9426 --- /dev/null +++ b/extension/src/platform/EterBase/FrameRateMode.h @@ -0,0 +1,31 @@ +#pragma once +// PORT: the player's frame-rate setting (system option dialog, app.Get/SetFrameRateMode), shared between +// the script thread and the host's render loop (native_render/main.cpp), which paces frames by it and +// saves it. 40250 has no counterpart: the PC client ran at the monitor's rate. + +#include + +namespace MtFrameRate { + +enum EMode { + MODE_SAVER = 0, // 30 fps + MODE_STANDARD = 1, // 60 fps, 30 after a while without input + MODE_HIGH = 2, // the display's highest rate (120 on current phones), 30 after a while without input + MODE_COUNT +}; + +inline std::atomic& Mode() +{ + static std::atomic mode{MODE_STANDARD}; + return mode; +} + +inline int Get() { return Mode().load(std::memory_order_relaxed); } + +inline void Set(int mode) +{ + if (mode >= 0 && mode < MODE_COUNT) + Mode().store(mode, std::memory_order_relaxed); +} + +} // namespace MtFrameRate diff --git a/extension/src/platform/ScriptLib/FrameRateOptionScript.inc b/extension/src/platform/ScriptLib/FrameRateOptionScript.inc new file mode 100644 index 00000000..fcda17ca --- /dev/null +++ b/extension/src/platform/ScriptLib/FrameRateOptionScript.inc @@ -0,0 +1,75 @@ +// The frame-rate row of the system option dialog (PORT; 40250 has no counterpart, the PC client ran at +// the monitor's rate), run by PythonBoot::RunMainScript as module mt_framerate. It wraps +// uiSystemOption.OptionDialog's __Load and fills the row the original layout leaves empty at y 210 (its +// shadow slider is commented out), in the style of the fog buttons above it. The value is +// app.Get/SetFrameRateMode (platform/EterBase/FrameRateMode.h); the host paces frames by it and saves it. +// Strings are UTF-8 here and go through player.AutoHuntLocalize into the locale's code page. +static const char c_szFrameRateOptionScript[] = R"PY(# -*- coding: utf-8 -*- +import app +import player +import ui +import uiSystemOption + +L = player.AutoHuntLocalize + +SMALL_BUTTON = "d:/ymir work/ui/public/small_button_%02d.sub" +ROW_Y = 210 +MODE_TEXTS = ("30帧", "60帧", "最高") + + +def _Refresh(buttons): + mode = app.GetFrameRateMode() + for i in xrange(len(buttons)): + if i == mode: + buttons[i].Down() + else: + buttons[i].SetUp() + + +def _Select(buttons, mode): + app.SetFrameRateMode(mode) + _Refresh(buttons) + + +def _Attach(dialog): + board = dialog.GetChild("board") + + label = ui.TextLine() + label.SetParent(board) + label.SetPosition(30, ROW_Y + 2) + label.SetText(L("画面帧率")) + label.Show() + + buttons = [] + for i in xrange(len(MODE_TEXTS)): + button = ui.RadioButton() + button.SetParent(board) + button.SetUpVisual(SMALL_BUTTON % 1) + button.SetOverVisual(SMALL_BUTTON % 2) + button.SetDownVisual(SMALL_BUTTON % 3) + button.SetPosition(110 + 50 * i, ROW_Y) + button.SetText(L(MODE_TEXTS[i])) + button.SetEvent(lambda mode=i: _Select(buttons, mode)) + button.Show() + buttons.append(button) + _Refresh(buttons) + + dialog.mtFrameRateLabel = label + dialog.mtFrameRateButtons = buttons + + +def __Install(): + load = uiSystemOption.OptionDialog._OptionDialog__Load + + def Load(self): + load(self) + try: + _Attach(self) + except: + import dbg + dbg.TraceError("mt_framerate: cannot add the frame-rate row") + + uiSystemOption.OptionDialog._OptionDialog__Load = Load + +__Install() +)PY"; diff --git a/extension/src/platform/ScriptLib/PythonBoot.cpp b/extension/src/platform/ScriptLib/PythonBoot.cpp index f150bc15..3f469d9b 100644 --- a/extension/src/platform/ScriptLib/PythonBoot.cpp +++ b/extension/src/platform/ScriptLib/PythonBoot.cpp @@ -876,6 +876,7 @@ namespace PythonBoot // have to run once the root scripts are importable; a failure leaves the game without them, not broken. #include "AutoHuntScript.inc" #include "RegisterScript.inc" +#include "FrameRateOptionScript.inc" static void install_script_module(const char* c_szModule, const char* c_szScript) { @@ -931,6 +932,7 @@ bool RunMainScript(const char* lpCmdLine, std::string* error) { install_script_module("mt_autohunt", c_szAutoHuntScript); install_script_module("mt_register", c_szRegisterScript); + install_script_module("mt_framerate", c_szFrameRateOptionScript); } return true; } diff --git a/extension/src/port/UserInterface/PythonApplicationModule.cpp b/extension/src/port/UserInterface/PythonApplicationModule.cpp index c9398a7a..6564744b 100644 --- a/extension/src/port/UserInterface/PythonApplicationModule.cpp +++ b/extension/src/port/UserInterface/PythonApplicationModule.cpp @@ -3,6 +3,7 @@ #include "resource.h" #include "PythonApplication.h" #include "../EterLib/Camera.h" +#include "../../platform/EterBase/FrameRateMode.h" // PORT: frame-rate setting extern bool PERF_CHECKER_RENDER_GAME; extern D3DXCOLOR g_fSpecularColor; @@ -1191,6 +1192,21 @@ PyObject* appLogoClose(PyObject* poSelf, PyObject* poArgs) return Py_BuildNone(); } +// PORT: the frame-rate setting of the system option dialog (platform/EterBase/FrameRateMode.h). +PyObject* appGetFrameRateMode(PyObject* poSelf, PyObject* poArgs) +{ + return Py_BuildValue("i", MtFrameRate::Get()); +} + +PyObject* appSetFrameRateMode(PyObject* poSelf, PyObject* poArgs) +{ + int iMode; + if (!PyTuple_GetInteger(poArgs, 0, &iMode)) + return Py_BuildException(); + MtFrameRate::Set(iMode); + return Py_BuildNone(); +} + void initapp() { static PyMethodDef s_methods[] = @@ -1257,6 +1273,8 @@ void initapp() { "GetCursorPosition", appGetCursorPosition, METH_VARARGS }, { "GetRandom", appGetRandom, METH_VARARGS }, + { "GetFrameRateMode", appGetFrameRateMode, METH_VARARGS }, // PORT + { "SetFrameRateMode", appSetFrameRateMode, METH_VARARGS }, // PORT { "RunPythonFile", appRunPythonFile, METH_VARARGS }, { "IsWebPageMode", appIsWebPageMode, METH_VARARGS }, { "ShowWebPage", appShowWebPage, METH_VARARGS }, diff --git a/native_render/android_perf.h b/native_render/android_perf.h index f157ad22..7fc99583 100644 --- a/native_render/android_perf.h +++ b/native_render/android_perf.h @@ -10,11 +10,18 @@ // - PerformanceHint: ADPF APerformanceHint (API 33). The loop reports each frame's CPU work against the // frame budget, so the governor raises the clocks before a frame misses instead of idling the CPU at // its lowest step between short bursts (the 556-748 MHz seen in perf-20260929-153205.csv). +// prefer_power_efficiency(): APerformanceHint_setPreferPowerEfficiency (API 35), set by the frame-rate +// policy (main.cpp FrameRatePolicy) except in the highest mode. +// - set_display_refresh_rate() / max_refresh_rate(): MainActivity switches the display mode at run time +// for the frame-rate setting (60 Hz unless the setting wants more). +// - battery_power(): BatteryManager current + voltage for the perf log's power columns; the power is only +// meaningful off the charger (USB adb counts as a charger: test over wireless adb). // // Every NDK entry point is looked up at run time: minSdk is 24. On other platforms all of this is a no-op. #include +#include #include #include @@ -50,11 +57,48 @@ inline double call_activity_float(const char* name) { return hz; } +inline void call_activity_set_float(const char* name, float value) { + auto* env = static_cast(SDL_GetAndroidJNIEnv()); + auto activity = static_cast(SDL_GetAndroidActivity()); + if (!env || !activity) return; + jclass cls = env->GetObjectClass(activity); + if (jmethodID method = env->GetMethodID(cls, name, "(F)V")) + env->CallVoidMethod(activity, method, jfloat(value)); + if (env->ExceptionCheck()) env->ExceptionClear(); + env->DeleteLocalRef(cls); + env->DeleteLocalRef(activity); +} + inline double display_refresh_rate() { return call_activity_float("currentRefreshRate"); } +// The highest refresh rate the display offers at its current resolution; -1 if unknown. +inline double max_refresh_rate() { return call_activity_float("maxRefreshRate"); } +// Switches the display mode to the refresh rate closest to `hz` (MainActivity.preferredDisplayModeId). +inline void set_display_refresh_rate(float hz) { call_activity_set_float("setDisplayRefreshRate", hz); } // The app-vsync rate (Choreographer); -1 until the first measurement. inline double render_rate() { return call_activity_float("currentRenderRate"); } inline double battery_celsius() { return call_activity_float("batteryTemperature"); } +struct BatteryPower { + double current_ma = 0; // drawn from the battery, positive while discharging + double voltage_mv = -1; + double power_mw = -1; // current x voltage; -1 while plugged in or unknown + bool plugged = false; +}; + +// BatteryManager's CURRENT_NOW is uA by the docs but mA on some vendors, and its sign convention varies: +// a game draws well over 20 mA, so a magnitude above 20000 can only be uA; unplugged, the whole current is +// the draw. Plugged in, the charger supplies part of it and the reading says nothing about the game. +inline BatteryPower battery_power() { + BatteryPower out; + const double raw = call_activity_float("batteryCurrentRaw"); + out.current_ma = std::fabs(raw) > 20000.0 ? std::fabs(raw) / 1000.0 : std::fabs(raw); + out.voltage_mv = call_activity_float("batteryVoltage"); + out.plugged = call_activity_float("batteryPlugged") > 0.5; + if (!out.plugged && out.current_ma > 0 && out.voltage_mv > 0) + out.power_mw = out.current_ma * out.voltage_mv / 1000.0; + return out; +} + // false when the call is unavailable (API < 30) or rejected. inline bool request_frame_rate(SDL_Window* window, float fps) { using SetFrameRate = int32_t (*)(ANativeWindow*, float, int8_t); @@ -79,6 +123,8 @@ public: update_target_ = reinterpret_cast(dlsym(lib, "APerformanceHint_updateTargetWorkDuration")); report_ = reinterpret_cast(dlsym(lib, "APerformanceHint_reportActualWorkDuration")); close_ = reinterpret_cast(dlsym(lib, "APerformanceHint_closeSession")); + prefer_efficiency_ = reinterpret_cast( + dlsym(lib, "APerformanceHint_setPreferPowerEfficiency")); if (!get_manager_ || !create_ || !report_ || !close_) return false; void* manager = get_manager_(); if (!manager) return false; @@ -103,17 +149,29 @@ public: if (session_ && actual_ns > 0) report_(session_, actual_ns); } + // API 35: the scheduler may favour efficiency cores and lower clocks for the session's threads. + // false when unavailable. + bool prefer_power_efficiency(bool enabled) { + if (!session_ || !prefer_efficiency_) return false; + if (enabled == prefer_efficiency_on_) return true; + prefer_efficiency_on_ = enabled; + return prefer_efficiency_(session_, enabled) == 0; + } + private: using GetManager = void* (*)(); using CreateSession = void* (*)(void*, const int32_t*, size_t, int64_t); using UpdateTarget = int (*)(void*, int64_t); using Report = int (*)(void*, int64_t); using Close = void (*)(void*); + using PreferEfficiency = int (*)(void*, bool); GetManager get_manager_ = nullptr; CreateSession create_ = nullptr; UpdateTarget update_target_ = nullptr; Report report_ = nullptr; Close close_ = nullptr; + PreferEfficiency prefer_efficiency_ = nullptr; + bool prefer_efficiency_on_ = false; void* session_ = nullptr; std::int64_t target_ns_ = 0; }; @@ -125,7 +183,14 @@ inline double display_refresh_rate() { return mode ? double(mode->refresh_rate) : -1.0; } inline double render_rate() { return display_refresh_rate(); } +inline double max_refresh_rate() { return display_refresh_rate(); } +inline void set_display_refresh_rate(float) {} inline double battery_celsius() { return -1.0; } +struct BatteryPower { + double current_ma = 0, voltage_mv = -1, power_mw = -1; + bool plugged = false; +}; +inline BatteryPower battery_power() { return {}; } inline bool request_frame_rate(SDL_Window*, float) { return false; } class PerformanceHint { public: @@ -133,6 +198,7 @@ public: bool active() const { return false; } void set_target(std::int64_t) {} void report(std::int64_t) {} + bool prefer_power_efficiency(bool) { return false; } }; inline int32_t current_thread_id() { return 0; } #endif diff --git a/native_render/main.cpp b/native_render/main.cpp index 5e9c2d87..b2404b86 100644 --- a/native_render/main.cpp +++ b/native_render/main.cpp @@ -12,6 +12,7 @@ #include "platform/ScriptLib/PythonBoot.h" #include "platform/UserInterface/ServerClock.h" #include "platform/EterBase/TraceErrorObserver.h" +#include "platform/EterBase/FrameRateMode.h" #include "../tests/port_login_flow_server.h" #include #endif @@ -1033,6 +1034,13 @@ public: out.write(reinterpret_cast(file.data()), std::streamsize(file.size())); } + // Seconds since the last touch, mouse or key event; 0 while a finger or mouse button is held (a held + // joystick or attack button sends no events). The frame-rate policy's idle test. + double input_idle_seconds() const { + if (fingers_down_ > 0 || buttons_down_ > 0) return 0.0; + return std::chrono::duration(std::chrono::steady_clock::now() - last_input_).count(); + } + void request_screenshot(std::string path, std::uint64_t frame) { screenshot_path_ = std::move(path); screenshot_frame_ = frame; @@ -1277,6 +1285,7 @@ public: #endif SDL_Event event; while (SDL_PollEvent(&event)) { + note_input(event); #ifdef MT_NATIVE_HAS_LIVE_CLIENT // Touches arrive as SDL_EVENT_FINGER_*; the touch-synthesized mouse copy // would register as a second finger (101) and turn every drag into a pinch. @@ -3570,6 +3579,27 @@ private: VkExtent2D extent_{}; std::vector image_views_; std::vector swapchain_images_; + void note_input(const SDL_Event& event) { + switch (event.type) { + case SDL_EVENT_FINGER_DOWN: ++fingers_down_; break; + case SDL_EVENT_FINGER_UP: + case SDL_EVENT_FINGER_CANCELED: fingers_down_ = std::max(0, fingers_down_ - 1); break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: ++buttons_down_; break; + case SDL_EVENT_MOUSE_BUTTON_UP: buttons_down_ = std::max(0, buttons_down_ - 1); break; + case SDL_EVENT_WINDOW_FOCUS_LOST: case SDL_EVENT_WILL_ENTER_BACKGROUND: + // An up event lost with the focus must not keep the client out of the idle rate for good. + fingers_down_ = buttons_down_ = 0; + break; + case SDL_EVENT_FINGER_MOTION: case SDL_EVENT_MOUSE_MOTION: case SDL_EVENT_MOUSE_WHEEL: + case SDL_EVENT_KEY_DOWN: case SDL_EVENT_KEY_UP: case SDL_EVENT_TEXT_INPUT: + case SDL_EVENT_WINDOW_FOCUS_GAINED: case SDL_EVENT_DID_ENTER_FOREGROUND: break; + default: return; + } + last_input_ = std::chrono::steady_clock::now(); + } + std::chrono::steady_clock::time_point last_input_ = std::chrono::steady_clock::now(); + int fingers_down_ = 0, buttons_down_ = 0; + // --screenshot-out: read back the swapchain image of one frame into a BMP file. std::string screenshot_path_; std::uint64_t screenshot_frame_ = 0; @@ -3739,6 +3769,74 @@ int g_fps_cap = 0; // --refresh-rate N: the surface's preferred frame rate (Android ANativeWindow_setFrameRate; MainActivity // also picks the matching display mode). 0 leaves the system default. int g_refresh_rate = 0; +// --fps-mode N: the frame-rate setting for this run (a test override: the saved setting stays); +// --idle-fps N: the rate after --idle-after S seconds without input (0 = never lower it). +int g_fps_mode = -1; +int g_idle_fps = 30; +double g_idle_after_s = 15.0; + +// The frame-rate setting (system option dialog, platform/EterBase/FrameRateMode.h) turned into a frame +// rate, a display refresh rate and the ADPF budget; --fps-cap / --refresh-rate pin those for a test run. +// Frames the player does not watch closely cost the same power as the ones they do, so after a while +// without input (and no auto hunt running) the rate drops to --idle-fps until the next touch. The display +// is asked for 60 Hz unless the setting wants more: a 120 Hz panel scanning out a 60 fps game costs power +// for nothing. +class FrameRatePolicy { +public: + struct Decision { + int mode = -1; + int fps = 0; // frames per second the loop aims at + int display_hz = 0; // the refresh rate asked of the display + bool idle = false; + bool operator==(const Decision&) const = default; + }; + + bool pinned() const { return g_fps_cap > 0 || g_refresh_rate > 0; } + + void load() { + path_ = settings_path(); + int mode = MtFrameRate::MODE_STANDARD; + if (std::ifstream in{path_}; in) in >> mode; + if (g_fps_mode >= 0) mode = g_fps_mode; + MtFrameRate::Set(mode); + saved_mode_ = MtFrameRate::Get(); + max_hz_ = int(std::lround(android_perf::max_refresh_rate())); + if (max_hz_ < 30) max_hz_ = 60; + } + + Decision decide(double idle_seconds, bool auto_hunt) { + Decision d; + d.mode = MtFrameRate::Get(); + if (d.mode != saved_mode_) save(saved_mode_ = d.mode); + const int active = d.mode == MtFrameRate::MODE_SAVER ? 30 + : d.mode == MtFrameRate::MODE_HIGH ? max_hz_ : std::min(60, max_hz_); + d.idle = g_idle_fps > 0 && g_idle_fps < active && idle_seconds >= g_idle_after_s && !auto_hunt; + d.fps = d.idle ? g_idle_fps : active; + d.display_hz = d.mode == MtFrameRate::MODE_HIGH && !d.idle ? max_hz_ : std::min(60, max_hz_); + return d; + } + + int max_hz() const { return max_hz_; } + +private: + static std::string settings_path() { + std::string dir; + if (char* pref = SDL_GetPrefPath("metin2port", "client")) { + dir = pref; + SDL_free(pref); + } + return dir + "frame_rate.cfg"; + } + + void save(int mode) { + std::ofstream out{path_, std::ios::trunc}; + out << mode << '\n'; + } + + std::string path_; + int saved_mode_ = -1; + int max_hz_ = 60; +}; bool py_exec(const std::string& code) { std::string err; @@ -4083,16 +4181,21 @@ int run_live_client( perf_log.set_refresh_rate_source([] { return android_perf::display_refresh_rate(); }); perf_log.set_render_rate_source([] { return android_perf::render_rate(); }); perf_log.set_battery_source([] { return android_perf::battery_celsius(); }); + perf_log.set_power_source([] { + const auto p = android_perf::battery_power(); + return native_perf::PowerSample{p.current_ma, p.voltage_mv, p.power_mw, p.plugged}; + }); // ADPF: each frame's CPU work (everything but the vsync/fence waits and the cap's sleep) against the // frame budget. Opened once the script thread exists, i.e. here. using steady = std::chrono::steady_clock; // The target is 80% of the frame period: the governor settles the clocks so the reported work just // meets the target, so a target equal to the period leaves every other frame late (cap 60 on the // test phone: 54 fps against the period, 60 against 80% of it). - const auto frame_budget = [] { - const int fps = g_fps_cap > 0 ? g_fps_cap : (g_refresh_rate > 0 ? g_refresh_rate : 60); - return std::chrono::nanoseconds(800'000'000LL / fps); - }(); + const auto budget_for = [](int fps) { return std::chrono::nanoseconds(800'000'000LL / std::max(1, fps)); }; + FrameRatePolicy policy; + policy.load(); + const auto frame_budget = budget_for( + g_fps_cap > 0 ? g_fps_cap : (g_refresh_rate > 0 ? g_refresh_rate : 60)); android_perf::PerformanceHint hint; { std::vector tids{android_perf::current_thread_id()}; @@ -4106,10 +4209,46 @@ int run_live_client( int completed = 0; std::vector frame_ms; if (frames > 0) frame_ms.reserve(static_cast(frames)); - const auto cap_period = g_fps_cap > 0 ? std::chrono::nanoseconds(1'000'000'000LL / g_fps_cap) - : std::chrono::nanoseconds(0); + auto cap_period = g_fps_cap > 0 ? std::chrono::nanoseconds(1'000'000'000LL / g_fps_cap) + : std::chrono::nanoseconds(0); auto next_frame_at = steady::now(); + FrameRatePolicy::Decision decision; + auto display_checked_at = steady::time_point{}; + // The loop sleeps to the policy's rate unless the display already runs at it (vsync then paces the + // loop, and a second clock beating against it would drop frames). + const auto apply_policy = [&] { + if (policy.pinned()) return; + const auto now = steady::now(); + const auto next = policy.decide(renderer.input_idle_seconds(), PythonBoot::AutoHuntIsEnabled()); + const bool changed = !(next == decision); + if (changed) { + if (next.display_hz != decision.display_hz) { + android_perf::set_display_refresh_rate(float(next.display_hz)); + android_perf::request_frame_rate(renderer.sdl_window(), float(next.display_hz)); + } + hint.set_target(budget_for(next.fps).count()); + // Efficiency cores and lower clocks, except where the setting asks for the highest rate. + hint.prefer_power_efficiency(next.mode != MtFrameRate::MODE_HIGH || next.idle); + perf_log.set_fps_cap(next.fps); + perf_log.set_frame_policy(next.mode, next.idle); + SDL_Log("frame rate: mode %d -> %d fps, display %d Hz%s", next.mode, next.fps, next.display_hz, + next.idle ? " (idle)" : ""); + decision = next; + } + if (changed || now - display_checked_at > std::chrono::seconds(1)) { + display_checked_at = now; + const double hz = android_perf::display_refresh_rate(); + const bool vsync_paced = hz > 0 && std::fabs(hz - decision.fps) < 2.0; + const auto period = vsync_paced ? std::chrono::nanoseconds(0) + : std::chrono::nanoseconds(1'000'000'000LL / decision.fps); + if (period != cap_period) { + cap_period = period; + next_frame_at = now; + } + } + }; while ((frames == 0 || completed < frames) && renderer.poll(true) && PythonBoot::IsAppLooping()) { + apply_policy(); double pace_ms = 0.0; if (cap_period.count()) { // Fixed cadence: a late frame starts the next one at once, a frame more than one period @@ -4255,6 +4394,9 @@ int main(int argc, char** argv) { else if (arg == "--perf-log") g_perf_log = true; else if (arg == "--fps-cap" && i+1 < argc) g_fps_cap = std::max(0, std::stoi(argv[++i])); else if (arg == "--refresh-rate" && i+1 < argc) g_refresh_rate = std::max(0, std::stoi(argv[++i])); + else if (arg == "--fps-mode" && i+1 < argc) g_fps_mode = std::stoi(argv[++i]); + else if (arg == "--idle-fps" && i+1 < argc) g_idle_fps = std::max(0, std::stoi(argv[++i])); + else if (arg == "--idle-after" && i+1 < argc) g_idle_after_s = std::max(0.0, std::stod(argv[++i])); #endif else if (arg == "--fake-idle-ms" && i+1 < argc) setenv("MT_FAKE_IDLE_MS", argv[++i], 1); else if (arg == "--frames-in-flight" && i+1 < argc) frames_in_flight = std::stoi(argv[++i]); @@ -4266,7 +4408,7 @@ int main(int argc, char** argv) { "[--draws N] [--triangles-per-draw N] [--capture FILE] [--animate-first-draw] " "[--animate-bones] [--no-vsync] [--live-client DIR] [--fake-mobs N] " "[--gpu-skinning|--no-gpu-skinning] [--no-terrain] [--mobile] [--capture-out FILE] [--screenshot-out FILE.bmp] [--show-fps] [--perf-log] " - "[--fps-cap N] [--refresh-rate HZ] [--frames-in-flight 1|2] [--msaa 1|2|4|8] [--fake-idle-ms MS] [--cpu-shadow]"); + "[--fps-cap N] [--refresh-rate HZ] [--fps-mode 0|1|2] [--idle-fps N] [--idle-after S] [--frames-in-flight 1|2] [--msaa 1|2|4|8] [--fake-idle-ms MS] [--cpu-shadow]"); } #ifdef MT_NATIVE_HAS_LIVE_CLIENT if (native_perf::PerfLog::requested_by_env()) g_perf_log = true; diff --git a/native_render/perf_log.h b/native_render/perf_log.h index 9ef86648..abd36d78 100644 --- a/native_render/perf_log.h +++ b/native_render/perf_log.h @@ -47,6 +47,12 @@ struct RendererTotals { const std::string* last_texture = nullptr; // name of the texture uploaded last }; +// One battery reading (android_perf::battery_power). power_mw < 0: unknown or on a charger. +struct PowerSample { + double current_ma = 0, voltage_mv = -1, power_mw = -1; + bool plugged = false; +}; + struct FrameInput { double frame_ms = 0; // whole loop iteration double app_ms = 0; // PythonBoot::UIUpdate: the script thread's Process() plus the baton handoff @@ -61,6 +67,7 @@ public: static constexpr double kIntervalS = 2.0; static constexpr double kHitchMs = 100.0; static constexpr int kKeepFiles = 10; + static constexpr double kPowerSampleS = 0.25; static bool requested_by_env() { const char* env = std::getenv("MT_PERF_LOG"); @@ -75,7 +82,11 @@ public: void set_render_rate_source(std::function source) { render_rate_ = std::move(source); } // Battery temperature in C (Android: the ACTION_BATTERY_CHANGED sticky intent; sysfs is SELinux-denied). void set_battery_source(std::function source) { battery_ = std::move(source); } + // Sampled every kPowerSampleS and averaged over the row: one reading swings with each frame's burst. + void set_power_source(std::function source) { power_ = std::move(source); } void set_fps_cap(int cap) { fps_cap_ = cap; } + // The frame-rate setting (FrameRateMode.h) and whether the idle rate is in effect, as of the row's end. + void set_frame_policy(int mode, bool idle) { fps_mode_ = mode; idle_ = idle; } const std::string& path() const { return path_; } // `context` goes into the file header (device, present mode, MSAA, size...). @@ -104,7 +115,8 @@ public: "render_game_ms,shadow_raster_ms,py_ms,py_calls,py_missing,host_ms,render_ms,pace_ms,sync_ms,fence_ms," "prepare_ms,geo_upload_ms,tex_upload_ms," "submit_ms,present_ms,gpu_ms,draws,skinned_draws,ui_batches,vertices,geo_uploads,geo_kb,tex_uploads,tex_kb," - "script_cpu,main_cpu,cpu_mhz,cpu_max_mhz,thermal,batt_c,rss_mb,last_texture\n"); + "script_cpu,main_cpu,cpu_mhz,cpu_max_mhz,thermal,batt_c,rss_mb,batt_ma,batt_mv,power_mw,plugged,fps_mode,idle," + "last_texture\n"); std::fflush(file_); MtPerf::Enabled() = true; MtPerf::Take(); @@ -163,9 +175,25 @@ public: script_cpu_ = game.scriptCpu; const auto now = std::chrono::steady_clock::now(); + if (power_ && std::chrono::duration(now - power_sampled_at_).count() >= kPowerSampleS) { + power_sampled_at_ = now; + const PowerSample sample = power_(); + power_sum_.current_ma += sample.current_ma; + power_sum_.voltage_mv += sample.voltage_mv; + if (sample.power_mw >= 0) { + power_sum_.power_mw += sample.power_mw; + ++power_valid_; + } + power_sum_.plugged = power_sum_.plugged || sample.plugged; + ++power_samples_; + } const double elapsed = std::chrono::duration(now - window_start_).count(); if (elapsed < kIntervalS) return; write_row(elapsed, map_name()); + power_sum_ = {}; + power_sum_.voltage_mv = 0; + power_sum_.power_mw = 0; + power_samples_ = power_valid_ = 0; window_ = {}; frame_times_.clear(); window_start_ = now; @@ -354,13 +382,18 @@ private: const int main_cpu = current_cpu(); const double refresh = refresh_rate_ ? refresh_rate_() : -1.0; const double render_rate = render_rate_ ? render_rate_() : -1.0; + const double ns = double(power_samples_); + const double batt_ma = power_samples_ ? power_sum_.current_ma / ns : -1.0; + const double batt_mv = power_samples_ ? power_sum_.voltage_mv / ns : -1.0; + // Only when every sample of the row was off the charger. + const double power_mw = power_valid_ && power_valid_ == power_samples_ ? power_sum_.power_mw / ns : -1.0; std::fprintf(file_, "%.1f,%s,%d,%llu,%.1f,%.1f,%.1f,%d,%.2f,%.2f,%.2f,%d,%d," "%.2f,%.2f,%.2f,%.3f,%.3f,%.3f,%.3f,%.3f,%.3f," "%.3f,%.3f,%.3f,%.1f,%.1f,%.3f,%.2f,%.2f,%.2f,%.2f," "%.2f,%.3f,%.3f," "%.3f,%.3f,%.2f,%llu,%llu,%llu,%llu,%.2f,%.1f,%.2f,%.1f," - "%d,%d,%s,%s,%d,%.1f,%.0f,%s\n", + "%d,%d,%s,%s,%d,%.1f,%.0f,%.0f,%.0f,%.0f,%d,%d,%d,%s\n", t, csv_field(map).c_str(), actors_, (unsigned long long)w.frames, fps, refresh, render_rate, fps_cap_, w.frame_ms / n, p95, sorted.back(), jank33, jank50, w.app_ms / n, g(MtPerf::SECTION_PROCESS), (w.app_ms - w.game_ms[MtPerf::SECTION_PROCESS]) / n, @@ -373,17 +406,20 @@ private: w.submit_ms / n, w.present_ms / n, gpu, (unsigned long long)draws_, (unsigned long long)skinned_draws_, (unsigned long long)ui_batches_, (unsigned long long)vertices_, double(w.geo_uploads) / n, double(w.geo_bytes) / 1024.0 / n, double(w.tex_uploads) / n, double(w.tex_bytes) / 1024.0 / n, - script_cpu_, main_cpu, mhz.c_str(), max_mhz.c_str(), thermal, batt, rss, csv_field(last_texture_).c_str()); + script_cpu_, main_cpu, mhz.c_str(), max_mhz.c_str(), thermal, batt, rss, batt_ma, batt_mv, power_mw, + int(power_sum_.plugged), fps_mode_, int(idle_), csv_field(last_texture_).c_str()); std::fflush(file_); if (!w.tex_uploads) last_texture_.clear(); log("perf fps=%.1f @%.0fHz/%.0f cap=%d frame=%.1f/p95 %.1f/max %.1f jank33=%d | app=%.1f (upd=%.1f rnd=%.1f " "shadow=%.1f py=%.1f) | render=%.1f (sync=%.1f fence=%.1f prep=%.1f up=%.1f/%.1f) pace=%.1f gpu=%.1f | " - "draws=%llu actors=%d map=%s thermal=%d batt=%.1f cpu=%s max=%s tex_uploads=%.2f last_tex=%s", + "draws=%llu actors=%d map=%s thermal=%d batt=%.1f cpu=%s max=%s tex_uploads=%.2f | pwr=%.0fmW %.0fmA%s mode=%d%s " + "last_tex=%s", fps, refresh, render_rate, fps_cap_, w.frame_ms / n, p95, sorted.back(), jank33, w.app_ms / n, g(MtPerf::SECTION_UPDATE_GAME), g(MtPerf::SECTION_RENDER_GAME), g(MtPerf::SECTION_SHADOW_RASTER), g(MtPerf::SECTION_PYTHON), w.render_ms / n, w.sync_ms / n, w.fence_ms / n, w.prepare_ms / n, w.geo_upload_ms / n, w.tex_upload_ms / n, w.pace_ms / n, gpu, (unsigned long long)draws_, actors_, - map.c_str(), thermal, batt, mhz.c_str(), max_mhz.c_str(), double(w.tex_uploads) / n, last_texture_.c_str()); + map.c_str(), thermal, batt, mhz.c_str(), max_mhz.c_str(), double(w.tex_uploads) / n, power_mw, batt_ma, + power_sum_.plugged ? " plugged" : "", fps_mode_, idle_ ? " idle" : "", last_texture_.c_str()); } void write_hitch(const Window& f, const MtPerf::SCounters& game) { @@ -413,6 +449,12 @@ private: std::function refresh_rate_; std::function render_rate_; std::function battery_; + std::function power_; + std::chrono::steady_clock::time_point power_sampled_at_{}; + PowerSample power_sum_{0, 0, 0, false}; + int power_samples_ = 0, power_valid_ = 0; + int fps_mode_ = -1; + bool idle_ = false; std::string last_texture_; }; diff --git a/tools/perf_summary.py b/tools/perf_summary.py index 9b2fdd21..05c38420 100644 --- a/tools/perf_summary.py +++ b/tools/perf_summary.py @@ -74,6 +74,19 @@ def main(): print("median fps %.1f frame p95 %.1f ms jank>33ms %d frames jank>50ms %d frames" % ( median(rows, "fps"), median(rows, "frame_p95_ms"), sum(int(num(r, "jank33")) for r in rows), sum(int(num(r, "jank50")) for r in rows))) + if "power_mw" in rows[0]: + # power_mw is -1 in a window with any sample on the charger: the battery current is then the + # charger's surplus, not the app's draw. Split by frame-rate mode and idle. + print("power (battery, unplugged windows only; plugged windows show current only)") + groups = {} + for r in rows: + groups.setdefault((r.get("fps_mode", "?"), r.get("idle", "?")), []).append(r) + for (mode, idle), rs in sorted(groups.items()): + powered = [r for r in rs if num(r, "power_mw") >= 0] + print(" mode %s%s: %d windows fps %.1f %s current %.0f mA" % ( + mode, " idle" if idle == "1" else "", len(rs), median(rs, "fps"), + ("power %.0f mW (%d windows)" % (median(powered, "power_mw"), len(powered))) if powered + else "power n/a (plugged)", median(rs, "batt_ma"))) print("median actors %.0f draws %.0f skinned %.0f python calls/frame %.0f (missing %.0f)" % ( median(rows, "actors"), median(rows, "draws"), median(rows, "skinned_draws"), median(rows, "py_calls"), median(rows, "py_missing")))