A geometry-cache hit moved every vertex vector out of the cache into the draw (deep copy of positions/normals/uv/diffuse/indices/bones each frame). The cache now lends its vectors to the frame's first draw of that geometry and takes them back in Render3DBeginFrame; later draws of the same key+revision in the frame (terrain splat layers, shadow-map pass) are marked shared_geometry and carry no vectors -- the native host looks the GPU geometry up by key+revision, and draw captures expand them (Render3DExpandSharedGeometry). Index reads reuse a scratch buffer. Desktop live game: RenderGame rnd 1.7 -> 1.2 ms, no visual change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
520 lines
23 KiB
C++
520 lines
23 KiB
C++
#include "live_client.h"
|
|
|
|
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
|
|
#include "android_perf.h"
|
|
#include "draw_capture.h"
|
|
#include "frame_policy.h"
|
|
#include "host_util.h"
|
|
#include "native_audio.h"
|
|
#include "perf_log.h"
|
|
#include "texture_decode.h"
|
|
#include "ui_scale.h"
|
|
#include "vulkan_window.h"
|
|
|
|
#include "platform/MilesLib/AudioCommands.h"
|
|
#include "platform/PackBackend.h"
|
|
#include "platform/ScriptLib/PythonBoot.h"
|
|
#include "platform/UserInterface/ServerClock.h"
|
|
#include "platform/EterBase/TraceErrorObserver.h"
|
|
#include "platform/EterBase/FrameRateMode.h"
|
|
#include "../tests/port/port_login_flow_server.h"
|
|
|
|
#include <SDL3/SDL.h>
|
|
#ifdef __APPLE__
|
|
#include <TargetConditionals.h>
|
|
#endif
|
|
|
|
#include <unistd.h>
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
namespace mt_host {
|
|
|
|
// --py-exec: a test hook run once the auto-login run reaches the GameWindow (or, with --login-screen,
|
|
// once the LoginWindow is up).
|
|
std::string g_py_exec_after_login;
|
|
// --perf-log / MT_PERF_LOG=1: src/host/perf_log.h.
|
|
bool g_perf_log = false;
|
|
|
|
bool py_exec(const std::string& code) {
|
|
std::string err;
|
|
if (!PythonBoot::RunLine(code.c_str(), &err)) {
|
|
std::cerr << "PythonBoot::RunLine failed: " << err << '\n';
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool py_eval_true(const char* expr) {
|
|
std::string res, err;
|
|
return PythonBoot::Evaluate(expr, &res, &err) && (res == "True" || res == "1");
|
|
}
|
|
|
|
#ifdef __ANDROID__
|
|
bool hide_mobile_taskbar_quickslots() {
|
|
return py_exec(
|
|
"_mobile_taskbar = _stream.curPhaseWindow.interface.wndTaskBar\n"
|
|
"for _mobile_quickslot in _mobile_taskbar.quickslot:\n"
|
|
" _mobile_quickslot.Hide()\n"
|
|
"_mobile_taskbar.GetChild('QuickSlotBoard').Hide()\n");
|
|
}
|
|
#endif
|
|
|
|
#if defined(__APPLE__) && TARGET_OS_OSX
|
|
bool configure_macos_numeric_quickslots() {
|
|
return py_exec(
|
|
"_mac_game = _stream.curPhaseWindow\n"
|
|
"_mac_game.onPressKeyDict[app.DIK_5] = lambda: _mac_game._GameWindow__PressQuickSlot(4)\n"
|
|
"_mac_game.onPressKeyDict[app.DIK_6] = lambda: _mac_game._GameWindow__PressQuickSlot(5)\n"
|
|
"_mac_game.onPressKeyDict[app.DIK_7] = lambda: _mac_game._GameWindow__PressQuickSlot(6)\n"
|
|
"_mac_game.onPressKeyDict[app.DIK_8] = lambda: _mac_game._GameWindow__PressQuickSlot(7)\n"
|
|
"for _mac_key in (app.DIK_F1, app.DIK_F2, app.DIK_F3, app.DIK_F4):\n"
|
|
" del _mac_game.onPressKeyDict[_mac_key]\n"
|
|
"_mac_taskbar = _mac_game.interface.wndTaskBar\n"
|
|
"for _mac_slot_number in range(5, 9):\n"
|
|
" _mac_taskbar.GetChild('slot_%d' % _mac_slot_number).LoadImage('d:/ymir work/ui/game/taskbar/%d.sub' % _mac_slot_number)\n");
|
|
}
|
|
#endif
|
|
|
|
bool parse_live_server_spec(const std::string& spec, std::string& host, int& auth_port, int& game_port) {
|
|
const auto p1 = spec.find(':');
|
|
if (p1 == std::string::npos) return false;
|
|
const auto p2 = spec.find(':', p1 + 1);
|
|
if (p2 == std::string::npos) return false;
|
|
host = spec.substr(0, p1);
|
|
auth_port = std::stoi(spec.substr(p1 + 1, p2 - p1 - 1));
|
|
game_port = std::stoi(spec.substr(p2 + 1));
|
|
return !host.empty() && auth_port > 0 && game_port > 0;
|
|
}
|
|
|
|
int run_live_client(
|
|
VulkanWindow& renderer,
|
|
const std::string& client_dir,
|
|
int frames,
|
|
int fake_mobs,
|
|
bool gpu_skinning,
|
|
bool native_terrain,
|
|
bool login_screen,
|
|
bool selection_screen,
|
|
const std::string& live_server_spec,
|
|
const std::string& capture_out,
|
|
const std::string& screenshot_out) {
|
|
if (fake_mobs > 0) {
|
|
const std::string mobs_str = std::to_string(fake_mobs);
|
|
setenv("MT_FAKE_MOB_COUNT", mobs_str.c_str(), 1);
|
|
}
|
|
#ifdef __ANDROID__
|
|
SetTraceErrorObserver([](const char* line) {
|
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "40250 SYSERR: %s", line);
|
|
});
|
|
#endif
|
|
char resolved_client_dir[4096] = {};
|
|
const std::string abs_client_dir = realpath(client_dir.c_str(), resolved_client_dir) ? std::string(resolved_client_dir) : client_dir;
|
|
setenv("MT_40250_CLIENT", abs_client_dir.c_str(), 1);
|
|
#ifdef __ANDROID__
|
|
SDL_Log("live stage: pack init %s", abs_client_dir.c_str());
|
|
#endif
|
|
if (chdir(abs_client_dir.c_str()) != 0 || !mtpack40250::initialize(".")) {
|
|
throw std::runtime_error("cannot initialize 40250 pack at: " + abs_client_dir);
|
|
}
|
|
|
|
const bool host_hardware_cursor = !renderer.touch_controller.is_enabled();
|
|
PythonBoot::SetHostHardwareCursorEnabled(host_hardware_cursor);
|
|
if (host_hardware_cursor) renderer.enable_game_hardware_cursor();
|
|
|
|
SetNativeTerrainRenderEnabled(native_terrain);
|
|
SetGpuSkinningEnabled(gpu_skinning);
|
|
// The shadow map is drawn by the GPU (offscreen pass); MT_CPU_SHADOW=1 / --cpu-shadow keeps the
|
|
// CPU rasterizer (RecordingDevice::rasterize_shadow) for comparison.
|
|
const char* cpu_shadow = std::getenv("MT_CPU_SHADOW");
|
|
SetGpuRenderTargetsEnabled(!(cpu_shadow && *cpu_shadow == '1'));
|
|
NativeAudioEngine audio;
|
|
#ifdef __ANDROID__
|
|
SDL_Log("live stage: audio initialized");
|
|
#endif
|
|
|
|
std::string server_host = "127.0.0.1";
|
|
int auth_port = 0;
|
|
int game_port = 0;
|
|
const bool use_external_server = !live_server_spec.empty();
|
|
FakeLoginServer server;
|
|
if (use_external_server) {
|
|
if (!parse_live_server_spec(live_server_spec, server_host, auth_port, game_port))
|
|
throw std::runtime_error("invalid --live-server spec (expected HOST:AUTH_PORT:GAME_PORT): " + live_server_spec);
|
|
} else {
|
|
if (!server.Start()) throw std::runtime_error("FakeLoginServer failed to start on loopback");
|
|
auth_port = server.AuthPort();
|
|
game_port = server.GamePort();
|
|
}
|
|
|
|
{
|
|
// HiDPI text: rasterise glyph pages at the swapchain's pixels per UI pixel (before any font
|
|
// exists). Rounded up: a fractional density (Android 1760x800 UI on 2376x1080 = 1.35) then
|
|
// scales finer glyphs down instead of stretching 1x ones. MT_FONT_OVERSAMPLE=1 keeps the
|
|
// 40250 bilevel 1x glyphs.
|
|
const float density = float(renderer.width()) / float(std::max(1u, renderer.logical_width()));
|
|
int oversample = std::clamp(int(std::ceil(density - 0.05f)), 1, 4);
|
|
if (const char* env = std::getenv("MT_FONT_OVERSAMPLE")) oversample = std::max(1, std::atoi(env));
|
|
UISetFontOversample(oversample);
|
|
}
|
|
|
|
std::string error;
|
|
const char* env_stdlib = std::getenv("MT_PYTHON_STDLIB");
|
|
std::string stdlib = (env_stdlib && *env_stdlib) ? env_stdlib : bundled_file_path("python27.zip");
|
|
#ifdef __ANDROID__
|
|
if (!env_stdlib || !*env_stdlib) {
|
|
std::size_t zip_size = 0;
|
|
void* zip = SDL_LoadFile("assets://python27.zip", &zip_size);
|
|
if (!zip) zip = SDL_LoadFile("python27.zip", &zip_size);
|
|
if (!zip) throw std::runtime_error("cannot load bundled python27.zip");
|
|
char* pref = SDL_GetPrefPath("mtgodot", "native-render");
|
|
if (!pref) { SDL_free(zip); throw std::runtime_error("cannot locate app data directory"); }
|
|
stdlib = std::string(pref) + "python27.zip";
|
|
SDL_free(pref);
|
|
std::ofstream output(stdlib, std::ios::binary | std::ios::trunc);
|
|
output.write(static_cast<const char*>(zip), static_cast<std::streamsize>(zip_size));
|
|
SDL_free(zip);
|
|
if (!output) throw std::runtime_error("cannot extract bundled python27.zip");
|
|
}
|
|
#endif
|
|
if (!PythonBoot::Start(stdlib.c_str(), &error))
|
|
throw std::runtime_error("PythonBoot::Start: " + error);
|
|
#ifdef __ANDROID__
|
|
SDL_Log("live stage: PythonBoot started");
|
|
#endif
|
|
|
|
SetPlatformServerTime(123456789);
|
|
PythonBoot::SetUISafeInset(renderer.ui_safe_inset());
|
|
PythonBoot::SetUISize(static_cast<int>(renderer.logical_width()), static_cast<int>(renderer.logical_height()));
|
|
if (!PythonBoot::RunMainScript("", &error) || !PythonBoot::IsAppLooping())
|
|
throw std::runtime_error("PythonBoot::RunMainScript: " + error);
|
|
#ifdef __ANDROID__
|
|
SDL_Log("live stage: main script started");
|
|
#endif
|
|
|
|
const std::unordered_map<std::string, std::vector<std::uint8_t>> empty_textures;
|
|
auto pump_until = [&](double max_seconds, int sleep_ms, const auto& cond) -> bool {
|
|
const auto deadline = std::chrono::steady_clock::now() + std::chrono::duration<double>(max_seconds);
|
|
while (std::chrono::steady_clock::now() < deadline && renderer.poll(true) && PythonBoot::IsAppLooping()) {
|
|
PythonBoot::UIUpdate();
|
|
renderer.sync_game_cursor();
|
|
PythonBoot::UIRender();
|
|
audio.pump();
|
|
unsigned ui_w = renderer.width(), ui_h = renderer.height();
|
|
UIRenderGetSize(&ui_w, &ui_h);
|
|
renderer.render(Render3DDraws(), empty_textures, ui_w, ui_h, UIRenderCommands());
|
|
if (cond()) return true;
|
|
if (sleep_ms > 0)
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(sleep_ms));
|
|
}
|
|
return cond();
|
|
};
|
|
|
|
if (!py_exec("import __main__, app, networkModule, introLogin, introSelect, introLoading, game\n"
|
|
"__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"))
|
|
throw std::runtime_error("failed to locate networkModule.MainStream");
|
|
|
|
if (!pump_until(20.0, 5, [] { return py_eval_true("isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"); }))
|
|
throw std::runtime_error("timed out waiting for LoginWindow");
|
|
#ifdef __ANDROID__
|
|
SDL_Log("live stage: LoginWindow open");
|
|
#endif
|
|
|
|
if (login_screen) {
|
|
char conn_cmd[512];
|
|
std::snprintf(
|
|
conn_cmd,
|
|
sizeof(conn_cmd),
|
|
"_stream.SetConnectInfo('%s', %d, '%s', %d)\n"
|
|
"_w = _stream.curPhaseWindow\n"
|
|
"_w._LoginWindow__OpenServerBoard()",
|
|
server_host.c_str(),
|
|
game_port,
|
|
server_host.c_str(),
|
|
auth_port);
|
|
if (!py_exec(conn_cmd))
|
|
throw std::runtime_error("failed to configure LoginWindow connection info");
|
|
for (int i = 0; i < 15; ++i) {
|
|
PythonBoot::UIUpdate();
|
|
renderer.sync_game_cursor();
|
|
PythonBoot::UIRender();
|
|
}
|
|
if (!g_py_exec_after_login.empty() && !py_exec(g_py_exec_after_login))
|
|
throw std::runtime_error("--py-exec failed");
|
|
} else {
|
|
char login_cmd[512];
|
|
std::snprintf(
|
|
login_cmd,
|
|
sizeof(login_cmd),
|
|
"_stream.SetConnectInfo('%s', %d, '%s', %d)\n"
|
|
"_w = _stream.curPhaseWindow\n"
|
|
"_w._LoginWindow__OpenLoginBoard()\n"
|
|
"_w.idEditLine.SetText('%s')\n"
|
|
"_w.pwdEditLine.SetText('%s')\n"
|
|
"_w._LoginWindow__OnClickLoginButton()",
|
|
server_host.c_str(),
|
|
game_port,
|
|
server_host.c_str(),
|
|
auth_port,
|
|
FakeLoginServer::kLogin,
|
|
FakeLoginServer::kPassword);
|
|
#ifdef __ANDROID__
|
|
SDL_Log("live stage: submitting fake login");
|
|
#endif
|
|
if (!py_exec(login_cmd))
|
|
throw std::runtime_error("failed to submit login credentials");
|
|
#ifdef __ANDROID__
|
|
SDL_Log("live stage: fake login submitted");
|
|
#endif
|
|
|
|
if (!pump_until(20.0, 5, [&] {
|
|
return (use_external_server || server.Has("game1:login2")) &&
|
|
py_eval_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)");
|
|
}))
|
|
throw std::runtime_error("timed out waiting for SelectCharacterWindow");
|
|
#ifdef __ANDROID__
|
|
SDL_Log("live stage: SelectCharacterWindow open");
|
|
#endif
|
|
|
|
if (!selection_screen) {
|
|
if (!py_exec("_stream.curPhaseWindow.SelectSlot(0)\n"
|
|
"_stream.curPhaseWindow.StartGame()"))
|
|
throw std::runtime_error("failed to start game from SelectCharacterWindow");
|
|
|
|
if (!use_external_server) {
|
|
if (!pump_until(30.0, 5, [&] { return server.Has("game2:client_version"); }))
|
|
throw std::runtime_error("timed out waiting for LoadingWindow (client_version)");
|
|
}
|
|
|
|
if (!pump_until(60.0, 0, [&] {
|
|
return (use_external_server || server.Has("game2:burst_pong")) &&
|
|
py_eval_true("isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()");
|
|
}))
|
|
throw std::runtime_error("timed out waiting for GameWindow (server error: " + server.Error() + ")");
|
|
|
|
#ifdef __ANDROID__
|
|
if (!hide_mobile_taskbar_quickslots())
|
|
throw std::runtime_error("failed to hide the desktop quick-slot bar on Android");
|
|
#endif
|
|
#if defined(__APPLE__) && TARGET_OS_OSX
|
|
if (!configure_macos_numeric_quickslots())
|
|
throw std::runtime_error("failed to configure numeric quick slots on macOS");
|
|
#endif
|
|
if (!g_py_exec_after_login.empty() && !py_exec(g_py_exec_after_login))
|
|
throw std::runtime_error("--py-exec failed");
|
|
|
|
const int warmup_frames = std::max(10, fake_mobs / 2 + 10);
|
|
for (int i = 0; i < warmup_frames; ++i) {
|
|
if (!renderer.poll(true) || !PythonBoot::IsAppLooping()) break;
|
|
PythonBoot::UIUpdate();
|
|
renderer.sync_game_cursor();
|
|
audio.pump();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Render one warmup frame to upload initial scene & UI textures/geometries before timed benchmark.
|
|
{
|
|
unsigned ui_w = renderer.width(), ui_h = renderer.height();
|
|
UIRenderGetSize(&ui_w, &ui_h);
|
|
renderer.render(Render3DDraws(), empty_textures, ui_w, ui_h, UIRenderCommands());
|
|
}
|
|
|
|
if (!capture_out.empty()) {
|
|
std::unordered_map<std::string, std::vector<std::uint8_t>> cap_textures;
|
|
auto add_tex = [&](const std::string& name) {
|
|
if (name.empty() || cap_textures.count(name)) return;
|
|
if (name.rfind("mem:", 0) == 0) {
|
|
UIMemoryTexture mem_tex;
|
|
if (UIRenderMemoryTexture(name, &mem_tex) && mem_tex.width > 0 && mem_tex.height > 0) {
|
|
auto mtra = native_draw_capture::encode_raw_argb_as_mtra(
|
|
static_cast<std::uint32_t>(mem_tex.width),
|
|
static_cast<std::uint32_t>(mem_tex.height),
|
|
mem_tex.argb.data());
|
|
if (!mtra.empty()) cap_textures.emplace(name, std::move(mtra));
|
|
}
|
|
return;
|
|
}
|
|
std::vector<std::uint8_t> bytes;
|
|
if (read_live_pack_texture(name, bytes))
|
|
cap_textures.emplace(name, std::move(bytes));
|
|
};
|
|
for (const auto& d : Render3DDraws()) {
|
|
add_tex(d.texture0);
|
|
add_tex(d.texture1);
|
|
}
|
|
for (const auto& c : UIRenderCommands()) {
|
|
if (c.kind == UIRenderCommand::Image) {
|
|
add_tex(c.text);
|
|
add_tex(c.mask);
|
|
}
|
|
}
|
|
unsigned ui_w = renderer.width(), ui_h = renderer.height();
|
|
UIRenderGetSize(&ui_w, &ui_h);
|
|
std::vector<UIRenderCommand> cap_ui = UIRenderCommands();
|
|
if (renderer.touch_controller.is_enabled()) {
|
|
renderer.touch_controller.update_screen_size(int(ui_w), int(ui_h));
|
|
renderer.touch_controller.append_ui_commands(cap_ui);
|
|
}
|
|
std::vector<Render3DDraw> cap_draws = Render3DDraws();
|
|
Render3DExpandSharedGeometry(cap_draws);
|
|
native_draw_capture::write(capture_out, cap_draws, cap_textures, ui_w, ui_h, cap_ui);
|
|
}
|
|
|
|
if (!screenshot_out.empty() && frames > 0) {
|
|
renderer.request_screenshot(screenshot_out, renderer.frame_number() + static_cast<std::uint64_t>(frames));
|
|
}
|
|
|
|
renderer.reset_timings();
|
|
native_perf::PerfLog perf_log;
|
|
if (g_perf_log) {
|
|
perf_log.open("device=" + renderer.device_name() + " present_mode=" + renderer.present_mode_name() +
|
|
" msaa=" + std::to_string(renderer.msaa_samples()) +
|
|
" render_scale=" + std::to_string(renderer.render_scale()) + " size=" + std::to_string(renderer.width()) +
|
|
"x" + std::to_string(renderer.height()) + " gpu_skinning=" + std::to_string(gpu_skinning) +
|
|
" terrain=" + std::to_string(native_terrain));
|
|
}
|
|
if (g_refresh_rate > 0) {
|
|
const bool ok = android_perf::request_frame_rate(renderer.sdl_window(), float(g_refresh_rate));
|
|
SDL_Log("frame rate request %d Hz: %s", g_refresh_rate, ok ? "accepted" : "unavailable");
|
|
}
|
|
perf_log.set_fps_cap(g_fps_cap);
|
|
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 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<int32_t> tids{android_perf::current_thread_id()};
|
|
if (const int script = PythonBoot::ScriptThreadId()) tids.push_back(script);
|
|
const bool ok = hint.open(tids, frame_budget.count());
|
|
SDL_Log("ADPF performance hint (%zu threads, %.2f ms): %s", tids.size(), frame_budget.count() / 1e6,
|
|
ok ? "on" : "unavailable");
|
|
}
|
|
const auto start = steady::now();
|
|
double update_ms = 0.0;
|
|
int completed = 0;
|
|
std::vector<double> frame_ms;
|
|
if (frames > 0) frame_ms.reserve(static_cast<std::size_t>(frames));
|
|
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();
|
|
save_ui_height_if_changed();
|
|
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
|
|
// late re-anchors instead of bursting to catch up.
|
|
const auto now = steady::now();
|
|
if (next_frame_at > now) {
|
|
std::this_thread::sleep_until(next_frame_at);
|
|
pace_ms = std::chrono::duration<double, std::milli>(steady::now() - now).count();
|
|
} else if (now - next_frame_at > cap_period) {
|
|
next_frame_at = now;
|
|
}
|
|
next_frame_at += cap_period;
|
|
}
|
|
const auto frame_start = steady::now();
|
|
const auto t0 = frame_start;
|
|
PythonBoot::UIUpdate();
|
|
const auto t_app = std::chrono::steady_clock::now();
|
|
renderer.sync_game_cursor();
|
|
PythonBoot::UIRender();
|
|
audio.pump();
|
|
const auto t1 = std::chrono::steady_clock::now();
|
|
update_ms += std::chrono::duration<double, std::milli>(t1 - t0).count();
|
|
unsigned ui_w = renderer.width(), ui_h = renderer.height();
|
|
UIRenderGetSize(&ui_w, &ui_h);
|
|
renderer.render(Render3DDraws(), empty_textures, ui_w, ui_h, UIRenderCommands());
|
|
const auto t2 = std::chrono::steady_clock::now();
|
|
const double elapsed_ms = std::chrono::duration<double, std::milli>(t2 - frame_start).count();
|
|
PythonBoot::SetNativeRenderFrameTime(static_cast<float>(elapsed_ms));
|
|
hint.report(std::int64_t((elapsed_ms - renderer.last_sync_ms()) * 1e6));
|
|
if (perf_log.enabled()) {
|
|
native_perf::FrameInput in;
|
|
in.frame_ms = elapsed_ms;
|
|
in.pace_ms = pace_ms;
|
|
in.app_ms = std::chrono::duration<double, std::milli>(t_app - t0).count();
|
|
in.host_ms = std::chrono::duration<double, std::milli>(t1 - t_app).count();
|
|
in.render_ms = std::chrono::duration<double, std::milli>(t2 - t1).count();
|
|
in.renderer = renderer.perf_totals();
|
|
perf_log.frame(in, [] { return PythonBoot::CurrentMapName(); });
|
|
}
|
|
frame_ms.push_back(elapsed_ms);
|
|
++completed;
|
|
}
|
|
renderer.finish_gpu_timings();
|
|
#ifdef __ANDROID__
|
|
SDL_Log("live stage: render loop complete (%d frames)", completed);
|
|
#endif
|
|
const auto wall_ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
|
|
print_summary(renderer, completed, wall_ms, update_ms, std::move(frame_ms));
|
|
|
|
PythonBoot::Stop();
|
|
#ifdef __ANDROID__
|
|
SDL_Log("live stage: PythonBoot stopped");
|
|
#endif
|
|
if (!use_external_server)
|
|
server.Stop();
|
|
return (frames == 0 || completed == frames) ? 0 : 2;
|
|
}
|
|
|
|
} // namespace mt_host
|
|
#endif
|