Files
metin2-client/src/host/live_client.cpp
T
shenleiandClaude Opus 5.5 6bfcac7d68 RecordingDevice: lend cached geometry instead of copying it per draw
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>
2026-09-30 11:39:01 +09:00

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