Adapt Android mobile controls and native client UI

This commit is contained in:
shen
2026-09-28 19:39:13 -07:00
parent a1ae69aa7a
commit 3c52ced02e
180 changed files with 688 additions and 23782 deletions
+147 -25
View File
@@ -9,6 +9,7 @@
#include "platform/PackBackend.h"
#include "platform/ScriptLib/PythonBoot.h"
#include "platform/UserInterface/ServerClock.h"
#include "platform/EterBase/TraceErrorObserver.h"
#include "../tests/port_login_flow_server.h"
#include <thread>
#endif
@@ -23,6 +24,7 @@
#ifdef __APPLE__
#include <AudioToolbox/AudioToolbox.h>
#include <TargetConditionals.h>
#endif
#include <algorithm>
@@ -230,8 +232,8 @@ std::uint64_t geometry_hash(const Render3DDraw& draw) {
return hash_bytes(hash, draw.indices.data(), draw.indices.size() * sizeof(std::uint32_t));
}
mtgodot::Image decode_tga(const std::uint8_t* data, std::size_t size) {
mtgodot::Image out;
mtimage::Image decode_tga(const std::uint8_t* data, std::size_t size) {
mtimage::Image out;
if (size < 18) return out;
const std::uint8_t id_len = data[0];
const std::uint8_t cmap_type = data[1];
@@ -306,7 +308,7 @@ mtgodot::Image decode_tga(const std::uint8_t* data, std::size_t size) {
return out;
}
mtgodot::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size) {
mtimage::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size) {
if (!data || size < 12) return {};
if (data[0] == 'M' && data[1] == 'T' && data[2] == 'R' && data[3] == 'A') {
std::uint32_t w = 0, h = 0;
@@ -314,7 +316,7 @@ mtgodot::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size)
std::memcpy(&h, data + 8, 4);
const std::size_t bytes = std::size_t(w) * std::size_t(h) * 4;
if (w > 0 && h > 0 && w <= 4096 && h <= 4096 && size == 12 + bytes) {
mtgodot::Image out;
mtimage::Image out;
out.w = static_cast<std::uint16_t>(w);
out.h = static_cast<std::uint16_t>(h);
out.rgba.assign(data + 12, data + 12 + bytes);
@@ -323,14 +325,14 @@ mtgodot::Image decode_texture_bytes(const std::uint8_t* data, std::size_t size)
return {};
}
if (data[0] == 'D' && data[1] == 'D' && data[2] == 'S' && data[3] == ' ')
return mtgodot::load_dds(data, size);
return mtimage::load_dds(data, size);
auto tga = decode_tga(data, size);
if (tga.ok()) return tga;
if (size > static_cast<std::size_t>(INT_MAX)) return {};
int w = 0, h = 0, channels = 0;
stbi_uc* pixels = stbi_load_from_memory(data, static_cast<int>(size), &w, &h, &channels, 4);
if (pixels && w > 0 && h > 0 && w <= 4096 && h <= 4096) {
mtgodot::Image out;
mtimage::Image out;
out.w = static_cast<std::uint16_t>(w);
out.h = static_cast<std::uint16_t>(h);
out.rgba.assign(pixels, pixels + std::size_t(w) * std::size_t(h) * 4);
@@ -847,6 +849,7 @@ public:
}
#endif
if (!SDL_Init(SDL_INIT_VIDEO)) throw std::runtime_error(SDL_GetError());
SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight");
window_ = SDL_CreateWindow(
"Metin2 Native Vulkan Client",
std::max(init_width, 320),
@@ -979,6 +982,7 @@ public:
screenshot_path_ = std::move(path);
screenshot_frame_ = frame;
}
std::uint64_t frame_number() const { return frame_number_; }
int msaa_samples() const { return int(samples_); }
std::uint32_t width() const { return extent_.width; }
@@ -986,11 +990,19 @@ public:
std::uint32_t logical_width() const {
int w = 0, h = 0;
if (window_) SDL_GetWindowSize(window_, &w, &h);
#ifdef __ANDROID__
// Keep the 40250 UI at the same height as the macOS 1280x800 window,
// while retaining the device aspect ratio on wider phone displays.
if (w > 0 && h > 0) return std::max(1, int(std::lround(double(w) * 800.0 / h)));
#endif
return w > 0 ? static_cast<std::uint32_t>(w) : extent_.width;
}
std::uint32_t logical_height() const {
int w = 0, h = 0;
if (window_) SDL_GetWindowSize(window_, &w, &h);
#ifdef __ANDROID__
if (w > 0 && h > 0) return 800;
#endif
return h > 0 ? static_cast<std::uint32_t>(h) : extent_.height;
}
@@ -1192,10 +1204,10 @@ public:
if (event.type == SDL_EVENT_WINDOW_RESIZED || event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) {
int ew = 0, eh = 0;
SDL_GetWindowSize(window_, &ew, &eh);
touch_controller.update_screen_size(ew, eh);
touch_controller.update_screen_size(int(logical_width()), int(logical_height()));
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
if (forward_to_live_client && ew > 0 && eh > 0) {
PythonBoot::SetUISize(ew, eh);
PythonBoot::SetUISize(int(logical_width()), int(logical_height()));
}
#endif
recreate_swapchain();
@@ -1658,9 +1670,12 @@ public:
present.waitSemaphoreCount = 1; present.pWaitSemaphores = &rendered_;
present.swapchainCount = 1; present.pSwapchains = &swapchain_; present.pImageIndices = &image_index;
const auto presented = vkQueuePresentKHR(queue_, &present);
if (presented == VK_ERROR_OUT_OF_DATE_KHR || presented == VK_SUBOPTIMAL_KHR) {
// An Android surface can continuously report SUBOPTIMAL while the app deliberately
// uses the identity transform. The image is still presentable; rebuilding every frame
// stalls rendering without changing that status.
if (presented == VK_ERROR_OUT_OF_DATE_KHR) {
recreate_swapchain();
} else if (presented != VK_SUCCESS) {
} else if (presented != VK_SUCCESS && presented != VK_SUBOPTIMAL_KHR) {
check(presented, "vkQueuePresentKHR");
}
const auto presented_at = std::chrono::steady_clock::now();
@@ -2027,7 +2042,17 @@ private:
info.imageFormat = format.format; info.imageColorSpace = format.colorSpace; info.imageExtent = extent_;
info.imageArrayLayers = 1; info.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT |
(capabilities.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_SRC_BIT);
info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; info.preTransform = capabilities.currentTransform;
info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
// Android surfaces can report a quarter-turn transform even when SDL's window is
// already landscape. Rendering in SDL window coordinates and requesting that transform
// again rotates the entire game image on presentation.
info.preTransform = (capabilities.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR)
? VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR : capabilities.currentTransform;
#ifdef __ANDROID__
SDL_Log("swapchain: window=%dx%d extent=%ux%u currentTransform=%u preTransform=%u",
width, height, extent_.width, extent_.height,
unsigned(capabilities.currentTransform), unsigned(info.preTransform));
#endif
info.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; info.presentMode = present_mode_;
info.clipped = VK_TRUE;
check(vkCreateSwapchainKHR(device_, &info, nullptr, &swapchain_), "vkCreateSwapchainKHR");
@@ -2617,7 +2642,7 @@ private:
}
it->second.last_decode_attempt_frame = frame_number_;
mtgodot::Image decoded;
mtimage::Image decoded;
if (auto raw = capture_textures.find(texture_name); raw != capture_textures.end() && !raw->second.empty()) {
decoded = decode_texture_bytes(raw->second.data(), raw->second.size());
}
@@ -3187,6 +3212,32 @@ bool py_eval_true(const char* expr) {
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;
@@ -3206,15 +3257,25 @@ int run_live_client(
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& 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);
}
@@ -3226,6 +3287,9 @@ int run_live_client(
SetNativeTerrainRenderEnabled(native_terrain);
SetGpuSkinningEnabled(gpu_skinning);
NativeAudioEngine audio;
#ifdef __ANDROID__
SDL_Log("live stage: audio initialized");
#endif
std::string server_host = "127.0.0.1";
int auth_port = 0;
@@ -3271,11 +3335,17 @@ int run_live_client(
#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::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 {
@@ -3301,6 +3371,9 @@ int run_live_client(
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];
@@ -3338,18 +3411,28 @@ int run_live_client(
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 (!py_exec("_stream.curPhaseWindow.SelectSlot(0)\n"
"_stream.curPhaseWindow.StartGame()"))
throw std::runtime_error("failed to start game from SelectCharacterWindow");
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"); }))
@@ -3362,12 +3445,22 @@ int run_live_client(
}))
throw std::runtime_error("timed out waiting for GameWindow (server error: " + server.Error() + ")");
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();
#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
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();
}
}
}
@@ -3417,6 +3510,10 @@ int run_live_client(
native_draw_capture::write(capture_out, Render3DDraws(), 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();
const auto start = std::chrono::steady_clock::now();
double update_ms = 0.0;
@@ -3442,10 +3539,16 @@ int run_live_client(
++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;
@@ -3461,6 +3564,7 @@ int main(int argc, char** argv) {
bool synthetic_specified = false;
bool interactive = false;
bool login_screen = false;
bool selection_screen = false;
int init_width = 1280;
int init_height = 800;
std::size_t draw_count = 64;
@@ -3489,6 +3593,9 @@ int main(int argc, char** argv) {
else if (arg == "--login-screen") {
login_screen = true;
interactive = true;
} else if (arg == "--selection-screen") {
selection_screen = true;
interactive = true;
} else if (arg == "--live-server" && i+1 < argc) {
live_server_spec = argv[++i];
interactive = true;
@@ -3525,7 +3632,7 @@ int main(int argc, char** argv) {
else if (arg == "--no-terrain") native_terrain = false;
else if (arg == "--mobile") mobile_mode = true;
else throw std::runtime_error(
"usage: mt_native_render [--frames N] [--interactive] [--login-screen] "
"usage: mt_native_render [--frames N] [--interactive] [--login-screen|--selection-screen] "
"[--live-server HOST:AUTH_PORT:GAME_PORT] [--width W] [--height H] "
"[--draws N] [--triangles-per-draw N] [--capture FILE] [--animate-first-draw] "
"[--animate-bones] [--no-vsync] [--live-client DIR] [--fake-mobs N] "
@@ -3558,6 +3665,12 @@ int main(int argc, char** argv) {
}
}
if (interactive && !frames_specified) frames = 0;
#ifdef __ANDROID__
SDL_Log("native startup: argc=%d live_client=%s selection=%d frames=%d", argc,
live_client_dir.c_str(), selection_screen, frames);
#endif
if (login_screen && selection_screen)
throw std::runtime_error("--login-screen and --selection-screen are mutually exclusive");
if (frames < 0 || (!interactive && frames == 0) || draw_count == 0 || draw_count > 4096 ||
triangles_per_draw == 0 || triangles_per_draw > 10000)
throw std::runtime_error("invalid frame/draw/triangle count");
@@ -3569,7 +3682,7 @@ int main(int argc, char** argv) {
}
if (!live_client_dir.empty()) {
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
return run_live_client(
const int result = run_live_client(
renderer,
live_client_dir,
frames,
@@ -3577,8 +3690,14 @@ int main(int argc, char** argv) {
gpu_skinning,
native_terrain,
login_screen,
selection_screen,
live_server_spec,
capture_out);
capture_out,
screenshot_out);
#ifdef __ANDROID__
SDL_Log("native live client exit: %d", result);
#endif
return result;
#else
throw std::runtime_error("mt_native_render was built without port_platform (--live-client unavailable)");
#endif
@@ -3620,6 +3739,9 @@ int main(int argc, char** argv) {
return (frames == 0 || completed == frames) ? 0 : 2;
} catch (const std::exception& error) {
std::cerr << "native renderer: " << error.what() << '\n';
#ifdef __ANDROID__
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "native renderer: %s", error.what());
#endif
return 1;
}
}