diff --git a/CMakeLists.txt b/CMakeLists.txt index 9db3ea6..6001688 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -66,6 +66,9 @@ wxl_add_external_extension(wxl-unit-outline "${WXL_EXTERNAL_MODULES_DIR}/wxl-uni # MoonWell-owned extensions. wxl-fdid-moonwell sorts after wxl-db2 and before # the modern asset extensions, so it can layer custom CSV mappings over wxl.fdid. wxl_add_external_extension(MoonWell "${WXL_LOCAL_MODULES_DIR}/moonwell") +if(TARGET MoonWell) + target_link_libraries(MoonWell PRIVATE shell32) +endif() wxl_add_external_extension(wxl-fdid-moonwell "${WXL_LOCAL_MODULES_DIR}/moonwell-fdid") wxl_add_external_extension(wxl-moonwell-storage-fallback "${WXL_LOCAL_MODULES_DIR}/moonwell-storage-fallback") diff --git a/LAUNCHER_AUTH.md b/LAUNCHER_AUTH.md new file mode 100644 index 0000000..47ba061 --- /dev/null +++ b/LAUNCHER_AUTH.md @@ -0,0 +1,87 @@ +# Авторизация MoonWell через лаунчер + +Клиентская часть не принимает логин и пароль в Release-сборке. Лаунчер передаёт игре +короткоживущую учётную сессию через environment block дочернего процесса, а GlueXML сразу +запускает стандартный SRP-вход и не показывает форму логина. + +## Контракт запуска клиента + +Перед `CreateProcess` лаунчер должен добавить в environment block только запускаемого `Wow.exe`: + +```text +MOONWELL_LAUNCH_ACCOUNT= +MOONWELL_LAUNCH_TICKET= +``` + +Обе переменные обязательны. Нативный модуль читает и удаляет их из окружения процесса до +загрузки GlueXML. Значения не должны передаваться в аргументах командной строки, писаться в +файл, лог или реестр. + +Ограничения значений: + +- account: печатный ASCII без пробелов, не более 320 байт; +- ticket: ровно 16 символов из `A-Z0-9` (около 82 бит при равномерной генерации); +- ticket должен быть криптографически случайным, одноразовым и жить не более 60 секунд; +- выпуск нового ticket должен отзывать предыдущий активный ticket этого аккаунта. + +Пример Win32-последовательности лаунчера: + +1. Пользователь авторизуется в лаунчере. +2. Лаунчер запрашивает у backend одноразовый game ticket. +3. Лаунчер создаёт отдельный environment block с двумя переменными выше. +4. Лаунчер запускает `Wow.exe` через `CreateProcessW`. +5. Лаунчер сразу затирает ticket в своей памяти. + +Кнопка «Авторизоваться» сначала ищет `MoonWell.exe` или `MoonWellLauncher.exe` рядом с `Wow.exe`. Если файла нет, +она открывает URI `moonwell://authorize?source=client`. Установщик лаунчера должен зарегистрировать +схему `moonwell` для текущего пользователя. + +## Обязательное изменение auth-сервера + +Скрытие полей в GlueXML не является серверной защитой: пользователь контролирует свой клиент. +Чтобы вход действительно был возможен только через лаунчер, auth-сервер обязан для обычных +аккаунтов отказаться от постоянного password verifier и принимать только активный launcher ticket. + +Для совместимости с неизменённым SRP-клиентом ticket используется как временный пароль: + +1. Backend хранит для аккаунта один активный ticket либо подготовленные из него временные + SRP `salt` и `verifier`. +2. На `CMD_AUTH_LOGON_CHALLENGE` auth-сервер ищет активный непросроченный ticket аккаунта. + Если ticket отсутствует, сервер отклоняет вход до выполнения обычной password-проверки. +3. SRP challenge строится по временному verifier. +4. После успешного `CMD_AUTH_LOGON_PROOF` ticket атомарно помечается использованным. +5. Повторный proof, повторный запуск и истёкший ticket отклоняются. + +Поскольку challenge содержит аккаунт, но не сам ticket, одновременно должен существовать не более +одного активного ticket на аккаунт. + +Рекомендуется дополнительно привязать ticket к версии клиента и идентификатору launcher-сессии, +ограничить частоту выпуска и не возвращать его ни в какие аналитические события. + +## Режим разработчика + +Ручной логин компилируется только в `Debug`-варианте `MoonWell.dll`. Для локального запуска: + +```powershell +.\run.ps1 -Env local -DeveloperLogin +``` + +Authserver должен независимо разрешить тот же аккаунт записью в `launcher_dev_account` +с точным `allowed_ip` и `enabled = 1`. Один только Debug-режим клиента никогда не обходит +серверную проверку. + +Скрипт собирает Debug runtime и передаёт `MOONWELL_DEV_LOGIN=1` только запускаемому процессу. +Переменная одноразово удаляется нативным модулем. В Release-сборке она игнорируется. + +Auth-сервер также должен отдельно разрешать постоянный SRP verifier только dev-аккаунтам — лучше +по allowlist аккаунтов вместе с VPN/IP-ограничением. Нельзя считать Debug-флаг клиента границей +безопасности и нельзя распространять Debug DLL игрокам. + +## Поведение интерфейса + +- валидная launcher-сессия: `AccountLoginUI` скрывается и сразу вызывается + `DefaultServerLogin(account, ticket)`; +- прямой Release-запуск: поля логина, пароля и параметры сохранения скрыты, основная кнопка + открывает лаунчер; +- Debug + `MOONWELL_DEV_LOGIN=1`: показывается прежняя форма ручного входа; +- сохранённые ранее логин и пароль удаляются при любом не-dev запуске. diff --git a/README.md b/README.md index 03fe75b..246a842 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,12 @@ $env:WOW_HOME = 'C:\Program Files (x86)\World of Warcraft' Диагностика запуска находится в `Logs\wxl-core.log` и `Logs\d3d9proxy.log` внутри клиента. +## Авторизация через лаунчер + +Release-клиент принимает только одноразовую launcher-сессию и при прямом запуске показывает +кнопку открытия лаунчера вместо полей логина и пароля. Контракт переменных запуска, требования +к auth-серверу и Debug-режим описаны в [LAUNCHER_AUTH.md](LAUNCHER_AUTH.md). + ## MPQ-пакеты Распакованные изменяемые слои уже находятся в `src/Data/`; полный набор базовых MPQ для сборки diff --git a/modules/moonwell/src/MoonWell.cpp b/modules/moonwell/src/MoonWell.cpp index 1abbcaa..d303eab 100644 --- a/modules/moonwell/src/MoonWell.cpp +++ b/modules/moonwell/src/MoonWell.cpp @@ -10,6 +10,7 @@ #include "SpellOverrides.hpp" #include +#include #include #include @@ -17,6 +18,7 @@ #include #include #include +#include namespace moonwell { @@ -69,6 +71,259 @@ namespace moonwell constexpr uint32_t kTraitorFlag = 0x40000000u; bool g_loginCharacterIsTraitor = false; + constexpr char kLaunchAccountVariable[] = "MOONWELL_LAUNCH_ACCOUNT"; + constexpr char kLaunchTicketVariable[] = "MOONWELL_LAUNCH_TICKET"; + constexpr char kDeveloperLoginVariable[] = "MOONWELL_DEV_LOGIN"; + constexpr char kLauncherLoginMarker[] = "__MOONWELL_LAUNCHER__"; + constexpr wchar_t kLauncherProtocol[] = L"moonwell://authorize?source=client"; + constexpr std::array kLauncherExecutables = { + L"MoonWell.exe", L"MoonWellLauncher.exe", + }; + constexpr size_t kMaxLaunchAccountLength = 320; + constexpr size_t kLaunchTicketLength = 16; + + std::array g_launchAccount{}; + std::array g_launchTicket{}; + volatile LONG g_hasLauncherAuth = 0; + bool g_launchedWithTicket = false; + bool g_developerLogin = false; + volatile LONG g_launcherLoginState = 0; // 0=pending, 1=claimed, 2=submitted + DWORD g_launcherAuthCapturedAt = 0; + DWORD g_clientThreadId = 0; + + template + bool ConsumeEnvironmentVariable(const char* name, std::array& output) + { + SetLastError(ERROR_SUCCESS); + const DWORD required = GetEnvironmentVariableA(name, nullptr, 0); + if (!required) + { + SetEnvironmentVariableA(name, nullptr); + return false; + } + + if (required > output.size()) + { + SetEnvironmentVariableA(name, nullptr); + WLOG_ERROR("moonwell: rejected oversized launcher environment value %s", name); + return false; + } + + const DWORD written = GetEnvironmentVariableA( + name, output.data(), static_cast(output.size())); + SetEnvironmentVariableA(name, nullptr); + return written > 0 && written < output.size(); + } + + template + bool IsPrintableAscii(const std::array& value) + { + for (const unsigned char character : value) + { + if (character == 0) + return true; + if (character < 0x21 || character > 0x7e) + return false; + } + return false; + } + + bool IsValidLauncherTicket() + { + if (std::strlen(g_launchTicket.data()) != kLaunchTicketLength) + return false; + + for (size_t index = 0; index < kLaunchTicketLength; ++index) + { + const char character = g_launchTicket[index]; + if (!((character >= 'A' && character <= 'Z') + || (character >= '0' && character <= '9'))) + return false; + } + return true; + } + + void ClearLauncherAuth() + { + SecureZeroMemory(g_launchAccount.data(), g_launchAccount.size()); + SecureZeroMemory(g_launchTicket.data(), g_launchTicket.size()); + InterlockedExchange(&g_hasLauncherAuth, 0); + } + + void CaptureLaunchEnvironment() + { + const bool hasAccount = ConsumeEnvironmentVariable( + kLaunchAccountVariable, g_launchAccount); + const bool hasTicket = ConsumeEnvironmentVariable( + kLaunchTicketVariable, g_launchTicket); + + g_hasLauncherAuth = hasAccount && hasTicket + && IsPrintableAscii(g_launchAccount) + && IsValidLauncherTicket() ? 1 : 0; + InterlockedExchange(&g_launcherLoginState, 0); + g_launchedWithTicket = g_hasLauncherAuth != 0; + g_launcherAuthCapturedAt = g_hasLauncherAuth ? GetTickCount() : 0; + + if (!g_hasLauncherAuth) + { + if (hasAccount || hasTicket) + WLOG_ERROR("moonwell: incomplete or invalid launcher authorization data"); + ClearLauncherAuth(); + } + + std::array developerValue{}; + const bool developerRequested = ConsumeEnvironmentVariable( + kDeveloperLoginVariable, developerValue); +#if defined(_DEBUG) + g_developerLogin = developerRequested + && std::strcmp(developerValue.data(), "1") == 0; +#else + g_developerLogin = false; +#endif + SecureZeroMemory(developerValue.data(), developerValue.size()); + + if (g_hasLauncherAuth) + WLOG_INFO("moonwell: launcher authorization data accepted"); + else if (g_developerLogin) + WLOG_INFO("moonwell: developer login enabled for this Debug build"); + } + + int __cdecl GetLaunchMode(void* state) + { + const char* mode = g_launchedWithTicket + ? "launcher" + : (g_developerLogin ? "developer" : "locked"); + wxl::game::script::PushString(state, mode); + return 1; + } + + int __cdecl ConsumeLauncherAuth(void* state) + { + constexpr uintptr_t kLoginGlueReady = 0x00B6B474; + constexpr uintptr_t kLoginServerHost = 0x00B6AF54; + constexpr uintptr_t kLoginServerPort = 0x00B6AF5C; + constexpr uintptr_t kLoginBusy = 0x00B6AA38; + if (!g_hasLauncherAuth + || !*reinterpret_cast(kLoginGlueReady) + || !*reinterpret_cast(kLoginServerHost) + || !*reinterpret_cast(kLoginServerPort) + || *reinterpret_cast(kLoginBusy)) + return 0; + + if (InterlockedCompareExchange(&g_launcherLoginState, 1, 0) != 0) + return 0; + if (!g_hasLauncherAuth) + { + InterlockedExchange(&g_launcherLoginState, 0); + return 0; + } + + wxl::game::script::PushString(state, g_launchAccount.data()); + wxl::game::script::PushString(state, g_launchTicket.data()); + ClearLauncherAuth(); + InterlockedExchange(&g_launcherLoginState, 2); + WLOG_INFO("moonwell: launcher authorization consumed by Lua bridge"); + return 2; + } + + bool TryOpenAdjacentLauncher() + { + std::array path{}; + const DWORD length = GetModuleFileNameW(nullptr, path.data(), + static_cast(path.size())); + if (!length || length >= path.size()) + return false; + + wchar_t* slash = std::wcsrchr(path.data(), L'\\'); + if (!slash) + return false; + + ++slash; + const size_t prefixLength = static_cast(slash - path.data()); + for (const wchar_t* executable : kLauncherExecutables) + { + const size_t launcherLength = std::wcslen(executable); + if (prefixLength + launcherLength >= path.size()) + continue; + + std::wmemcpy(slash, executable, launcherLength + 1); + if (GetFileAttributesW(path.data()) == INVALID_FILE_ATTRIBUTES) + continue; + + if (reinterpret_cast(ShellExecuteW( + nullptr, L"open", path.data(), nullptr, nullptr, SW_SHOWNORMAL)) > 32) + return true; + } + return false; + } + + int __cdecl OpenLauncher(void* state) + { + bool opened = TryOpenAdjacentLauncher(); + if (!opened) + { + opened = reinterpret_cast(ShellExecuteW( + nullptr, L"open", kLauncherProtocol, nullptr, nullptr, SW_SHOWNORMAL)) > 32; + } + + wxl::game::script::PushBoolean(state, opened); + return 1; + } + + using DefaultServerLoginCallbackFn = int(__cdecl*)(void*); + using BeginServerLoginFn = void(__cdecl*)(const char*, const char*); + DefaultServerLoginCallbackFn g_nextDefaultServerLogin = nullptr; + + bool SubmitLauncherAuthorization() + { + if (!g_hasLauncherAuth || g_launcherLoginState != 0) + return false; + + constexpr uintptr_t kBeginServerLogin = 0x004D8A30; + constexpr uintptr_t kLoginGlueReady = 0x00B6B474; + constexpr uintptr_t kLoginServerHost = 0x00B6AF54; + constexpr uintptr_t kLoginServerPort = 0x00B6AF5C; + constexpr uintptr_t kLoginBusy = 0x00B6AA38; + if (!*reinterpret_cast(kLoginGlueReady) + || !*reinterpret_cast(kLoginServerHost) + || !*reinterpret_cast(kLoginServerPort) + || *reinterpret_cast(kLoginBusy)) + return false; + + if (InterlockedCompareExchange(&g_launcherLoginState, 1, 0) != 0) + return false; + if (!g_hasLauncherAuth) + { + InterlockedExchange(&g_launcherLoginState, 0); + return false; + } + + reinterpret_cast(kBeginServerLogin)( + g_launchAccount.data(), g_launchTicket.data()); + if (!*reinterpret_cast(kLoginBusy)) + { + InterlockedExchange(&g_launcherLoginState, 0); + return false; + } + + ClearLauncherAuth(); + InterlockedExchange(&g_launcherLoginState, 2); + WLOG_INFO("moonwell: launcher authorization submitted to login engine"); + return true; + } + + int __cdecl DefaultServerLoginHook(void* state) + { + const char* account = wxl::game::script::IsString(state, 1) + ? wxl::game::script::ToString(state, 1) + : nullptr; + if (!account || std::strcmp(account, kLauncherLoginMarker) != 0) + return g_nextDefaultServerLogin ? g_nextDefaultServerLogin(state) : 0; + + SubmitLauncherAuthorization(); + return 0; + } + using GxSetProjectionFn = void(__fastcall*)(void* self, void* edx, const void* projection); GxSetProjectionFn g_nextSetProjection = nullptr; @@ -439,55 +694,207 @@ namespace moonwell using RegisterFunctionFn = void(__cdecl*)(const char*, wxl::game::script::Function); using ValidateCallbackFn = void(__cdecl*)(uintptr_t); using GetContextFn = void*(__cdecl*)(); - RegisterFunctionFn g_nextRegisterFunction = nullptr; + using ExecuteFn = void(__cdecl*)(const char*, uintptr_t, uintptr_t); + using InitializeLuaFn = int(__cdecl*)(void*); + using FramePumpFn = void(__cdecl*)(float, uint32_t); + using FileOpenFn = int(__stdcall*)(void*, const char*, uint32_t, void**); + using GlueModelRenderFn = void(__cdecl*)(void*); ValidateCallbackFn g_nextValidateCallback = nullptr; - void* g_registeredState = nullptr; + ExecuteFn g_nextExecute = nullptr; + InitializeLuaFn g_nextInitializeLua = nullptr; + FramePumpFn g_nextFramePump = nullptr; + FileOpenFn g_nextFileOpen = nullptr; + GlueModelRenderFn g_nextGlueModelRender = nullptr; + PVOID volatile g_registeredState = nullptr; bool g_registeringMoonWell = false; bool IsMoonWellCallback(uintptr_t callback) { return callback == reinterpret_cast(&SetLoginCharacterFlags) || callback == reinterpret_cast(&IsTraitor) - || callback == reinterpret_cast(&SetCharacterCreateCamera); + || callback == reinterpret_cast(&SetCharacterCreateCamera) + || callback == reinterpret_cast(&GetLaunchMode) + || callback == reinterpret_cast(&ConsumeLauncherAuth) + || callback == reinterpret_cast(&OpenLauncher); } + void RegisterLuaFunctionsForCurrentState(); + void __cdecl ValidateCallbackHook(uintptr_t callback) { + RegisterLuaFunctionsForCurrentState(); if (!IsMoonWellCallback(callback) && g_nextValidateCallback) g_nextValidateCallback(callback); } - void RegisterLuaFunctionsForCurrentState() + void RegisterLuaFunctions(void* state) { - if (!g_nextRegisterFunction || g_registeringMoonWell) return; - constexpr uintptr_t kGetContext = 0x00817DB0; - void* state = reinterpret_cast(kGetContext)(); + if (g_registeringMoonWell) return; if (!state || state == g_registeredState) return; + constexpr uintptr_t kRegisterFunction = 0x00817F90; + const auto registrar = reinterpret_cast(kRegisterFunction); g_registeringMoonWell = true; - g_nextRegisterFunction("MoonWellSetLoginCharacterFlags", &SetLoginCharacterFlags); - g_nextRegisterFunction("MoonWellIsTraitor", &IsTraitor); - g_nextRegisterFunction("MoonWellSetCharacterCreateCamera", &SetCharacterCreateCamera); + registrar("MoonWellSetLoginCharacterFlags", &SetLoginCharacterFlags); + registrar("MoonWellIsTraitor", &IsTraitor); + registrar("MoonWellSetCharacterCreateCamera", &SetCharacterCreateCamera); + registrar("MoonWellGetLaunchMode", &GetLaunchMode); + registrar("MoonWellConsumeLauncherAuth", &ConsumeLauncherAuth); + registrar("MoonWellOpenLauncher", &OpenLauncher); g_registeringMoonWell = false; - g_registeredState = state; + InterlockedExchangePointer(&g_registeredState, state); WLOG_INFO("moonwell: Lua functions registered for state %p", state); } - void __cdecl RegisterFunctionHook(const char* name, wxl::game::script::Function function) + void RegisterLuaFunctionsForCurrentState() + { + constexpr uintptr_t kGetContext = 0x00817DB0; + RegisterLuaFunctions(reinterpret_cast(kGetContext)()); + } + + void RegisterLuaFunctionsBeforeHooksEnable() { - if (g_nextRegisterFunction) g_nextRegisterFunction(name, function); RegisterLuaFunctionsForCurrentState(); } + void __cdecl OnClientMessage(void*, const void*) + { + RegisterLuaFunctionsForCurrentState(); + } + + struct WindowSearch + { + DWORD processId; + HWND window; + }; + + BOOL CALLBACK FindClientWindow(HWND window, LPARAM parameter) + { + auto* search = reinterpret_cast(parameter); + DWORD processId = 0; + GetWindowThreadProcessId(window, &processId); + if (processId != search->processId || !IsWindowVisible(window)) + return TRUE; + + search->window = window; + return FALSE; + } + + DWORD WINAPI ScheduleLuaBootstrap(LPVOID) + { + HWND clientWindow = nullptr; + WLOG_INFO("moonwell: Lua bootstrap scheduler started"); + for (unsigned attempt = 0; attempt < 600; ++attempt) + { + if (!clientWindow) + { + clientWindow = FindWindowW(L"GxWindowClass", nullptr); + DWORD ownerProcessId = 0; + if (clientWindow) + GetWindowThreadProcessId(clientWindow, &ownerProcessId); + if (ownerProcessId != GetCurrentProcessId()) + clientWindow = nullptr; + if (!clientWindow) + { + WindowSearch search{GetCurrentProcessId(), nullptr}; + EnumWindows(&FindClientWindow, reinterpret_cast(&search)); + clientWindow = search.window; + } + } + if (clientWindow) + { + WLOG_INFO("moonwell: client window %p found for Lua bootstrap", clientWindow); + // BeginServerLogin only snapshots the supplied credentials and starts + // the client's asynchronous login state machine. Waiting here keeps the + // call clear of engine initialization and Glue archive mounting. + for (unsigned attempt = 0; + attempt < 80 && g_hasLauncherAuth; + ++attempt) + { + Sleep(250); + SubmitLauncherAuthorization(); + } + return g_hasLauncherAuth ? 1 : 0; + } + Sleep(100); + } + WLOG_ERROR("moonwell: timed out waiting for UI-thread Lua bootstrap"); + return 1; + } + + void __cdecl ExecuteHook(const char* source, uintptr_t argument2, uintptr_t argument3) + { + RegisterLuaFunctionsForCurrentState(); + if (g_nextExecute) g_nextExecute(source, argument2, argument3); + } + + int __cdecl InitializeLuaHook(void* allocatorContext) + { + const int initialized = g_nextInitializeLua + ? g_nextInitializeLua(allocatorContext) + : 0; + if (initialized) + RegisterLuaFunctionsForCurrentState(); + return initialized; + } + + void __cdecl FramePumpHook(float deltaSeconds, uint32_t frameTimeMs) + { + RegisterLuaFunctionsForCurrentState(); + if (g_nextFramePump) g_nextFramePump(deltaSeconds, frameTimeMs); + RegisterLuaFunctionsForCurrentState(); + if (g_hasLauncherAuth + && GetTickCount() - g_launcherAuthCapturedAt >= 3000) + SubmitLauncherAuthorization(); + } + + int __stdcall FileOpenHook(void* archive, const char* name, uint32_t flags, void** out) + { + // The engine-init callback and synchronous Glue loader run on the same client + // thread. Polling here catches Lua becoming live before AccountLogin executes, + // without ever touching the state from background asset I/O workers. + if (GetCurrentThreadId() == g_clientThreadId) + RegisterLuaFunctionsForCurrentState(); + const int result = g_nextFileOpen ? g_nextFileOpen(archive, name, flags, out) : 0; + return result; + } + + void __cdecl GlueModelRenderHook(void* frame) + { + RegisterLuaFunctionsForCurrentState(); + if (g_nextGlueModelRender) g_nextGlueModelRender(frame); + } + + void __cdecl OnFrame(void*, const void*) + { + RegisterLuaFunctionsForCurrentState(); + // Glue scripting is not a reliable authentication trigger: a broken + // cosmetic widget can abort AccountLogin_OnShow before it reaches the + // login call. Present is emitted on the client thread after the engine + // and Glue subsystem are ready, so submit once after a short grace period. + if (g_hasLauncherAuth + && GetTickCount() - g_launcherAuthCapturedAt >= 3000) + SubmitLauncherAuthorization(); + } + bool InstallLuaBridge() { const bool validator = HookByName("Lua.ValidateFunctionPointer", &ValidateCallbackHook, &g_nextValidateCallback); - const bool registrar = HookByName("Lua.RegisterFunction", &RegisterFunctionHook, - &g_nextRegisterFunction); - if (!validator || !registrar) + const bool executor = HookByName("Lua.Execute", &ExecuteHook, &g_nextExecute); + const bool initializer = Hook("Lua.Initialize", 0x00819BB0, + &InitializeLuaHook, &g_nextInitializeLua); + const bool defaultLogin = Hook("MoonWell.DefaultServerLogin", 0x004DC260, + &DefaultServerLoginHook, &g_nextDefaultServerLogin); + const bool framePump = HookByName("Frame.Pump", &FramePumpHook, &g_nextFramePump); + const bool fileOpen = HookByName("Io.FileOpen", &FileOpenHook, &g_nextFileOpen); + const bool glueRender = HookByName("Gx.GlueModelRender", &GlueModelRenderHook, + &g_nextGlueModelRender); + if (!validator || !executor || !initializer || !defaultLogin + || !framePump || !fileOpen || !glueRender) WLOG_ERROR("moonwell: Lua bridge hook installation failed"); - return validator && registrar; + return validator && executor && initializer && defaultLogin + && framePump && fileOpen && glueRender; } } } @@ -504,9 +911,24 @@ int __cdecl WXL_Load(const WXL_Api* api) { if (!api || api->apiVersion != WXL_API_VERSION) return 0; moonwell::g_api = api; + moonwell::g_clientThreadId = GetCurrentThreadId(); + moonwell::CaptureLaunchEnvironment(); moonwell::InstallBoot(); const bool lua = moonwell::InstallLuaBridge(); + // Event ordinal 5 is wxl::events::Event::OnFrame in API v1. It is emitted + // from Present on the client thread even while only the Glue UI is active. + if (api->Subscribe) + { + api->Subscribe(5, &moonwell::OnFrame, nullptr); + api->Subscribe(17, &moonwell::OnClientMessage, nullptr); + } + moonwell::RegisterLuaFunctionsBeforeHooksEnable(); + if (HANDLE bootstrapThread = CreateThread( + nullptr, 0, &moonwell::ScheduleLuaBootstrap, nullptr, 0, nullptr)) + CloseHandle(bootstrapThread); + else + api->Log(WXL_LOG_ERROR, "MoonWell", "%s", "failed to start Lua bootstrap scheduler"); moonwell::InstallCharacterCreateCamera(); moonwell::InstallEncounterJournalModelPreview(); const bool spells = moonwell::spells::Install(api); diff --git a/run.ps1 b/run.ps1 index c720a11..17c2797 100644 --- a/run.ps1 +++ b/run.ps1 @@ -1,7 +1,8 @@ param( [Parameter(Mandatory=$false)] [ValidateSet("production", "ptr", "local")] - [string]$Env = "local" + [string]$Env = "local", + [switch]$DeveloperLogin ) # Stop on errors @@ -68,9 +69,16 @@ if ((Test-Path -LiteralPath $installedWarcraftXL -PathType Leaf) -and -not (Test-ExistingFileWriteAccess -Path $installedWarcraftXL) -and -not $isAdministrator) { Write-Host "WarcraftXL.dll requires administrator access. Requesting elevation..." + $elevationArguments = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", + "`"$($MyInvocation.MyCommand.Path)`"", "-Env", $Env + ) + if ($DeveloperLogin) { + $elevationArguments += "-DeveloperLogin" + } $elevatedProcess = Start-Process -FilePath "powershell.exe" -Verb RunAs -PassThru -Wait ` -WorkingDirectory $ROOT ` - -ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$($MyInvocation.MyCommand.Path)`"", "-Env", $Env + -ArgumentList $elevationArguments exit $elevatedProcess.ExitCode } @@ -149,7 +157,8 @@ if (!(Test-Path (Join-Path $DIST_DIR "Data"))) { # are uploaded by upload_to_s3.py and consumed by the launcher manifest. Stop-WowRuntime -ClientPath $WOW_HOME Write-Host "Building and packaging WarcraftXL..." -& $WXL_BUILD_SCRIPT -Configuration Release -ClientPath $WOW_HOME ` +$runtimeConfiguration = if ($DeveloperLogin) { "Debug" } else { "Release" } +& $WXL_BUILD_SCRIPT -Configuration $runtimeConfiguration -ClientPath $WOW_HOME ` -PackagePath $DIST_DIR -Deploy if ($LASTEXITCODE -ne 0) { Write-Error "WarcraftXL build/deploy failed!" @@ -200,4 +209,11 @@ if ($realmlist) { # --- Run WoW reload script Write-Host "Launching WoW..." -cmd /c $RELOAD_SCRIPT +if ($DeveloperLogin) { + $env:MOONWELL_DEV_LOGIN = "1" +} +try { + cmd /c $RELOAD_SCRIPT +} finally { + Remove-Item Env:MOONWELL_DEV_LOGIN -ErrorAction SilentlyContinue +} diff --git a/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/AccountLogin.lua b/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/AccountLogin.lua index b6e1666..72d0c98 100644 --- a/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/AccountLogin.lua +++ b/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/AccountLogin.lua @@ -43,7 +43,8 @@ local Config = { } local LoginState = { - autoLoginTimer = nil, autoLoginDelay = Config.AUTO_LOGIN_DELAY, autoLoginAttempted = false, musicTimer = 0, sceneTimer = 0, currentFrame = 0, lastUpdateTime = 0, currentScene = 1 + autoLoginTimer = nil, autoLoginDelay = Config.AUTO_LOGIN_DELAY, autoLoginAttempted = false, musicTimer = 0, sceneTimer = 0, currentFrame = 0, lastUpdateTime = 0, currentScene = 1, + launchMode = "locked", launcherLoginAttempted = false, luaBridgePending = true } local ModelManager = { @@ -51,7 +52,7 @@ local ModelManager = { } local UICache = { - accountEdit = nil, passwordEdit = nil, saveAccountName = nil, savePassword = nil, autoLogin = nil, autoLoginText = nil, loginButton = nil, versionText = nil, realmName = nil, upgradeButton = nil, tosFrame = nil, tosAccept = nil, tosDecline = nil, backgroundTexture = nil, animatedTexture = nil, newLogo = nil, newLogoFrame = nil + accountEdit = nil, passwordEdit = nil, saveAccountName = nil, savePassword = nil, autoLogin = nil, autoLoginText = nil, saveOptionsFrame = nil, launcherHint = nil, loginButton = nil, versionText = nil, realmName = nil, upgradeButton = nil, tosFrame = nil, tosAccept = nil, tosDecline = nil, backgroundTexture = nil, animatedTexture = nil, newLogo = nil, newLogoFrame = nil } LOGIN_MODEL_LIGHTS = { @@ -78,6 +79,8 @@ local function InitializeUICache() UICache.savePassword = _G["AccountLoginSavePassword"] UICache.autoLogin = _G["AccountLoginAutoLogin"] UICache.autoLoginText = _G["AccountLoginAutoLoginText"] + UICache.saveOptionsFrame = _G["AccountLoginSaveOptionsFrame"] + UICache.launcherHint = _G["AccountLoginLauncherHint"] UICache.loginButton = _G["AccountLoginLoginButton"] UICache.versionText = _G["AccountLoginVersion"] UICache.realmName = _G["AccountLoginRealmName"] @@ -94,6 +97,98 @@ end function GetLoginState() return LoginState end + +local function ResolveLaunchMode() + if type(MoonWellGetLaunchMode) ~= "function" then + return "locked" + end + + local mode = MoonWellGetLaunchMode() + if mode == "launcher" or mode == "developer" then + return mode + end + return "locked" +end + +local function ApplyLaunchMode() + local developerMode = LoginState.launchMode == "developer" + + if developerMode then + UICache.accountEdit:Show() + UICache.passwordEdit:Show() + UICache.saveOptionsFrame:Show() + UICache.launcherHint:Hide() + UICache.loginButton:SetText(LOGIN) + else + UICache.accountEdit:Hide() + UICache.passwordEdit:Hide() + UICache.saveOptionsFrame:Hide() + UICache.launcherHint:Show() + UICache.loginButton:SetText("Авторизоваться") + end +end + +function AccountLogin_OpenLauncher() + PlaySound("gsLogin") + local opened = false + if type(MoonWellOpenLauncher) == "function" then + opened = MoonWellOpenLauncher() and true or false + end + + if not opened then + GlueDialog_Show("MOONWELL_LAUNCHER_NOT_FOUND") + end +end + +function AccountLogin_PrimaryAction() + if LoginState.launchMode == "developer" then + AccountLogin_Login() + else + AccountLogin_OpenLauncher() + end +end + +local function TryLauncherLogin() + if LoginState.launchMode ~= "launcher" or LoginState.launcherLoginAttempted then + return false + end + + LoginState.launcherLoginAttempted = true + if type(MoonWellConsumeLauncherAuth) ~= "function" then + LoginState.launchMode = "locked" + ApplyLaunchMode() + return false + end + + local accountName, ticket = MoonWellConsumeLauncherAuth() + if not accountName or accountName == "" or not ticket or ticket == "" then + LoginState.launchMode = "locked" + ApplyLaunchMode() + return false + end + + SetSavedAccountName("") + SetSavedAccountList("") + SetUsesToken(false) + AccountLoginUI:Hide() + PlaySound("gsLogin") + DefaultServerLogin(accountName, ticket) + accountName = nil + ticket = nil + return true +end + +local function TryLauncherFallbackLogin() + if not LoginState.luaBridgePending or LoginState.launcherLoginAttempted then + return false + end + + -- MoonWell.dll intercepts this stock callback and substitutes the one-time + -- launcher credentials. With no launcher data it intentionally does nothing. + LoginState.launcherLoginAttempted = true + DefaultServerLogin("__MOONWELL_LAUNCHER__", "__MOONWELL_LAUNCHER__") + return true +end -- ============================================================================ -- SISTEMA DE ANIMACIÓN BLP -- ============================================================================ @@ -144,6 +239,11 @@ GlueDialogTypes["AUTO_LOGIN"] = { UICache.autoLogin:SetChecked(0) end, } + +GlueDialogTypes["MOONWELL_LAUNCHER_NOT_FOUND"] = { + text = "Не удалось открыть лаунчер MoonWell. Запустите его вручную и нажмите «Играть».", + button1 = OKAY, +} -- ============================================================================ -- GESTIÓN DE MODELOS 3D -- ============================================================================ @@ -151,10 +251,8 @@ local function CreateLoginModel(parent, modelData) local model = CreateFrame("Model", nil, parent) local width, height = parent:GetSize() - model:SetSize( - width * (modelData[LOGIN_MODEL_STRUCT.WIDTH_SQUISH] or 1), - height * (modelData[LOGIN_MODEL_STRUCT.HEIGHT_SQUISH] or 1) - ) + model:SetWidth(width * (modelData[LOGIN_MODEL_STRUCT.WIDTH_SQUISH] or 1)) + model:SetHeight(height * (modelData[LOGIN_MODEL_STRUCT.HEIGHT_SQUISH] or 1)) model:SetPoint("CENTER") model:SetModel("Character/Human/Male/HumanMale.mdx") model:SetCamera(1) @@ -194,6 +292,13 @@ end -- ACTUALIZACIÓN DE LA ESCENA -- ============================================================================ function LoginScene_OnUpdate(self, elapsed) + if LoginState.luaBridgePending and type(MoonWellGetLaunchMode) == "function" then + LoginState.luaBridgePending = false + LoginState.launchMode = ResolveLaunchMode() + ApplyLaunchMode() + TryLauncherLogin() + end + if Config.LOGIN_AMBIENCE and not self.ambiencePlayed then PlayGlueAmbience(Config.LOGIN_AMBIENCE, Config.AMBIENCE_FADE_TIME) self.ambiencePlayed = true @@ -283,8 +388,8 @@ function AccountLogin_OnLoad(self) model:SetModel("Character/Human/Male/HumanMale.mdx") model:SetPoint("CENTER", 0, 0) - model:SetSize(self:GetWidth() / (data[LOGIN_MODEL_STRUCT.WIDTH_SQUISH] or 1), - self:GetHeight() / (data[LOGIN_MODEL_STRUCT.HEIGHT_SQUISH] or 1)) + model:SetWidth(self:GetWidth() / (data[LOGIN_MODEL_STRUCT.WIDTH_SQUISH] or 1)) + model:SetHeight(self:GetHeight() / (data[LOGIN_MODEL_STRUCT.HEIGHT_SQUISH] or 1)) model:SetCamera(1) if data[LOGIN_MODEL_STRUCT.LIGHT] then @@ -312,6 +417,10 @@ function AccountLogin_OnLoad(self) end function AccountLogin_OnShow(self) + -- Submit launcher credentials before constructing the decorative login UI. + -- A cosmetic Lua error must never be able to block authentication. + TryLauncherFallbackLogin() + self:Show() self:SetAlpha(1) WorldOfWarcraftRating:Hide() @@ -346,7 +455,8 @@ function AccountLogin_OnShow(self) if not self.newLogo then self.newLogoFrame = CreateFrame("Frame", nil, self) - self.newLogoFrame:SetSize(Config.LOGO_SIZE, Config.LOGO_SIZE) + self.newLogoFrame:SetWidth(Config.LOGO_SIZE) + self.newLogoFrame:SetHeight(Config.LOGO_SIZE) self.newLogoFrame:SetPoint("TOPLEFT", Config.LOGO_POSITION_X, Config.LOGO_POSITION_Y) self.newLogo = self.newLogoFrame:CreateTexture("AccountLoginNewLogo", "OVERLAY") @@ -376,7 +486,16 @@ function AccountLogin_OnShow(self) self.modelUpdateCount = 0 self.modelUpdateTimer = 0 - local accountName, password = unpack(string_explode(GetSavedAccountName(), "#&|&#")) + LoginState.luaBridgePending = type(MoonWellGetLaunchMode) ~= "function" + LoginState.launchMode = ResolveLaunchMode() + local accountName, password = "", "" + if LoginState.launchMode == "developer" then + accountName, password = unpack(string_explode(GetSavedAccountName(), "#&|&#")) + else + SetSavedAccountName("") + SetSavedAccountList("") + SetUsesToken(false) + end UICache.accountEdit:SetText(accountName or "") UICache.passwordEdit:SetText(password or "") @@ -390,13 +509,15 @@ function AccountLogin_OnShow(self) UICache.realmName:SetText("No Recent Server") end - if accountName == "" then - AccountLogin_FocusAccountName() - else - AccountLogin_FocusPassword() + if LoginState.launchMode == "developer" then + if accountName == "" then + AccountLogin_FocusAccountName() + else + AccountLogin_FocusPassword() + end end - if UICache.savePassword:GetChecked() then + if LoginState.launchMode == "developer" and UICache.savePassword:GetChecked() then UICache.autoLoginText:Show() UICache.autoLogin:Show() else @@ -415,7 +536,11 @@ function AccountLogin_OnShow(self) ACCOUNT_MSG_BODY_LOADED = false ACCOUNT_MSG_CURRENT_INDEX = nil + ApplyLaunchMode() AccountLogin_CheckAutoLogin() + if not TryLauncherLogin() then + TryLauncherFallbackLogin() + end self:SetScript("OnUpdate", LoginScene_OnUpdate) end @@ -450,11 +575,15 @@ function AccountLogin_OnHide(self) end function AccountLogin_FocusPassword() - UICache.passwordEdit:SetFocus() + if LoginState.launchMode == "developer" then + UICache.passwordEdit:SetFocus() + end end function AccountLogin_FocusAccountName() - UICache.accountEdit:SetFocus() + if LoginState.launchMode == "developer" then + UICache.accountEdit:SetFocus() + end end function AccountLogin_OnKeyDown(key) @@ -474,7 +603,7 @@ function AccountLogin_OnKeyDown(key) elseif SurveyNotificationFrame:IsShown() then AccountLogin_SurveyNotificationDone(1) end - AccountLogin_Login() + AccountLogin_PrimaryAction() elseif key == "PRINTSCREEN" then Screenshot() end @@ -526,6 +655,11 @@ end -- SISTEMA DE LOGIN CON GUARDADO DE CONTRASEÑA -- ============================================================================ function AccountLogin_Login() + if LoginState.launchMode ~= "developer" then + AccountLogin_OpenLauncher() + return + end + PlaySound("gsLogin") local accountName = UICache.accountEdit:GetText() local password = UICache.passwordEdit:GetText() @@ -557,6 +691,11 @@ end -- SISTEMA DE AUTOLOGIN -- ============================================================================ function AccountLogin_CheckAutoLogin() + if LoginState.launchMode ~= "developer" then + LoginState.autoLoginTimer = nil + return + end + if not LoginState.autoLoginAttempted then LoginState.autoLoginAttempted = true local savedAccountInfo = GetSavedAccountName() diff --git a/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/AccountLogin.xml b/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/AccountLogin.xml index 9c86b56..70e3675 100644 --- a/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/AccountLogin.xml +++ b/src/Data/ruRU/patch-ruRU-4/Interface/GlueXML/AccountLogin.xml @@ -115,6 +115,18 @@ + + + + + + + + + + + +