#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 #ifdef __APPLE__ #include #endif #include #include #include #include #include #include #include #include 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(zip), static_cast(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(renderer.logical_width()), static_cast(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> 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(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> 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(mem_tex.width), static_cast(mem_tex.height), mem_tex.argb.data()); if (!mtra.empty()) cap_textures.emplace(name, std::move(mtra)); } return; } std::vector 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 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 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(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 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 frame_ms; if (frames > 0) frame_ms.reserve(static_cast(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(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(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(t2 - frame_start).count(); PythonBoot::SetNativeRenderFrameTime(static_cast(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(t_app - t0).count(); in.host_ms = std::chrono::duration(t1 - t_app).count(); in.render_ms = std::chrono::duration(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(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