diff --git a/CMakeLists.txt b/CMakeLists.txt index 63071a0ed7..d35b7e6150 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,6 +76,7 @@ list(APPEND MINECRAFT_SHARED_DEFINES ${PLATFORM_DEFINES}) # --- # Sources # --- +add_subdirectory(MCAuth) add_subdirectory(Minecraft.World) add_subdirectory(Minecraft.Client) if(PLATFORM_NAME STREQUAL "Windows64") # Server is only supported on Windows for now @@ -111,3 +112,4 @@ set_property(DIRECTORY PROPERTY VS_STARTUP_PROJECT Minecraft.Client) # Setup folders for Visual Studio, just hides the build targets under a sub folder set_property(GLOBAL PROPERTY USE_FOLDERS ON) set_property(TARGET GenerateBuildVer PROPERTY FOLDER "Build") +set_property(TARGET MCAuth PROPERTY FOLDER "Libraries") diff --git a/MCAuth/CMakeLists.txt b/MCAuth/CMakeLists.txt new file mode 100644 index 0000000000..47589ca1a5 --- /dev/null +++ b/MCAuth/CMakeLists.txt @@ -0,0 +1,40 @@ +set(MCAUTH_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/src/MCAuthCrypto.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/MCAuthCrypto.h" + "${CMAKE_CURRENT_SOURCE_DIR}/src/MCAuthHttp.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/MCAuthHttp.h" + "${CMAKE_CURRENT_SOURCE_DIR}/src/MCAuthInternal.h" + "${CMAKE_CURRENT_SOURCE_DIR}/src/MCAuthJava.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/MCAuthManager.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/MCAuthElyby.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/MCAuthSession.cpp" +) +source_group("src" FILES ${MCAUTH_SOURCES}) + +set(MCAUTH_HEADERS + "${CMAKE_CURRENT_SOURCE_DIR}/include/MCAuth.h" + "${CMAKE_CURRENT_SOURCE_DIR}/include/MCAuthManager.h" +) +source_group("include" FILES ${MCAUTH_HEADERS}) + +add_library(MCAuth STATIC ${MCAUTH_SOURCES} ${MCAUTH_HEADERS}) + +target_include_directories(MCAuth + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/include" + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" +) + +target_compile_definitions(MCAuth PRIVATE + _LIB + $<$:_DEBUG> + _CRT_SECURE_NO_WARNINGS +) + +configure_compiler_target(MCAuth) + +target_link_libraries(MCAuth PRIVATE + winhttp + bcrypt +) diff --git a/MCAuth/MCAuth.vcxproj b/MCAuth/MCAuth.vcxproj new file mode 100644 index 0000000000..f700df8b70 --- /dev/null +++ b/MCAuth/MCAuth.vcxproj @@ -0,0 +1,156 @@ + + + + + Debug + Win32 + + + Release + Win32 + + + Debug + x64 + + + Release + x64 + + + + + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} + Win32Proj + MCAuth + 10.0 + + + + + + StaticLibrary + true + v143 + Unicode + + + StaticLibrary + false + v143 + Unicode + + + StaticLibrary + true + v143 + Unicode + + + StaticLibrary + false + v143 + Unicode + + + + + + $(SolutionDir)bin\$(Configuration)\$(Platform)\ + $(SolutionDir)obj\$(ProjectName)\$(Configuration)\$(Platform)\ + + + $(SolutionDir)bin\$(Configuration)\$(Platform)\ + $(SolutionDir)obj\$(ProjectName)\$(Configuration)\$(Platform)\ + + + $(SolutionDir)bin\$(Configuration)\$(Platform)\ + $(SolutionDir)obj\$(ProjectName)\$(Configuration)\$(Platform)\ + + + $(SolutionDir)bin\$(Configuration)\$(Platform)\ + $(SolutionDir)obj\$(ProjectName)\$(Configuration)\$(Platform)\ + + + + + Level3 + true + WIN32;_DEBUG;_LIB;%(PreprocessorDefinitions) + stdcpp17 + $(ProjectDir)include;%(AdditionalIncludeDirectories) + Disabled + MultiThreadedDebug + + + $(OutDir)MCAuth.lib + + + + + + Level3 + true + WIN32;NDEBUG;_LIB;%(PreprocessorDefinitions) + stdcpp17 + $(ProjectDir)include;%(AdditionalIncludeDirectories) + MaxSpeed + true + true + MultiThreaded + + + $(OutDir)MCAuth.lib + + + + + + Level3 + true + WIN32;_DEBUG;_LIB;%(PreprocessorDefinitions) + stdcpp17 + $(ProjectDir)include;%(AdditionalIncludeDirectories) + Disabled + MultiThreadedDebug + + + $(OutDir)MCAuth.lib + + + + + + Level3 + true + WIN32;NDEBUG;_LIB;%(PreprocessorDefinitions) + stdcpp17 + $(ProjectDir)include;%(AdditionalIncludeDirectories) + MaxSpeed + true + true + MultiThreaded + + + $(OutDir)MCAuth.lib + + + + + + + + + + + + + + + + + + + + + diff --git a/MCAuth/include/MCAuth.h b/MCAuth/include/MCAuth.h new file mode 100644 index 0000000000..c2055799a7 --- /dev/null +++ b/MCAuth/include/MCAuth.h @@ -0,0 +1,230 @@ +#pragma once +/* + * MCAuth - Microsoft/Xbox Live Authentication library for Minecraft Java Edition + * C++ port of the MinecraftAuth Java library by RaphiMC + * + * Usage: + * MCAuth::JavaAuthManager auth; + * MCAuth::JavaSession session; + * std::string error; + * + * bool ok = auth.Login( + * [](const MCAuth::DeviceCodeInfo& dc) { + * // Show dc.userCode and dc.directUri to the user + * }, + * session, error + * ); + */ + +#include +#include +#include +#include +#include + +namespace MCAuth { + +// ----- Error codes ----- + +enum class AuthErrorCode { + None = 0, + NetworkTimeout, // connection timed out or DNS failure + NetworkError, // general HTTP/TLS failure (send/receive) + HttpError, // unexpected HTTP status code + InvalidCredentials, // wrong token, expired refresh token, account not authorised + TokenExpired, // access token expired (needs refresh) + ServerUnavailable, // Mojang/MS service returned 5xx + RateLimited, // HTTP 429 + ProfileNotOwned, // MS account exists but doesn't own MC Java Edition + Cancelled, // operation was cancelled (e.g. device code timeout) + InternalError, // unexpected exception / crypto failure +}; + +struct AuthError { + AuthErrorCode code = AuthErrorCode::None; + std::string message; + int httpStatus = 0; // 0 if not applicable + + bool ok() const { return code == AuthErrorCode::None; } + explicit operator bool() const { return !ok(); } // true means error +}; + +// ----- Constants ---- + +// Java Edition title ID (Win32, used for SISU auth flow) +static constexpr const char* JAVA_CLIENT_ID = "00000000402b5328"; + +static constexpr const char* MSA_SCOPE = "service::user.auth.xboxlive.com::MBI_SSL"; + +// ----- Public data types ----- + +struct DeviceCodeInfo { + std::string userCode; // e.g. "ABCD1234" + std::string verificationUri; // e.g. "https://www.microsoft.com/link" + std::string directUri; // verificationUri + "?otc=" + userCode + std::string deviceCode; // internal polling token + int64_t expiresMs; // absolute timestamp (ms since epoch) + int64_t intervalMs; // polling interval in ms +}; + +using DeviceCodeCallback = std::function; + +struct JavaSession { + std::string username; // Minecraft username (from /minecraft/profile) + std::string uuid; // Player UUID (dashed, e.g. "069a79f4-44e9-4726-a5be-fca90e38aaf5") + std::string accessToken; // Full Authorization header value ("Bearer ") + int64_t expireMs; // When the access token expires (ms since epoch) +}; + +// ----- Java Edition auth manager ----- + +class JavaAuthManager { +public: + JavaAuthManager(); + ~JavaAuthManager(); + + JavaAuthManager(const JavaAuthManager&) = delete; + JavaAuthManager& operator=(const JavaAuthManager&) = delete; + + // Synchronous device-code login (blocks until complete or timeout). + bool Login(DeviceCodeCallback onDeviceCode, + JavaSession& outSession, + std::string& error, + int timeoutSeconds = 300); + + // Refresh using the saved MSA refresh token. + bool Refresh(JavaSession& outSession, std::string& error); + + bool IsLoggedIn() const; + void Logout(); + + // Signal a blocking Login() to abort as soon as possible. + // Thread-safe — can be called from any thread while Login() is running. + void RequestCancel(); + + bool SaveTokens(const std::string& filePath) const; + bool LoadTokens(const std::string& filePath); + +private: + struct Impl; + std::unique_ptr m_impl; +}; + +// ----- Ely.by Yggdrasil auth (free functions, no class) ----- + +struct ElybyTokens { + std::string accessToken; + std::string clientToken; + std::string uuid; // undashed + std::string username; +}; + +// POST https://authserver.ely.by/auth/authenticate +// On 2FA requirement, error is set to "elyby_2fa_required"; caller retries with password = "pass:totp_code" +bool ElybyLogin(const std::string& username, const std::string& password, + ElybyTokens& outTokens, std::string& error); + +// POST https://authserver.ely.by/auth/refresh +bool ElybyRefresh(ElybyTokens& tokens, std::string& error); + +// POST https://authserver.ely.by/auth/validate +bool ElybyValidate(const std::string& accessToken, std::string& error); + +// Token persistence — simple JSON: {accessToken, clientToken, uuid, username} +bool ElybyLoadTokens(const std::string& path, ElybyTokens& out); +bool ElybySaveTokens(const std::string& path, const ElybyTokens& tokens); + +// ----- Skin key utility ----- + +// Build the canonical memory-texture key for a Mojang skin. +// uuid can be dashed or undashed — dashes are stripped automatically. +// Returns e.g. "mojang_skin_069a79f444e94726a5befca90e38aaf5.png" +std::string MakeSkinKey(const std::string& uuid); + +// ----- UUID utilities ----- + +// Remove dashes from a UUID string: "069a79f4-44e9-..." → "069a79f444e9..." +std::string UndashUuid(const std::string& dashed); + +// Insert dashes into a 32-char hex UUID: "069a79f444e9..." → "069a79f4-44e9-..." +std::string DashUuid(const std::string& undashed); + +// Generate a Minecraft-compatible offline UUID (UUID v3, namespace "OfflinePlayer:" + username). +// Returns dashed format. +std::string GenerateOfflineUuid(const std::string& username); + +// Parse a dashed UUID into two 64-bit halves (big-endian). +struct Uuid128 { + uint64_t hi = 0; + uint64_t lo = 0; + bool isValid() const { return hi != 0 || lo != 0; } +}; +Uuid128 ParseUuid128(const std::string& dashed); + +// ----- Session verification (Mojang sessionserver) ----- + +// Client-side: call BEFORE connecting to an online-mode server. +// Posts to sessionserver.mojang.com/session/minecraft/join. +// accessToken = raw token (without "Bearer " prefix). +// undashedUuid = 32-char hex UUID of the player. +// serverId = the server's challenge string. +// Returns true on success (HTTP 204). +bool JoinServer(const std::string& accessToken, + const std::string& undashedUuid, + const std::string& serverId, + std::string& error); + +// Server-side result from HasJoined verification. +struct HasJoinedResult { + bool success = false; + AuthError error; // structured error (code + message + httpStatus) + std::string username; // verified username from Mojang + std::string uuid; // undashed UUID + std::string skinUrl; // Mojang skin texture URL (from base64 properties) + std::string capeUrl; // Mojang cape texture URL (from base64 properties) +}; + +// Server-side: verify that a player has called /join. +// GETs sessionserver.mojang.com/session/minecraft/hasJoined?username=X&serverId=Y. +// Returns success=true with username+uuid on HTTP 200, success=false on 204 (not joined). +HasJoinedResult HasJoined(const std::string& username, + const std::string& serverId, + std::string& error); + +// Download a skin/cape PNG from a URL. +// Returns the raw PNG bytes, or empty vector on failure. +std::vector FetchSkinPng(const std::string& url, std::string& error); + +// Fetch the skin URL for a player UUID from Mojang's session server. +// uuid can be dashed or undashed. +// Returns the skin texture URL, or empty string on failure. +std::string FetchProfileSkinUrl(const std::string& uuid, std::string& error); + +// Download a skin PNG and return the raw bytes WITHOUT cropping to 64x32. +// Use this when you need the full 64x64 texture (e.g. for rendering the head). +std::vector FetchSkinPngRaw(const std::string& url, std::string& error); + +// ----- Ely.by session functions (same Yggdrasil protocol, different URLs) ----- + +bool ElybyJoinServer(const std::string& accessToken, + const std::string& undashedUuid, + const std::string& serverId, + std::string& error); + +HasJoinedResult ElybyHasJoined(const std::string& username, + const std::string& serverId, + std::string& error); + +std::string ElybyFetchProfileSkinUrl(const std::string& uuid, std::string& error); + +std::string MakeElybySkinKey(const std::string& uuid); + +// ----- Skin validation ----- + +// Maximum allowed skin PNG size in bytes (32KB — a 64x64 RGBA PNG is ~4KB compressed) +static constexpr size_t kMaxSkinBytes = 32768; + +// Check PNG magic, size cap, and 64x32 or 64x64 dimensions. +bool ValidateSkinPng(const uint8_t* data, size_t size); + +} // namespace MCAuth diff --git a/MCAuth/include/MCAuthManager.h b/MCAuth/include/MCAuthManager.h new file mode 100644 index 0000000000..8075556538 --- /dev/null +++ b/MCAuth/include/MCAuthManager.h @@ -0,0 +1,211 @@ +#pragma once +/* + * MCAuthManager - Thread-safe singleton that wraps JavaAuthManager with + * multi-account and per-slot session support (for splitscreen). + * + * Architecture: + * + * - Account list (m_javaAccounts) is SHARED — one global list of saved accounts. + * - Each player slot (0..XUSER_MAX_COUNT-1) has its own AuthSlot containing: + * - A shared_ptr (ownership shared with background worker) + * - Its own session, mutex, generation counter + * - Slot 0 is the primary player. Slots 1-3 are splitscreen. + * + * Thread model (fire-and-forget): + * - Background workers are detached threads that capture a shared_ptr copy + * of the auth engine they operate on. + * - To cancel: increment generation + RequestCancel on the old auth engine, + * then create a fresh shared_ptr for the slot. + * - The old thread runs to completion harmlessly — generation mismatch causes + * it to discard results, and the old auth engine is freed when the thread + * exits (shared_ptr ref-count drops to zero). + * - The main thread NEVER calls join(). No blocking. Ever. + * + * Usage: + * auto& mgr = MCAuthManager::Get(); + * mgr.LoadJavaAccountIndex(); + * mgr.TryRestoreActiveJavaAccount(); // restore slot 0 + * + * mgr.SetAccountForSlot(0, accountIndex); // switch slot 0's account + * auto session = mgr.GetSlotSession(0); // get slot 0's session + */ + +#include "MCAuth.h" +#include +#include +#include +#include +#include +#include +#include + +#ifndef XUSER_MAX_COUNT +#define XUSER_MAX_COUNT 4 +#endif + +class MCAuthManager { +public: + enum class State { + Idle, + WaitingForCode, // device code displayed, waiting for user + Authenticating, // polling / running auth chain + Success, + Failed, + }; + + // Per-account info stored in the index. + struct JavaAccountInfo { + std::string username; // Minecraft username + std::string uuid; // Player UUID (dashed) + std::string tokenFile; // e.g. "java_auth_0.json" (empty for offline) + bool isOffline = false; + std::string authProvider; // "mojang", "elyby", "offline" (default: "mojang") + }; + + // Per-player-slot auth state. Each slot has its own auth engine + session. + struct AuthSlot { + // Auth engine is heap-allocated and shared with background worker threads. + // When we cancel, we abandon the old shared_ptr and create a fresh one. + // The old thread's copy prevents use-after-free; it finishes harmlessly. + std::shared_ptr auth; + + MCAuth::ElybyTokens elybyTokens; // Ely.by tokens (no engine class needed) + MCAuth::JavaSession session; // current session data + std::atomic accountIndex{-1}; // index into m_javaAccounts (-1 = none) + mutable std::mutex mutex; + std::atomic generation{0}; // invalidates stale workers + std::atomic state{State::Idle}; + mutable std::condition_variable cv; // signals when state changes + std::string lastError; + + // No std::thread member — workers are detached (fire-and-forget). + + bool hasSession() const { return !session.uuid.empty(); } + + AuthSlot() : auth(std::make_shared()) {} + }; + + using DeviceCodeCb = MCAuth::DeviceCodeCallback; + using JavaCompleteCb = std::function; + + static MCAuthManager& Get(); + + MCAuthManager(const MCAuthManager&) = delete; + MCAuthManager& operator=(const MCAuthManager&) = delete; + + // ---- Java Edition — Account Index ---- + + bool LoadJavaAccountIndex(); + bool SaveJavaAccountIndex() const; + + std::vector GetJavaAccounts() const; + + // ---- Java Edition — Per-Slot Session Management ---- + + // Get the auth slot for a player index (0 = primary, 1-3 = splitscreen). + const AuthSlot& GetSlot(int slot) const; + + // Set which account a slot uses. Loads tokens + refreshes in background. + // The old session remains valid until the new one is ready (no empty window). + bool SetAccountForSlot(int slot, int accountIndex); + + // Clear a slot (e.g. when a splitscreen player leaves). + void ClearSlot(int slot); + + // Get the session for a specific slot. + MCAuth::JavaSession GetSlotSession(int slot) const; + + // Check if a slot has a valid logged-in session. + bool IsSlotLoggedIn(int slot) const; + + // Check if the slot's token is expiring soon (within 60s). + bool IsTokenExpiringSoon(int slot) const; + + // Proactively refresh a slot's token in the background. + void RefreshSlot(int slot); + + // Block until a slot's auth settles (state leaves Authenticating/WaitingForCode). + // Returns true if login succeeded, false on timeout or failure. + bool WaitForSlotReady(int slot, int timeoutMs = 15000) const; + + // Check if an online account is already assigned to another slot. + bool IsAccountInUseByOtherSlot(int slot, int accountIndex) const; + + // ---- Java Edition — Slot 0 Convenience (backward compat) ---- + + int GetActiveJavaAccountIndex() const; + bool SetActiveJavaAccount(int index) { return SetAccountForSlot(0, index); } + MCAuth::JavaSession GetJavaSession() const; + bool IsJavaLoggedIn() const; + State GetState() const { return m_slots[0].state.load(); } + State GetSlotState(int slot) const { + if (slot < 0 || slot >= XUSER_MAX_COUNT) slot = 0; + return m_slots[slot].state.load(); + } + bool WaitForAuthReady(int timeoutMs = 15000) const { return WaitForSlotReady(0, timeoutMs); } + + // ---- Java Edition — Account Management ---- + + // Add a new account via device code flow (background thread on slot 0). + void BeginAddJavaAccount(DeviceCodeCb onDeviceCode, JavaCompleteCb onComplete, + int timeoutSeconds = 300); + + // Add an Ely.by account via username+password. + using ElybyCompleteCb = std::function; + using Elyby2FACb = std::function; + void BeginAddElybyAccount(const std::string& username, const std::string& password, + ElybyCompleteCb onComplete, Elyby2FACb on2FA = nullptr); + + // Add an offline account. Returns new account index or -1. + int AddOfflineJavaAccount(const std::string& username); + + // Remove an account by index. Deletes its token file. + bool RemoveJavaAccount(int index); + + // Restore the active account from disk at startup (slot 0). + void TryRestoreActiveJavaAccount(); + + // ---- Device code info (valid during add-account flow) ---- + + std::string GetJavaDeviceCode() const; + std::string GetJavaDirectUri() const; + std::string GetLastError() const; + + // File paths + static constexpr const char* kJavaAccountsFile = "java_accounts.json"; + +private: + MCAuthManager(); + ~MCAuthManager(); + + // Generate a unique token filename for a new account. + std::string AllocTokenFile(const std::string& uuid) const; + + // Synthesize an offline session from account info. + static MCAuth::JavaSession SynthesizeOfflineSession(const JavaAccountInfo& acct); + + // Abandon the current auth engine for a slot and create a fresh one. + // Calls RequestCancel on the old engine so its worker exits promptly. + // Returns the fresh shared_ptr for capturing in the new worker lambda. + std::shared_ptr ResetSlotAuth(AuthSlot& s); + + // Shared Ely.by refresh logic used by RefreshSlot and SetAccountForSlot. + // If failOpen is true, sets state to Success even when refresh fails (fail-open). + // If alwaysSaveIndex is true, calls SaveJavaAccountIndex even on failure. + void RunElybyRefresh(int slot, uint32_t gen, const std::string& tokenFile, + bool failOpen, bool alwaysSaveIndex); + + // Per-slot auth state + AuthSlot m_slots[XUSER_MAX_COUNT]; + + // Shared account list (protected by m_accountsMutex) + mutable std::mutex m_accountsMutex; + std::vector m_javaAccounts; + + // Device code state (for BeginAddJavaAccount, always on slot 0) + mutable std::mutex m_deviceCodeMutex; + std::string m_javaDeviceCode; + std::string m_javaDirectUri; + + bool m_javaRestoreAttempted = false; +}; diff --git a/MCAuth/src/MCAuthCrypto.cpp b/MCAuth/src/MCAuthCrypto.cpp new file mode 100644 index 0000000000..71dda6236b --- /dev/null +++ b/MCAuth/src/MCAuthCrypto.cpp @@ -0,0 +1,398 @@ +#include "MCAuthCrypto.h" + +#define WIN32_LEAN_AND_MEAN +#include +#include +#pragma comment(lib, "bcrypt.lib") + +#include +#include +#include +#include +#include + +namespace MCAuth { + +static void ThrowIfFailed(NTSTATUS status, const char* ctx) { + if (!BCRYPT_SUCCESS(status)) { + char buf[128]; + snprintf(buf, sizeof(buf), "BCrypt error 0x%08X in %s", (unsigned)status, ctx); + throw std::runtime_error(buf); + } +} + +// Build SubjectPublicKeyInfo DER for an EC public key. +// Hardcoded for P-256 and P-384 (the only two we use). +std::vector BuildSubjectPublicKeyInfoDER( + int bitSize, + const std::vector& x, + const std::vector& y) +{ + // OIDs + // ecPublicKey: 1.2.840.10045.2.1 -> 2a 86 48 ce 3d 02 01 (7 bytes) + static const uint8_t OID_EC_PUBLIC_KEY[] = { 0x2a,0x86,0x48,0xce,0x3d,0x02,0x01 }; + // secp256r1 : 1.2.840.10045.3.1.7 -> 2a 86 48 ce 3d 03 01 07 (8 bytes) + static const uint8_t OID_P256[] = { 0x2a,0x86,0x48,0xce,0x3d,0x03,0x01,0x07 }; + // secp384r1 : 1.3.132.0.34 -> 2b 81 04 00 22 (5 bytes) + static const uint8_t OID_P384[] = { 0x2b,0x81,0x04,0x00,0x22 }; + + size_t coordSize = (bitSize == 256) ? 32 : 48; + const uint8_t* curveOid = (bitSize == 256) ? OID_P256 : OID_P384; + size_t curveOidLen = (bitSize == 256) ? 8 : 5; + + // AlgorithmIdentifier SEQUENCE: + // 06 07 + // 06 + size_t algIdInnerLen = 2 + sizeof(OID_EC_PUBLIC_KEY) + 2 + curveOidLen; + + // BIT STRING content: 00 04 X Y + size_t bitStringContent = 1 + 1 + coordSize + coordSize; // 00 04 X Y + size_t bitStringLen = bitStringContent; + + // Outer SEQUENCE content + size_t outerContent = (2 + algIdInnerLen) + (2 + bitStringLen); + + // Single-byte DER lengths suffice for P-256 (89 bytes) and P-384 (118 bytes) + + std::vector der; + auto appendLen = [&](size_t len) { + if (len < 128) { + der.push_back((uint8_t)len); + } else { + der.push_back(0x82); + der.push_back((uint8_t)(len >> 8)); + der.push_back((uint8_t)(len & 0xFF)); + } + }; + + // Outer SEQUENCE + der.push_back(0x30); + appendLen(outerContent); + + // AlgorithmIdentifier SEQUENCE + der.push_back(0x30); + appendLen(algIdInnerLen); + der.push_back(0x06); der.push_back((uint8_t)sizeof(OID_EC_PUBLIC_KEY)); + der.insert(der.end(), OID_EC_PUBLIC_KEY, OID_EC_PUBLIC_KEY + sizeof(OID_EC_PUBLIC_KEY)); + der.push_back(0x06); der.push_back((uint8_t)curveOidLen); + der.insert(der.end(), curveOid, curveOid + curveOidLen); + + // BIT STRING + der.push_back(0x03); + appendLen(bitStringLen + 1); // +1 for the "unused bits" byte + der.push_back(0x00); // 0 unused bits + der.push_back(0x04); // uncompressed point + der.insert(der.end(), x.begin(), x.end()); + der.insert(der.end(), y.begin(), y.end()); + + return der; +} + +// Generate a key pair using BCrypt. +static ECKeyPair GenerateKeyPair(int bitSize) { + LPCWSTR algId = (bitSize == 256) ? BCRYPT_ECDSA_P256_ALGORITHM + : BCRYPT_ECDSA_P384_ALGORITHM; + + BCRYPT_ALG_HANDLE hAlg = nullptr; + BCRYPT_KEY_HANDLE hKey = nullptr; + + try { + ThrowIfFailed(BCryptOpenAlgorithmProvider(&hAlg, algId, nullptr, 0), "OpenAlgProvider"); + + ThrowIfFailed(BCryptGenerateKeyPair(hAlg, &hKey, bitSize, 0), "GenerateKeyPair"); + ThrowIfFailed(BCryptFinalizeKeyPair(hKey, 0), "FinalizeKeyPair"); + + // Export private blob + ULONG privBlobLen = 0; + ThrowIfFailed(BCryptExportKey(hKey, nullptr, BCRYPT_ECCPRIVATE_BLOB, nullptr, 0, &privBlobLen, 0), "ExportPriv(len)"); + std::vector privBlob(privBlobLen); + ThrowIfFailed(BCryptExportKey(hKey, nullptr, BCRYPT_ECCPRIVATE_BLOB, privBlob.data(), privBlobLen, &privBlobLen, 0), "ExportPriv"); + + // Export public blob + ULONG pubBlobLen = 0; + ThrowIfFailed(BCryptExportKey(hKey, nullptr, BCRYPT_ECCPUBLIC_BLOB, nullptr, 0, &pubBlobLen, 0), "ExportPub(len)"); + std::vector pubBlob(pubBlobLen); + ThrowIfFailed(BCryptExportKey(hKey, nullptr, BCRYPT_ECCPUBLIC_BLOB, pubBlob.data(), pubBlobLen, &pubBlobLen, 0), "ExportPub"); + + BCryptDestroyKey(hKey); + BCryptCloseAlgorithmProvider(hAlg, 0); + + // BCRYPT_ECCKEY_BLOB header: 4-byte Magic + 4-byte cbKey + // Public blob: [8-byte header] [X:cbKey bytes] [Y:cbKey bytes] + const BCRYPT_ECCKEY_BLOB* hdr = reinterpret_cast(pubBlob.data()); + ULONG cbKey = hdr->cbKey; + + std::vector x(pubBlob.begin() + 8, pubBlob.begin() + 8 + cbKey); + std::vector y(pubBlob.begin() + 8 + cbKey, pubBlob.begin() + 8 + cbKey * 2); + + ECKeyPair kp; + kp.bitSize = bitSize; + kp.privateBlob = std::move(privBlob); + kp.publicBlob = std::move(pubBlob); + kp.x = x; + kp.y = y; + kp.publicKeyDER = BuildSubjectPublicKeyInfoDER(bitSize, x, y); + return kp; + + } catch (...) { + if (hKey) BCryptDestroyKey(hKey); + if (hAlg) BCryptCloseAlgorithmProvider(hAlg, 0); + throw; + } +} + +ECKeyPair GenerateP256KeyPair() { return GenerateKeyPair(256); } +ECKeyPair GenerateP384KeyPair() { return GenerateKeyPair(384); } + +std::vector SignSHA256P256(const ECKeyPair& kp, + const uint8_t* data, + size_t len) +{ + if (kp.bitSize != 256) + throw std::runtime_error("SignSHA256P256: key is not P-256"); + + BCRYPT_ALG_HANDLE hAlg = nullptr; + BCRYPT_KEY_HANDLE hKey = nullptr; + BCRYPT_ALG_HANDLE hHashAlg = nullptr; + BCRYPT_HASH_HANDLE hHash = nullptr; + + try { + ThrowIfFailed(BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_ECDSA_P256_ALGORITHM, nullptr, 0), "Sign/OpenAlg"); + + // Import private key + ThrowIfFailed(BCryptImportKeyPair(hAlg, nullptr, BCRYPT_ECCPRIVATE_BLOB, + &hKey, + const_cast(kp.privateBlob.data()), + (ULONG)kp.privateBlob.size(), 0), "Sign/ImportKey"); + + // Hash the data with SHA-256 + ThrowIfFailed(BCryptOpenAlgorithmProvider(&hHashAlg, BCRYPT_SHA256_ALGORITHM, nullptr, 0), "Sign/OpenHashAlg"); + + ThrowIfFailed(BCryptCreateHash(hHashAlg, &hHash, nullptr, 0, nullptr, 0, 0), "Sign/CreateHash"); + ThrowIfFailed(BCryptHashData(hHash, const_cast(data), (ULONG)len, 0), "Sign/HashData"); + + uint8_t digest[32]; + ThrowIfFailed(BCryptFinishHash(hHash, digest, sizeof(digest), 0), "Sign/FinishHash"); + BCryptDestroyHash(hHash); hHash = nullptr; + BCryptCloseAlgorithmProvider(hHashAlg, 0); hHashAlg = nullptr; + + // Sign the hash + ULONG sigLen = 0; + ThrowIfFailed(BCryptSignHash(hKey, nullptr, digest, sizeof(digest), nullptr, 0, &sigLen, 0), "Sign/GetSigLen"); + std::vector sig(sigLen); + ThrowIfFailed(BCryptSignHash(hKey, nullptr, digest, sizeof(digest), sig.data(), sigLen, &sigLen, 0), "Sign/SignHash"); + + BCryptDestroyKey(hKey); + BCryptCloseAlgorithmProvider(hAlg, 0); + + sig.resize(sigLen); + return sig; + + } catch (...) { + if (hHash) BCryptDestroyHash(hHash); + if (hHashAlg) BCryptCloseAlgorithmProvider(hHashAlg, 0); + if (hKey) BCryptDestroyKey(hKey); + if (hAlg) BCryptCloseAlgorithmProvider(hAlg, 0); + throw; + } +} + +static const char kB64Chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +static const char kB64UrlChars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +static std::string Base64EncodeInternal(const uint8_t* data, size_t len, const char* alpha, bool pad) { + std::string out; + out.reserve(((len + 2) / 3) * 4); + for (size_t i = 0; i < len; i += 3) { + uint32_t v = (uint32_t)data[i] << 16; + if (i + 1 < len) v |= (uint32_t)data[i + 1] << 8; + if (i + 2 < len) v |= (uint32_t)data[i + 2]; + + out += alpha[(v >> 18) & 0x3F]; + out += alpha[(v >> 12) & 0x3F]; + out += (i + 1 < len) ? alpha[(v >> 6) & 0x3F] : (pad ? '=' : '\0'); + out += (i + 2 < len) ? alpha[(v >> 0) & 0x3F] : (pad ? '=' : '\0'); + } + // Remove trailing null chars (only happens for url encode without pad) + while (!out.empty() && out.back() == '\0') out.pop_back(); + return out; +} + +std::string Base64Encode(const uint8_t* data, size_t len) { + return Base64EncodeInternal(data, len, kB64Chars, true); +} +std::string Base64Encode(const std::vector& data) { + return Base64Encode(data.data(), data.size()); +} +std::string Base64UrlEncode(const uint8_t* data, size_t len) { + return Base64EncodeInternal(data, len, kB64UrlChars, false); +} +std::string Base64UrlEncode(const std::vector& data) { + return Base64UrlEncode(data.data(), data.size()); +} + +std::string BuildProofKeyJson(const ECKeyPair& p256) { + std::string xEnc = Base64UrlEncode(p256.x); + std::string yEnc = Base64UrlEncode(p256.y); + return "{\"kty\":\"EC\",\"alg\":\"ES256\",\"crv\":\"P-256\",\"use\":\"sig\"," + "\"x\":\"" + xEnc + "\",\"y\":\"" + yEnc + "\"}"; +} + +// XBL Signature header +// Payload to sign (all fields big-endian where integer): +// INT32 policy_version = 1 +// BYTE 0x00 +// INT64 windows_timestamp +// BYTE 0x00 +// BYTES method (e.g. "POST") +// BYTE 0x00 +// BYTES url_path_and_query +// BYTE 0x00 +// BYTES authorization_header (empty string if none) +// BYTE 0x00 +// BYTES request_body +// BYTE 0x00 +// +// Signature header value (base64 of): +// INT32 policy_version = 1 +// INT64 windows_timestamp +// BYTES 64-byte P1363 ECDSA-SHA256 signature + +static void AppendBE32(std::vector& buf, uint32_t v) { + buf.push_back((uint8_t)(v >> 24)); + buf.push_back((uint8_t)(v >> 16)); + buf.push_back((uint8_t)(v >> 8)); + buf.push_back((uint8_t)(v )); +} +static void AppendBE64(std::vector& buf, uint64_t v) { + buf.push_back((uint8_t)(v >> 56)); + buf.push_back((uint8_t)(v >> 48)); + buf.push_back((uint8_t)(v >> 40)); + buf.push_back((uint8_t)(v >> 32)); + buf.push_back((uint8_t)(v >> 24)); + buf.push_back((uint8_t)(v >> 16)); + buf.push_back((uint8_t)(v >> 8)); + buf.push_back((uint8_t)(v )); +} + +std::string BuildXblSignatureHeader(const ECKeyPair& p256, + const std::string& method, + const std::string& urlPath, + const std::string& body, + const std::string& authHdr) +{ + // Windows timestamp: (unix_seconds + 11644473600) * 10_000_000 + using namespace std::chrono; + int64_t epochSec = duration_cast( + system_clock::now().time_since_epoch()).count(); + uint64_t winTs = (uint64_t)(epochSec + 11644473600LL) * 10000000ULL; + + // Build the content to sign + std::vector content; + content.reserve(512); + + AppendBE32(content, 1); // policy version + content.push_back(0x00); + AppendBE64(content, winTs); // timestamp + content.push_back(0x00); + content.insert(content.end(), method.begin(), method.end()); + content.push_back(0x00); + content.insert(content.end(), urlPath.begin(), urlPath.end()); + content.push_back(0x00); + content.insert(content.end(), authHdr.begin(), authHdr.end()); + content.push_back(0x00); + content.insert(content.end(), body.begin(), body.end()); + content.push_back(0x00); + + // Sign + std::vector sig = SignSHA256P256(p256, content.data(), content.size()); + + // Build header blob + std::vector hdrBlob; + hdrBlob.reserve(4 + 8 + sig.size()); + AppendBE32(hdrBlob, 1); + AppendBE64(hdrBlob, winTs); + hdrBlob.insert(hdrBlob.end(), sig.begin(), sig.end()); + + return Base64Encode(hdrBlob); +} + +std::string GenerateUUID() { + uint8_t bytes[16]; + NTSTATUS st = BCryptGenRandom(nullptr, bytes, sizeof(bytes), BCRYPT_USE_SYSTEM_PREFERRED_RNG); + if (!BCRYPT_SUCCESS(st)) { + // fallback to rand (not cryptographic, but OK for device ID purposes) + for (auto& b : bytes) b = (uint8_t)(rand() & 0xFF); + } + // Set version 4 and variant bits + bytes[6] = (bytes[6] & 0x0F) | 0x40; + bytes[8] = (bytes[8] & 0x3F) | 0x80; + + char buf[37]; + snprintf(buf, sizeof(buf), + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], + bytes[6], bytes[7], + bytes[8], bytes[9], + bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]); + return buf; +} + +std::vector Base64Decode(const std::string& s) { + static const int kInv[256] = { + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63, + 52,53,54,55,56,57,58,59,60,61,-1,-1,-1,-1,-1,-1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14, + 15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1, + -1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40, + 41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + }; + std::vector out; + int buf = 0, bits = 0; + for (unsigned char c : s) { + if (c == '=' || kInv[c] < 0) continue; + buf = (buf << 6) | kInv[c]; + bits += 6; + if (bits >= 8) { + bits -= 8; + out.push_back((uint8_t)(buf >> bits)); + buf &= (1 << bits) - 1; + } + } + return out; +} + +std::string Base64DecodeStr(const std::string& encoded) { + auto bytes = Base64Decode(encoded); + return std::string(bytes.begin(), bytes.end()); +} + +std::vector ComputeMD5(const void* data, size_t len) { + std::vector result(16, 0); + BCRYPT_ALG_HANDLE hAlg = nullptr; + BCRYPT_HASH_HANDLE hHash = nullptr; + + if (BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_MD5_ALGORITHM, nullptr, 0) == 0) { + if (BCryptCreateHash(hAlg, &hHash, nullptr, 0, nullptr, 0, 0) == 0) { + if (BCryptHashData(hHash, (PUCHAR)data, (ULONG)len, 0) == 0) { + BCryptFinishHash(hHash, result.data(), 16, 0); + } + BCryptDestroyHash(hHash); + } + BCryptCloseAlgorithmProvider(hAlg, 0); + } + return result; +} + +} // namespace MCAuth diff --git a/MCAuth/src/MCAuthCrypto.h b/MCAuth/src/MCAuthCrypto.h new file mode 100644 index 0000000000..85d071ff4b --- /dev/null +++ b/MCAuth/src/MCAuthCrypto.h @@ -0,0 +1,70 @@ +#pragma once +#include +#include +#include + +namespace MCAuth { + +// Raw ECDSA key pair stored as BCrypt key blobs. +struct ECKeyPair { + int bitSize; // 256 or 384 + std::vector privateBlob; // BCRYPT_ECCPRIVATE_BLOB + std::vector publicBlob; // BCRYPT_ECCPUBLIC_BLOB + // Raw coordinates extracted from publicBlob (each cbKey bytes, big-endian) + std::vector x; + std::vector y; + // SubjectPublicKeyInfo DER encoding of the public key (base64 for MC APIs) + std::vector publicKeyDER; +}; + +// Generate fresh key pairs +ECKeyPair GenerateP256KeyPair(); +ECKeyPair GenerateP384KeyPair(); + +// ECDSA-SHA256 over `data`, returns 64-byte P1363 signature (r||s) +std::vector SignSHA256P256(const ECKeyPair& kp, + const uint8_t* data, + size_t len); + +// Base64 (standard alphabet, with padding) +std::string Base64Encode(const uint8_t* data, size_t len); +std::string Base64Encode(const std::vector& data); + +// Base64url (no padding, url-safe alphabet) +std::string Base64UrlEncode(const uint8_t* data, size_t len); +std::string Base64UrlEncode(const std::vector& data); + +// Build the JWK ProofKey JSON fragment for a P-256 key: +// {"kty":"EC","alg":"ES256","crv":"P-256","use":"sig","x":"...","y":"..."} +std::string BuildProofKeyJson(const ECKeyPair& p256); + +// Build the XBL "Signature" header value. +// Constructs the signed payload, signs it with the P-256 device key, +// and returns the Base64-encoded header blob. +// method : "POST" +// urlPath : "/device/authenticate" (path + query if any) +// body : raw request body bytes +// authHdr : value of Authorization header (empty string if none) +std::string BuildXblSignatureHeader(const ECKeyPair& p256, + const std::string& method, + const std::string& urlPath, + const std::string& body, + const std::string& authHdr = ""); + +// Random UUID v4 string (lowercase, dashed) +std::string GenerateUUID(); + +// Base64 decode (standard alphabet) +std::vector Base64Decode(const std::string& b64); +std::string Base64DecodeStr(const std::string& b64); + +// MD5 hash using Windows BCrypt +std::vector ComputeMD5(const void* data, size_t len); + +// Build SubjectPublicKeyInfo DER for an EC public key (P-256 or P-384) +std::vector BuildSubjectPublicKeyInfoDER( + int bitSize, + const std::vector& x, + const std::vector& y); + +} // namespace MCAuth diff --git a/MCAuth/src/MCAuthElyby.cpp b/MCAuth/src/MCAuthElyby.cpp new file mode 100644 index 0000000000..78c5fafcad --- /dev/null +++ b/MCAuth/src/MCAuthElyby.cpp @@ -0,0 +1,169 @@ +/* + * MCAuthElyby.cpp — Ely.by Yggdrasil authentication + * + * Stateless free functions for username+password → accessToken flow. + * Uses the same HTTP/JSON helpers as the rest of MCAuth. + */ + +#include "../include/MCAuth.h" +#include "MCAuthCrypto.h" +#include "MCAuthHttp.h" +#include "MCAuthInternal.h" + +#include +#include + +namespace MCAuth { + +using namespace mcauth; + +// --------------------------------------------------------------------------- +// ElybyLogin — POST /auth/authenticate +// --------------------------------------------------------------------------- +bool ElybyLogin(const std::string& username, const std::string& password, + ElybyTokens& outTokens, std::string& error) +{ + // Generate clientToken if not already set + std::string clientToken = outTokens.clientToken; + if (clientToken.empty()) + clientToken = GenerateUUID(); + + std::string body = "{\"agent\":{\"name\":\"Minecraft\",\"version\":1}" + ",\"username\":" + JsonStr(username) + + ",\"password\":" + JsonStr(password) + + ",\"clientToken\":" + JsonStr(clientToken) + "}"; + + try { + auto resp = HttpPost("https://authserver.ely.by/auth/authenticate", + body, "application/json"); + + if (resp.statusCode == 200 && !resp.body.empty()) { + outTokens.accessToken = JsonGetString(resp.body, "accessToken"); + outTokens.clientToken = JsonGetString(resp.body, "clientToken"); + + // selectedProfile contains id and name + std::string profile = JsonRawValue(resp.body, "selectedProfile"); + if (!profile.empty()) { + outTokens.uuid = JsonGetString(profile, "id"); + outTokens.username = JsonGetString(profile, "name"); + } + + if (outTokens.accessToken.empty()) { + error = "ElybyLogin: empty accessToken in response"; + return false; + } + return true; + } + + // Check for 2FA requirement + if (resp.statusCode == 401) { + std::string errMsg = JsonGetString(resp.body, "errorMessage"); + if (errMsg.find("two factor auth") != std::string::npos || + errMsg.find("Account protected with two factor auth") != std::string::npos) { + error = "elyby_2fa_required"; + return false; + } + error = "ElybyLogin: invalid credentials"; + if (!errMsg.empty()) error += " (" + errMsg + ")"; + return false; + } + + error = "ElybyLogin HTTP " + std::to_string(resp.statusCode); + if (!resp.body.empty()) { + std::string msg = JsonGetString(resp.body, "errorMessage"); + if (!msg.empty()) error += ": " + msg; + } + } catch (const std::exception& e) { + error = std::string("ElybyLogin network error: ") + e.what(); + } + return false; +} + +// --------------------------------------------------------------------------- +// ElybyRefresh — POST /auth/refresh +// --------------------------------------------------------------------------- +bool ElybyRefresh(ElybyTokens& tokens, std::string& error) +{ + std::string body = "{\"accessToken\":" + JsonStr(tokens.accessToken) + + ",\"clientToken\":" + JsonStr(tokens.clientToken) + "}"; + + try { + auto resp = HttpPost("https://authserver.ely.by/auth/refresh", + body, "application/json"); + + if (resp.statusCode == 200 && !resp.body.empty()) { + tokens.accessToken = JsonGetString(resp.body, "accessToken"); + tokens.clientToken = JsonGetString(resp.body, "clientToken"); + + std::string profile = JsonRawValue(resp.body, "selectedProfile"); + if (!profile.empty()) { + tokens.uuid = JsonGetString(profile, "id"); + tokens.username = JsonGetString(profile, "name"); + } + return true; + } + + error = "ElybyRefresh HTTP " + std::to_string(resp.statusCode); + if (!resp.body.empty()) { + std::string msg = JsonGetString(resp.body, "errorMessage"); + if (!msg.empty()) error += ": " + msg; + } + } catch (const std::exception& e) { + error = std::string("ElybyRefresh network error: ") + e.what(); + } + return false; +} + +// --------------------------------------------------------------------------- +// ElybyValidate — POST /auth/validate +// --------------------------------------------------------------------------- +bool ElybyValidate(const std::string& accessToken, std::string& error) +{ + std::string body = "{\"accessToken\":" + JsonStr(accessToken) + "}"; + + try { + auto resp = HttpPost("https://authserver.ely.by/auth/validate", + body, "application/json"); + // 204 = valid, anything else = invalid + return (resp.statusCode == 204 || resp.statusCode == 200); + } catch (const std::exception& e) { + error = std::string("ElybyValidate network error: ") + e.what(); + } + return false; +} + +// --------------------------------------------------------------------------- +// Token persistence — simple JSON file +// --------------------------------------------------------------------------- +bool ElybyLoadTokens(const std::string& path, ElybyTokens& out) +{ + std::ifstream f(path); + if (!f) return false; + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + if (content.empty()) return false; + + out.accessToken = JsonGetString(content, "accessToken"); + out.clientToken = JsonGetString(content, "clientToken"); + out.uuid = JsonGetString(content, "uuid"); + out.username = JsonGetString(content, "username"); + return !out.accessToken.empty(); +} + +bool ElybySaveTokens(const std::string& path, const ElybyTokens& tokens) +{ + std::ostringstream o; + o << "{\n"; + o << "\"accessToken\":" << JsonStr(tokens.accessToken) << ",\n"; + o << "\"clientToken\":" << JsonStr(tokens.clientToken) << ",\n"; + o << "\"uuid\":" << JsonStr(tokens.uuid) << ",\n"; + o << "\"username\":" << JsonStr(tokens.username) << "\n"; + o << "}"; + + std::ofstream f(path); + if (!f) return false; + f << o.str(); + return f.good(); +} + +} // namespace MCAuth diff --git a/MCAuth/src/MCAuthHttp.cpp b/MCAuth/src/MCAuthHttp.cpp new file mode 100644 index 0000000000..c8ad1c3471 --- /dev/null +++ b/MCAuth/src/MCAuthHttp.cpp @@ -0,0 +1,221 @@ +#include "MCAuthHttp.h" + +#define WIN32_LEAN_AND_MEAN +#include +#include +#pragma comment(lib, "winhttp.lib") + +#include +#include + +namespace MCAuth { + +// Internal URL parser + +struct ParsedUrl { + std::wstring scheme; // L"https" + std::wstring host; + INTERNET_PORT port; + std::wstring path; // includes query string +}; + +static std::wstring Utf8ToWide(const std::string& s) { + if (s.empty()) return {}; + int len = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0); + std::wstring w(len, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), w.data(), len); + return w; +} + +static std::string WideToUtf8(const std::wstring& w) { + if (w.empty()) return {}; + int len = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), nullptr, 0, nullptr, nullptr); + std::string s(len, '\0'); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), s.data(), len, nullptr, nullptr); + return s; +} + +static ParsedUrl ParseUrl(const std::string& url) { + std::wstring wurl = Utf8ToWide(url); + + URL_COMPONENTS uc = {}; + uc.dwStructSize = sizeof(uc); + + wchar_t scheme[16] = {}, host[256] = {}, path[2048] = {}; + uc.lpszScheme = scheme; uc.dwSchemeLength = _countof(scheme); + uc.lpszHostName = host; uc.dwHostNameLength = _countof(host); + uc.lpszUrlPath = path; uc.dwUrlPathLength = _countof(path); + + if (!WinHttpCrackUrl(wurl.c_str(), (DWORD)wurl.size(), 0, &uc)) + throw std::runtime_error("ParseUrl: WinHttpCrackUrl failed for: " + url); + + ParsedUrl p; + p.scheme = scheme; + p.host = host; + p.port = uc.nPort; + p.path = path; + if (p.path.empty()) p.path = L"/"; + return p; +} + +HttpResponse HttpPost(const std::string& url, + const std::string& body, + const std::string& contentType, + const std::map& headers) +{ + ParsedUrl parsed = ParseUrl(url); + bool isHttps = (parsed.scheme == L"https"); + + HINTERNET hSession = WinHttpOpen( + L"MCAuth/1.0", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + if (!hSession) + throw std::runtime_error("HttpPost: WinHttpOpen failed"); + + // Set explicit timeouts to avoid blocking the caller indefinitely + // (DNS resolve=10s, connect=10s, send=10s, receive=15s) + WinHttpSetTimeouts(hSession, 10000, 10000, 10000, 15000); + + HINTERNET hConnect = WinHttpConnect(hSession, parsed.host.c_str(), parsed.port, 0); + if (!hConnect) { + WinHttpCloseHandle(hSession); + throw std::runtime_error("HttpPost: WinHttpConnect failed for host: " + WideToUtf8(parsed.host)); + } + + DWORD flags = isHttps ? WINHTTP_FLAG_SECURE : 0; + HINTERNET hRequest = WinHttpOpenRequest( + hConnect, + L"POST", + parsed.path.c_str(), + nullptr, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + flags); + if (!hRequest) { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + throw std::runtime_error("HttpPost: WinHttpOpenRequest failed"); + } + + // Add Content-Type + std::wstring ct = L"Content-Type: " + Utf8ToWide(contentType); + WinHttpAddRequestHeaders(hRequest, ct.c_str(), (DWORD)-1, WINHTTP_ADDREQ_FLAG_ADD); + + // Add extra headers + for (auto& kv : headers) { + std::wstring h = Utf8ToWide(kv.first) + L": " + Utf8ToWide(kv.second); + WinHttpAddRequestHeaders(hRequest, h.c_str(), (DWORD)-1, WINHTTP_ADDREQ_FLAG_ADD); + } + + BOOL sent = WinHttpSendRequest( + hRequest, + WINHTTP_NO_ADDITIONAL_HEADERS, 0, + const_cast(body.c_str()), (DWORD)body.size(), + (DWORD)body.size(), + 0); + + if (!sent || !WinHttpReceiveResponse(hRequest, nullptr)) { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + throw std::runtime_error("HttpPost: request failed for: " + url); + } + + // Read status code + DWORD statusCode = 0; + DWORD statusLen = sizeof(statusCode); + WinHttpQueryHeaders(hRequest, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, + &statusCode, &statusLen, WINHTTP_NO_HEADER_INDEX); + + // Read body + std::string responseBody; + DWORD bytesAvailable = 0; + while (WinHttpQueryDataAvailable(hRequest, &bytesAvailable) && bytesAvailable > 0) { + std::string chunk(bytesAvailable, '\0'); + DWORD bytesRead = 0; + WinHttpReadData(hRequest, chunk.data(), bytesAvailable, &bytesRead); + responseBody.append(chunk.data(), bytesRead); + } + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + + return { (int)statusCode, std::move(responseBody) }; +} + +HttpResponse HttpGet(const std::string& url, + const std::map& headers) +{ + ParsedUrl parsed = ParseUrl(url); + bool isHttps = (parsed.scheme == L"https"); + + HINTERNET hSession = WinHttpOpen(L"MCAuth/1.0", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSession) + throw std::runtime_error("HttpGet: WinHttpOpen failed"); + + // Set explicit timeouts to avoid blocking the caller indefinitely + // (DNS resolve=10s, connect=10s, send=10s, receive=15s) + WinHttpSetTimeouts(hSession, 10000, 10000, 10000, 15000); + + HINTERNET hConnect = WinHttpConnect(hSession, parsed.host.c_str(), parsed.port, 0); + if (!hConnect) { + WinHttpCloseHandle(hSession); + throw std::runtime_error("HttpGet: WinHttpConnect failed"); + } + + DWORD flags = isHttps ? WINHTTP_FLAG_SECURE : 0; + HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"GET", + parsed.path.c_str(), nullptr, + WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags); + if (!hRequest) { + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + throw std::runtime_error("HttpGet: WinHttpOpenRequest failed"); + } + + for (auto& kv : headers) { + std::wstring h = Utf8ToWide(kv.first) + L": " + Utf8ToWide(kv.second); + WinHttpAddRequestHeaders(hRequest, h.c_str(), (DWORD)-1, WINHTTP_ADDREQ_FLAG_ADD); + } + + if (!WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + WINHTTP_NO_REQUEST_DATA, 0, 0, 0) || + !WinHttpReceiveResponse(hRequest, nullptr)) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + throw std::runtime_error("HttpGet: request failed for: " + url); + } + + DWORD statusCode = 0, statusLen = sizeof(statusCode); + WinHttpQueryHeaders(hRequest, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, + &statusCode, &statusLen, WINHTTP_NO_HEADER_INDEX); + + std::string responseBody; + DWORD bytesAvailable = 0; + while (WinHttpQueryDataAvailable(hRequest, &bytesAvailable) && bytesAvailable > 0) { + std::string chunk(bytesAvailable, '\0'); + DWORD bytesRead = 0; + WinHttpReadData(hRequest, chunk.data(), bytesAvailable, &bytesRead); + responseBody.append(chunk.data(), bytesRead); + } + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + + return { (int)statusCode, std::move(responseBody) }; +} + +} // namespace MCAuth diff --git a/MCAuth/src/MCAuthHttp.h b/MCAuth/src/MCAuthHttp.h new file mode 100644 index 0000000000..35eaafdebe --- /dev/null +++ b/MCAuth/src/MCAuthHttp.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include + +namespace MCAuth { + +struct HttpResponse { + int statusCode; + std::string body; +}; + +// HTTPS POST +HttpResponse HttpPost(const std::string& url, + const std::string& body, + const std::string& contentType, + const std::map& headers = {}); + +// HTTPS GET +HttpResponse HttpGet(const std::string& url, + const std::map& headers = {}); + +} // namespace MCAuth diff --git a/MCAuth/src/MCAuthInternal.h b/MCAuth/src/MCAuthInternal.h new file mode 100644 index 0000000000..9d5dbcf8c3 --- /dev/null +++ b/MCAuth/src/MCAuthInternal.h @@ -0,0 +1,205 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mcauth { + +inline std::string JsonRawValue(const std::string& json, const std::string& key) { + std::string search = "\"" + key + "\""; + size_t pos = 0; + while ((pos = json.find(search, pos)) != std::string::npos) { + size_t colon = json.find_first_not_of(" \t\r\n", pos + search.size()); + if (colon == std::string::npos || json[colon] != ':') { pos++; continue; } + size_t vstart = json.find_first_not_of(" \t\r\n", colon + 1); + if (vstart == std::string::npos) return ""; + char c = json[vstart]; + if (c == '"') { + size_t end = vstart + 1; + while (end < json.size()) { + if (json[end] == '\\') { end += 2; continue; } + if (json[end] == '"') { end++; break; } + end++; + } + return json.substr(vstart, end - vstart); + } else if (c == '{' || c == '[') { + char open = c, close = (c == '{') ? '}' : ']'; + int depth = 1; + size_t end = vstart + 1; + while (end < json.size() && depth > 0) { + if (json[end] == open) depth++; + else if (json[end] == close) depth--; + else if (json[end] == '"') { + end++; + while (end < json.size() && json[end] != '"') { + if (json[end] == '\\') end++; + end++; + } + } + end++; + } + return json.substr(vstart, end - vstart); + } else { + size_t end = json.find_first_of(",}\r\n", vstart); + if (end == std::string::npos) end = json.size(); + std::string v = json.substr(vstart, end - vstart); + while (!v.empty() && isspace((unsigned char)v.back())) v.pop_back(); + return v; + } + } + return ""; +} + +inline std::string JsonUnquote(const std::string& s) { + if (s.size() >= 2 && s.front() == '"' && s.back() == '"') + return s.substr(1, s.size() - 2); + return s; +} + +inline std::string JsonGetString(const std::string& json, const std::string& key) { + return JsonUnquote(JsonRawValue(json, key)); +} + +inline int64_t JsonGetInt(const std::string& json, const std::string& key) { + auto s = JsonUnquote(JsonRawValue(json, key)); + if (s.empty()) return 0; + try { return std::stoll(s); } catch (...) { return 0; } +} + +inline std::string JsonFirstArrayObject(const std::string& arrStr) { + size_t pos = arrStr.find('['); + if (pos == std::string::npos) return ""; + pos = arrStr.find_first_not_of(" \t\r\n", pos + 1); + if (pos == std::string::npos || arrStr[pos] == ']') return ""; + if (arrStr[pos] != '{') return ""; + int depth = 1; + size_t end = pos + 1; + while (end < arrStr.size() && depth > 0) { + if (arrStr[end] == '{') depth++; + else if (arrStr[end] == '}') depth--; + else if (arrStr[end] == '"') { + end++; + while (end < arrStr.size() && arrStr[end] != '"') { + if (arrStr[end] == '\\') end++; + end++; + } + } + end++; + } + return arrStr.substr(pos, end - pos); +} + +inline std::string JsonNthArrayString(const std::string& arrStr, int n) { + size_t pos = arrStr.find('['); + if (pos == std::string::npos) return ""; + pos++; + int idx = 0; + while (pos < arrStr.size()) { + pos = arrStr.find_first_not_of(" \t\r\n,", pos); + if (pos == std::string::npos || arrStr[pos] == ']') break; + if (arrStr[pos] == '"') { + size_t end = pos + 1; + while (end < arrStr.size()) { + if (arrStr[end] == '\\') { end += 2; continue; } + if (arrStr[end] == '"') { end++; break; } + end++; + } + if (idx == n) return arrStr.substr(pos + 1, end - pos - 2); + pos = end; + idx++; + } else { + pos++; + } + } + return ""; +} + +inline std::vector JsonArrayObjects(const std::string& json) { + std::vector result; + size_t pos = json.find('['); + if (pos == std::string::npos) return result; + ++pos; + while (pos < json.size()) { + pos = json.find('{', pos); + if (pos == std::string::npos) break; + int depth = 0; + size_t start = pos; + for (; pos < json.size(); ++pos) { + if (json[pos] == '{') ++depth; + else if (json[pos] == '}') { --depth; if (depth == 0) { ++pos; break; } } + } + result.push_back(json.substr(start, pos - start)); + } + return result; +} + +inline std::string UrlEncode(const std::string& s) { + std::string out; + out.reserve(s.size() * 3); + for (unsigned char c : s) { + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + out += c; + } else { + char buf[4]; + snprintf(buf, sizeof(buf), "%%%02X", c); + out += buf; + } + } + return out; +} + +inline int64_t NowMs() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +inline std::string BuildFormBody(const std::map& params) { + std::string body; + for (auto& kv : params) { + if (!body.empty()) body += '&'; + body += UrlEncode(kv.first) + '=' + UrlEncode(kv.second); + } + return body; +} + +inline std::string JsonEscape(const std::string& s) { + std::string out; + for (char c : s) { + if (c == '"') out += "\\\""; + else if (c == '\\') out += "\\\\"; + else if (c == '\n') out += "\\n"; + else if (c == '\r') out += "\\r"; + else out += c; + } + return out; +} + +inline std::string JsonStr(const std::string& s) { + return "\"" + JsonEscape(s) + "\""; +} + +inline int64_t ParseIso8601Ms(const std::string& s) { + int Y=0, M=0, D=0, h=0, m=0; + double sec=0.0; + if (sscanf_s(s.c_str(), "%d-%d-%dT%d:%d:%lf", &Y, &M, &D, &h, &m, &sec) < 6) + return 0; + struct tm t = {}; + t.tm_year = Y - 1900; + t.tm_mon = M - 1; + t.tm_mday = D; + t.tm_hour = h; + t.tm_min = m; + t.tm_sec = (int)sec; + t.tm_isdst = 0; + time_t epoch = _mkgmtime(&t); + if (epoch == (time_t)-1) return 0; + int ms = (int)((sec - (int)sec) * 1000.0); + return (int64_t)epoch * 1000LL + ms; +} + +} // namespace mcauth diff --git a/MCAuth/src/MCAuthJava.cpp b/MCAuth/src/MCAuthJava.cpp new file mode 100644 index 0000000000..4eeff4a84c --- /dev/null +++ b/MCAuth/src/MCAuthJava.cpp @@ -0,0 +1,453 @@ +/* + * MCAuthJava.cpp — Minecraft Java Edition authentication chain + * + * Chain (using Java Win32 Title ID "00000000402b5328" via SISU): + * + * 1. MSA Device Code → POST login.live.com/oauth20_connect.srf + * 2. MSA Token → poll login.live.com/oauth20_token.srf + * 3. XBL Device Auth → POST device.auth.xboxlive.com (signed P-256) + * 4. SISU Authorize → POST sisu.xboxlive.com (signed P-256) + * RelyingParty = "rp://api.minecraftservices.com/" + * → JavaXstsToken {token, uhs} + * 5. Launcher Login → POST api.minecraftservices.com/launcher/login + * xtoken = "XBL3.0 x=;" + * → access_token, token_type, expires_in + * 6. Profile → GET api.minecraftservices.com/minecraft/profile + * Authorization: + * → id (undashed UUID), name + */ + +#include "../include/MCAuth.h" +#include "MCAuthCrypto.h" +#include "MCAuthHttp.h" +#include "MCAuthInternal.h" + +#define WIN32_LEAN_AND_MEAN +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace MCAuth { + +using namespace mcauth; + +struct JavaAuthManager::Impl { + + // Persistent + struct MsaState { + std::string accessToken, refreshToken; + int64_t expireMs = 0; + bool IsExpired() const { return NowMs() >= expireMs - 60000; } + } msa; + + ECKeyPair deviceKeyP256; + std::string deviceId; + + // Derived / cached + struct XblDeviceState { + std::string token, did; + int64_t expireMs = 0; + bool IsExpired() const { return NowMs() >= expireMs - 60000; } + } xblDevice; + + struct JavaXstsState { + std::string token, uhs; + int64_t expireMs = 0; + bool IsExpired() const { return NowMs() >= expireMs - 60000; } + } javaXsts; + + struct MinecraftTokenState { + std::string tokenType, accessToken; + int64_t expireMs = 0; + std::string AuthHeader() const { return tokenType + " " + accessToken; } + bool IsExpired() const { return NowMs() >= expireMs - 60000; } + } mcToken; + + std::atomic loggedIn{false}; + + // Cancellation: condition_variable for instant wakeup from poll sleep + std::atomic cancelRequested{false}; + std::mutex cancelMutex; + std::condition_variable cancelCv; + + // Waitable sleep that returns immediately on cancel. + // Returns true if cancel was requested. + bool SleepOrCancel(int64_t ms) { + std::unique_lock lock(cancelMutex); + return cancelCv.wait_for(lock, std::chrono::milliseconds(ms), [this] { + return cancelRequested.load(std::memory_order_relaxed); + }); + } + + Impl() { + deviceKeyP256 = GenerateP256KeyPair(); + deviceId = GenerateUUID(); + } + + // Step 1: Request MSA device code + struct DevCodeState { + std::string deviceCode, userCode, verificationUri; + int64_t expiresMs, intervalMs; + }; + + DevCodeState RequestDeviceCode() { + auto resp = HttpPost( + "https://login.live.com/oauth20_connect.srf", + BuildFormBody({{"client_id", JAVA_CLIENT_ID}, + {"scope", MSA_SCOPE}, // same scope as Bedrock + {"response_type", "device_code"}}), + "application/x-www-form-urlencoded"); + + if (resp.statusCode != 200) + throw std::runtime_error("Java DeviceCode HTTP " + + std::to_string(resp.statusCode) + ": " + resp.body); + + DevCodeState dc; + dc.deviceCode = JsonGetString(resp.body, "device_code"); + dc.userCode = JsonGetString(resp.body, "user_code"); + dc.verificationUri = JsonGetString(resp.body, "verification_uri"); + if (dc.deviceCode.empty() || dc.userCode.empty() || dc.verificationUri.empty()) + throw std::runtime_error("Java DeviceCode: missing required fields in response"); + int64_t expiresIn = JsonGetInt(resp.body, "expires_in"); + int64_t intervalIn = JsonGetInt(resp.body, "interval"); + dc.expiresMs = NowMs() + (expiresIn > 0 ? expiresIn : 300) * 1000; + dc.intervalMs = (intervalIn > 0 ? intervalIn : 5) * 1000; + return dc; + } + + // Step 2: Poll for MSA token + bool PollMsaToken(const DevCodeState& dc, int timeoutSec, std::string& error) { + int64_t deadline = NowMs() + (int64_t)timeoutSec * 1000; + int64_t intervalMs = dc.intervalMs > 0 ? dc.intervalMs : 5000; + while (NowMs() < deadline && NowMs() < dc.expiresMs) { + if (SleepOrCancel(intervalMs)) { + error = "cancelled"; + return false; + } + + HttpResponse resp; + try { + resp = HttpPost( + "https://login.live.com/oauth20_token.srf", + BuildFormBody({{"client_id", JAVA_CLIENT_ID}, + {"scope", MSA_SCOPE}, + {"grant_type", "device_code"}, + {"device_code", dc.deviceCode}}), + "application/x-www-form-urlencoded"); + } catch (const std::exception& ex) { + error = std::string("MSA poll network error: ") + ex.what(); + return false; + } + + if (resp.statusCode == 200) { + msa.accessToken = JsonGetString(resp.body, "access_token"); + msa.refreshToken = JsonGetString(resp.body, "refresh_token"); + msa.expireMs = NowMs() + JsonGetInt(resp.body, "expires_in") * 1000; + return true; + } + std::string err = JsonGetString(resp.body, "error"); + if (err == "authorization_pending") continue; + if (err == "slow_down") { intervalMs += 5000; continue; } + error = "MSA poll error: " + err; + return false; + } + error = "MSA device code login timed out"; + return false; + } + + // Step 2b: Refresh MSA token + bool RefreshMsaToken(std::string& error) { + if (msa.refreshToken.empty()) { error = "No refresh token"; return false; } + HttpResponse resp; + try { + resp = HttpPost( + "https://login.live.com/oauth20_token.srf", + BuildFormBody({{"client_id", JAVA_CLIENT_ID}, + {"scope", MSA_SCOPE}, + {"grant_type", "refresh_token"}, + {"refresh_token", msa.refreshToken}}), + "application/x-www-form-urlencoded"); + } catch (const std::exception& ex) { + error = std::string("MSA refresh network error: ") + ex.what(); + return false; + } + + if (resp.statusCode != 200) { + error = "MSA refresh HTTP " + std::to_string(resp.statusCode); + return false; + } + msa.accessToken = JsonGetString(resp.body, "access_token"); + std::string nr = JsonGetString(resp.body, "refresh_token"); + if (!nr.empty()) msa.refreshToken = nr; + msa.expireMs = NowMs() + JsonGetInt(resp.body, "expires_in") * 1000; + return true; + } + + // Step 3: XBL Device Authentication + bool AuthXblDevice(std::string& error) { + std::string proofKey = BuildProofKeyJson(deviceKeyP256); + std::string bodyJson = + "{\"Properties\":{" + "\"DeviceType\":\"Win32\"," + "\"Id\":\"{" + deviceId + "}\"," + "\"AuthMethod\":\"ProofOfPossession\"," + "\"ProofKey\":" + proofKey + + "},\"RelyingParty\":\"http://auth.xboxlive.com\"," + "\"TokenType\":\"JWT\"}"; + + std::string sig = BuildXblSignatureHeader( + deviceKeyP256, "POST", "/device/authenticate", bodyJson); + + auto resp = HttpPost( + "https://device.auth.xboxlive.com/device/authenticate", + bodyJson, "application/json", + {{"x-xbl-contract-version","1"},{"Signature", sig}}); + + if (resp.statusCode != 200) { + error = "XBL device auth HTTP " + std::to_string(resp.statusCode); + return false; + } + xblDevice.token = JsonGetString(resp.body, "Token"); + xblDevice.expireMs = ParseIso8601Ms(JsonGetString(resp.body, "NotAfter")); + std::string xdi = JsonRawValue(JsonRawValue(resp.body, "DisplayClaims"), "xdi"); + xblDevice.did = JsonGetString(xdi, "did"); + return true; + } + + // Step 4: SISU Authorize — Java relying party + bool AuthSisu(std::string& error) { + std::string proofKey = BuildProofKeyJson(deviceKeyP256); + std::string bodyJson = + "{\"Sandbox\":\"RETAIL\"," + "\"UseModernGamertag\":true," + "\"AppId\":\"" + std::string(JAVA_CLIENT_ID) + "\"," + "\"AccessToken\":\"t=" + msa.accessToken + "\"," + "\"DeviceToken\":\"" + xblDevice.token + "\"," + "\"ProofKey\":" + proofKey + "," + // Java Edition relying party + "\"RelyingParty\":\"rp://api.minecraftservices.com/\"}"; + + std::string sig = BuildXblSignatureHeader( + deviceKeyP256, "POST", "/authorize", bodyJson); + + auto resp = HttpPost( + "https://sisu.xboxlive.com/authorize", + bodyJson, "application/json", {{"Signature", sig}}); + + if (resp.statusCode != 200) { + error = "SISU authorize HTTP " + std::to_string(resp.statusCode) + + ": " + resp.body; + return false; + } + + // AuthorizationToken → JavaXstsToken + std::string authObj = JsonRawValue(resp.body, "AuthorizationToken"); + javaXsts.token = JsonGetString(authObj, "Token"); + javaXsts.expireMs = ParseIso8601Ms(JsonGetString(authObj, "NotAfter")); + + std::string disp = JsonRawValue(authObj, "DisplayClaims"); + std::string xui = JsonRawValue(disp, "xui"); + std::string xui0 = JsonFirstArrayObject(xui); + javaXsts.uhs = JsonGetString(xui0, "uhs"); + if (javaXsts.token.empty() || javaXsts.uhs.empty()) { + error = "SISU response missing XSTS token or uhs"; + return false; + } + return true; + } + + // Step 5: Minecraft Launcher Login + bool AuthMcLauncherLogin(std::string& error) { + std::string xtoken = "XBL3.0 x=" + javaXsts.uhs + ";" + javaXsts.token; + std::string bodyJson = + "{\"platform\":\"PC_LAUNCHER\"," + "\"xtoken\":\"" + xtoken + "\"}"; + + auto resp = HttpPost( + "https://api.minecraftservices.com/launcher/login", + bodyJson, "application/json"); + + if (resp.statusCode != 200) { + error = "Launcher login HTTP " + std::to_string(resp.statusCode) + + ": " + resp.body; + return false; + } + + mcToken.tokenType = JsonGetString(resp.body, "token_type"); + mcToken.accessToken = JsonGetString(resp.body, "access_token"); + mcToken.expireMs = NowMs() + JsonGetInt(resp.body, "expires_in") * 1000; + return true; + } + + // Step 6: Minecraft Profile + bool FetchProfile(JavaSession& out, std::string& error) { + auto resp = HttpGet( + "https://api.minecraftservices.com/minecraft/profile", + {{"Authorization", mcToken.AuthHeader()}}); + + if (resp.statusCode == 404) { + // Account exists but doesn't own Minecraft Java Edition + error = "This Microsoft account does not own Minecraft Java Edition"; + return false; + } + if (resp.statusCode != 200) { + error = "Profile HTTP " + std::to_string(resp.statusCode) + + ": " + resp.body; + return false; + } + + std::string rawId = JsonGetString(resp.body, "id"); + out.uuid = DashUuid(rawId); + out.username = JsonGetString(resp.body, "name"); + out.accessToken = mcToken.AuthHeader(); + out.expireMs = mcToken.expireMs; + if (out.uuid.empty() || out.username.empty()) { + error = "Minecraft profile response missing uuid or username"; + return false; + } + return true; + } + + // Full login chain + bool DoFullAuth(DeviceCodeCallback onDeviceCode, + JavaSession& out, std::string& error, int timeoutSec) { + cancelRequested.store(false, std::memory_order_relaxed); + DevCodeState dc; + try { dc = RequestDeviceCode(); } + catch (const std::exception& ex) { + error = std::string("DeviceCode network error: ") + ex.what(); + return false; + } + + DeviceCodeInfo info; + info.userCode = dc.userCode; + info.verificationUri = dc.verificationUri; + info.directUri = dc.verificationUri + "?otc=" + dc.userCode; + info.deviceCode = dc.deviceCode; + info.expiresMs = dc.expiresMs; + info.intervalMs = dc.intervalMs; + onDeviceCode(info); + + if (!PollMsaToken(dc, timeoutSec, error)) return false; + return DoAuthChain(out, error); + } + + bool DoAuthChain(JavaSession& out, std::string& error) { + try { + if (!AuthXblDevice(error)) return false; + if (!AuthSisu(error)) return false; + if (!AuthMcLauncherLogin(error)) return false; + if (!FetchProfile(out, error)) return false; + } catch (const std::runtime_error& ex) { + // Network/crypto exceptions carry context; propagate as-is + error = ex.what(); + return false; + } catch (const std::exception& ex) { + error = std::string("Unexpected auth error: ") + ex.what(); + return false; + } catch (...) { + error = "Unknown internal error during auth chain"; + return false; + } + loggedIn = true; + return true; + } + + // Serialisation helpers + std::string Serialise() const { + std::ostringstream o; + o << "{\n" + << "\"msaAccessToken\":" << mcauth::JsonStr(msa.accessToken) << ",\n" + << "\"msaRefreshToken\":" << mcauth::JsonStr(msa.refreshToken) << ",\n" + << "\"msaExpireMs\":" << msa.expireMs << ",\n" + << "\"deviceId\":" << mcauth::JsonStr(deviceId) << ",\n" + << "\"deviceKeyPriv\":" << mcauth::JsonStr(Base64Encode(deviceKeyP256.privateBlob)) << ",\n" + << "\"deviceKeyPub\":" << mcauth::JsonStr(Base64Encode(deviceKeyP256.publicBlob)) << "\n" + << "}"; + return o.str(); + } + + static ECKeyPair RebuildP256(const std::vector& priv, + const std::vector& pub) { + if (pub.size() < sizeof(BCRYPT_ECCKEY_BLOB)) return {}; + const BCRYPT_ECCKEY_BLOB* hdr = + reinterpret_cast(pub.data()); + ULONG cbKey = hdr->cbKey; + if (pub.size() < 8 + (size_t)cbKey * 2) return {}; + std::vector x(pub.begin()+8, pub.begin()+8+cbKey); + std::vector y(pub.begin()+8+cbKey, pub.begin()+8+cbKey*2); + + ECKeyPair kp; + kp.bitSize = 256; kp.privateBlob = priv; kp.publicBlob = pub; + kp.x = x; kp.y = y; + kp.publicKeyDER = BuildSubjectPublicKeyInfoDER(256, x, y); + return kp; + } + + bool Deserialise(const std::string& json) { + msa.accessToken = JsonGetString(json, "msaAccessToken"); + msa.refreshToken = JsonGetString(json, "msaRefreshToken"); + msa.expireMs = JsonGetInt(json, "msaExpireMs"); + deviceId = JsonGetString(json, "deviceId"); + auto priv = Base64Decode(JsonGetString(json, "deviceKeyPriv")); + auto pub = Base64Decode(JsonGetString(json, "deviceKeyPub")); + if (priv.empty() || pub.empty()) return false; + deviceKeyP256 = RebuildP256(priv, pub); + return true; + } +}; + +JavaAuthManager::JavaAuthManager() : m_impl(std::make_unique()) {} +JavaAuthManager::~JavaAuthManager() = default; + +bool JavaAuthManager::Login(DeviceCodeCallback onDeviceCode, + JavaSession& out, std::string& error, int timeoutSec) +{ + return m_impl->DoFullAuth(onDeviceCode, out, error, timeoutSec); +} + +bool JavaAuthManager::Refresh(JavaSession& out, std::string& error) { + m_impl->cancelRequested.store(false, std::memory_order_relaxed); + if (!m_impl->RefreshMsaToken(error)) return false; + return m_impl->DoAuthChain(out, error); +} + +bool JavaAuthManager::IsLoggedIn() const { return m_impl->loggedIn; } + +void JavaAuthManager::Logout() { + m_impl->msa = {}; + m_impl->javaXsts = {}; + m_impl->mcToken = {}; + m_impl->loggedIn = false; +} + +void JavaAuthManager::RequestCancel() { + m_impl->cancelRequested.store(true, std::memory_order_release); + m_impl->cancelCv.notify_all(); +} + +bool JavaAuthManager::SaveTokens(const std::string& path) const { + std::ofstream f(path); + if (!f) return false; + f << m_impl->Serialise(); + return f.good(); +} + +bool JavaAuthManager::LoadTokens(const std::string& path) { + std::ifstream f(path); + if (!f) return false; + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + return m_impl->Deserialise(content); +} + +} // namespace MCAuth diff --git a/MCAuth/src/MCAuthManager.cpp b/MCAuth/src/MCAuthManager.cpp new file mode 100644 index 0000000000..8faca1d7f2 --- /dev/null +++ b/MCAuth/src/MCAuthManager.cpp @@ -0,0 +1,986 @@ +#define _CRT_SECURE_NO_WARNINGS +#include "../include/MCAuthManager.h" +#include "MCAuthInternal.h" +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif +static void AuthLogImpl(const char* fmt, ...) { + char buf[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); +#ifdef _WIN32 + OutputDebugStringA(buf); +#endif + // Use _fsopen with _SH_DENYWR so only this process can write + FILE* f = _fsopen("mcauth_debug.log", "a", _SH_DENYWR); + if (f) { fputs(buf, f); fclose(f); } +} +#define AUTH_LOG(msg, ...) AuthLogImpl("[MCAuth] " msg "\n", ##__VA_ARGS__) + +using namespace mcauth; + +MCAuthManager& MCAuthManager::Get() { + static MCAuthManager instance; + return instance; +} + +MCAuthManager::MCAuthManager() = default; + +MCAuthManager::~MCAuthManager() { + // Cancel all in-flight workers so detached threads exit promptly. + for (int i = 0; i < XUSER_MAX_COUNT; ++i) { + ++m_slots[i].generation; + if (m_slots[i].auth) + m_slots[i].auth->RequestCancel(); + } +} + +std::shared_ptr MCAuthManager::ResetSlotAuth(AuthSlot& s) { + // Cancel the old engine's blocking operations + if (s.auth) + s.auth->RequestCancel(); + + // Create a fresh engine — old threads still hold their shared_ptr copy + auto fresh = std::make_shared(); + s.auth = fresh; + return fresh; +} + +MCAuth::JavaSession MCAuthManager::SynthesizeOfflineSession(const JavaAccountInfo& acct) { + MCAuth::JavaSession s; + s.username = acct.username; + s.uuid = acct.uuid; + s.accessToken = ""; + s.expireMs = 0; + return s; +} + +bool MCAuthManager::LoadJavaAccountIndex() { + std::ifstream f(kJavaAccountsFile); + if (!f) { + AUTH_LOG("LoadJavaAccountIndex: file '%s' not found", kJavaAccountsFile); + return false; + } + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + + std::lock_guard lock(m_accountsMutex); + m_javaAccounts.clear(); + + int activeIndex = (int)JsonGetInt(content, "activeIndex"); + + // Parse accounts array + std::vector objects; + { + size_t pos = content.find("\"accounts\""); + if (pos != std::string::npos) { + pos = content.find('[', pos); + if (pos != std::string::npos) { + int depth = 0; size_t start = pos; + for (; pos < content.size(); ++pos) { + if (content[pos] == '[') ++depth; + else if (content[pos] == ']') { --depth; if (depth == 0) { ++pos; break; } } + } + std::string arr = content.substr(start, pos - start); + objects = JsonArrayObjects(arr); + } + } + } + + for (auto& obj : objects) { + JavaAccountInfo info; + info.username = JsonGetString(obj, "username"); + info.uuid = JsonGetString(obj, "uuid"); + info.tokenFile = JsonGetString(obj, "tokenFile"); + info.isOffline = (JsonRawValue(obj, "isOffline") == "true"); + info.authProvider = JsonGetString(obj, "authProvider"); + if (info.authProvider.empty()) + info.authProvider = info.isOffline ? "offline" : "mojang"; + if (!info.tokenFile.empty() || info.isOffline) + m_javaAccounts.push_back(std::move(info)); + } + + // Set slot 0's active account from saved index + if (activeIndex >= 0 && activeIndex < (int)m_javaAccounts.size()) + m_slots[0].accountIndex = activeIndex; + else + m_slots[0].accountIndex = m_javaAccounts.empty() ? -1 : 0; + + AUTH_LOG("LoadJavaAccountIndex: loaded %d accounts, active=%d", + (int)m_javaAccounts.size(), m_slots[0].accountIndex.load()); + return true; +} + +bool MCAuthManager::SaveJavaAccountIndex() const { + std::lock_guard lock(m_accountsMutex); + + std::ostringstream o; + o << "{\n"; + o << "\"activeIndex\":" << m_slots[0].accountIndex.load() << ",\n"; + o << "\"accounts\":[\n"; + for (size_t i = 0; i < m_javaAccounts.size(); ++i) { + auto& a = m_javaAccounts[i]; + o << " {\"username\":" << JsonStr(a.username) + << ",\"uuid\":" << JsonStr(a.uuid) + << ",\"tokenFile\":" << JsonStr(a.tokenFile) + << ",\"isOffline\":" << (a.isOffline ? "true" : "false") + << ",\"authProvider\":" << JsonStr(a.authProvider.empty() ? "mojang" : a.authProvider) << "}"; + if (i + 1 < m_javaAccounts.size()) o << ","; + o << "\n"; + } + o << "]\n}"; + + std::ofstream f(kJavaAccountsFile); + if (!f) return false; + f << o.str(); + AUTH_LOG("SaveJavaAccountIndex: saved %d accounts, active=%d", + (int)m_javaAccounts.size(), m_slots[0].accountIndex.load()); + return f.good(); +} + +std::vector MCAuthManager::GetJavaAccounts() const { + std::lock_guard lock(m_accountsMutex); + return m_javaAccounts; +} + +std::string MCAuthManager::AllocTokenFile(const std::string& uuid) const { + if (!uuid.empty()) + return uuid + ".json"; + for (int i = 0; ; ++i) { + char buf[64]; + snprintf(buf, sizeof(buf), "java_auth_%d.json", i); + std::string name(buf); + bool taken = false; + for (auto& a : m_javaAccounts) { + if (a.tokenFile == name) { taken = true; break; } + } + if (!taken) return name; + } +} + +const MCAuthManager::AuthSlot& MCAuthManager::GetSlot(int slot) const { + if (slot < 0 || slot >= XUSER_MAX_COUNT) slot = 0; + return m_slots[slot]; +} + +MCAuth::JavaSession MCAuthManager::GetSlotSession(int slot) const { + if (slot < 0 || slot >= XUSER_MAX_COUNT) slot = 0; + auto& s = m_slots[slot]; + + // Check offline status first (accounts mutex), then get session (slot mutex). + { + std::lock_guard alock(m_accountsMutex); + if (s.accountIndex >= 0 && s.accountIndex < (int)m_javaAccounts.size() + && m_javaAccounts[s.accountIndex].isOffline) + return SynthesizeOfflineSession(m_javaAccounts[s.accountIndex]); + } + std::lock_guard lock(s.mutex); + return s.session; +} + +bool MCAuthManager::IsSlotLoggedIn(int slot) const { + if (slot < 0 || slot >= XUSER_MAX_COUNT) return false; + auto& s = m_slots[slot]; + + { + std::lock_guard alock(m_accountsMutex); + if (s.accountIndex >= 0 && s.accountIndex < (int)m_javaAccounts.size()) { + if (m_javaAccounts[s.accountIndex].isOffline) + return true; + if (m_javaAccounts[s.accountIndex].authProvider == "elyby") { + std::lock_guard slock(s.mutex); + return !s.elybyTokens.accessToken.empty(); + } + } + } + + return s.auth && s.auth->IsLoggedIn(); +} + +bool MCAuthManager::IsTokenExpiringSoon(int slot) const { + if (slot < 0 || slot >= XUSER_MAX_COUNT) return false; + auto& s = m_slots[slot]; + std::lock_guard lock(s.mutex); + if (s.session.expireMs <= 0) return false; + return NowMs() > s.session.expireMs - 60000; // within 60s of expiry +} + +bool MCAuthManager::IsAccountInUseByOtherSlot(int slot, int accountIndex) const { + std::lock_guard alock(m_accountsMutex); + if (accountIndex < 0 || accountIndex >= (int)m_javaAccounts.size()) return false; + + for (int i = 0; i < XUSER_MAX_COUNT; ++i) { + if (i == slot) continue; + if (m_slots[i].accountIndex == accountIndex) return true; + } + return false; +} + +void MCAuthManager::RunElybyRefresh(int slot, uint32_t gen, const std::string& tokenFile, + bool failOpen, bool alwaysSaveIndex) { + auto& s = m_slots[slot]; + std::string error; + MCAuth::ElybyTokens tokens; + { + std::lock_guard lock(s.mutex); + tokens = s.elybyTokens; + } + bool ok = MCAuth::ElybyRefresh(tokens, error); + if (s.generation != gen) return; + + { + std::lock_guard lock(s.mutex); + if (ok) { + s.elybyTokens = tokens; + s.session.username = tokens.username; + s.session.uuid = MCAuth::DashUuid(tokens.uuid); + s.session.accessToken = tokens.accessToken; + s.lastError.clear(); + s.state = State::Success; + } else { + s.lastError = error; + s.state = failOpen ? State::Success : State::Failed; + } + s.cv.notify_all(); + } + if (ok) { + if (!tokenFile.empty()) + MCAuth::ElybySaveTokens(tokenFile, tokens); + { + std::lock_guard alock(m_accountsMutex); + if (s.accountIndex >= 0 && s.accountIndex < (int)m_javaAccounts.size()) { + m_javaAccounts[s.accountIndex].username = tokens.username; + m_javaAccounts[s.accountIndex].uuid = MCAuth::DashUuid(tokens.uuid); + } + } + } + if (ok || alwaysSaveIndex) + SaveJavaAccountIndex(); +} + +void MCAuthManager::RefreshSlot(int slot) { + if (slot < 0 || slot >= XUSER_MAX_COUNT) return; + auto& s = m_slots[slot]; + + // Check if this is an ely.by account — if so, use ely.by refresh + std::string provider; + std::string elyTokenFile; + { + std::lock_guard alock(m_accountsMutex); + if (s.accountIndex >= 0 && s.accountIndex < (int)m_javaAccounts.size()) { + provider = m_javaAccounts[s.accountIndex].authProvider; + elyTokenFile = m_javaAccounts[s.accountIndex].tokenFile; + } + } + + if (provider == "elyby") { + uint32_t gen = ++s.generation; + { + std::lock_guard lock(s.mutex); + s.state = State::Authenticating; + s.cv.notify_all(); + } + std::thread([this, slot, gen, elyTokenFile]() { + RunElybyRefresh(slot, gen, elyTokenFile, /*failOpen=*/false, /*alwaysSaveIndex=*/false); + }).detach(); + return; + } + + // Increment generation to invalidate any in-flight work + uint32_t gen = ++s.generation; + + { + std::lock_guard lock(s.mutex); + s.state = State::Authenticating; + s.cv.notify_all(); + } + + // Capture auth engine by shared_ptr so it stays alive if replaced. + auto authCopy = s.auth; + + std::thread([this, slot, gen, authCopy]() { + auto& s = m_slots[slot]; + std::string error; + MCAuth::JavaSession session; + bool ok = false; + try { + ok = authCopy->Refresh(session, error); + } catch (const std::exception& ex) { + error = std::string("RefreshSlot network error: ") + ex.what(); + } catch (...) { + error = "RefreshSlot unknown error"; + } + + // Stale check: if a newer operation started, discard silently + if (s.generation != gen) { + AUTH_LOG("RefreshSlot(%d): generation mismatch (%u vs %u), discarding", + slot, gen, s.generation.load()); + return; + } + + std::string tokenFile; + { + std::lock_guard lock(s.mutex); + if (ok) { + s.session = session; + s.lastError.clear(); + s.state = State::Success; + } else { + s.lastError = error; + s.state = State::Failed; + } + s.cv.notify_all(); + } + + // Update account info outside slot mutex (consistent lock order) + if (ok) { + std::lock_guard alock(m_accountsMutex); + if (s.accountIndex >= 0 && s.accountIndex < (int)m_javaAccounts.size()) { + m_javaAccounts[s.accountIndex].username = session.username; + m_javaAccounts[s.accountIndex].uuid = session.uuid; + tokenFile = m_javaAccounts[s.accountIndex].tokenFile; + } + } + + AUTH_LOG("RefreshSlot(%d): ok=%d, username='%s', error='%s'", + slot, (int)ok, ok ? session.username.c_str() : "", error.c_str()); + + // File I/O outside all mutexes + if (ok && !tokenFile.empty()) { + authCopy->SaveTokens(tokenFile); + SaveJavaAccountIndex(); + } + }).detach(); +} + +bool MCAuthManager::SetAccountForSlot(int slot, int accountIndex) { + if (slot < 0 || slot >= XUSER_MAX_COUNT) return false; + auto& s = m_slots[slot]; + + bool isOffline = false; + bool isElyby = false; + std::string tokenFile; + { + std::lock_guard alock(m_accountsMutex); + if (accountIndex < 0 || accountIndex >= (int)m_javaAccounts.size()) return false; + isOffline = m_javaAccounts[accountIndex].isOffline; + isElyby = (m_javaAccounts[accountIndex].authProvider == "elyby"); + tokenFile = m_javaAccounts[accountIndex].tokenFile; + } + + // Invalidate any in-flight work for this slot + uint32_t gen = ++s.generation; + + if (isOffline) { + // Offline: set session immediately, no background work. + MCAuth::JavaSession offlineSession; + std::string offlineName; + { + std::lock_guard alock(m_accountsMutex); + offlineSession = SynthesizeOfflineSession(m_javaAccounts[accountIndex]); + offlineName = m_javaAccounts[accountIndex].username; + } + { + std::lock_guard lock(s.mutex); + s.accountIndex = accountIndex; + s.session = offlineSession; + s.state = State::Success; + s.cv.notify_all(); + } + AUTH_LOG("SetAccountForSlot(%d): switched to offline account %d '%s'", + slot, accountIndex, offlineName.c_str()); + (void)gen; + } else if (isElyby) { + // Ely.by: load tokens from file, build session, refresh in background + { + std::lock_guard lock(s.mutex); + s.accountIndex = accountIndex; + } + + AUTH_LOG("SetAccountForSlot(%d): switching to elyby account %d, tokenFile='%s'", + slot, accountIndex, tokenFile.c_str()); + + MCAuth::ElybyTokens elyTokens; + if (!MCAuth::ElybyLoadTokens(tokenFile, elyTokens)) { + AUTH_LOG("SetAccountForSlot(%d): ElybyLoadTokens failed", slot); + std::lock_guard lock(s.mutex); + s.state = State::Failed; + s.cv.notify_all(); + return false; + } + + // Store tokens and build session + { + std::lock_guard lock(s.mutex); + s.elybyTokens = elyTokens; + s.session.username = elyTokens.username; + s.session.uuid = MCAuth::DashUuid(elyTokens.uuid); + s.session.accessToken = elyTokens.accessToken; + s.session.expireMs = 0; // Ely.by tokens don't have a fixed expiry + s.state = State::Authenticating; + s.cv.notify_all(); + } + + // Refresh in background + uint32_t elyGen = ++s.generation; + std::thread([this, slot, elyGen, tokenFile]() { + RunElybyRefresh(slot, elyGen, tokenFile, /*failOpen=*/true, /*alwaysSaveIndex=*/true); + }).detach(); + } else { + // Online (Mojang/Microsoft): create a fresh auth engine, load tokens, refresh in background + { + std::lock_guard lock(s.mutex); + s.accountIndex = accountIndex; + } + + AUTH_LOG("SetAccountForSlot(%d): switching to online account %d, tokenFile='%s'", + slot, accountIndex, tokenFile.c_str()); + + auto freshAuth = ResetSlotAuth(s); + + if (!freshAuth->LoadTokens(tokenFile)) { + AUTH_LOG("SetAccountForSlot(%d): LoadTokens failed", slot); + std::lock_guard lock(s.mutex); + s.state = State::Failed; + s.cv.notify_all(); + return false; + } + + // Refresh in background (uses the fresh auth engine via s.auth) + RefreshSlot(slot); + } + + SaveJavaAccountIndex(); + return true; +} + +void MCAuthManager::ClearSlot(int slot) { + if (slot < 0 || slot >= XUSER_MAX_COUNT) return; + auto& s = m_slots[slot]; + + ++s.generation; // invalidate in-flight work + + ResetSlotAuth(s); + + std::lock_guard lock(s.mutex); + s.session = {}; + s.accountIndex = -1; + s.state = State::Idle; + s.lastError.clear(); + s.cv.notify_all(); +} + +bool MCAuthManager::WaitForSlotReady(int slot, int timeoutMs) const { + if (slot < 0 || slot >= XUSER_MAX_COUNT) return false; + auto& s = m_slots[slot]; + std::unique_lock lock(s.mutex); + bool ok = s.cv.wait_for(lock, std::chrono::milliseconds(timeoutMs), [&] { + State st = s.state.load(); + return st != State::Authenticating && st != State::WaitingForCode; + }); + return ok && (s.state == State::Success); +} + +int MCAuthManager::GetActiveJavaAccountIndex() const { + return m_slots[0].accountIndex; +} + +MCAuth::JavaSession MCAuthManager::GetJavaSession() const { + return GetSlotSession(0); +} + +bool MCAuthManager::IsJavaLoggedIn() const { + return IsSlotLoggedIn(0); +} + +int MCAuthManager::AddOfflineJavaAccount(const std::string& username) { + if (username.empty()) return -1; + + std::string uuid = MCAuth::GenerateOfflineUuid(username); + if (uuid.empty()) return -1; + + std::lock_guard alock(m_accountsMutex); + + // Check if this offline account already exists (by UUID) + for (int i = 0; i < (int)m_javaAccounts.size(); ++i) { + if (m_javaAccounts[i].uuid == uuid) { + m_javaAccounts[i].username = username; + + // Set slot 0 to this account + { + std::lock_guard lock(m_slots[0].mutex); + m_slots[0].accountIndex = i; + m_slots[0].session = SynthesizeOfflineSession(m_javaAccounts[i]); + m_slots[0].state = State::Success; + m_slots[0].cv.notify_all(); + } + + AUTH_LOG("AddOfflineJavaAccount: updated existing offline account idx=%d '%s'", + i, username.c_str()); + return i; + } + } + + JavaAccountInfo info; + info.username = username; + info.uuid = uuid; + info.tokenFile = ""; + info.isOffline = true; + info.authProvider = "offline"; + m_javaAccounts.push_back(std::move(info)); + int idx = (int)m_javaAccounts.size() - 1; + + // Set slot 0 to this account + { + std::lock_guard lock(m_slots[0].mutex); + m_slots[0].accountIndex = idx; + m_slots[0].session = SynthesizeOfflineSession(m_javaAccounts[idx]); + m_slots[0].state = State::Success; + m_slots[0].cv.notify_all(); + } + + AUTH_LOG("AddOfflineJavaAccount: added new offline account idx=%d '%s' uuid='%s'", + idx, username.c_str(), uuid.c_str()); + return idx; +} + +void MCAuthManager::BeginAddJavaAccount(DeviceCodeCb onDeviceCode, + JavaCompleteCb onComplete, + int timeoutSeconds) +{ + auto& s = m_slots[0]; + + // Invalidate any in-flight work on slot 0 + uint32_t gen = ++s.generation; + + // Clear stale device code + { + std::lock_guard lock(m_deviceCodeMutex); + m_javaDeviceCode.clear(); + m_javaDirectUri.clear(); + } + + { + std::lock_guard lock(s.mutex); + s.state = State::WaitingForCode; + s.cv.notify_all(); + } + + auto freshAuth = ResetSlotAuth(s); + + std::thread([this, freshAuth, onDeviceCode, onComplete, timeoutSeconds, gen]() { + try { + auto& s = m_slots[0]; + + MCAuth::JavaSession session; + std::string error; + + bool ok = freshAuth->Login( + [&](const MCAuth::DeviceCodeInfo& dc) { + // If a newer flow started, don't overwrite its device code + if (s.generation != gen) return; + + { + std::lock_guard lock(s.mutex); + s.state = State::WaitingForCode; + s.cv.notify_all(); + } + { + std::lock_guard lock(m_deviceCodeMutex); + m_javaDeviceCode = dc.userCode; + m_javaDirectUri = dc.directUri; + } + if (onDeviceCode) onDeviceCode(dc); + { + std::lock_guard lock(s.mutex); + s.state = State::Authenticating; + s.cv.notify_all(); + } + }, + session, error, timeoutSeconds); + + // ---- STALE CHECK (under mutex for correctness) ---- + // If a newer operation has started, discard results silently. + { + std::lock_guard lock(s.mutex); + if (s.generation != gen) { + AUTH_LOG("BeginAddJavaAccount: gen %u stale (current %u), discarding", + gen, s.generation.load()); + if (onComplete) onComplete(false, {}, "cancelled"); + return; + } + } + + if (ok) { + std::string tokenFile; + { + std::lock_guard alock(m_accountsMutex); + + // Check if this account already exists (by UUID) + int existingIdx = -1; + for (int i = 0; i < (int)m_javaAccounts.size(); ++i) { + if (m_javaAccounts[i].uuid == session.uuid) { + existingIdx = i; + break; + } + } + + if (existingIdx >= 0) { + m_javaAccounts[existingIdx].username = session.username; + tokenFile = m_javaAccounts[existingIdx].tokenFile; + s.accountIndex = existingIdx; + AUTH_LOG("BeginAddJavaAccount: updated existing account idx=%d '%s'", + existingIdx, session.username.c_str()); + } else { + JavaAccountInfo info; + info.username = session.username; + info.uuid = session.uuid; + info.tokenFile = AllocTokenFile(session.uuid); + info.authProvider = "mojang"; + tokenFile = info.tokenFile; + m_javaAccounts.push_back(std::move(info)); + s.accountIndex = (int)m_javaAccounts.size() - 1; + AUTH_LOG("BeginAddJavaAccount: added new account idx=%d '%s' file='%s'", + s.accountIndex.load(), session.username.c_str(), + m_javaAccounts.back().tokenFile.c_str()); + } + } + + // File I/O outside all mutexes + if (!tokenFile.empty()) + freshAuth->SaveTokens(tokenFile); + + { + std::lock_guard lock(s.mutex); + s.session = session; + s.lastError.clear(); + s.state = State::Success; + s.cv.notify_all(); + } + + SaveJavaAccountIndex(); + } else { + { + std::lock_guard lock(s.mutex); + s.lastError = error; + s.state = State::Failed; + s.cv.notify_all(); + } + + // If we had an active account, reload it into the current auth engine + std::string reloadFile; + { + std::lock_guard alock(m_accountsMutex); + if (s.accountIndex >= 0 && s.accountIndex < (int)m_javaAccounts.size()) + reloadFile = m_javaAccounts[s.accountIndex].tokenFile; + } + if (!reloadFile.empty()) + freshAuth->LoadTokens(reloadFile); + } + + if (onComplete) onComplete(ok, session, error); + } catch (const std::exception& ex) { + AUTH_LOG("BeginAddJavaAccount: uncaught exception: %s", ex.what()); + auto& s = m_slots[0]; + { + std::lock_guard lock(s.mutex); + s.lastError = std::string("Auth network error: ") + ex.what(); + s.state = State::Failed; + s.cv.notify_all(); + } + if (onComplete) onComplete(false, {}, s.lastError); + } catch (...) { + AUTH_LOG("BeginAddJavaAccount: unknown uncaught exception"); + auto& s = m_slots[0]; + { + std::lock_guard lock(s.mutex); + s.lastError = "Unknown auth error"; + s.state = State::Failed; + s.cv.notify_all(); + } + if (onComplete) onComplete(false, {}, "Unknown auth error"); + } + }).detach(); +} + +void MCAuthManager::BeginAddElybyAccount(const std::string& username, const std::string& password, + ElybyCompleteCb onComplete, Elyby2FACb on2FA) +{ + auto& s = m_slots[0]; + uint32_t gen = ++s.generation; + + { + std::lock_guard lock(s.mutex); + s.state = State::Authenticating; + s.cv.notify_all(); + } + + std::thread([this, username, password, onComplete, on2FA, gen]() { + try { + auto& s = m_slots[0]; + MCAuth::ElybyTokens tokens; + std::string error; + + bool ok = MCAuth::ElybyLogin(username, password, tokens, error); + + if (!ok && error == "elyby_2fa_required") { + // Signal caller that 2FA is needed + { + std::lock_guard lock(s.mutex); + s.state = State::WaitingForCode; // repurpose: waiting for 2FA input + s.cv.notify_all(); + } + if (on2FA) on2FA(); + if (onComplete) onComplete(false, {}, "elyby_2fa_required"); + return; + } + + { + std::lock_guard lock(s.mutex); + if (s.generation != gen) { + if (onComplete) onComplete(false, {}, "cancelled"); + return; + } + } + + if (ok) { + std::string dashedUuid = MCAuth::DashUuid(tokens.uuid); + std::string tokenFile; + { + std::lock_guard alock(m_accountsMutex); + + int existingIdx = -1; + for (int i = 0; i < (int)m_javaAccounts.size(); ++i) { + if (m_javaAccounts[i].uuid == dashedUuid) { + existingIdx = i; + break; + } + } + + if (existingIdx >= 0) { + m_javaAccounts[existingIdx].username = tokens.username; + m_javaAccounts[existingIdx].authProvider = "elyby"; + tokenFile = m_javaAccounts[existingIdx].tokenFile; + s.accountIndex = existingIdx; + } else { + JavaAccountInfo info; + info.username = tokens.username; + info.uuid = dashedUuid; + info.tokenFile = AllocTokenFile(dashedUuid); + info.isOffline = false; + info.authProvider = "elyby"; + tokenFile = info.tokenFile; + m_javaAccounts.push_back(std::move(info)); + s.accountIndex = (int)m_javaAccounts.size() - 1; + } + } + + if (!tokenFile.empty()) + MCAuth::ElybySaveTokens(tokenFile, tokens); + + MCAuth::JavaSession session; + session.username = tokens.username; + session.uuid = dashedUuid; + session.accessToken = tokens.accessToken; + session.expireMs = 0; + + { + std::lock_guard lock(s.mutex); + s.elybyTokens = tokens; + s.session = session; + s.lastError.clear(); + s.state = State::Success; + s.cv.notify_all(); + } + + SaveJavaAccountIndex(); + if (onComplete) onComplete(true, session, ""); + } else { + { + std::lock_guard lock(s.mutex); + s.lastError = error; + s.state = State::Failed; + s.cv.notify_all(); + } + if (onComplete) onComplete(false, {}, error); + } + } catch (const std::exception& ex) { + auto& s = m_slots[0]; + { + std::lock_guard lock(s.mutex); + s.lastError = std::string("Elyby auth error: ") + ex.what(); + s.state = State::Failed; + s.cv.notify_all(); + } + if (onComplete) onComplete(false, {}, s.lastError); + } catch (...) { + auto& s = m_slots[0]; + { + std::lock_guard lock(s.mutex); + s.lastError = "Unknown elyby auth error"; + s.state = State::Failed; + s.cv.notify_all(); + } + if (onComplete) onComplete(false, {}, "Unknown elyby auth error"); + } + }).detach(); +} + +bool MCAuthManager::RemoveJavaAccount(int index) { + std::lock_guard alock(m_accountsMutex); + if (index < 0 || index >= (int)m_javaAccounts.size()) return false; + + AUTH_LOG("RemoveJavaAccount: removing index %d ('%s')", + index, m_javaAccounts[index].username.c_str()); + + std::remove(m_javaAccounts[index].tokenFile.c_str()); + m_javaAccounts.erase(m_javaAccounts.begin() + index); + + // Adjust all slots' account indices + for (int i = 0; i < XUSER_MAX_COUNT; ++i) { + auto& s = m_slots[i]; + if (s.accountIndex == index) { + // This slot was using the removed account + if (m_javaAccounts.empty()) { + s.accountIndex = -1; + std::lock_guard lock(s.mutex); + s.session = {}; + s.state = State::Idle; + s.cv.notify_all(); + } else { + s.accountIndex = 0; + // Caller should call SetAccountForSlot to reload + } + } else if (s.accountIndex > index) { + --s.accountIndex; + } + } + + return true; // caller must call SaveJavaAccountIndex() after +} + +void MCAuthManager::TryRestoreActiveJavaAccount() { + AUTH_LOG("TryRestoreActiveJavaAccount called (already attempted=%d)", + (int)m_javaRestoreAttempted); + + if (m_javaRestoreAttempted) return; + m_javaRestoreAttempted = true; + + if (!LoadJavaAccountIndex()) { + // Try legacy single-file migration + auto& s = m_slots[0]; + if (s.auth->LoadTokens("java_auth.json")) { + AUTH_LOG("TryRestoreActiveJavaAccount: migrating legacy java_auth.json"); + std::lock_guard alock(m_accountsMutex); + JavaAccountInfo info; + info.username = ""; + info.uuid = ""; + info.tokenFile = "legacy_migrated.json"; + s.auth->SaveTokens(info.tokenFile); + m_javaAccounts.push_back(std::move(info)); + s.accountIndex = 0; + std::remove("java_auth.json"); + } else { + AUTH_LOG("TryRestoreActiveJavaAccount: no accounts found"); + return; + } + } + + auto& s = m_slots[0]; + int activeIdx = s.accountIndex; + + std::string tokenFile; + bool isOffline = false; + bool isElyby = false; + { + std::lock_guard alock(m_accountsMutex); + if (activeIdx < 0 || activeIdx >= (int)m_javaAccounts.size()) { + AUTH_LOG("TryRestoreActiveJavaAccount: no active account"); + return; + } + isOffline = m_javaAccounts[activeIdx].isOffline; + isElyby = (m_javaAccounts[activeIdx].authProvider == "elyby"); + tokenFile = m_javaAccounts[activeIdx].tokenFile; + } + + if (isElyby) { + AUTH_LOG("TryRestoreActiveJavaAccount: elyby account %d, loading tokens from '%s'", + activeIdx, tokenFile.c_str()); + MCAuth::ElybyTokens elyTokens; + if (!MCAuth::ElybyLoadTokens(tokenFile, elyTokens)) { + AUTH_LOG("TryRestoreActiveJavaAccount: ElybyLoadTokens failed for '%s'", tokenFile.c_str()); + std::lock_guard lock(s.mutex); + s.state = State::Failed; + s.lastError = "Failed to load ely.by token file: " + tokenFile; + s.cv.notify_all(); + return; + } + { + std::lock_guard lock(s.mutex); + s.elybyTokens = elyTokens; + s.session.username = elyTokens.username; + s.session.uuid = MCAuth::DashUuid(elyTokens.uuid); + s.session.accessToken = elyTokens.accessToken; + s.session.expireMs = 0; + s.state = State::Success; + s.cv.notify_all(); + } + // Refresh in background + RefreshSlot(0); + return; + } + + if (isOffline) { + AUTH_LOG("TryRestoreActiveJavaAccount: offline account %d, no refresh needed", activeIdx); + MCAuth::JavaSession offlineSession; + { + std::lock_guard alock(m_accountsMutex); + offlineSession = SynthesizeOfflineSession(m_javaAccounts[activeIdx]); + } + { + std::lock_guard lock(s.mutex); + s.session = offlineSession; + s.state = State::Success; + s.cv.notify_all(); + } + return; + } + + AUTH_LOG("TryRestoreActiveJavaAccount: loading tokenFile='%s' for account %d", + tokenFile.c_str(), activeIdx); + + if (!s.auth->LoadTokens(tokenFile)) { + AUTH_LOG("TryRestoreActiveJavaAccount: LoadTokens failed for '%s'", tokenFile.c_str()); + std::lock_guard lock(s.mutex); + s.state = State::Failed; + s.lastError = "Failed to load token file: " + tokenFile; + s.cv.notify_all(); + return; + } + + RefreshSlot(0); +} + +std::string MCAuthManager::GetJavaDeviceCode() const { + std::lock_guard lock(m_deviceCodeMutex); + return m_javaDeviceCode; +} + +std::string MCAuthManager::GetJavaDirectUri() const { + std::lock_guard lock(m_deviceCodeMutex); + return m_javaDirectUri; +} + +std::string MCAuthManager::GetLastError() const { + auto& s = m_slots[0]; + std::lock_guard lock(s.mutex); + return s.lastError; +} diff --git a/MCAuth/src/MCAuthSession.cpp b/MCAuth/src/MCAuthSession.cpp new file mode 100644 index 0000000000..637eb28715 --- /dev/null +++ b/MCAuth/src/MCAuthSession.cpp @@ -0,0 +1,476 @@ +/* + * MCAuthSession.cpp — UUID utilities and Mojang session verification + * + * UUID v3 generation uses MD5 with the "OfflinePlayer:" namespace, + * matching vanilla Minecraft's offline UUID derivation. + * + * Session verification: + * - JoinServer() — client calls before connecting (POST /session/minecraft/join) + * - HasJoined() — server verifies after receiving auth response (GET /session/minecraft/hasJoined) + */ + +#include "../include/MCAuth.h" +#include "MCAuthCrypto.h" +#include "MCAuthHttp.h" +#include "MCAuthInternal.h" + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include + +#include +#include +#include +#include + +#pragma comment(lib, "bcrypt.lib") +#pragma comment(lib, "windowscodecs.lib") +#pragma comment(lib, "ole32.lib") + +namespace MCAuth { + +using namespace mcauth; + +std::string MakeSkinKey(const std::string& uuid) { + return "mojang_skin_" + UndashUuid(uuid) + ".png"; +} + +std::string UndashUuid(const std::string& dashed) { + std::string out; + out.reserve(32); + for (char c : dashed) { + if (c != '-') out += c; + } + return out; +} + +std::string DashUuid(const std::string& u) { + if (u.size() != 32) return u; + // 8-4-4-4-12 + return u.substr(0, 8) + "-" + u.substr(8, 4) + "-" + u.substr(12, 4) + + "-" + u.substr(16, 4) + "-" + u.substr(20); +} + +std::string GenerateOfflineUuid(const std::string& username) { + std::string input = "OfflinePlayer:" + username; + auto md5 = ComputeMD5(input.data(), input.size()); + if (md5.size() < 16) return ""; + uint8_t hash[16]; + memcpy(hash, md5.data(), 16); + + // Set version to 3 (byte 6, high nibble) + hash[6] = (hash[6] & 0x0F) | 0x30; + // Set variant to RFC 4122 (byte 8, high 2 bits = 10) + hash[8] = (hash[8] & 0x3F) | 0x80; + + // Convert to hex string + char hex[33]; + for (int i = 0; i < 16; i++) + snprintf(hex + i * 2, 3, "%02x", hash[i]); + hex[32] = '\0'; + + return DashUuid(std::string(hex, 32)); +} + +static uint8_t HexNibble(char c) { + if (c >= '0' && c <= '9') return (uint8_t)(c - '0'); + if (c >= 'a' && c <= 'f') return (uint8_t)(c - 'a' + 10); + if (c >= 'A' && c <= 'F') return (uint8_t)(c - 'A' + 10); + return 0; +} + +Uuid128 ParseUuid128(const std::string& dashed) { + std::string hex = UndashUuid(dashed); + if (hex.size() != 32) return {}; + + Uuid128 result; + result.hi = 0; + result.lo = 0; + for (int i = 0; i < 16; i++) { + uint8_t byte = (HexNibble(hex[i * 2]) << 4) | HexNibble(hex[i * 2 + 1]); + if (i < 8) + result.hi = (result.hi << 8) | byte; + else + result.lo = (result.lo << 8) | byte; + } + return result; +} + +// Classify an HTTP status code into an AuthErrorCode +static AuthErrorCode ClassifyHttpStatus(int status) { + if (status == 401 || status == 403) return AuthErrorCode::InvalidCredentials; + if (status == 429) return AuthErrorCode::RateLimited; + if (status >= 500 && status < 600) return AuthErrorCode::ServerUnavailable; + return AuthErrorCode::HttpError; +} + +static bool JoinServerImpl(const std::string& url, + const std::string& accessToken, + const std::string& undashedUuid, + const std::string& serverId, + const std::string& errorPrefix, + std::string& error) +{ + std::string body = "{\"accessToken\":\"" + JsonEscape(accessToken) + + "\",\"selectedProfile\":\"" + JsonEscape(undashedUuid) + + "\",\"serverId\":\"" + JsonEscape(serverId) + "\"}"; + + try { + auto resp = HttpPost(url, body, "application/json"); + + if (resp.statusCode == 204 || resp.statusCode == 200) + return true; + + error = errorPrefix + " HTTP " + std::to_string(resp.statusCode); + if (!resp.body.empty()) { + std::string msg = JsonGetString(resp.body, "errorMessage"); + if (!msg.empty()) error += ": " + msg; + else error += " body: " + resp.body.substr(0, 200); + } + } catch (const std::exception& e) { + error = errorPrefix + " network error: " + e.what(); + } + return false; +} + +bool JoinServer(const std::string& accessToken, + const std::string& undashedUuid, + const std::string& serverId, + std::string& error) +{ + return JoinServerImpl( + "https://sessionserver.mojang.com/session/minecraft/join", + accessToken, undashedUuid, serverId, "JoinServer", error); +} + +// Extract texture URLs from HasJoined response body. +// The "properties" array contains a "textures" entry whose value is base64-encoded JSON. +static void ParseTextureProperties(const std::string& body, std::string& skinUrl, std::string& capeUrl) { + // Find the "properties" array, then find the entry with name "textures" + size_t propsPos = body.find("\"properties\""); + if (propsPos == std::string::npos) return; + + // Find "textures" name entry + size_t texNamePos = body.find("\"textures\"", propsPos); + if (texNamePos == std::string::npos) return; + + // Find the "value" field after "textures" + std::string b64Value = JsonGetString(body.substr(texNamePos), "value"); + if (b64Value.empty()) return; + + std::string decoded = Base64DecodeStr(b64Value); + if (decoded.empty()) return; + + // Parse the decoded JSON for SKIN and CAPE urls + // Structure: {"textures":{"SKIN":{"url":"...","metadata":{...}},"CAPE":{"url":"..."}}} + // Use JsonRawValue for proper brace-depth tracking (SKIN may contain nested "metadata") + std::string skinObj = JsonRawValue(decoded, "SKIN"); + if (!skinObj.empty()) + skinUrl = JsonGetString(skinObj, "url"); + + std::string capeObj = JsonRawValue(decoded, "CAPE"); + if (!capeObj.empty()) + capeUrl = JsonGetString(capeObj, "url"); +} + +static HasJoinedResult HasJoinedImpl(const std::string& baseUrl, + const std::string& username, + const std::string& serverId, + const std::string& errorPrefix, + std::string& error) +{ + std::string url = baseUrl + "?username=" + + UrlEncode(username) + "&serverId=" + UrlEncode(serverId); + + HasJoinedResult result; + + try { + auto resp = HttpGet(url); + + if (resp.statusCode == 200 && !resp.body.empty()) { + result.success = true; + result.uuid = JsonGetString(resp.body, "id"); + result.username = JsonGetString(resp.body, "name"); + ParseTextureProperties(resp.body, result.skinUrl, result.capeUrl); + return result; + } + + result.success = false; + if (resp.statusCode == 204) { + error = "Player has not joined (session not found)"; + result.error = { AuthErrorCode::InvalidCredentials, error, 204 }; + } else { + error = errorPrefix + " HTTP " + std::to_string(resp.statusCode); + result.error = { ClassifyHttpStatus(resp.statusCode), error, resp.statusCode }; + } + } catch (const std::exception& e) { + error = errorPrefix + " network error: " + e.what(); + result.success = false; + result.error = { AuthErrorCode::NetworkError, error, 0 }; + } + return result; +} + +HasJoinedResult HasJoined(const std::string& username, + const std::string& serverId, + std::string& error) +{ + return HasJoinedImpl( + "https://sessionserver.mojang.com/session/minecraft/hasJoined", + username, serverId, "HasJoined", error); +} + +// CropSkinTo64x32 — convert 64x64 Java Edition skin to 64x32 LCE format. +// Uses WIC for PNG decode/encode. Returns original data unchanged if not 64x64. +static std::vector CropSkinTo64x32(const std::vector& pngData) { + // Guard against exceptions in WIC/COM — this runs in a detached thread + // (PendingConnection auth verification) where unhandled exceptions crash the process. + try { + HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + bool needUninit = SUCCEEDED(hr); + + IWICImagingFactory* factory = nullptr; + IWICStream* stream = nullptr; + IWICBitmapDecoder* decoder = nullptr; + IWICBitmapFrameDecode*frame = nullptr; + IWICFormatConverter* converter = nullptr; + IWICStream* outStream = nullptr; + IStream* memStream = nullptr; + IWICBitmapEncoder* encoder = nullptr; + IWICBitmapFrameEncode*outFrame = nullptr; + + // Lambda-based cleanup — captures pointers by reference so it always + // releases whatever was allocated up to the point of the call. + auto cleanup = [&]() { + if (outFrame) outFrame->Release(); + if (encoder) encoder->Release(); + if (outStream) outStream->Release(); + if (memStream) memStream->Release(); + if (converter) converter->Release(); + if (frame) frame->Release(); + if (decoder) decoder->Release(); + if (stream) stream->Release(); + if (factory) factory->Release(); + if (needUninit) CoUninitialize(); + }; + + hr = CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&factory)); + if (FAILED(hr)) { cleanup(); return pngData; } + + // Macro for concise HRESULT checking — on failure, clean up and return + // the original PNG (graceful fallback: skin displayed uncropped). + #define WIC_CHECK(expr) do { hr = (expr); if (FAILED(hr)) { cleanup(); return pngData; } } while(0) + + // Decode the PNG from memory + WIC_CHECK(factory->CreateStream(&stream)); + WIC_CHECK(stream->InitializeFromMemory((BYTE*)pngData.data(), (DWORD)pngData.size())); + + WIC_CHECK(factory->CreateDecoderFromStream(stream, nullptr, WICDecodeMetadataCacheOnDemand, &decoder)); + + WIC_CHECK(decoder->GetFrame(0, &frame)); + + UINT width = 0, height = 0; + WIC_CHECK(frame->GetSize(&width, &height)); + + // Only crop if the skin is 64x64 (Java Edition format) + if (width != 64 || height != 64) { cleanup(); return pngData; } + + // Convert to 32bpp BGRA + WIC_CHECK(factory->CreateFormatConverter(&converter)); + WIC_CHECK(converter->Initialize(frame, GUID_WICPixelFormat32bppBGRA, + WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)); + + // Copy the top 32 rows (64x32 crop) + const UINT cropW = 64, cropH = 32; + const UINT stride = cropW * 4; + std::vector pixels(stride * cropH); + + WICRect cropRect = { 0, 0, (INT)cropW, (INT)cropH }; + WIC_CHECK(converter->CopyPixels(&cropRect, stride, (UINT)pixels.size(), pixels.data())); + + // Encode the cropped image back to PNG + WIC_CHECK(factory->CreateStream(&outStream)); + hr = CreateStreamOnHGlobal(nullptr, TRUE, &memStream); + if (FAILED(hr)) { cleanup(); return pngData; } + WIC_CHECK(outStream->InitializeFromIStream(memStream)); + + WIC_CHECK(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, &encoder)); + WIC_CHECK(encoder->Initialize(outStream, WICBitmapEncoderNoCache)); + + WIC_CHECK(encoder->CreateNewFrame(&outFrame, nullptr)); + WIC_CHECK(outFrame->Initialize(nullptr)); + WIC_CHECK(outFrame->SetSize(cropW, cropH)); + + WICPixelFormatGUID pixFmt = GUID_WICPixelFormat32bppBGRA; + WIC_CHECK(outFrame->SetPixelFormat(&pixFmt)); + WIC_CHECK(outFrame->WritePixels(cropH, stride, (UINT)pixels.size(), pixels.data())); + WIC_CHECK(outFrame->Commit()); + WIC_CHECK(encoder->Commit()); + + // Read the encoded PNG from the IStream + STATSTG stat = {}; + WIC_CHECK(memStream->Stat(&stat, STATFLAG_NONAME)); + ULONG pngSize = (ULONG)stat.cbSize.QuadPart; + + #undef WIC_CHECK + + std::vector result(pngSize); + LARGE_INTEGER zero = {}; + memStream->Seek(zero, STREAM_SEEK_SET, nullptr); + memStream->Read(result.data(), pngSize, nullptr); + + cleanup(); + return result; + + } catch (...) { + // WIC/COM failure — return original data rather than crashing the detached thread + return pngData; + } +} + +bool ValidateSkinPng(const uint8_t* data, size_t size) { + // PNG magic: 89 50 4E 47 0D 0A 1A 0A + static const uint8_t kPngMagic[8] = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + + if (data == nullptr || size < 24) // PNG header + IHDR minimum + return false; + if (size > kMaxSkinBytes) + return false; + if (memcmp(data, kPngMagic, 8) != 0) + return false; + + // Read IHDR dimensions (bytes 16-23 in a PNG: 4 bytes width + 4 bytes height, big-endian) + uint32_t width = ((uint32_t)data[16] << 24) | ((uint32_t)data[17] << 16) | + ((uint32_t)data[18] << 8) | (uint32_t)data[19]; + uint32_t height = ((uint32_t)data[20] << 24) | ((uint32_t)data[21] << 16) | + ((uint32_t)data[22] << 8) | (uint32_t)data[23]; + + // Minecraft skins are 64x32 (legacy) or 64x64 (modern) + if (width != 64) + return false; + if (height != 32 && height != 64) + return false; + + return true; +} + +std::vector FetchSkinPng(const std::string& url, std::string& error) { + if (url.empty()) { + error = "Empty skin URL"; + return {}; + } + + try { + auto resp = HttpGet(url); + if (resp.statusCode == 200 && !resp.body.empty()) { + auto rawPng = std::vector(resp.body.begin(), resp.body.end()); + // Validate before processing — reject non-PNG or wrong dimensions + if (!ValidateSkinPng(rawPng.data(), rawPng.size())) { + error = "FetchSkinPng: invalid PNG data (" + std::to_string(rawPng.size()) + " bytes)"; + return {}; + } + // Convert 64x64 Java skin to 64x32 LCE format + return CropSkinTo64x32(rawPng); + } + error = "FetchSkinPng HTTP " + std::to_string(resp.statusCode); + } catch (const std::exception& e) { + error = std::string("FetchSkinPng exception: ") + e.what(); + } + return {}; +} + +std::vector FetchSkinPngRaw(const std::string& url, std::string& error) { + if (url.empty()) { + error = "Empty skin URL"; + return {}; + } + + try { + auto resp = HttpGet(url); + if (resp.statusCode == 200 && !resp.body.empty()) { + auto rawPng = std::vector(resp.body.begin(), resp.body.end()); + if (!ValidateSkinPng(rawPng.data(), rawPng.size())) { + error = "FetchSkinPngRaw: invalid PNG data"; + return {}; + } + return rawPng; // Return original RGBA PNG — game's LoadTextureData handles format + } + error = "FetchSkinPngRaw HTTP " + std::to_string(resp.statusCode); + } catch (const std::exception& e) { + error = std::string("FetchSkinPngRaw exception: ") + e.what(); + } + return {}; +} + +static std::string FetchProfileSkinUrlImpl(const std::string& baseUrl, + const std::string& uuid, + const std::string& errorPrefix, + const std::string& noSkinMsg, + std::string& error) +{ + std::string undashed = UndashUuid(uuid); + if (undashed.empty()) { + error = "Empty UUID"; + return ""; + } + + std::string url = baseUrl + undashed; + + try { + auto resp = HttpGet(url); + if (resp.statusCode != 200 || resp.body.empty()) { + error = errorPrefix + " HTTP " + std::to_string(resp.statusCode); + return ""; + } + + std::string skinUrl, capeUrl; + ParseTextureProperties(resp.body, skinUrl, capeUrl); + if (skinUrl.empty()) + error = noSkinMsg; + return skinUrl; + } catch (const std::exception& e) { + error = errorPrefix + " exception: " + e.what(); + return ""; + } +} + +std::string FetchProfileSkinUrl(const std::string& uuid, std::string& error) { + return FetchProfileSkinUrlImpl( + "https://sessionserver.mojang.com/session/minecraft/profile/", + uuid, "Profile", "No skin in profile", error); +} + +// Ely.by session functions — same Yggdrasil protocol, different base URLs + +std::string MakeElybySkinKey(const std::string& uuid) { + return "elyby_skin_" + UndashUuid(uuid) + ".png"; +} + +bool ElybyJoinServer(const std::string& accessToken, + const std::string& undashedUuid, + const std::string& serverId, + std::string& error) +{ + return JoinServerImpl( + "https://authserver.ely.by/session/join", + accessToken, undashedUuid, serverId, "ElybyJoinServer", error); +} + +HasJoinedResult ElybyHasJoined(const std::string& username, + const std::string& serverId, + std::string& error) +{ + return HasJoinedImpl( + "https://authserver.ely.by/session/hasJoined", + username, serverId, "ElybyHasJoined", error); +} + +std::string ElybyFetchProfileSkinUrl(const std::string& uuid, std::string& error) { + return FetchProfileSkinUrlImpl( + "https://authserver.ely.by/session/profile/", + uuid, "ElybyProfile", "No skin in ely.by profile", error); +} + +} // namespace MCAuth diff --git a/Minecraft.Client/CMakeLists.txt b/Minecraft.Client/CMakeLists.txt index 9f75efd219..9e97dbafa9 100644 --- a/Minecraft.Client/CMakeLists.txt +++ b/Minecraft.Client/CMakeLists.txt @@ -51,6 +51,7 @@ set_target_properties(Minecraft.Client PROPERTIES target_link_libraries(Minecraft.Client PRIVATE Minecraft.World + MCAuth d3d11 d3dcompiler XInput9_1_0 diff --git a/Minecraft.Client/ClientConnection.cpp b/Minecraft.Client/ClientConnection.cpp index 325e949bbf..eb7b2d97ce 100644 --- a/Minecraft.Client/ClientConnection.cpp +++ b/Minecraft.Client/ClientConnection.cpp @@ -28,6 +28,11 @@ #include "..\Minecraft.World\net.minecraft.world.level.tile.h" #include "..\Minecraft.World\net.minecraft.world.inventory.h" #include "..\Minecraft.World\net.minecraft.world.h" +#ifdef _WINDOWS64 +#include "..\..\MCAuth\include\MCAuth.h" +#include "..\..\MCAuth\include\MCAuthManager.h" +#include "..\Minecraft.World\GameUUID.h" +#endif #include "..\Minecraft.World\net.minecraft.world.level.saveddata.h" #include "..\Minecraft.World\net.minecraft.world.level.dimension.h" #include "..\Minecraft.World\net.minecraft.world.effect.h" @@ -365,7 +370,14 @@ void ClientConnection::handleLogin(shared_ptr packet) } minecraft->player->setPlayerIndex( packet->m_playerIndex ); - minecraft->player->setCustomSkin( app.GetPlayerSkinId(m_userIndex) ); + if (!m_mojangSkinUrl.empty()) + { + minecraft->player->customTextureUrl = m_mojangSkinUrl; + } + else + { + minecraft->player->setCustomSkin( app.GetPlayerSkinId(m_userIndex) ); + } minecraft->player->setCustomCape( app.GetPlayerCapeId(m_userIndex) ); @@ -441,7 +453,14 @@ void ClientConnection::handleLogin(shared_ptr packet) player->entityId = packet->clientVersion; player->setPlayerIndex( packet->m_playerIndex ); - player->setCustomSkin( app.GetPlayerSkinId(m_userIndex) ); + if (!m_mojangSkinUrl.empty()) + { + player->customTextureUrl = m_mojangSkinUrl; + } + else + { + player->setCustomSkin( app.GetPlayerSkinId(m_userIndex) ); + } player->setCustomCape( app.GetPlayerCapeId(m_userIndex) ); @@ -840,22 +859,42 @@ void ClientConnection::handleAddPlayer(shared_ptr packet) return; } } -#endif -/*#ifdef _WINDOWS64 - // On Windows64 all XUIDs are INVALID_XUID so the XUID check above never fires. - // packet->m_playerIndex is the server-assigned sequential index (set via LoginPacket), - // NOT the controller slot — so we must scan all local player slots and match by - // their stored server index rather than using it directly as an array subscript. - for(unsigned int idx = 0; idx < XUSER_MAX_COUNT; ++idx) + + // Additional check: compare against MCAuth session UUIDs for all slots. + // This catches the case where the AddPlayerPacket arrives BEFORE the local player + // entity is created (race condition during splitscreen join), preventing ghost entities. { - if(minecraft->localplayers[idx] != nullptr && - minecraft->localplayers[idx]->getPlayerIndex() == packet->m_playerIndex) + auto& mgr = MCAuthManager::Get(); + for (int slot = 0; slot < XUSER_MAX_COUNT; ++slot) { - app.DebugPrintf("AddPlayerPacket received for local player (controller %d, server index %d), skipping RemotePlayer creation\n", idx, packet->m_playerIndex); - return; + if (!mgr.IsSlotLoggedIn(slot)) continue; + auto session = mgr.GetSlotSession(slot); + if (session.uuid.empty()) continue; + PlayerUID authXuid = GameUUID::fromDashed(session.uuid); + if (authXuid != INVALID_XUID && authXuid == packet->xuid) + { + app.DebugPrintf("AddPlayerPacket for local auth slot %d ('%s'), skipping RemotePlayer creation\n", + slot, session.username.c_str()); + return; + } } } -#endif*/ + + // Also check by local player entity XUID (set during createExtraLocalPlayer) + for (unsigned int idx = 0; idx < XUSER_MAX_COUNT; ++idx) + { + if (minecraft->localplayers[idx] != nullptr) + { + PlayerUID localXuid = minecraft->localplayers[idx]->getXuid(); + if (localXuid != INVALID_XUID && localXuid == packet->xuid) + { + app.DebugPrintf("AddPlayerPacket matched local player %ls by XUID, skipping\n", + minecraft->localplayers[idx]->name.c_str()); + return; + } + } + } +#endif double x = packet->x / 32.0; double y = packet->y / 32.0; @@ -884,21 +923,8 @@ void ClientConnection::handleAddPlayer(shared_ptr packet) { IQNetPlayer* matchedQNetPlayer = nullptr; PlayerUID pktXuid = player->getXuid(); - const PlayerUID WIN64_XUID_BASE = (PlayerUID)0xe000d45248242f2e; - // Legacy compatibility path for peers still using embedded smallId XUIDs. - if (pktXuid >= WIN64_XUID_BASE && pktXuid < WIN64_XUID_BASE + MINECRAFT_NET_MAX_PLAYERS) - { - BYTE smallId = (BYTE)(pktXuid - WIN64_XUID_BASE); - INetworkPlayer* np = g_NetworkManager.GetPlayerBySmallId(smallId); - if (np != nullptr) - { - NetworkPlayerXbox* npx = (NetworkPlayerXbox*)np; - matchedQNetPlayer = npx->GetQNetPlayer(); - } - } - // Current Win64 path: identify QNet player by name and attach packet XUID. - if (matchedQNetPlayer == nullptr) + // Identify QNet player by name and attach packet XUID. { for (int i = 0; i < MINECRAFT_NET_MAX_PLAYERS; ++i) { @@ -919,8 +945,7 @@ void ClientConnection::handleAddPlayer(shared_ptr packet) if (matchedQNetPlayer != nullptr) { - // Store packet-authoritative XUID on this network slot so later lookups by XUID - // (e.g. remove player, display mapping) work for both legacy and uid.dat clients. + // Store packet-authoritative UUID on this network slot so later lookups work. matchedQNetPlayer->m_resolvedXuid = pktXuid; if (matchedQNetPlayer->m_gamertag[0] == 0) { @@ -997,6 +1022,13 @@ void ClientConnection::handleAddPlayer(shared_ptr packet) player->getEntityData()->assignValues(unpackedData); } + // Retry any SetEntityLinkPackets that were waiting for this player to be added. + // Critical for splitscreen: when player 2 joins, the server may broadcast a + // SetEntityLinkPacket for player 1 (e.g. riding a horse) before player 2's + // connection has received the AddPlayerPacket for player 1. Without this call, + // the deferred packet would never be resolved and the riding state would be lost + // (the original code only called this from handleAddEntity, not handleAddPlayer). + checkDeferredEntityLinkPackets(packet->id); } void ClientConnection::handleTeleportEntity(shared_ptr packet) @@ -1403,6 +1435,7 @@ void ClientConnection::handleTileUpdate(shared_ptr packet) void ClientConnection::handleDisconnect(shared_ptr packet) { + app.DebugPrintf("[Auth-Client] Received DisconnectPacket: reason=%d\n", (int)packet->reason); connection->close(DisconnectPacket::eDisconnect_Kicked); done = true; @@ -1418,6 +1451,7 @@ void ClientConnection::handleDisconnect(shared_ptr packet) void ClientConnection::onDisconnect(DisconnectPacket::eDisconnectReason reason, void *reasonObjects) { + app.DebugPrintf("[Auth-Client] onDisconnect called: reason=%d, done=%d\n", (int)reason, (int)done); if (done) return; done = true; @@ -2417,39 +2451,18 @@ void ClientConnection::handlePreLogin(shared_ptr packet) { Minecraft::GetInstance()->progressRenderer->progressStagePercentage((eCCPreLoginReceived * 100)/ (eCCConnected)); } - // need to use the XUID here - PlayerUID offlineXUID = INVALID_XUID; - PlayerUID onlineXUID = INVALID_XUID; - if( ProfileManager.IsSignedInLive(m_userIndex) ) - { - // Guest don't have an offline XUID as they cannot play offline, so use their online one - ProfileManager.GetXUID(m_userIndex,&onlineXUID,true); - } -#ifdef __PSVITA__ - if(CGameNetworkManager::usingAdhocMode() && onlineXUID.getOnlineID()[0] == 0) - { - // player doesn't have an online UID, set it from the player name - onlineXUID.setForAdhoc(); - } -#endif + // Cache ugcPlayersVersion for later LoginPacket + m_cachedUgcPlayersVersion = packet->m_ugcPlayersVersion; - // On PS3, all non-signed in players (even guests) can get a useful offlineXUID -#if !(defined __PS3__ || defined _DURANGO ) - if( !ProfileManager.IsGuest( m_userIndex ) ) -#endif - { - // All other players we use their offline XUID so that they can play the game offline - ProfileManager.GetXUID(m_userIndex,&offlineXUID,false); - } - BOOL allAllowed, friendsAllowed; - ProfileManager.AllowedPlayerCreatedContent(m_userIndex,true,&allAllowed,&friendsAllowed); - send(std::make_shared(minecraft->user->name, SharedConstants::NETWORK_PROTOCOL_VERSION, offlineXUID, onlineXUID, (allAllowed != TRUE && friendsAllowed == TRUE), - packet->m_ugcPlayersVersion, app.GetPlayerSkinId(m_userIndex), app.GetPlayerCapeId(m_userIndex), ProfileManager.IsGuest(m_userIndex))); - - if(!g_NetworkManager.IsHost() ) + // If auth already completed (e.g., UGC version mismatch re-send from server), + // re-send LoginPacket immediately instead of waiting for AuthSchemePacket. + if (!m_assignedUuid.empty()) { - Minecraft::GetInstance()->progressRenderer->progressStagePercentage((eCCLoginSent * 100)/ (eCCConnected)); + app.DebugPrintf("[Auth-Client] Auth already completed, re-sending LoginPacket (UGC resync)\n"); + sendLoginPacketAfterAuth(); + return; } + // LoginPacket sent after auth completes in sendLoginPacketAfterAuth() } #else // 4J - removed @@ -2486,6 +2499,261 @@ void ClientConnection::close() connection->close(DisconnectPacket::eDisconnect_Closed); } +void ClientConnection::disconnectWithReason(DisconnectPacket::eDisconnectReason reason) +{ + if (done) return; + connection->close(reason); + done = true; + + Minecraft *pMinecraft = Minecraft::GetInstance(); + pMinecraft->connectionDisconnected(m_userIndex, reason); + app.SetDisconnectReason(reason); + app.SetAction(m_userIndex, eAppAction_ExitWorld, (void *)TRUE); +} + +void ClientConnection::handleAuthScheme(shared_ptr packet) +{ +#ifdef _WINDOWS64 + auto& mgr = MCAuthManager::Get(); + + // Safety net: primary gate is in StartGameFromSave; this 10s wait should rarely trigger + if (mgr.GetSlotState(m_userIndex) == MCAuthManager::State::Authenticating) + { + app.DebugPrintf("[Auth-Client] Token refresh still in progress, waiting (max 10s)...\n"); + mgr.WaitForSlotReady(m_userIndex, 10000); + app.DebugPrintf("[Auth-Client] Auth settled, state=%d\n", (int)mgr.GetSlotState(m_userIndex)); + } + + auto session = mgr.GetSlotSession(m_userIndex); + bool isLoggedIn = mgr.IsSlotLoggedIn(m_userIndex); + bool hasToken = !session.accessToken.empty(); + bool tokenExpired = mgr.IsTokenExpiringSoon(m_userIndex); + bool hasOnlineAccount = isLoggedIn && hasToken && !tokenExpired; + + string serverId(packet->serverId.begin(), packet->serverId.end()); + + app.DebugPrintf("[Auth-Client] Received AuthSchemePacket: serverId='%s'\n", serverId.c_str()); + app.DebugPrintf("[Auth-Client] Session: username='%s', uuid='%s'\n", + session.username.c_str(), session.uuid.c_str()); + + bool serverHasMojang = false, serverHasOffline = false, serverHasElyby = false; + for (auto& s : packet->schemes) + { + if (s == L"mojang") serverHasMojang = true; + if (s == L"offline") serverHasOffline = true; + if (s == L"elyby") serverHasElyby = true; + } + + // Determine account's auth provider + std::string provider = "mojang"; + { + int acctIdx = mgr.GetSlot(m_userIndex).accountIndex.load(); + if (acctIdx >= 0) { + auto accounts = mgr.GetJavaAccounts(); + if (acctIdx < (int)accounts.size()) + provider = accounts[acctIdx].authProvider; + } + } + + app.DebugPrintf("[Auth-Client] Server offers: mojang=%d, offline=%d, elyby=%d (account provider=%s)\n", + (int)serverHasMojang, (int)serverHasOffline, (int)serverHasElyby, provider.c_str()); + + // Block connection if no account is configured at all (empty username/uuid). + // The player should sign in or create an offline account first. + if (session.username.empty() || session.uuid.empty()) + { + app.DebugPrintf("[Auth-Client] REJECTED: No account configured (username/uuid empty). Player must sign in first.\n"); + disconnectWithReason(DisconnectPacket::eDisconnect_AuthFailed); + return; + } + + wstring wUuid(session.uuid.begin(), session.uuid.end()); + wstring wName(session.username.begin(), session.username.end()); + + if (hasOnlineAccount && provider == "mojang" && serverHasMojang) + { + app.DebugPrintf("[Auth-Client] Choosing 'mojang' auth scheme\n"); + string token = session.accessToken; + if (token.size() > 7 && token.substr(0, 7) == "Bearer ") token = token.substr(7); + string uuid = MCAuth::UndashUuid(session.uuid); + + app.DebugPrintf("[Auth-Client] Calling JoinServer for uuid='%s'...\n", uuid.c_str()); + string error; + bool ok = MCAuth::JoinServer(token, uuid, serverId, error); + if (!ok) + { + app.DebugPrintf("[Auth-Client] JoinServer FAILED: %s\n", error.c_str()); + disconnectWithReason(DisconnectPacket::eDisconnect_AuthFailed); + return; + } + app.DebugPrintf("[Auth-Client] JoinServer succeeded, sending AuthResponse(mojang)\n"); + + send(make_shared(L"mojang", wUuid, wName)); + } + else if (hasOnlineAccount && provider == "elyby" && serverHasElyby) + { + app.DebugPrintf("[Auth-Client] Choosing 'elyby' auth scheme\n"); + string token = session.accessToken; + string uuid = MCAuth::UndashUuid(session.uuid); + + string error; + bool ok = MCAuth::ElybyJoinServer(token, uuid, serverId, error); + if (!ok) + { + app.DebugPrintf("[Auth-Client] ElybyJoinServer FAILED: %s\n", error.c_str()); + disconnectWithReason(DisconnectPacket::eDisconnect_AuthFailed); + return; + } + app.DebugPrintf("[Auth-Client] ElybyJoinServer succeeded, sending AuthResponse(elyby)\n"); + + send(make_shared(L"elyby", wUuid, wName)); + } + else if (serverHasOffline) + { + app.DebugPrintf("[Auth-Client] Choosing 'offline' auth scheme\n"); + send(make_shared(L"offline", wUuid, wName)); + } + else + { + app.DebugPrintf("[Auth-Client] No compatible auth scheme. Your account uses '%s' but the server requires%s%s%s. Switch account or ask the server admin to enable your provider.\n", + provider.c_str(), + serverHasMojang ? " mojang" : "", + serverHasElyby ? " elyby" : "", + serverHasOffline ? " offline" : ""); + disconnectWithReason(DisconnectPacket::eDisconnect_AuthFailed); + return; + } +#else + // Non-Windows64 platforms: MCAuth not available, try offline scheme only + bool serverHasOffline = false; + for (auto& s : packet->schemes) + { + if (s == L"offline") serverHasOffline = true; + } + + if (serverHasOffline) + { + wstring wName = minecraft->user->name; + send(make_shared(L"offline", L"", wName)); + } + else + { + disconnectWithReason(DisconnectPacket::eDisconnect_AuthFailed); + return; + } +#endif +} + + +void ClientConnection::handleAuthResult(shared_ptr packet) +{ + app.DebugPrintf("[Auth-Client] Received AuthResultPacket: success=%d\n", (int)packet->success); + + if (!packet->success) + { + string err(packet->errorMessage.begin(), packet->errorMessage.end()); + app.DebugPrintf("[Auth-Client] Auth REJECTED by server: '%s'\n", err.c_str()); + disconnectWithReason(DisconnectPacket::eDisconnect_AuthFailed); + return; + } + + m_assignedUuid = string(packet->assignedUuid.begin(), packet->assignedUuid.end()); + m_assignedUsername = string(packet->assignedUsername.begin(), packet->assignedUsername.end()); + app.DebugPrintf("[Auth-Client] Auth SUCCESS: assigned username='%s', uuid='%s'\n", + m_assignedUsername.c_str(), m_assignedUuid.c_str()); + + // Update the IQNet gamertag for THIS connection's slot so the TAB player list + // shows the auth-verified name. Each slot writes to its OWN m_player entry. + // user->name is only updated for the primary player (slot 0) since it's a + // shared global — splitscreen players must not clobber it. + if (!m_assignedUsername.empty()) + { + wstring wName(m_assignedUsername.begin(), m_assignedUsername.end()); + if (m_userIndex < MINECRAFT_NET_MAX_PLAYERS) + wcsncpy_s(IQNet::m_player[m_userIndex].m_gamertag, 32, wName.c_str(), _TRUNCATE); + if (m_userIndex == 0) + minecraft->user->name = wName; + } + +#ifdef _WINDOWS64 + // Store the Mojang skin received inline from the server (no separate download needed) + if (!packet->skinKey.empty() && !packet->skinData.empty() && + MCAuth::ValidateSkinPng(packet->skinData.data(), packet->skinData.size())) + { + m_mojangSkinUrl = packet->skinKey; + if (m_userIndex < XUSER_MAX_COUNT) + app.m_mojangSkinKey[m_userIndex] = m_mojangSkinUrl; + + DWORD skinSize = (DWORD)packet->skinData.size(); + PBYTE skinBuf = new BYTE[skinSize]; + memcpy(skinBuf, packet->skinData.data(), skinSize); + app.AddMemoryTextureFile(m_mojangSkinUrl, skinBuf, skinSize); + app.DebugPrintf("Client received Mojang skin inline: %zu bytes\n", packet->skinData.size()); + } + else if (!packet->skinData.empty()) + { + app.DebugPrintf("[Auth-Client] Rejected invalid skin data (%zu bytes)\n", packet->skinData.size()); + } +#endif + + // Send LoginPacket immediately — it serves as the auth handshake acknowledgement + sendLoginPacketAfterAuth(); +} + +void ClientConnection::sendLoginPacketAfterAuth() +{ + // Priority: auth-assigned > MCAuth session > user->name (shared global, always player 1 in splitscreen) + wstring loginName; + if (!m_assignedUsername.empty()) + { + loginName = wstring(m_assignedUsername.begin(), m_assignedUsername.end()); + } +#ifdef _WINDOWS64 + else + { + auto& mgr = MCAuthManager::Get(); + if (mgr.IsSlotLoggedIn(m_userIndex)) + { + auto session = mgr.GetSlotSession(m_userIndex); + if (!session.username.empty()) + loginName = wstring(session.username.begin(), session.username.end()); + } + } +#endif + if (loginName.empty()) + { + loginName = minecraft->user->name; + } + + PlayerUID loginUuid = INVALID_XUID; + if (!m_assignedUuid.empty()) + { + loginUuid = GameUUID::fromDashed(m_assignedUuid); + } + if (!loginUuid.isValid()) + { + loginUuid = GameUUID::generateOffline(std::string(loginName.begin(), loginName.end())); + } + + BOOL allAllowed, friendsAllowed; + ProfileManager.AllowedPlayerCreatedContent(m_userIndex,true,&allAllowed,&friendsAllowed); + + auto loginPacket = std::make_shared(loginName, SharedConstants::NETWORK_PROTOCOL_VERSION, loginUuid, (allAllowed != TRUE && friendsAllowed == TRUE), + m_cachedUgcPlayersVersion, app.GetPlayerSkinId(m_userIndex), app.GetPlayerCapeId(m_userIndex), ProfileManager.IsGuest(m_userIndex)); + + if (!m_assignedUuid.empty()) + { + loginPacket->m_mojangUuid = wstring(m_assignedUuid.begin(), m_assignedUuid.end()); + } + + send(loginPacket); + + if(!g_NetworkManager.IsHost() ) + { + Minecraft::GetInstance()->progressRenderer->progressStagePercentage((eCCLoginSent * 100)/ (eCCConnected)); + } +} + void ClientConnection::handleAddMob(shared_ptr packet) { double x = packet->x / 32.0; @@ -2536,6 +2804,12 @@ void ClientConnection::handleAddMob(shared_ptr packet) shared_ptr slime = dynamic_pointer_cast(mob); slime->setSize( slime->getSize() ); } + + // Retry any SetEntityLinkPackets that were waiting for this mob to be added. + // Without this, a deferred link where this mob is the source or dest would + // never be resolved (the original code only called this from handleAddEntity, + // missing mobs added via AddMobPacket). + checkDeferredEntityLinkPackets(packet->id); } void ClientConnection::handleSetTime(shared_ptr packet) @@ -2557,13 +2831,31 @@ void ClientConnection::handleEntityLinkPacket(shared_ptr pa shared_ptr sourceEntity = getEntity(packet->sourceId); shared_ptr destEntity = getEntity(packet->destId); - // 4J: If the destination entity couldn't be found, defer handling of this packet - // This was added to support leashing (the entity link packet is sent before the add entity packet) - if (destEntity == nullptr && packet->destId >= 0) + // 4J: If either entity couldn't be found, defer handling of this packet. + // + // With real TCP splitscreen connections, the server's EntityTracker can send + // a SetEntityLinkPacket (e.g. villager riding a boat, player on a horse) + // BEFORE the AddEntityPacket/AddMobPacket/AddPlayerPacket for one of the + // linked entities has been received by this connection. This happens because: + // - TrackedEntity::tick() broadcasts riding state every 3 seconds to all + // connections currently tracking the entity. + // - TrackedEntity::updatePlayer() sends AddEntity then SetEntityLink, but + // processes entities in arbitrary order, so entity A's link to entity B + // can arrive before entity B's AddEntity. + // - A dead splitscreen player's connection may have stale entity tracking + // state, causing gaps when entity updates resume. + // + // Original 4J code only deferred when destEntity was missing (for leashing). + // Extended to also defer when sourceEntity is missing, since the same race + // occurs for the source (observed crash during splitscreen with a dead player + // and AFK player in a village — villager/mob entity link arrived before the + // source entity was added to this connection's world). + // + // The deferred packet is retried when the missing entity arrives + // (see checkDeferredEntityLinkPackets), or dropped after 1000ms timeout. + if ((destEntity == nullptr && packet->destId >= 0) || + (sourceEntity == nullptr && packet->sourceId >= 0)) { - // We don't handle missing source entities because it shouldn't happen - assert(!(sourceEntity == nullptr && packet->sourceId >= 0)); - deferredEntityLinkPackets.push_back(DeferredEntityLinkPacket(packet)); return; } @@ -2728,6 +3020,16 @@ void ClientConnection::handleTextureAndGeometry(shared_ptrtextureName.c_str()); #endif + // Validate mojang skin PNGs before loading into memory + bool isMojangSkin = (packet->textureName.length() > 6 && + packet->textureName.substr(0, 6) == L"mojang"); + if (isMojangSkin && !MCAuth::ValidateSkinPng(packet->pbData, packet->dwTextureBytes)) + { + app.DebugPrintf("Rejected invalid mojang skin texture %ls (%u bytes)\n", + packet->textureName.c_str(), packet->dwTextureBytes); + return; + } + // Add the texture data app.AddMemoryTextureFile(packet->textureName,packet->pbData,packet->dwTextureBytes); // Add the geometry data @@ -2824,6 +3126,12 @@ void ClientConnection::handleTextureAndGeometryChange(shared_ptrsetCustomSkin( app.getSkinIdFromPath( packet->path ) ); + // Override customTextureUrl for Mojang skins (setCustomSkin resolves to a DLC path) + if (!packet->path.empty() && packet->path.substr(0, 3).compare(L"def") != 0) + { + player->customTextureUrl = packet->path; + } + #ifndef _CONTENT_PACKAGE wprintf(L"Skin for remote player %ls has changed to %ls (%d)\n", player->name.c_str(), player->customTextureUrl.c_str(), player->getPlayerDefaultSkin() ); #endif @@ -4090,31 +4398,48 @@ void ClientConnection::handleUpdateAttributes(shared_ptr } } -// 4J: Check for deferred entity link packets related to this entity ID and handle them +// Called when a new entity is added to the world (from handleAddEntity, +// handleAddMob, or handleAddPlayer). Checks whether any previously deferred +// SetEntityLinkPackets were waiting for this entity and retries them. +// +// Original 4J code only matched on destId (the destination/mount entity). +// Extended to also match sourceId so that packets deferred because the +// *source* entity was missing (e.g. a player not yet added during splitscreen +// join) are also retried when that entity arrives. +// +// Snapshot vector size before iterating -- handleEntityLinkPacket may re-defer (push_back). +// Without the snapshot limit we would process the newly pushed entry in the +// same pass, match it again on the same newEntityId, and loop forever. +// Newly deferred entries will be picked up on the next entity arrival. void ClientConnection::checkDeferredEntityLinkPackets(int newEntityId) { if (deferredEntityLinkPackets.empty()) return; - for (size_t i = 0; i < deferredEntityLinkPackets.size(); i++) - { - DeferredEntityLinkPacket *deferred = &deferredEntityLinkPackets[i]; + size_t originalCount = deferredEntityLinkPackets.size(); + for (size_t i = 0; i < originalCount; i++) + { bool remove = false; - // Only consider recently deferred packets - int tickInterval = GetTickCount() - deferred->m_recievedTick; + // Only consider recently deferred packets (within 1000ms) + int tickInterval = GetTickCount() - deferredEntityLinkPackets[i].m_recievedTick; if (tickInterval < MAX_ENTITY_LINK_DEFERRAL_INTERVAL) { - // Note: we assume it's the destination entity - if (deferred->m_packet->destId == newEntityId) + // Match on both source and dest — either could be the newly arrived entity + shared_ptr pkt = deferredEntityLinkPackets[i].m_packet; + if (pkt->destId == newEntityId || pkt->sourceId == newEntityId) { - handleEntityLinkPacket(deferred->m_packet); + // Retry the packet. If the OTHER entity is still missing, + // handleEntityLinkPacket will re-defer it (appended past + // originalCount, so we won't re-process it in this pass). + handleEntityLinkPacket(pkt); remove = true; } } else { - // This is an old packet, remove (shouldn't really come up but seems prudent) + // Timed out — drop the packet. The entity link will be corrected + // by the server's periodic 3-second riding/leash broadcast. remove = true; } @@ -4122,6 +4447,7 @@ void ClientConnection::checkDeferredEntityLinkPackets(int newEntityId) { deferredEntityLinkPackets.erase(deferredEntityLinkPackets.begin() + i); i--; + originalCount--; // the range we're iterating over just shrunk } } } diff --git a/Minecraft.Client/ClientConnection.h b/Minecraft.Client/ClientConnection.h index 3448496d07..f4f636c743 100644 --- a/Minecraft.Client/ClientConnection.h +++ b/Minecraft.Client/ClientConnection.h @@ -101,6 +101,7 @@ class ClientConnection : public PacketListener virtual void handleEntityActionAtPosition(shared_ptr packet); virtual void handlePreLogin(shared_ptr packet); void close(); + void disconnectWithReason(DisconnectPacket::eDisconnectReason reason); virtual void handleAddMob(shared_ptr packet); virtual void handleSetTime(shared_ptr packet); virtual void handleSetSpawn(shared_ptr packet); @@ -155,6 +156,10 @@ class ClientConnection : public PacketListener virtual void handleUpdateGameRuleProgressPacket(shared_ptr packet); virtual void handleXZ(shared_ptr packet); + // Auth handshake + virtual void handleAuthScheme(shared_ptr packet); + virtual void handleAuthResult(shared_ptr packet); + void displayPrivilegeChanges(shared_ptr player, unsigned int oldPrivileges); virtual void handleAddObjective(shared_ptr packet); @@ -179,4 +184,11 @@ class ClientConnection : public PacketListener static const int MAX_ENTITY_LINK_DEFERRAL_INTERVAL = 1000; void checkDeferredEntityLinkPackets(int newEntityId); + + // Auth handshake state + std::string m_assignedUuid; // UUID assigned by server after auth + std::string m_assignedUsername; // username assigned by server + wstring m_mojangSkinUrl; // Mojang skin key from server (e.g. "mojang_skin_{uuid}.png") + DWORD m_cachedUgcPlayersVersion = 0; // stored from PreLoginPacket for later LoginPacket + void sendLoginPacketAfterAuth(); }; \ No newline at end of file diff --git a/Minecraft.Client/Common/Consoles_App.cpp b/Minecraft.Client/Common/Consoles_App.cpp index 0a2fd159a4..51773329ae 100644 --- a/Minecraft.Client/Common/Consoles_App.cpp +++ b/Minecraft.Client/Common/Consoles_App.cpp @@ -32,6 +32,9 @@ #include "..\Xbox\XML\xmlFilesCallback.h" #endif #include "Minecraft_Macros.h" +#ifdef _WINDOWS64 +#include "..\..\MCAuth\include\MCAuthManager.h" +#endif #include "..\PlayerList.h" #include "..\ServerPlayer.h" #include "GameRules\ConsoleGameRules.h" @@ -153,8 +156,6 @@ CMinecraftApp::CMinecraftApp() //ZeroMemory(m_PreviewBuffer,sizeof(XSOCIAL_PREVIEWIMAGE)*XUSER_MAX_COUNT); - m_xuidNotch = INVALID_XUID; - ZeroMemory(&m_InviteData,sizeof(JoinFromInviteData) ); // m_bRead_TMS_XUIDS_XML=false; @@ -258,6 +259,7 @@ void CMinecraftApp::DebugPrintf(const char *szFormat, ...) vsnprintf(buf, sizeof(buf), szFormat, ap); va_end(ap); OutputDebugStringA(buf); + fputs(buf, stdout); #endif } @@ -1640,6 +1642,18 @@ void CMinecraftApp::SetPlayerSkin(int iPad,DWORD dwSkinId) } +void CMinecraftApp::SetPlayerMojangSkin(int iPad) +{ + SetPlayerSkin(iPad, (DWORD)eDefaultSkins_MojangSkin); + + // Override customTextureUrl to point to this player's Mojang memory texture + if (iPad >= 0 && iPad < XUSER_MAX_COUNT && + !m_mojangSkinKey[iPad].empty() && Minecraft::GetInstance()->localplayers[iPad] != nullptr) + { + Minecraft::GetInstance()->localplayers[iPad]->customTextureUrl = m_mojangSkinKey[iPad]; + } +} + wstring CMinecraftApp::GetPlayerSkinName(int iPad) { return app.getSkinPathFromId(GameSettingsA[iPad]->dwSelectedSkin); @@ -3298,6 +3312,14 @@ void CMinecraftApp::HandleXuiActions(void) // 4J Stu - Fix for #13257 - CRASH: Gameplay: Title crashed after exiting the tutorial // It doesn't matter if they were in the tutorial already pMinecraft->playerLeftTutorial( idx ); + +#ifdef _WINDOWS64 + // Clear splitscreen auth slots when leaving the world so the + // account picker doesn't show stale "Player N" badges in the + // main menu. Keep slot 0 (primary player) intact. + if (idx != (unsigned int)ProfileManager.GetPrimaryPad()) + MCAuthManager::Get().ClearSlot(idx); +#endif } LoadingInputParams *loadingParams = new LoadingInputParams(); @@ -4560,6 +4582,8 @@ int CMinecraftApp::SignoutExitWorldThreadProc( void* lpParameter ) int exitReasonStringId = -1; bool saveStats = false; + app.DebugPrintf("[Auth-Client] ExitWorld: isClientSide=%d, IsInSession=%d, lpParameter=%p, disconnectReason=%d\n", + (int)pMinecraft->isClientSide(), (int)g_NetworkManager.IsInSession(), lpParameter, (int)app.GetDisconnectReason()); if (pMinecraft->isClientSide() || g_NetworkManager.IsInSession() ) { if(lpParameter != nullptr ) @@ -4592,6 +4616,7 @@ int CMinecraftApp::SignoutExitWorldThreadProc( void* lpParameter ) default: exitReasonStringId = IDS_DISCONNECTED; } + app.DebugPrintf("[Auth-Client] ExitWorld: showing exitReasonStringId=%d\n", exitReasonStringId); pMinecraft->progressRenderer->progressStartNoAbort( exitReasonStringId ); // 4J - Force a disconnection, this handles the situation that the server has already disconnected if( pMinecraft->levels[0] != nullptr ) pMinecraft->levels[0]->disconnect(false); @@ -4599,6 +4624,7 @@ int CMinecraftApp::SignoutExitWorldThreadProc( void* lpParameter ) } else { + app.DebugPrintf("[Auth-Client] ExitWorld: lpParameter=null, showing IDS_EXITING_GAME\n"); exitReasonStringId = IDS_EXITING_GAME; pMinecraft->progressRenderer->progressStartNoAbort( IDS_EXITING_GAME ); @@ -4649,6 +4675,7 @@ int CMinecraftApp::SignoutExitWorldThreadProc( void* lpParameter ) break; case DisconnectPacket::eDisconnect_OutdatedClient: exitReasonStringId = IDS_DISCONNECTED_CLIENT_OLD; + break; default: exitReasonStringId = IDS_DISCONNECTED; } @@ -5694,14 +5721,6 @@ void CMinecraftApp::UpdateTime() m_Time.fAppTime = m_Time.fSecsPerTick * static_cast(m_Time.qwAppTime.QuadPart); } -bool CMinecraftApp::isXuidNotch(PlayerUID xuid) -{ - if(m_xuidNotch != INVALID_XUID && xuid != INVALID_XUID) - { - return ProfileManager.AreXUIDSEqual(xuid, m_xuidNotch) == TRUE; - } - return false; -} bool CMinecraftApp::isXuidDeadmau5(PlayerUID xuid) { diff --git a/Minecraft.Client/Common/Consoles_App.h b/Minecraft.Client/Common/Consoles_App.h index 0c1c261efd..9ce2f1de4b 100644 --- a/Minecraft.Client/Common/Consoles_App.h +++ b/Minecraft.Client/Common/Consoles_App.h @@ -349,7 +349,6 @@ class CMinecraftApp virtual void StoreLaunchData(); virtual void ExitGame(); - bool isXuidNotch(PlayerUID xuid); bool isXuidDeadmau5(PlayerUID xuid); void AddMemoryTextureFile(const wstring &wName, PBYTE pbData, DWORD dwBytes); @@ -357,6 +356,10 @@ class CMinecraftApp void GetMemFileDetails(const wstring &wName,PBYTE *ppbData,DWORD *pdwBytes); bool IsFileInMemoryTextures(const wstring &wName); + // Mojang skin key per player slot (set after auth, e.g. "mojang_skin_{uuid}.png") + wstring m_mojangSkinKey[XUSER_MAX_COUNT]; + void SetPlayerMojangSkin(int iPad); + // Texture Pack Data files (icon, banner, comparison shot & text) void AddMemoryTPDFile(int iConfig,PBYTE pbData,DWORD dwBytes); void RemoveMemoryTPDFile(int iConfig); @@ -378,7 +381,6 @@ class CMinecraftApp void AddCreditText(LPCWSTR lpStr); private: - PlayerUID m_xuidNotch; #ifdef _DURANGO unordered_map m_GTS_Files; #else diff --git a/Minecraft.Client/Common/Media/platformskin.swf b/Minecraft.Client/Common/Media/platformskin.swf new file mode 100644 index 0000000000..f7c64ef0d8 Binary files /dev/null and b/Minecraft.Client/Common/Media/platformskin.swf differ diff --git a/Minecraft.Client/Common/Media/platformskinHD.swf b/Minecraft.Client/Common/Media/platformskinHD.swf new file mode 100644 index 0000000000..2d85415898 Binary files /dev/null and b/Minecraft.Client/Common/Media/platformskinHD.swf differ diff --git a/Minecraft.Client/Common/Network/GameNetworkManager.cpp b/Minecraft.Client/Common/Network/GameNetworkManager.cpp index 50aeae689e..5c40880752 100644 --- a/Minecraft.Client/Common/Network/GameNetworkManager.cpp +++ b/Minecraft.Client/Common/Network/GameNetworkManager.cpp @@ -43,7 +43,7 @@ #ifdef _WINDOWS64 #include "..\..\Windows64\Network\WinsockNetLayer.h" -#include "..\..\Windows64\Windows64_Xuid.h" +#include "..\..\Windows64\Windows64_Uuid.h" #endif // Global instance @@ -476,7 +476,7 @@ bool CGameNetworkManager::StartNetworkGame(Minecraft *minecraft, LPVOID lpParame { INetworkPlayer *pNetworkPlayer = g_NetworkManager.GetLocalPlayerByUserIndex(idx); Socket *socket = pNetworkPlayer->GetSocket(); - app.DebugPrintf("Closing socket due to player %d not being signed in any more\n"); + app.DebugPrintf("Closing socket due to player %d not being signed in any more\n", idx); if( !socket->close(false) ) socket->close(true); continue; @@ -698,7 +698,7 @@ bool CGameNetworkManager::IsPrivateGame() return s_pPlatformNetworkManager->IsPrivateGame(); } -void CGameNetworkManager::HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, unsigned char publicSlots, unsigned char privateSlots) +void CGameNetworkManager::HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, int publicSlots, int privateSlots) { // 4J Stu - clear any previous connection errors Minecraft::GetInstance()->clearConnectionFailed(); @@ -1544,7 +1544,7 @@ void CGameNetworkManager::CreateSocket( INetworkPlayer *pNetworkPlayer, bool loc // The NetworkPlayerXbox created by NotifyPlayerJoined already points to // m_player[padIdx], so we just set the smallId for network routing. IQNet::m_player[padIdx].m_smallId = assignedSmallId; - IQNet::m_player[padIdx].m_resolvedXuid = Win64Xuid::DeriveXuidForPad(Win64Xuid::ResolvePersistentXuid(), padIdx); + IQNet::m_player[padIdx].m_resolvedXuid = INVALID_XUID; // Will be set by auth handshake // Network socket (not hostLocal) — data goes through TCP via GetLocalSocket socket = new Socket(pNetworkPlayer, false, false); diff --git a/Minecraft.Client/Common/Network/GameNetworkManager.h b/Minecraft.Client/Common/Network/GameNetworkManager.h index 3357b3cddf..56a556855b 100644 --- a/Minecraft.Client/Common/Network/GameNetworkManager.h +++ b/Minecraft.Client/Common/Network/GameNetworkManager.h @@ -88,7 +88,7 @@ class CGameNetworkManager bool IsLocalGame(); void SetPrivateGame(bool isPrivate); bool IsPrivateGame(); - void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, unsigned char publicSlots = MINECRAFT_NET_MAX_PLAYERS, unsigned char privateSlots = 0); + void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, int publicSlots = MINECRAFT_NET_MAX_PLAYERS, int privateSlots = 0); bool IsHost(); bool IsInStatsEnabledSession(); diff --git a/Minecraft.Client/Common/Network/PlatformNetworkManagerInterface.h b/Minecraft.Client/Common/Network/PlatformNetworkManagerInterface.h index 3ed0f888fc..98db7790a2 100644 --- a/Minecraft.Client/Common/Network/PlatformNetworkManagerInterface.h +++ b/Minecraft.Client/Common/Network/PlatformNetworkManagerInterface.h @@ -68,7 +68,7 @@ class CPlatformNetworkManager virtual void SendInviteGUI(int quadrant) = 0; virtual bool IsAddingPlayer() = 0; - virtual void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, unsigned char publicSlots = MINECRAFT_NET_MAX_PLAYERS, unsigned char privateSlots = 0) = 0; + virtual void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, int publicSlots = MINECRAFT_NET_MAX_PLAYERS, int privateSlots = 0) = 0; virtual int JoinGame(FriendSessionInfo *searchResult, int dwLocalUsersMask, int dwPrimaryUserIndex ) = 0; virtual void CancelJoinGame() {}; virtual bool SetLocalGame(bool isLocal) = 0; @@ -88,7 +88,7 @@ class CPlatformNetworkManager private: virtual bool _LeaveGame(bool bMigrateHost, bool bLeaveRoom) = 0; - virtual void _HostGame(int usersMask, unsigned char publicSlots = MINECRAFT_NET_MAX_PLAYERS, unsigned char privateSlots = 0) = 0; + virtual void _HostGame(int usersMask, int publicSlots = MINECRAFT_NET_MAX_PLAYERS, int privateSlots = 0) = 0; virtual bool _StartGame() = 0; diff --git a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp index 1e625098ba..bad951a913 100644 --- a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp +++ b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.cpp @@ -5,7 +5,7 @@ #include "..\..\Xbox\Network\NetworkPlayerXbox.h" #ifdef _WINDOWS64 #include "..\..\Windows64\Network\WinsockNetLayer.h" -#include "..\..\Windows64\Windows64_Xuid.h" +#include "..\..\Windows64\Windows64_Uuid.h" #include "..\..\Minecraft.h" #include "..\..\User.h" #include "..\..\MinecraftServer.h" @@ -332,7 +332,7 @@ bool CPlatformNetworkManagerStub::AddLocalPlayerByUserIndex( int userIndex ) bool CPlatformNetworkManagerStub::RemoveLocalPlayerByUserIndex( int userIndex ) { #ifdef _WINDOWS64 - if (userIndex > 0 && userIndex < XUSER_MAX_COUNT && !m_pIQNet->IsHost()) + if (userIndex > 0 && userIndex < XUSER_MAX_COUNT) { IQNetPlayer* qp = &IQNet::m_player[userIndex]; @@ -343,7 +343,15 @@ bool CPlatformNetworkManagerStub::RemoveLocalPlayerByUserIndex( int userIndex ) } // Close the split-screen TCP connection and reset WinsockNetLayer state - WinsockNetLayer::CloseSplitScreenConnection(userIndex); + if (!m_pIQNet->IsHost()) + WinsockNetLayer::CloseSplitScreenConnection(userIndex); + + // Release Mojang skin memory texture for this slot + if (!app.m_mojangSkinKey[userIndex].empty()) + { + app.RemoveMemoryTextureFile(app.m_mojangSkinKey[userIndex]); + app.m_mojangSkinKey[userIndex].clear(); + } // Clear the IQNet slot so it can be reused on rejoin qp->m_smallId = 0; @@ -418,7 +426,7 @@ bool CPlatformNetworkManagerStub::_LeaveGame(bool bMigrateHost, bool bLeaveRoom) return true; } -void CPlatformNetworkManagerStub::HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, unsigned char publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, unsigned char privateSlots /*= 0*/) +void CPlatformNetworkManagerStub::HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, int publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, int privateSlots /*= 0*/) { // #ifdef _XBOX // 4J Stu - We probably did this earlier as well, but just to be sure! @@ -437,9 +445,8 @@ void CPlatformNetworkManagerStub::HostGame(int localUsersMask, bool bOnlineGame, #ifdef _WINDOWS64 IQNet::m_player[0].m_smallId = 0; IQNet::m_player[0].m_isRemote = false; - // world host is pinned to legacy host XUID to keep old player data compatibility. IQNet::m_player[0].m_isHostPlayer = true; - IQNet::m_player[0].m_resolvedXuid = Win64Xuid::GetLegacyEmbeddedHostXuid(); + IQNet::m_player[0].m_resolvedXuid = INVALID_XUID; IQNet::s_playerCount = 1; #endif @@ -482,7 +489,7 @@ void CPlatformNetworkManagerStub::HostGame(int localUsersMask, bool bOnlineGame, //#endif } -void CPlatformNetworkManagerStub::_HostGame(int usersMask, unsigned char publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, unsigned char privateSlots /*= 0*/) +void CPlatformNetworkManagerStub::_HostGame(int usersMask, int publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, int privateSlots /*= 0*/) { } @@ -511,8 +518,7 @@ int CPlatformNetworkManagerStub::JoinGame(FriendSessionInfo* searchResult, int l IQNet::m_player[0].m_smallId = 0; IQNet::m_player[0].m_isRemote = true; IQNet::m_player[0].m_isHostPlayer = true; - // Remote host still maps to legacy host XUID in mixed old/new sessions. - IQNet::m_player[0].m_resolvedXuid = Win64Xuid::GetLegacyEmbeddedHostXuid(); + IQNet::m_player[0].m_resolvedXuid = INVALID_XUID; // Will be set when host identity is received wcsncpy_s(IQNet::m_player[0].m_gamertag, 32, searchResult->data.hostName, _TRUNCATE); WinsockNetLayer::StopDiscovery(); @@ -528,8 +534,7 @@ int CPlatformNetworkManagerStub::JoinGame(FriendSessionInfo* searchResult, int l IQNet::m_player[localSmallId].m_smallId = localSmallId; IQNet::m_player[localSmallId].m_isRemote = false; IQNet::m_player[localSmallId].m_isHostPlayer = false; - // Local non-host identity is the persistent uid.dat XUID. - IQNet::m_player[localSmallId].m_resolvedXuid = Win64Xuid::ResolvePersistentXuid(); + IQNet::m_player[localSmallId].m_resolvedXuid = INVALID_XUID; Minecraft* pMinecraft = Minecraft::GetInstance(); wcscpy_s(IQNet::m_player[localSmallId].m_gamertag, 32, pMinecraft->user->name.c_str()); @@ -789,7 +794,9 @@ wstring CPlatformNetworkManagerStub::GatherRTTStats() for(unsigned int i = 0; i < GetPlayerCount(); ++i) { - IQNetPlayer *pQNetPlayer = static_cast(GetPlayerByIndex(i))->GetQNetPlayer(); + INetworkPlayer* np = GetPlayerByIndex(i); + if (np == nullptr) continue; + IQNetPlayer *pQNetPlayer = static_cast(np)->GetQNetPlayer(); if(!pQNetPlayer->IsLocal()) { @@ -972,6 +979,8 @@ void CPlatformNetworkManagerStub::removeNetworkPlayer(IQNetPlayer *pQNetPlayer) { if( *it == pNetworkPlayer ) { + delete *it; + pQNetPlayer->SetCustomDataValue(0); currentNetworkPlayers.erase(it); return; } diff --git a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.h b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.h index 4a3f4068d3..aca06b8ca5 100644 --- a/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.h +++ b/Minecraft.Client/Common/Network/PlatformNetworkManagerStub.h @@ -40,7 +40,7 @@ class CPlatformNetworkManagerStub : public CPlatformNetworkManager virtual void SendInviteGUI(int quadrant); virtual bool IsAddingPlayer(); - virtual void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, unsigned char publicSlots = MINECRAFT_NET_MAX_PLAYERS, unsigned char privateSlots = 0); + virtual void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, int publicSlots = MINECRAFT_NET_MAX_PLAYERS, int privateSlots = 0); virtual int JoinGame(FriendSessionInfo *searchResult, int localUsersMask, int primaryUserIndex ); virtual bool SetLocalGame(bool isLocal); virtual bool IsLocalGame() { return m_bIsOfflineGame; } @@ -59,7 +59,7 @@ class CPlatformNetworkManagerStub : public CPlatformNetworkManager private: bool isSystemPrimaryPlayer(IQNetPlayer *pQNetPlayer); virtual bool _LeaveGame(bool bMigrateHost, bool bLeaveRoom); - virtual void _HostGame(int dwUsersMask, unsigned char publicSlots = MINECRAFT_NET_MAX_PLAYERS, unsigned char privateSlots = 0); + virtual void _HostGame(int dwUsersMask, int publicSlots = MINECRAFT_NET_MAX_PLAYERS, int privateSlots = 0); virtual bool _StartGame(); IQNet * m_pIQNet; // pointer to QNet interface diff --git a/Minecraft.Client/Common/Network/Sony/PlatformNetworkManagerSony.cpp b/Minecraft.Client/Common/Network/Sony/PlatformNetworkManagerSony.cpp index 107101f4ae..f36682822b 100644 --- a/Minecraft.Client/Common/Network/Sony/PlatformNetworkManagerSony.cpp +++ b/Minecraft.Client/Common/Network/Sony/PlatformNetworkManagerSony.cpp @@ -751,7 +751,7 @@ bool CPlatformNetworkManagerSony::_LeaveGame(bool bMigrateHost, bool bLeaveRoom) return true; } -void CPlatformNetworkManagerSony::HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, unsigned char publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, unsigned char privateSlots /*= 0*/) +void CPlatformNetworkManagerSony::HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, int publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, int privateSlots /*= 0*/) { // #ifdef _XBOX // 4J Stu - We probably did this earlier as well, but just to be sure! @@ -766,7 +766,7 @@ void CPlatformNetworkManagerSony::HostGame(int localUsersMask, bool bOnlineGame, //#endif } -void CPlatformNetworkManagerSony::_HostGame(int usersMask, unsigned char publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, unsigned char privateSlots /*= 0*/) +void CPlatformNetworkManagerSony::_HostGame(int usersMask, int publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, int privateSlots /*= 0*/) { // Start hosting a new game diff --git a/Minecraft.Client/Common/Network/Sony/PlatformNetworkManagerSony.h b/Minecraft.Client/Common/Network/Sony/PlatformNetworkManagerSony.h index 9131e89795..30a147e06f 100644 --- a/Minecraft.Client/Common/Network/Sony/PlatformNetworkManagerSony.h +++ b/Minecraft.Client/Common/Network/Sony/PlatformNetworkManagerSony.h @@ -48,7 +48,7 @@ class CPlatformNetworkManagerSony : public CPlatformNetworkManager, ISQRNetworkM virtual void SendInviteGUI(int quadrant); virtual bool IsAddingPlayer(); - virtual void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, unsigned char publicSlots = MINECRAFT_NET_MAX_PLAYERS, unsigned char privateSlots = 0); + virtual void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, int publicSlots = MINECRAFT_NET_MAX_PLAYERS, int privateSlots = 0); virtual int JoinGame(FriendSessionInfo *searchResult, int localUsersMask, int primaryUserIndex ); virtual bool SetLocalGame(bool isLocal); virtual bool IsLocalGame(); @@ -74,7 +74,7 @@ class CPlatformNetworkManagerSony : public CPlatformNetworkManager, ISQRNetworkM private: bool isSystemPrimaryPlayer(SQRNetworkPlayer *pQNetPlayer); virtual bool _LeaveGame(bool bMigrateHost, bool bLeaveRoom); - virtual void _HostGame(int dwUsersMask, unsigned char publicSlots = MINECRAFT_NET_MAX_PLAYERS, unsigned char privateSlots = 0); + virtual void _HostGame(int dwUsersMask, int publicSlots = MINECRAFT_NET_MAX_PLAYERS, int privateSlots = 0); virtual bool _StartGame(); #ifdef __PSVITA__ diff --git a/Minecraft.Client/Common/UI/IUIScene_PauseMenu.cpp b/Minecraft.Client/Common/UI/IUIScene_PauseMenu.cpp index e88ed08cdb..77b8caddf8 100644 --- a/Minecraft.Client/Common/UI/IUIScene_PauseMenu.cpp +++ b/Minecraft.Client/Common/UI/IUIScene_PauseMenu.cpp @@ -508,6 +508,10 @@ void IUIScene_PauseMenu::_ExitWorld(LPVOID lpParameter) exitReasonTitleId = IDS_CONNECTION_FAILED; break; #endif + case DisconnectPacket::eDisconnect_AuthFailed: + exitReasonStringId = IDS_DISCONNECTED; + exitReasonTitleId = IDS_CONNECTION_FAILED; + break; default: exitReasonStringId = IDS_CONNECTION_LOST_SERVER; } diff --git a/Minecraft.Client/Common/UI/NativeUIRenderer.cpp b/Minecraft.Client/Common/UI/NativeUIRenderer.cpp new file mode 100644 index 0000000000..a341606746 --- /dev/null +++ b/Minecraft.Client/Common/UI/NativeUIRenderer.cpp @@ -0,0 +1,2030 @@ +#include "stdafx.h" +#include "NativeUIRenderer.h" +#include "UI.h" +#include +#include +#include +#include +#pragma comment(lib, "windowscodecs.lib") + +#ifdef _WINDOWS64 +#include +#include "../../Windows64/KeyboardMouseInput.h" +#pragma comment(lib, "shell32.lib") +extern KeyboardMouseInput g_KBMInput; +extern HWND g_hWnd; +#endif + +#pragma comment(lib, "d3dcompiler.lib") + +extern ID3D11Device* g_pd3dDevice; +extern ID3D11DeviceContext* g_pImmediateContext; +extern ID3D11RenderTargetView* g_pRenderTargetView; +extern ID3D11DepthStencilView* g_pDepthStencilView; + +#include + +#include "../../Font.h" +#include "../../Textures.h" +#include "../../ResourceLocation.h" + + +// Internal constants & types + +namespace +{ + static constexpr float kVW = 1280.0f; + static constexpr float kVH = 720.0f; + static constexpr float kFontUnitH = 8.0f; + static constexpr int kMaxVerts = 8192; + static constexpr int kMaxClipStack = 16; + + inline Font* GetFont() + { + return Minecraft::GetInstance()->font; + } + + inline float ScaleForSize(float size) + { + return size / kFontUnitH; + } + + struct UIVertex + { + float x, y; + float r, g, b, a; + float u, v; + }; + + // Batch mode determines which pixel shader to use + enum BatchMode { BATCH_COLOR, BATCH_TEXT, BATCH_TEXTURE }; + + // --- D3D11 resources --- + static ID3D11VertexShader* s_pVS = nullptr; + static ID3D11PixelShader* s_pPS_Color = nullptr; + static ID3D11PixelShader* s_pPS_Tex = nullptr; // font alpha-only + static ID3D11PixelShader* s_pPS_TexFull = nullptr; // full RGBA texture + static ID3D11InputLayout* s_pInputLayout = nullptr; + static ID3D11Buffer* s_pVB = nullptr; + static ID3D11RasterizerState* s_pRastState = nullptr; + static ID3D11RasterizerState* s_pRastScissor = nullptr; + static ID3D11DepthStencilState* s_pDepthState = nullptr; + static ID3D11BlendState* s_pBlendState = nullptr; + static ID3D11SamplerState* s_pSampler = nullptr; // point (fonts) + static ID3D11SamplerState* s_pSamplerLinear = nullptr; // bilinear (textures) + static bool s_initialized = false; + + // Batching + static UIVertex s_vertices[kMaxVerts]; + static int s_vertCount = 0; + static bool s_inFrame = false; + static BatchMode s_batchMode = BATCH_COLOR; + static int s_batchTexId = -1; // for BATCH_TEXTURE mode + + // File texture cache: path → {texId, width, height} + struct FileTexEntry { int id; int w; int h; }; + static std::unordered_map s_fileTexCache; + + // Clip stack + struct ClipRect { float x, y, w, h; }; + static ClipRect s_clipStack[kMaxClipStack]; + static int s_clipDepth = 0; + + // Backbuffer dimensions (cached per frame) + static float s_bbWidth = kVW; + static float s_bbHeight = kVH; + + // 16:9 viewport within the backbuffer (pillarboxed/letterboxed) + static float s_vpX = 0.0f; // viewport offset X in backbuffer pixels + static float s_vpY = 0.0f; // viewport offset Y in backbuffer pixels + static float s_vpW = kVW; // viewport width in backbuffer pixels + static float s_vpH = kVH; // viewport height in backbuffer pixels + + // Saved D3D11 state + static ID3D11RenderTargetView* s_savedRTV = nullptr; + static ID3D11DepthStencilView* s_savedDSV = nullptr; + static D3D11_VIEWPORT s_savedViewport = {}; + static ID3D11RasterizerState* s_savedRast = nullptr; + static ID3D11DepthStencilState* s_savedDepth = nullptr; + static UINT s_savedStencilRef = 0; + static ID3D11BlendState* s_savedBlend = nullptr; + static float s_savedBlendFactor[4] = {}; + static UINT s_savedSampleMask = 0; + static ID3D11VertexShader* s_savedVS = nullptr; + static ID3D11PixelShader* s_savedPS = nullptr; + static ID3D11InputLayout* s_savedIL = nullptr; + static D3D11_PRIMITIVE_TOPOLOGY s_savedTopo = D3D11_PRIMITIVE_TOPOLOGY_UNDEFINED; + + + // HLSL shaders + + static const char* s_vsCode = + "struct VS_IN { float2 pos : POSITION; float4 col : COLOR; float2 uv : TEXCOORD0; };\n" + "struct VS_OUT { float4 pos : SV_Position; float4 col : COLOR; float2 uv : TEXCOORD0; };\n" + "VS_OUT main(VS_IN i)\n" + "{\n" + " VS_OUT o;\n" + " o.pos.x = i.pos.x / 640.0 - 1.0;\n" + " o.pos.y = 1.0 - i.pos.y / 360.0;\n" + " o.pos.z = 0.0;\n" + " o.pos.w = 1.0;\n" + " o.col = i.col;\n" + " o.uv = i.uv;\n" + " return o;\n" + "}\n"; + + static const char* s_psColorCode = + "struct PS_IN { float4 pos : SV_Position; float4 col : COLOR; float2 uv : TEXCOORD0; };\n" + "float4 main(PS_IN i) : SV_Target { return i.col; }\n"; + + // Font text: sample alpha from atlas, color from vertex + static const char* s_psTexCode = + "Texture2D tex : register(t0);\n" + "SamplerState samp : register(s0);\n" + "struct PS_IN { float4 pos : SV_Position; float4 col : COLOR; float2 uv : TEXCOORD0; };\n" + "float4 main(PS_IN i) : SV_Target\n" + "{\n" + " float4 t = tex.Sample(samp, i.uv);\n" + " return float4(i.col.rgb, i.col.a * t.a);\n" + "}\n"; + + // Full-color texture: sample RGBA from texture, multiply by vertex color (tint) + static const char* s_psTexFullCode = + "Texture2D tex : register(t0);\n" + "SamplerState samp : register(s0);\n" + "struct PS_IN { float4 pos : SV_Position; float4 col : COLOR; float2 uv : TEXCOORD0; };\n" + "float4 main(PS_IN i) : SV_Target\n" + "{\n" + " float4 t = tex.Sample(samp, i.uv);\n" + " return t * i.col;\n" + "}\n"; + + + // Helpers + + static void DecodeColor(uint32_t color, float& r, float& g, float& b, float& a) + { + a = ((color >> 24) & 0xFF) / 255.0f; + r = ((color >> 16) & 0xFF) / 255.0f; + g = ((color >> 8) & 0xFF) / 255.0f; + b = ((color ) & 0xFF) / 255.0f; + } + + static ID3D11PixelShader* CompilePS(const char* code, const char* name) + { + ID3DBlob* blob = nullptr; + ID3DBlob* err = nullptr; + HRESULT hr = D3DCompile(code, strlen(code), name, nullptr, nullptr, + "main", "ps_4_0", D3DCOMPILE_ENABLE_STRICTNESS, 0, &blob, &err); + if (FAILED(hr)) { if (err) err->Release(); return nullptr; } + if (err) err->Release(); + + ID3D11PixelShader* ps = nullptr; + hr = g_pd3dDevice->CreatePixelShader(blob->GetBufferPointer(), + blob->GetBufferSize(), nullptr, &ps); + blob->Release(); + return SUCCEEDED(hr) ? ps : nullptr; + } + + + // Init / Shutdown + + static void InitD3D() + { + if (s_initialized || !g_pd3dDevice) return; + + // Clean up any leftover objects from a previous failed attempt + auto SafeRelease = [](auto*& p) { if (p) { p->Release(); p = nullptr; } }; + SafeRelease(s_pVS); + SafeRelease(s_pInputLayout); + SafeRelease(s_pPS_Color); + SafeRelease(s_pPS_Tex); + SafeRelease(s_pPS_TexFull); + SafeRelease(s_pVB); + SafeRelease(s_pRastState); + SafeRelease(s_pRastScissor); + SafeRelease(s_pDepthState); + SafeRelease(s_pBlendState); + SafeRelease(s_pSampler); + SafeRelease(s_pSamplerLinear); + + HRESULT hr; + ID3DBlob* vsBlob = nullptr; + ID3DBlob* err = nullptr; + + hr = D3DCompile(s_vsCode, strlen(s_vsCode), "NativeUI_VS", nullptr, nullptr, + "main", "vs_4_0", D3DCOMPILE_ENABLE_STRICTNESS, 0, &vsBlob, &err); + if (FAILED(hr)) { if (err) err->Release(); return; } + if (err) { err->Release(); err = nullptr; } + + hr = g_pd3dDevice->CreateVertexShader(vsBlob->GetBufferPointer(), + vsBlob->GetBufferSize(), nullptr, &s_pVS); + if (FAILED(hr)) { vsBlob->Release(); return; } + + D3D11_INPUT_ELEMENT_DESC layout[] = { + { "POSITION", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 }, + { "COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, sizeof(float) * 2, D3D11_INPUT_PER_VERTEX_DATA, 0 }, + { "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, sizeof(float) * 6, D3D11_INPUT_PER_VERTEX_DATA, 0 }, + }; + hr = g_pd3dDevice->CreateInputLayout(layout, 3, vsBlob->GetBufferPointer(), + vsBlob->GetBufferSize(), &s_pInputLayout); + vsBlob->Release(); + if (FAILED(hr)) return; + + s_pPS_Color = CompilePS(s_psColorCode, "NativeUI_PS_Color"); + if (!s_pPS_Color) return; + + s_pPS_Tex = CompilePS(s_psTexCode, "NativeUI_PS_Tex"); + if (!s_pPS_Tex) return; + + s_pPS_TexFull = CompilePS(s_psTexFullCode, "NativeUI_PS_TexFull"); + if (!s_pPS_TexFull) return; + + // Dynamic vertex buffer + D3D11_BUFFER_DESC vbDesc = {}; + vbDesc.ByteWidth = sizeof(s_vertices); + vbDesc.Usage = D3D11_USAGE_DYNAMIC; + vbDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER; + vbDesc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; + hr = g_pd3dDevice->CreateBuffer(&vbDesc, nullptr, &s_pVB); + if (FAILED(hr)) return; + + // Rasterizer — no scissor + D3D11_RASTERIZER_DESC rasDesc = {}; + rasDesc.FillMode = D3D11_FILL_SOLID; + rasDesc.CullMode = D3D11_CULL_NONE; + rasDesc.DepthClipEnable = FALSE; + rasDesc.ScissorEnable = FALSE; + g_pd3dDevice->CreateRasterizerState(&rasDesc, &s_pRastState); + + // Rasterizer — with scissor + rasDesc.ScissorEnable = TRUE; + g_pd3dDevice->CreateRasterizerState(&rasDesc, &s_pRastScissor); + + // Depth off + D3D11_DEPTH_STENCIL_DESC dsDesc = {}; + dsDesc.DepthEnable = FALSE; + dsDesc.StencilEnable = FALSE; + g_pd3dDevice->CreateDepthStencilState(&dsDesc, &s_pDepthState); + + // Alpha blending + D3D11_BLEND_DESC blendDesc = {}; + blendDesc.RenderTarget[0].BlendEnable = TRUE; + blendDesc.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC_ALPHA; + blendDesc.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA; + blendDesc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD; + blendDesc.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE; + blendDesc.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_INV_SRC_ALPHA; + blendDesc.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD; + blendDesc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL; + g_pd3dDevice->CreateBlendState(&blendDesc, &s_pBlendState); + + // Point sampler for crisp pixel font + D3D11_SAMPLER_DESC sampDesc = {}; + sampDesc.Filter = D3D11_FILTER_MIN_MAG_MIP_POINT; + sampDesc.AddressU = D3D11_TEXTURE_ADDRESS_CLAMP; + sampDesc.AddressV = D3D11_TEXTURE_ADDRESS_CLAMP; + sampDesc.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; + g_pd3dDevice->CreateSamplerState(&sampDesc, &s_pSampler); + + // Bilinear sampler for texture rendering + D3D11_SAMPLER_DESC sampLinDesc = {}; + sampLinDesc.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; + sampLinDesc.AddressU = D3D11_TEXTURE_ADDRESS_CLAMP; + sampLinDesc.AddressV = D3D11_TEXTURE_ADDRESS_CLAMP; + sampLinDesc.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; + g_pd3dDevice->CreateSamplerState(&sampLinDesc, &s_pSamplerLinear); + + s_initialized = true; + } + + // Bind the right texture SRV for the current batch mode + static void BindBatchTexture(ID3D11DeviceContext* ctx) + { + if (s_batchMode == BATCH_TEXT) + { + Font* font = GetFont(); + if (font) + { + ResourceLocation* loc = font->getTextureLocation(); + if (loc && loc->isPreloaded()) + { + int texId = font->getTextures()->loadTexture(loc->getTexture()); + ID3D11ShaderResourceView* srv = RenderManager.TextureGetTexture(texId); + if (srv) + { + ctx->PSSetShaderResources(0, 1, &srv); + ctx->PSSetSamplers(0, 1, &s_pSampler); // point sampler for fonts + } + } + } + } + else if (s_batchMode == BATCH_TEXTURE && s_batchTexId >= 0) + { + ID3D11ShaderResourceView* srv = RenderManager.TextureGetTexture(s_batchTexId); + if (srv) + { + ctx->PSSetShaderResources(0, 1, &srv); + ctx->PSSetSamplers(0, 1, &s_pSampler); // nearest (point) for textures + } + } + } + + static void FlushBatch() + { + if (s_vertCount == 0 || !s_initialized) return; + + ID3D11DeviceContext* ctx = g_pImmediateContext; + + D3D11_MAPPED_SUBRESOURCE mapped; + HRESULT hr = ctx->Map(s_pVB, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped); + if (FAILED(hr)) { s_vertCount = 0; return; } + memcpy(mapped.pData, s_vertices, s_vertCount * sizeof(UIVertex)); + ctx->Unmap(s_pVB, 0); + + if (s_batchMode == BATCH_COLOR) + ctx->PSSetShader(s_pPS_Color, nullptr, 0); + else if (s_batchMode == BATCH_TEXT) + { + ctx->PSSetShader(s_pPS_Tex, nullptr, 0); // alpha-only font shader + BindBatchTexture(ctx); + } + else // BATCH_TEXTURE + { + ctx->PSSetShader(s_pPS_TexFull, nullptr, 0); // full RGBA texture shader + BindBatchTexture(ctx); + } + + ctx->Draw(s_vertCount, 0); + s_vertCount = 0; + } + + static void EnsureBatchMode(BatchMode mode, int texId = -1) + { + if (s_vertCount > 0 && (s_batchMode != mode || (mode == BATCH_TEXTURE && s_batchTexId != texId))) + FlushBatch(); + s_batchMode = mode; + s_batchTexId = texId; + } + + + // State save/restore + + static void SaveD3DState() + { + ID3D11DeviceContext* ctx = g_pImmediateContext; + ctx->OMGetRenderTargets(1, &s_savedRTV, &s_savedDSV); + UINT numVP = 1; + ctx->RSGetViewports(&numVP, &s_savedViewport); + ctx->RSGetState(&s_savedRast); + ctx->OMGetDepthStencilState(&s_savedDepth, &s_savedStencilRef); + ctx->OMGetBlendState(&s_savedBlend, s_savedBlendFactor, &s_savedSampleMask); + ctx->VSGetShader(&s_savedVS, nullptr, nullptr); + ctx->PSGetShader(&s_savedPS, nullptr, nullptr); + ctx->IAGetInputLayout(&s_savedIL); + ctx->IAGetPrimitiveTopology(&s_savedTopo); + } + + static void RestoreD3DState() + { + ID3D11DeviceContext* ctx = g_pImmediateContext; + ctx->OMSetRenderTargets(1, &s_savedRTV, s_savedDSV); + ctx->RSSetViewports(1, &s_savedViewport); + ctx->RSSetState(s_savedRast); + ctx->OMSetDepthStencilState(s_savedDepth, s_savedStencilRef); + ctx->OMSetBlendState(s_savedBlend, s_savedBlendFactor, s_savedSampleMask); + ctx->VSSetShader(s_savedVS, nullptr, 0); + ctx->PSSetShader(s_savedPS, nullptr, 0); + ctx->IASetInputLayout(s_savedIL); + ctx->IASetPrimitiveTopology(s_savedTopo); + + if (s_savedRTV) { s_savedRTV->Release(); s_savedRTV = nullptr; } + if (s_savedDSV) { s_savedDSV->Release(); s_savedDSV = nullptr; } + if (s_savedRast) { s_savedRast->Release(); s_savedRast = nullptr; } + if (s_savedDepth) { s_savedDepth->Release(); s_savedDepth = nullptr; } + if (s_savedBlend) { s_savedBlend->Release(); s_savedBlend = nullptr; } + if (s_savedVS) { s_savedVS->Release(); s_savedVS = nullptr; } + if (s_savedPS) { s_savedPS->Release(); s_savedPS = nullptr; } + if (s_savedIL) { s_savedIL->Release(); s_savedIL = nullptr; } + } + + static void SetupD3DPipeline() + { + ID3D11DeviceContext* ctx = g_pImmediateContext; + + ctx->OMSetRenderTargets(1, &g_pRenderTargetView, nullptr); + + ID3D11Resource* rtvResource = nullptr; + g_pRenderTargetView->GetResource(&rtvResource); + ID3D11Texture2D* rtvTex = nullptr; + rtvResource->QueryInterface(__uuidof(ID3D11Texture2D), (void**)&rtvTex); + rtvResource->Release(); + + if (rtvTex) + { + D3D11_TEXTURE2D_DESC desc; + rtvTex->GetDesc(&desc); + s_bbWidth = (float)desc.Width; + s_bbHeight = (float)desc.Height; + rtvTex->Release(); + } + else + { + s_bbWidth = kVW; + s_bbHeight = kVH; + } + + // Compute a 16:9 viewport centered in the backbuffer (pillarbox on ultrawide) + { + float bbAspect = s_bbWidth / s_bbHeight; + constexpr float targetAspect = kVW / kVH; // 16:9 + + if (bbAspect > targetAspect) + { + // Wider than 16:9 → pillarbox (bars on left/right) + s_vpH = s_bbHeight; + s_vpW = s_bbHeight * targetAspect; + s_vpX = (s_bbWidth - s_vpW) * 0.5f; + s_vpY = 0.0f; + } + else if (bbAspect < targetAspect) + { + // Taller than 16:9 → letterbox (bars on top/bottom) + s_vpW = s_bbWidth; + s_vpH = s_bbWidth / targetAspect; + s_vpX = 0.0f; + s_vpY = (s_bbHeight - s_vpH) * 0.5f; + } + else + { + // Exactly 16:9 + s_vpX = 0.0f; + s_vpY = 0.0f; + s_vpW = s_bbWidth; + s_vpH = s_bbHeight; + } + } + + D3D11_VIEWPORT vp = {}; + vp.TopLeftX = s_vpX; + vp.TopLeftY = s_vpY; + vp.Width = s_vpW; + vp.Height = s_vpH; + vp.MinDepth = 0.0f; + vp.MaxDepth = 1.0f; + ctx->RSSetViewports(1, &vp); + + ctx->IASetInputLayout(s_pInputLayout); + ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + + UINT stride = sizeof(UIVertex); + UINT offset = 0; + ctx->IASetVertexBuffers(0, 1, &s_pVB, &stride, &offset); + + ctx->VSSetShader(s_pVS, nullptr, 0); + ctx->PSSetShader(s_pPS_Color, nullptr, 0); + ctx->RSSetState(s_pRastState); + ctx->OMSetDepthStencilState(s_pDepthState, 0); + + constexpr float bf[4] = { 0, 0, 0, 0 }; + ctx->OMSetBlendState(s_pBlendState, bf, 0xFFFFFFFF); + } + + + // Vertex push helpers + + static void PushQuad(float x0, float y0, float x1, float y1, + float r, float g, float b, float a, + float u0, float v0, float u1, float v1) + { + if (s_vertCount + 6 > kMaxVerts) FlushBatch(); + + UIVertex* v = &s_vertices[s_vertCount]; + v[0] = { x0, y0, r, g, b, a, u0, v0 }; + v[1] = { x1, y0, r, g, b, a, u1, v0 }; + v[2] = { x0, y1, r, g, b, a, u0, v1 }; + v[3] = { x0, y1, r, g, b, a, u0, v1 }; + v[4] = { x1, y0, r, g, b, a, u1, v0 }; + v[5] = { x1, y1, r, g, b, a, u1, v1 }; + s_vertCount += 6; + } + + // Gradient quad — top color to bottom color + static void PushGradientQuad(float x0, float y0, float x1, float y1, + float tr, float tg, float tb, float ta, + float br, float bg, float bb, float ba) + { + if (s_vertCount + 6 > kMaxVerts) FlushBatch(); + + UIVertex* v = &s_vertices[s_vertCount]; + v[0] = { x0, y0, tr, tg, tb, ta, 0, 0 }; + v[1] = { x1, y0, tr, tg, tb, ta, 0, 0 }; + v[2] = { x0, y1, br, bg, bb, ba, 0, 0 }; + v[3] = { x0, y1, br, bg, bb, ba, 0, 0 }; + v[4] = { x1, y0, tr, tg, tb, ta, 0, 0 }; + v[5] = { x1, y1, br, bg, bb, ba, 0, 0 }; + s_vertCount += 6; + } + + + // Clip rect helpers + + static void ApplyScissor() + { + ID3D11DeviceContext* ctx = g_pImmediateContext; + if (s_clipDepth <= 0) + { + ctx->RSSetState(s_pRastState); + return; + } + + ctx->RSSetState(s_pRastScissor); + + // Convert virtual coords to backbuffer pixels using the 16:9 viewport + const ClipRect& c = s_clipStack[s_clipDepth - 1]; + float scaleX = s_vpW / kVW; + float scaleY = s_vpH / kVH; + + D3D11_RECT r; + r.left = (LONG)(s_vpX + c.x * scaleX); + r.top = (LONG)(s_vpY + c.y * scaleY); + r.right = (LONG)(s_vpX + (c.x + c.w) * scaleX); + r.bottom = (LONG)(s_vpY + (c.y + c.h) * scaleY); + ctx->RSSetScissorRects(1, &r); + } + + + // Glyph rendering + + static void DrawGlyphString(Font* font, const std::wstring& str, + float startX, float startY, float scale, + float cr, float cg, float cb, float ca) + { + EnsureBatchMode(BATCH_TEXT); + + const int cols = font->getCols(); + const int cw = font->getCharWidth(); + const int ch = font->getCharHeight(); + const float atlasW = (float)(cols * cw); + const float atlasH = (float)(font->getRows() * ch); + + float curX = startX; + + for (size_t i = 0; i < str.length(); ++i) + { + wchar_t c = str[i]; + + // Skip section sign format codes + if (c == 167 && i + 1 < str.length()) + { + ++i; + continue; + } + + wchar_t mapped = font->mapChar(c); + int charW = font->getCharPixelWidth(c); + + float uOff = (float)(mapped % cols * cw); + float vOff = (float)(mapped / cols * ch); + + float u0 = uOff / atlasW; + float v0 = vOff / atlasH; + float u1 = (uOff + (float)charW - 0.01f) / atlasW; + float v1 = (vOff + 7.99f) / atlasH; + + float gx0 = curX; + float gy0 = startY; + float gx1 = curX + ((float)charW - 0.01f) * scale; + float gy1 = startY + (float)ch * scale; + + PushQuad(gx0, gy0, gx1, gy1, cr, cg, cb, ca, u0, v0, u1, v1); + + curX += (float)charW * scale; + } + } + + static void ApplyAlignment(Font* font, const std::wstring& wstr, + float x, float y, + float scale, uint32_t align, + float& drawX, float& drawY) + { + float textW = static_cast(font->width(wstr)) * scale; + float textH = kFontUnitH * scale; + + drawX = x; + if (align & NativeUI::ALIGN_CENTER_X) drawX = x - textW * 0.5f; + else if (align & NativeUI::ALIGN_RIGHT) drawX = x - textW; + + drawY = y; + if (align & NativeUI::ALIGN_CENTER_Y) drawY = y - textH * 0.5f; + else if (align & NativeUI::ALIGN_BOTTOM) drawY = y - textH; + } +} + +// ======================================================================= +// NativeUI public API +// ======================================================================= +namespace NativeUI +{ + +// ---- Lifecycle -------------------------------------------------------- + +void BeginFrame() +{ + InitD3D(); + if (!s_initialized) return; + + SaveD3DState(); + SetupD3DPipeline(); + + s_vertCount = 0; + s_batchMode = BATCH_COLOR; + s_batchTexId = -1; + s_clipDepth = 0; + s_inFrame = true; +} + +void EndFrame() +{ + if (!s_inFrame) return; + + FlushBatch(); + + // Unbind SRV to avoid hazards + ID3D11ShaderResourceView* nullSrv = nullptr; + g_pImmediateContext->PSSetShaderResources(0, 1, &nullSrv); + + RestoreD3DState(); + s_inFrame = false; +} + +void Shutdown() +{ + auto SafeRelease = [](auto*& p) { if (p) { p->Release(); p = nullptr; } }; + SafeRelease(s_pBlendState); + SafeRelease(s_pDepthState); + SafeRelease(s_pRastState); + SafeRelease(s_pRastScissor); + SafeRelease(s_pSampler); + SafeRelease(s_pSamplerLinear); + SafeRelease(s_pVB); + SafeRelease(s_pInputLayout); + SafeRelease(s_pPS_TexFull); + SafeRelease(s_pPS_Tex); + SafeRelease(s_pPS_Color); + SafeRelease(s_pVS); + s_initialized = false; +} + +// ---- Primitives ------------------------------------------------------- + +void DrawRect(float x, float y, float w, float h, uint32_t color) +{ + if (!s_inFrame) return; + EnsureBatchMode(BATCH_COLOR); + float r, g, b, a; + DecodeColor(color, r, g, b, a); + PushQuad(x, y, x + w, y + h, r, g, b, a, 0, 0, 0, 0); +} + +void DrawRectFullscreen(uint32_t color) +{ + if (!s_inFrame) return; + + // Flush pending geometry that uses the 16:9 viewport + FlushBatch(); + + // Switch to full-backbuffer viewport + D3D11_VIEWPORT fullVP = {}; + fullVP.TopLeftX = 0; + fullVP.TopLeftY = 0; + fullVP.Width = s_bbWidth; + fullVP.Height = s_bbHeight; + fullVP.MinDepth = 0.0f; + fullVP.MaxDepth = 1.0f; + g_pImmediateContext->RSSetViewports(1, &fullVP); + + // Emit a fullscreen quad (in 1280x720 virtual, which the shader maps to NDC) + EnsureBatchMode(BATCH_COLOR); + float r, g, b, a; + DecodeColor(color, r, g, b, a); + PushQuad(0, 0, kVW, kVH, r, g, b, a, 0, 0, 0, 0); + FlushBatch(); + + // Restore the 16:9 centered viewport + D3D11_VIEWPORT vp = {}; + vp.TopLeftX = s_vpX; + vp.TopLeftY = s_vpY; + vp.Width = s_vpW; + vp.Height = s_vpH; + vp.MinDepth = 0.0f; + vp.MaxDepth = 1.0f; + g_pImmediateContext->RSSetViewports(1, &vp); +} + +static void EmitCornerFans(const float cx[4], const float cy[4], + const float startAngle[4], float radius, + const float cr[4], const float cg[4], + const float cb[4], const float ca[4]) +{ + static constexpr int kSeg = 6; + static constexpr float kHalfPi = 1.5707963f; + + for (int corner = 0; corner < 4; ++corner) + { + for (int seg = 0; seg < kSeg; ++seg) + { + float a0 = startAngle[corner] + kHalfPi * seg / kSeg; + float a1 = startAngle[corner] + kHalfPi * (seg + 1) / kSeg; + + float px0 = cx[corner] + cosf(a0) * radius; + float py0 = cy[corner] + sinf(a0) * radius; + float px1 = cx[corner] + cosf(a1) * radius; + float py1 = cy[corner] + sinf(a1) * radius; + + if (s_vertCount + 3 > kMaxVerts) FlushBatch(); + UIVertex* v = &s_vertices[s_vertCount]; + v[0] = { cx[corner], cy[corner], cr[corner], cg[corner], cb[corner], ca[corner], 0, 0 }; + v[1] = { px0, py0, cr[corner], cg[corner], cb[corner], ca[corner], 0, 0 }; + v[2] = { px1, py1, cr[corner], cg[corner], cb[corner], ca[corner], 0, 0 }; + s_vertCount += 3; + } + } +} + +void DrawRoundedRect(float x, float y, float w, float h, + float radius, uint32_t color) +{ + if (!s_inFrame) return; + EnsureBatchMode(BATCH_COLOR); + + float r, g, b, a; + DecodeColor(color, r, g, b, a); + + // Clamp radius + float maxR = fminf(w, h) * 0.5f; + if (radius > maxR) radius = maxR; + + static constexpr float kHalfPi = 1.5707963f; + + // Center cross + PushQuad(x + radius, y, x + w - radius, y + h, r, g, b, a, 0, 0, 0, 0); + PushQuad(x, y + radius, x + radius, y + h - radius, r, g, b, a, 0, 0, 0, 0); + PushQuad(x + w - radius, y + radius, x + w, y + h - radius, r, g, b, a, 0, 0, 0, 0); + + // Corner fans (TL, TR, BL, BR) + float cx[4] = { x + radius, x + w - radius, x + radius, x + w - radius }; + float cy[4] = { y + radius, y + radius, y + h - radius, y + h - radius }; + float startAngle[4] = { kHalfPi * 2, kHalfPi * 3, kHalfPi, 0 }; + float cr[4] = { r, r, r, r }; + float cg[4] = { g, g, g, g }; + float cb[4] = { b, b, b, b }; + float ca[4] = { a, a, a, a }; + + EmitCornerFans(cx, cy, startAngle, radius, cr, cg, cb, ca); +} + +void DrawRoundedBorder(float x, float y, float w, float h, + float radius, float thickness, uint32_t color) +{ + if (!s_inFrame) return; + + // Draw as four rounded-rect strips (outer minus inner) + float maxR = fminf(w, h) * 0.5f; + if (radius > maxR) radius = maxR; + float ri = fmaxf(0.0f, radius - thickness); + + // Top edge + DrawRect(x + radius, y, w - 2 * radius, thickness, color); + // Bottom edge + DrawRect(x + radius, y + h - thickness, w - 2 * radius, thickness, color); + // Left edge + DrawRect(x, y + radius, thickness, h - 2 * radius, color); + // Right edge + DrawRect(x + w - thickness, y + radius, thickness, h - 2 * radius, color); + + // Corner arcs + EnsureBatchMode(BATCH_COLOR); + float r, g, b, a; + DecodeColor(color, r, g, b, a); + + static constexpr int kSeg = 6; + static constexpr float kHalfPi = 1.5707963f; + + float cx_[4] = { x + radius, x + w - radius, x + radius, x + w - radius }; + float cy_[4] = { y + radius, y + radius, y + h - radius, y + h - radius }; + float startA[4] = { kHalfPi * 2, kHalfPi * 3, kHalfPi, 0 }; + + for (int corner = 0; corner < 4; ++corner) + { + for (int seg = 0; seg < kSeg; ++seg) + { + float a0 = startA[corner] + kHalfPi * seg / kSeg; + float a1 = startA[corner] + kHalfPi * (seg + 1) / kSeg; + + // Outer edge + float ox0 = cx_[corner] + cosf(a0) * radius; + float oy0 = cy_[corner] + sinf(a0) * radius; + float ox1 = cx_[corner] + cosf(a1) * radius; + float oy1 = cy_[corner] + sinf(a1) * radius; + // Inner edge + float ix0 = cx_[corner] + cosf(a0) * ri; + float iy0 = cy_[corner] + sinf(a0) * ri; + float ix1 = cx_[corner] + cosf(a1) * ri; + float iy1 = cy_[corner] + sinf(a1) * ri; + + if (s_vertCount + 6 > kMaxVerts) FlushBatch(); + UIVertex* v = &s_vertices[s_vertCount]; + v[0] = { ox0, oy0, r, g, b, a, 0, 0 }; + v[1] = { ox1, oy1, r, g, b, a, 0, 0 }; + v[2] = { ix0, iy0, r, g, b, a, 0, 0 }; + v[3] = { ix0, iy0, r, g, b, a, 0, 0 }; + v[4] = { ox1, oy1, r, g, b, a, 0, 0 }; + v[5] = { ix1, iy1, r, g, b, a, 0, 0 }; + s_vertCount += 6; + } + } +} + +void DrawBorder(float x, float y, float w, float h, + float thickness, uint32_t color) +{ + DrawRect(x, y, w, thickness, color); + DrawRect(x, y + h - thickness, w, thickness, color); + DrawRect(x, y + thickness, thickness, h - 2.0f * thickness, color); + DrawRect(x + w - thickness, y + thickness, thickness, h - 2.0f * thickness, color); +} + +void DrawLine(float x, float y, float length, float thickness, uint32_t color) +{ + DrawRect(x, y, length, thickness, color); +} + +void DrawLineV(float x, float y, float length, float thickness, uint32_t color) +{ + DrawRect(x, y, thickness, length, color); +} + +void DrawGradientRect(float x, float y, float w, float h, + uint32_t topColor, uint32_t bottomColor) +{ + if (!s_inFrame) return; + EnsureBatchMode(BATCH_COLOR); + + float tr, tg, tb, ta, br, bg, bb, ba; + DecodeColor(topColor, tr, tg, tb, ta); + DecodeColor(bottomColor, br, bg, bb, ba); + PushGradientQuad(x, y, x + w, y + h, tr, tg, tb, ta, br, bg, bb, ba); +} + +void DrawGradientRoundedRect(float x, float y, float w, float h, + float radius, + uint32_t topColor, uint32_t bottomColor) +{ + if (!s_inFrame) return; + EnsureBatchMode(BATCH_COLOR); + + float maxR = fminf(w, h) * 0.5f; + if (radius > maxR) radius = maxR; + + float tr, tg, tb, ta, br, bg, bb, ba; + DecodeColor(topColor, tr, tg, tb, ta); + DecodeColor(bottomColor, br, bg, bb, ba); + + // Center cross with gradient + PushGradientQuad(x + radius, y, x + w - radius, y + h, + tr, tg, tb, ta, br, bg, bb, ba); + + // Left strip — interpolate color based on vertical position + float midTopY = y + radius; + float midBotY = y + h - radius; + PushGradientQuad(x, midTopY, x + radius, midBotY, + tr, tg, tb, ta, br, bg, bb, ba); + // Right strip + PushGradientQuad(x + w - radius, midTopY, x + w, midBotY, + tr, tg, tb, ta, br, bg, bb, ba); + + // Corners — use top color for TL/TR, bottom for BL/BR + static constexpr float kHalfPi = 1.5707963f; + + float cx_[4] = { x + radius, x + w - radius, x + radius, x + w - radius }; + float cy_[4] = { y + radius, y + radius, y + h - radius, y + h - radius }; + float startA[4] = { kHalfPi * 2, kHalfPi * 3, kHalfPi, 0 }; + // TL=top, TR=top, BL=bottom, BR=bottom + float cr[4] = { tr, tr, br, br }; + float cg[4] = { tg, tg, bg, bg }; + float cb[4] = { tb, tb, bb, bb }; + float ca[4] = { ta, ta, ba, ba }; + + EmitCornerFans(cx_, cy_, startA, radius, cr, cg, cb, ca); +} + +void DrawDropShadow(float x, float y, float w, float h, + float offset, float spread, uint32_t color) +{ + if (!s_inFrame) return; + + // Multi-layer shadow for soft appearance + float r, g, b, a; + DecodeColor(color, r, g, b, a); + + int layers = 3; + for (int i = 0; i < layers; ++i) + { + float t = (float)(i + 1) / (float)layers; + float expand = spread * t; + float off = offset * t; + float layerA = a * (1.0f - t * 0.6f) / (float)layers; + + DrawRect(x - expand + off, y - expand + off, + w + expand * 2, h + expand * 2, + ((uint32_t)(layerA * 255.0f) << 24) | (color & 0x00FFFFFFu)); + } +} + +void DrawPanel(float x, float y, float w, float h, + float radius, uint32_t bgColor, uint32_t borderColor, + float borderThick, bool shadow) +{ + if (shadow) + DrawDropShadow(x, y, w, h, 4.0f, 6.0f, 0x60000000u); + + if (radius > 0.0f) + { + DrawRoundedRect(x, y, w, h, radius, bgColor); + if (borderThick > 0.0f) + DrawRoundedBorder(x, y, w, h, radius, borderThick, borderColor); + } + else + { + DrawRect(x, y, w, h, bgColor); + if (borderThick > 0.0f) + DrawBorder(x, y, w, h, borderThick, borderColor); + } +} + +void DrawDivider(float x, float y, float w, uint32_t color, float thickness) +{ + DrawRect(x, y, w, thickness, color); +} + +// ---- Text ------------------------------------------------------------- + +void DrawText(float x, float y, const wchar_t* text, + uint32_t color, float size, uint32_t align) +{ + if (!text || !text[0] || !s_inFrame) return; + Font* font = GetFont(); + if (!font) return; + + std::wstring wstr = font->sanitize(std::wstring(text)); + const float scale = ScaleForSize(size); + + float drawX, drawY; + ApplyAlignment(font, wstr, x, y, scale, align, drawX, drawY); + + if ((color & 0xFC000000) == 0) color |= 0xFF000000; + float r, g, b, a; + DecodeColor(color, r, g, b, a); + DrawGlyphString(font, wstr, drawX, drawY, scale, r, g, b, a); +} + +void DrawShadowText(float x, float y, const wchar_t* text, + uint32_t color, float size, uint32_t align) +{ + if (!text || !text[0] || !s_inFrame) return; + Font* font = GetFont(); + if (!font) return; + + std::wstring wstr = font->sanitize(std::wstring(text)); + const float scale = ScaleForSize(size); + + float drawX, drawY; + ApplyAlignment(font, wstr, x, y, scale, align, drawX, drawY); + + if ((color & 0xFC000000) == 0) color |= 0xFF000000; + + uint32_t shadow = (color & 0xfcfcfc) >> 2 | (color & 0xFF000000); + float sr, sg, sb, sa; + DecodeColor(shadow, sr, sg, sb, sa); + DrawGlyphString(font, wstr, drawX + scale, drawY + scale, scale, sr, sg, sb, sa); + + float r, g, b, a; + DecodeColor(color, r, g, b, a); + DrawGlyphString(font, wstr, drawX, drawY, scale, r, g, b, a); +} + +float DrawTextWrapped(float x, float y, const wchar_t* text, + float maxWidth, uint32_t color, + float size, uint32_t align) +{ + if (!text || !text[0] || !s_inFrame) return 0.0f; + Font* font = GetFont(); + if (!font) return 0.0f; + + std::wstring wstr = font->sanitize(std::wstring(text)); + const float scale = ScaleForSize(size); + const float lineH = LineHeight(size); + + float curY = y; + size_t lineStart = 0; + size_t lastSpace = std::wstring::npos; + + for (size_t i = 0; i <= wstr.length(); ++i) + { + bool endOfString = (i == wstr.length()); + bool isSpace = !endOfString && wstr[i] == L' '; + + if (isSpace) lastSpace = i; + + // Measure current segment + std::wstring segment = wstr.substr(lineStart, i - lineStart); + float segW = static_cast(font->width(segment)) * scale; + + if (segW > maxWidth || endOfString) + { + size_t breakAt; + if (endOfString) + breakAt = i; + else if (lastSpace != std::wstring::npos && lastSpace > lineStart) + breakAt = lastSpace; + else + breakAt = i > lineStart ? i - 1 : i; + + std::wstring line = wstr.substr(lineStart, breakAt - lineStart); + if (!line.empty()) + { + float drawX, drawY; + ApplyAlignment(font, line, x, curY, scale, align, drawX, drawY); + + if ((color & 0xFC000000) == 0) color |= 0xFF000000; + float r, g, b, a; + DecodeColor(color, r, g, b, a); + DrawGlyphString(font, line, drawX, drawY, scale, r, g, b, a); + + curY += lineH; + } + + lineStart = breakAt; + // Skip the space at the break point + if (lineStart < wstr.length() && wstr[lineStart] == L' ') + ++lineStart; + lastSpace = std::wstring::npos; + i = lineStart; + } + } + + return curY - y; +} + +void MeasureText(const wchar_t* text, float size, + float* outWidth, float* outHeight) +{ + if (!text || !text[0]) + { + if (outWidth) *outWidth = 0.0f; + if (outHeight) *outHeight = 0.0f; + return; + } + Font* font = GetFont(); + if (!font) + { + if (outWidth) *outWidth = 0.0f; + if (outHeight) *outHeight = 0.0f; + return; + } + const float scale = ScaleForSize(size); + if (outWidth) *outWidth = static_cast(font->width(std::wstring(text))) * scale; + if (outHeight) *outHeight = size; +} + +float LineHeight(float size) +{ + return size + 2.0f * ScaleForSize(size); +} + +// ---- Clipping --------------------------------------------------------- + +void PushClipRect(float x, float y, float w, float h) +{ + if (!s_inFrame) return; + FlushBatch(); + + ClipRect newClip = { x, y, w, h }; + + // Intersect with parent clip + if (s_clipDepth > 0) + { + const ClipRect& parent = s_clipStack[s_clipDepth - 1]; + float nx0 = fmaxf(x, parent.x); + float ny0 = fmaxf(y, parent.y); + float nx1 = fminf(x + w, parent.x + parent.w); + float ny1 = fminf(y + h, parent.y + parent.h); + newClip.x = nx0; + newClip.y = ny0; + newClip.w = fmaxf(0.0f, nx1 - nx0); + newClip.h = fmaxf(0.0f, ny1 - ny0); + } + + if (s_clipDepth < kMaxClipStack) + s_clipStack[s_clipDepth++] = newClip; + + ApplyScissor(); +} + +void PopClipRect() +{ + if (!s_inFrame) return; + FlushBatch(); + + if (s_clipDepth > 0) --s_clipDepth; + ApplyScissor(); +} + +// ---- Widgets ---------------------------------------------------------- + +// Cached gui/gui.png texture ID +static int s_guiTexId = -2; // -2 = not yet loaded + +static int GetGuiTexture() +{ + if (s_guiTexId == -2) + s_guiTexId = LoadTexture(L"/gui/gui.png"); + return s_guiTexId; +} + +// Draw a 9-slice sub-region from gui.png using UV sub-rects. +// srcY/srcH in texel coords (256x256 atlas). +// capL/capR = horizontal border in texels, capT/capB = vertical border in texels. +// Borders are scaled uniformly (scale = h/srcH), only center stretches. +static void DrawGuiSlice(float x, float y, float w, float h, + int texId, float srcY, float srcH, + float capL = 2.0f, float capR = 2.0f, + float capT = 2.0f, float capB = 3.0f) +{ + const float texSz = 256.0f; + const float srcW = 200.0f; + + // Scale: each texel → this many virtual pixels + float scale = h / srcH; + float cL = capL * scale; + float cR = capR * scale; + float cT = capT * scale; + float cB = capB * scale; + + // Clamp if too small + if (cL + cR > w) { float s = w / (cL + cR); cL *= s; cR *= s; } + if (cT + cB > h) { float s = h / (cT + cB); cT *= s; cB *= s; } + + float midW = w - cL - cR; + float midH = h - cT - cB; + + // UV coordinates + float u0 = 0.0f; + float uL = capL / texSz; + float uR = (srcW - capR) / texSz; + float u1 = srcW / texSz; + + float v0 = srcY / texSz; + float vT = (srcY + capT) / texSz; + float vB = (srcY + srcH - capB) / texSz; + float v1 = (srcY + srcH) / texSz; + + // Top row + DrawTextureUV(x, y, cL, cT, texId, u0, v0, uL, vT); + DrawTextureUV(x + cL, y, midW, cT, texId, uL, v0, uR, vT); + DrawTextureUV(x + w - cR, y, cR, cT, texId, uR, v0, u1, vT); + + // Middle row + DrawTextureUV(x, y + cT, cL, midH, texId, u0, vT, uL, vB); + DrawTextureUV(x + cL, y + cT, midW, midH, texId, uL, vT, uR, vB); + DrawTextureUV(x + w - cR, y + cT, cR, midH, texId, uR, vT, u1, vB); + + // Bottom row + DrawTextureUV(x, y + h - cB, cL, cB, texId, u0, vB, uL, v1); + DrawTextureUV(x + cL, y + h - cB, midW, cB, texId, uL, vB, uR, v1); + DrawTextureUV(x + w - cR, y + h - cB, cR, cB, texId, uR, vB, u1, v1); +} + +void DrawButton(float x, float y, float w, float h, + const wchar_t* label, bool focused, bool hovered, + float labelSize) +{ + int texId = GetGuiTexture(); + + bool active = focused || hovered; + + if (texId >= 0) + { + // gui.png: button normal at y=66 (20px), hovered/focused at y=86 (20px) + float srcY = active ? 86.0f : 66.0f; + DrawGuiSlice(x, y, w, h, texId, srcY, 20.0f, 2.0f, 2.0f, 2.0f, 3.0f); + } + else + { + uint32_t bg = active ? 0xFF1B5A8Cu : 0xFF282828u; + DrawRoundedRect(x, y, w, h, 4.0f, bg); + } + + if (label && label[0]) + { + // White normally, yellow (#FFFF55) when focused/hovered — standard MC behavior + uint32_t textColor = active ? 0xFFFFFF55u : 0xFFFFFFFFu; + DrawShadowText(x + w * 0.5f, y + h * 0.5f, label, textColor, + labelSize, ALIGN_CENTER_X | ALIGN_CENTER_Y); + } +} + +// Cached texture IDs for reusable widgets +static int s_enchantBtnTex = -2; +static int s_sliderTrackTex = -2; +static int s_sliderBtnTex = -2; + +void DrawTextBox(float x, float y, float w, float h, uint32_t tint) +{ + if (s_enchantBtnTex == -2) + s_enchantBtnTex = LoadTextureFromFile( + "Common/Media/Graphics/EnchantmentButtonEmpty.png"); + + if (s_enchantBtnTex >= 0) + { + // EnchantmentButtonEmpty.png = 240x42, 3-slice with ~6 texel caps + float scale = h / 42.0f; + float capW = 6.0f * scale; + if (capW * 2 > w) capW = w * 0.5f; + float midW = w - capW * 2; + float uCap = 6.0f / 240.0f; + float uMid = (240.0f - 6.0f) / 240.0f; + + DrawTextureUV(x, y, capW, h, s_enchantBtnTex, 0.0f, 0.0f, uCap, 1.0f, tint); + DrawTextureUV(x + capW, y, midW, h, s_enchantBtnTex, uCap, 0.0f, uMid, 1.0f, tint); + DrawTextureUV(x + w - capW, y, capW, h, s_enchantBtnTex, uMid, 0.0f, 1.0f, 1.0f, tint); + } + else + { + DrawRoundedRect(x, y, w, h, 2.0f, 0xFF1A1A2Au); + DrawRoundedBorder(x, y, w, h, 2.0f, 1.0f, 0xFF444444u); + } +} + +void DrawProgressBar(float x, float y, float w, float h, + float progress, + uint32_t fillColor, uint32_t trackColor) +{ + float radius = h * 0.5f; + DrawRoundedRect(x, y, w, h, radius, trackColor); + float fill = w * (progress < 0.0f ? 0.0f : progress > 1.0f ? 1.0f : progress); + if (fill > radius * 2.0f) + DrawRoundedRect(x, y, fill, h, radius, fillColor); + else if (fill > 0.0f) + DrawRoundedRect(x, y, fmaxf(fill, h), h, radius, fillColor); +} + +void DrawSpinner(float cx, float cy, float radius, int tick, uint32_t color) +{ + static constexpr int kDots = 10; + static constexpr float kTwoPi = 6.28318530f; + + const uint32_t baseA = (color >> 24) & 0xFFu; + const uint32_t rgb = color & 0x00FFFFFFu; + + for (int i = 0; i < kDots; ++i) + { + const float angle = (float)i / kDots * kTwoPi - kTwoPi * 0.25f; + const float dx = cosf(angle) * radius; + const float dy = sinf(angle) * radius; + + const int age = (i - tick % kDots + kDots) % kDots; + const float t = 1.0f - (float)age / kDots; + // Scale dot size based on fade — leading dot is bigger + const float dotR = radius * (0.10f + 0.12f * t); + const uint32_t a = (uint32_t)(baseA * (0.15f + 0.85f * t)) & 0xFFu; + + DrawRoundedRect(cx + dx - dotR, cy + dy - dotR, + dotR * 2.0f, dotR * 2.0f, dotR, (a << 24) | rgb); + } +} + +void DrawCheckbox(float x, float y, float size, bool checked, bool focused, + bool hovered) +{ + uint32_t bgColor, borderColor; + float radius = 3.0f; + + if (focused) + { + bgColor = 0xFF1A3A55u; + borderColor = 0xFF4DC3FFu; + } + else if (hovered) + { + bgColor = 0xFF2A3A48u; + borderColor = 0xFF3DA8E0u; + } + else + { + bgColor = 0xFF222222u; + borderColor = 0xFF555555u; + } + + DrawRoundedRect(x, y, size, size, radius, bgColor); + DrawRoundedBorder(x, y, size, size, radius, 2.0f, borderColor); + + if (checked) + { + float pad = size * 0.22f; + DrawRoundedRect(x + pad, y + pad, size - pad * 2, size - pad * 2, + 2.0f, 0xFF4DC3FFu); + } +} + +void DrawSlider(float x, float y, float w, float h, + float value, bool focused, bool hovered, + uint32_t fillColor, uint32_t trackColor) +{ + value = value < 0.0f ? 0.0f : value > 1.0f ? 1.0f : value; + + // Load slider textures once + if (s_sliderTrackTex == -2) + s_sliderTrackTex = LoadTextureFromFile( + "Common/Media/Graphics/Slider_Track.png"); + if (s_sliderBtnTex == -2) + s_sliderBtnTex = LoadTextureFromFile( + "Common/Media/Graphics/Slider_Button.png"); + + if (s_sliderTrackTex >= 0) + { + // Slider_Track.png = 600x32, 3-slice with ~8 texel caps + float scale = h / 32.0f; + float capW = 8.0f * scale; + if (capW * 2 > w) capW = w * 0.5f; + float midW = w - capW * 2; + float uCap = 8.0f / 600.0f; + float uMid = (600.0f - 8.0f) / 600.0f; + + DrawTextureUV(x, y, capW, h, s_sliderTrackTex, 0.0f, 0.0f, uCap, 1.0f); + DrawTextureUV(x + capW, y, midW, h, s_sliderTrackTex, uCap, 0.0f, uMid, 1.0f); + DrawTextureUV(x + w - capW, y, capW, h, s_sliderTrackTex, uMid, 0.0f, 1.0f, 1.0f); + } + else + { + // Fallback + float radius = h * 0.5f; + DrawRoundedRect(x, y, w, h, radius, trackColor); + } + + // Thumb — Slider_Button.png = 16x32 + if (s_sliderBtnTex >= 0) + { + // Scale thumb to match track height, keep aspect ratio (16:32 = 1:2) + float thumbH = h; + float thumbW = thumbH * (16.0f / 32.0f); + float thumbX = x + value * (w - thumbW); + if (thumbX < x) thumbX = x; + if (thumbX + thumbW > x + w) thumbX = x + w - thumbW; + + DrawTexture(thumbX, y, thumbW, thumbH, s_sliderBtnTex); + } + else + { + // Fallback thumb + float thumbR = h * 0.8f; + float thumbX = x + w * value; + if (thumbX < x + thumbR) thumbX = x + thumbR; + if (thumbX > x + w - thumbR) thumbX = x + w - thumbR; + + uint32_t thumbColor = (focused || hovered) ? 0xFFFFFFFFu : 0xFFCCCCCCu; + DrawRoundedRect(thumbX - thumbR, y + h * 0.5f - thumbR, + thumbR * 2, thumbR * 2, thumbR, thumbColor); + } +} + +void DrawTooltip(float x, float y, const wchar_t* text, float size) +{ + if (!text || !text[0]) return; + + float tw, th; + MeasureText(text, size, &tw, &th); + + float padX = 10.0f, padY = 6.0f; + float boxW = tw + padX * 2; + float boxH = th + padY * 2; + float radius = 4.0f; + + // Position above the point, centered + float bx = x - boxW * 0.5f; + float by = y - boxH - 6.0f; + + DrawDropShadow(bx, by, boxW, boxH, 3.0f, 4.0f, 0x50000000u); + DrawRoundedRect(bx, by, boxW, boxH, radius, 0xF0181818u); + DrawRoundedBorder(bx, by, boxW, boxH, radius, 1.0f, 0xFF555555u); + DrawShadowText(x, by + padY, text, 0xFFFFFFFFu, size, ALIGN_CENTER_X); +} + +// ---- Texture drawing -------------------------------------------------- + +int LoadTexture(const wchar_t* path) +{ + if (!path || !path[0]) return -1; + Minecraft* mc = Minecraft::GetInstance(); + if (!mc || !mc->textures) return -1; + + // loadTexture(TN_COUNT, path) loads from file and caches by path. + // Returns the RenderManager texture ID. + return mc->textures->loadTextureByPath(std::wstring(path)); +} + +int LoadTextureByName(int textureName) +{ + Minecraft* mc = Minecraft::GetInstance(); + if (!mc || !mc->textures) return -1; + return mc->textures->loadTexture(textureName); +} + +int LoadTextureFromFile(const char* filePath, int* outWidth, int* outHeight) +{ + if (!filePath || !filePath[0]) return -1; + + // Check cache first + std::string key(filePath); + auto it = s_fileTexCache.find(key); + if (it != s_fileTexCache.end()) + { + if (outWidth) *outWidth = it->second.w; + if (outHeight) *outHeight = it->second.h; + return it->second.id; + } + + // Load pixel data from file via RenderManager + D3DXIMAGE_INFO info; + ZeroMemory(&info, sizeof(info)); + int* pixelData = nullptr; + + HRESULT hr = RenderManager.LoadTextureData(filePath, &info, &pixelData); + if (FAILED(hr) || !pixelData) + return -1; + + int texId = RenderManager.TextureCreate(); + if (texId < 0) + { + free(pixelData); + return -1; + } + + RenderManager.TextureBind(texId); + RenderManager.TextureData(info.Width, info.Height, pixelData, 0); + free(pixelData); + + // Cache it + s_fileTexCache[key] = { texId, info.Width, info.Height }; + + if (outWidth) *outWidth = info.Width; + if (outHeight) *outHeight = info.Height; + return texId; +} + +int LoadTextureFromFileDirect(const char* filePath, int* outWidth, int* outHeight) +{ + if (!filePath || !filePath[0]) return -1; + + // Check cache + std::string key = std::string("$direct$") + filePath; + auto it = s_fileTexCache.find(key); + if (it != s_fileTexCache.end()) + { + if (outWidth) *outWidth = it->second.w; + if (outHeight) *outHeight = it->second.h; + return it->second.id; + } + + // Decode PNG via WIC into RGBA pixel buffer + HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + bool needUninit = SUCCEEDED(hr); + + IWICImagingFactory* factory = nullptr; + IWICBitmapDecoder* decoder = nullptr; + IWICBitmapFrameDecode*frame = nullptr; + IWICFormatConverter* converter = nullptr; + + auto cleanup = [&]() { + if (converter) converter->Release(); + if (frame) frame->Release(); + if (decoder) decoder->Release(); + if (factory) factory->Release(); + if (needUninit) CoUninitialize(); + }; + + hr = CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&factory)); + if (FAILED(hr)) { cleanup(); return -1; } + + // Convert narrow path to wide + int wlen = MultiByteToWideChar(CP_UTF8, 0, filePath, -1, nullptr, 0); + std::vector wpath(wlen); + MultiByteToWideChar(CP_UTF8, 0, filePath, -1, wpath.data(), wlen); + + hr = factory->CreateDecoderFromFilename(wpath.data(), nullptr, + GENERIC_READ, WICDecodeMetadataCacheOnDemand, &decoder); + if (FAILED(hr)) { cleanup(); return -1; } + + hr = decoder->GetFrame(0, &frame); + if (FAILED(hr)) { cleanup(); return -1; } + + UINT w = 0, h = 0; + frame->GetSize(&w, &h); + + // Convert to 32bpp RGBA (matches TEXTURE_FORMAT_RxGyBzAw) + hr = factory->CreateFormatConverter(&converter); + if (FAILED(hr)) { cleanup(); return -1; } + + converter->Initialize(frame, GUID_WICPixelFormat32bppRGBA, + WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom); + + UINT stride = w * 4; + std::vector pixels(stride * h); + converter->CopyPixels(nullptr, stride, (UINT)pixels.size(), pixels.data()); + + cleanup(); + + int texId = RenderManager.TextureCreate(); + if (texId < 0) return -1; + + RenderManager.TextureBind(texId); + RenderManager.TextureData(w, h, pixels.data(), 0); // TEXTURE_FORMAT_RxGyBzAw (default) + + s_fileTexCache[key] = { texId, (int)w, (int)h }; + if (outWidth) *outWidth = (int)w; + if (outHeight) *outHeight = (int)h; + return texId; +} + +NineSlice LoadNineSlice(const char* basePath) +{ + NineSlice ns = {}; + ns.valid = false; + if (!basePath || !basePath[0]) return ns; + + std::string base(basePath); + int cw = 0, ch = 0; + + // Try convention A first: _TL, _TM, _TR, _ML, _MM, _MR, _BL, _BM, _BR + ns.tl = LoadTextureFromFile((base + "_TL.png").c_str(), &cw, &ch); + + if (ns.tl >= 0) + { + // Convention A + ns.tm = LoadTextureFromFile((base + "_TM.png").c_str()); + ns.tr = LoadTextureFromFile((base + "_TR.png").c_str()); + ns.ml = LoadTextureFromFile((base + "_ML.png").c_str()); + ns.mm = LoadTextureFromFile((base + "_MM.png").c_str()); + ns.mr = LoadTextureFromFile((base + "_MR.png").c_str()); + ns.bl = LoadTextureFromFile((base + "_BL.png").c_str()); + ns.bm = LoadTextureFromFile((base + "_BM.png").c_str()); + ns.br = LoadTextureFromFile((base + "_BR.png").c_str()); + } + else + { + // Convention B: _Top_L, _Top_M, _Top_R, _Mid_L, _Mid_M, _Mid_R, _Bot_L, _Bot_M, _Bot_R + ns.tl = LoadTextureFromFile((base + "_Top_L.png").c_str(), &cw, &ch); + ns.tm = LoadTextureFromFile((base + "_Top_M.png").c_str()); + ns.tr = LoadTextureFromFile((base + "_Top_R.png").c_str()); + ns.ml = LoadTextureFromFile((base + "_Mid_L.png").c_str()); + ns.mm = LoadTextureFromFile((base + "_Mid_M.png").c_str()); + ns.mr = LoadTextureFromFile((base + "_Mid_R.png").c_str()); + ns.bl = LoadTextureFromFile((base + "_Bot_L.png").c_str()); + ns.bm = LoadTextureFromFile((base + "_Bot_M.png").c_str()); + ns.br = LoadTextureFromFile((base + "_Bot_R.png").c_str()); + } + + ns.cornerW = cw; + ns.cornerH = ch; + + ns.valid = (ns.tl >= 0 && ns.tm >= 0 && ns.tr >= 0 && + ns.ml >= 0 && ns.mm >= 0 && ns.mr >= 0 && + ns.bl >= 0 && ns.bm >= 0 && ns.br >= 0 && + cw > 0 && ch > 0); + + return ns; +} + +ThreeSlice LoadThreeSlice(const char* basePath) +{ + ThreeSlice ts = {}; + ts.valid = false; + if (!basePath || !basePath[0]) return ts; + + std::string base(basePath); + int cw = 0, ch = 0; + + ts.left = LoadTextureFromFile((base + "_Left.png").c_str(), &cw, &ch); + ts.mid = LoadTextureFromFile((base + "_Middle.png").c_str()); + ts.right = LoadTextureFromFile((base + "_Right.png").c_str()); + + ts.capW = cw; + ts.capH = ch; + + ts.valid = (ts.left >= 0 && ts.mid >= 0 && ts.right >= 0 && + cw > 0 && ch > 0); + + return ts; +} + +void DrawNineSlice(float x, float y, float w, float h, + const NineSlice& ns, uint32_t tint) +{ + if (!ns.valid || !s_inFrame) return; + + // Corner size in virtual pixels (scale from texture pixels) + float cw = (float)ns.cornerW; + float ch = (float)ns.cornerH; + + // Clamp corners if panel is too small + if (cw * 2 > w) cw = w * 0.5f; + if (ch * 2 > h) ch = h * 0.5f; + + float midW = w - cw * 2; + float midH = h - ch * 2; + + // Top row + DrawTexture(x, y, cw, ch, ns.tl, tint); + DrawTexture(x + cw, y, midW, ch, ns.tm, tint); + DrawTexture(x + w - cw, y, cw, ch, ns.tr, tint); + + // Middle row + DrawTexture(x, y + ch, cw, midH, ns.ml, tint); + DrawTexture(x + cw, y + ch, midW, midH, ns.mm, tint); + DrawTexture(x + w - cw, y + ch, cw, midH, ns.mr, tint); + + // Bottom row + DrawTexture(x, y + h - ch, cw, ch, ns.bl, tint); + DrawTexture(x + cw, y + h - ch, midW, ch, ns.bm, tint); + DrawTexture(x + w - cw, y + h - ch, cw, ch, ns.br, tint); +} + +void DrawThreeSlice(float x, float y, float w, float h, + const ThreeSlice& ts, uint32_t tint) +{ + if (!ts.valid || !s_inFrame) return; + + float capW = (float)ts.capW; + + // Clamp caps if strip is too narrow + if (capW * 2 > w) capW = w * 0.5f; + + float midW = w - capW * 2; + + DrawTexture(x, y, capW, h, ts.left, tint); + DrawTexture(x + capW, y, midW, h, ts.mid, tint); + DrawTexture(x + w - capW, y, capW, h, ts.right, tint); +} + +void DrawTexture(float x, float y, float w, float h, + int textureId, uint32_t tint) +{ + DrawTextureUV(x, y, w, h, textureId, 0.0f, 0.0f, 1.0f, 1.0f, tint); +} + +void DrawTextureUV(float x, float y, float w, float h, + int textureId, + float u0, float v0, float u1, float v1, + uint32_t tint) +{ + if (!s_inFrame || textureId < 0) return; + + EnsureBatchMode(BATCH_TEXTURE, textureId); + + float r, g, b, a; + DecodeColor(tint, r, g, b, a); + PushQuad(x, y, x + w, y + h, r, g, b, a, u0, v0, u1, v1); +} + +void DrawTextureRounded(float x, float y, float w, float h, + int textureId, float radius, uint32_t tint) +{ + if (!s_inFrame || textureId < 0) return; + + // Use clip rect to approximate rounded corners + // Draw full texture, clipped to the rounded region + PushClipRect(x + radius, y, w - radius * 2, h); + DrawTexture(x, y, w, h, textureId, tint); + PopClipRect(); + + // Left/right strips (excluding corners) + PushClipRect(x, y + radius, radius, h - radius * 2); + DrawTexture(x, y, w, h, textureId, tint); + PopClipRect(); + + PushClipRect(x + w - radius, y + radius, radius, h - radius * 2); + DrawTexture(x, y, w, h, textureId, tint); + PopClipRect(); + + // Corner arcs — approximate with small clip rects + static constexpr int kSteps = 8; + static constexpr float kHalfPi = 1.5707963f; + + float cx_[4] = { x + radius, x + w - radius, x + radius, x + w - radius }; + float cy_[4] = { y + radius, y + radius, y + h - radius, y + h - radius }; + float startA[4] = { kHalfPi * 2, kHalfPi * 3, kHalfPi, 0 }; + + for (int corner = 0; corner < 4; ++corner) + { + for (int step = 0; step < kSteps; ++step) + { + float a0 = startA[corner] + kHalfPi * step / kSteps; + float a1 = startA[corner] + kHalfPi * (step + 1) / kSteps; + + // Bounding box of this arc slice + float px0 = cx_[corner] + fminf(0.0f, fminf(cosf(a0), cosf(a1))) * radius; + float py0 = cy_[corner] + fminf(0.0f, fminf(sinf(a0), sinf(a1))) * radius; + float px1 = cx_[corner] + fmaxf(0.0f, fmaxf(cosf(a0), cosf(a1))) * radius; + float py1 = cy_[corner] + fmaxf(0.0f, fmaxf(sinf(a0), sinf(a1))) * radius; + + if (px1 > px0 && py1 > py0) + { + PushClipRect(px0, py0, px1 - px0, py1 - py0); + DrawTexture(x, y, w, h, textureId, tint); + PopClipRect(); + } + } + } +} + +void DrawTextureFit(float x, float y, float w, float h, + int textureId, int texW, int texH, + uint32_t tint) +{ + if (!s_inFrame || textureId < 0 || texW <= 0 || texH <= 0) return; + + float aspect = (float)texW / (float)texH; + float boxAspect = w / h; + + float drawW, drawH; + if (aspect > boxAspect) + { + // Texture is wider — fit to width + drawW = w; + drawH = w / aspect; + } + else + { + // Texture is taller — fit to height + drawH = h; + drawW = h * aspect; + } + + float drawX = x + (w - drawW) * 0.5f; + float drawY = y + (h - drawH) * 0.5f; + + DrawTexture(drawX, drawY, drawW, drawH, textureId, tint); +} + +// ---- Input helpers ---------------------------------------------------- + +bool NativeUI::GetMouseVirtual(float& outX, float& outY) +{ +#ifdef _WINDOWS64 + if (!g_hWnd) return false; + RECT rc; + GetClientRect(g_hWnd, &rc); + int winW = rc.right - rc.left; + int winH = rc.bottom - rc.top; + if (winW <= 0 || winH <= 0) return false; + + // Map window pixel coords to backbuffer coords, then to the 16:9 viewport + float pixX = (float)g_KBMInput.GetMouseX() / (float)winW * s_bbWidth; + float pixY = (float)g_KBMInput.GetMouseY() / (float)winH * s_bbHeight; + + // Convert from backbuffer pixel space to virtual canvas (relative to viewport) + outX = (pixX - s_vpX) / s_vpW * kVW; + outY = (pixY - s_vpY) / s_vpH * kVH; + return true; +#else + return false; +#endif +} + +// ---- FocusList -------------------------------------------------------- + +void FocusList::Add(int id, float x, float y, float w, float h) +{ + m_entries.push_back({ id, x, y, w, h }); + if (m_focusIdx >= (int)m_entries.size()) + m_focusIdx = 0; +} + +int FocusList::GetFocused() const +{ + if (m_entries.empty()) return -1; + return m_entries[m_focusIdx].id; +} + +void FocusList::MoveNext() +{ + if (m_entries.empty()) return; + m_focusIdx = (m_focusIdx + 1) % (int)m_entries.size(); + ui.PlayUISFX(eSFX_Focus); +} + +void FocusList::MovePrev() +{ + if (m_entries.empty()) return; + m_focusIdx = (m_focusIdx - 1 + (int)m_entries.size()) % (int)m_entries.size(); + ui.PlayUISFX(eSFX_Focus); +} + +void FocusList::SetFocus(int id) +{ + for (int i = 0; i < (int)m_entries.size(); ++i) + { + if (m_entries[i].id == id) + { + m_focusIdx = i; + return; + } + } +} + +bool FocusList::HitTest(float mx, float my, int& outId) const +{ + for (const Entry& e : m_entries) + { + if (mx >= e.x && mx <= e.x + e.w && + my >= e.y && my <= e.y + e.h) + { + outId = e.id; + return true; + } + } + return false; +} + +bool FocusList::UpdateHover(float mx, float my) +{ + int hitId; + if (HitTest(mx, my, hitId)) + { + m_hoveredId = hitId; + return true; + } + m_hoveredId = -1; + return false; +} + +void FocusList::TickMouse() +{ +#ifdef _WINDOWS64 + // Release consumption lock when the mouse button is no longer held. + if (m_mouseConsumed && !g_KBMInput.IsMouseButtonDown(KeyboardMouseInput::MOUSE_LEFT)) + m_mouseConsumed = false; + + // Only process mouse hover if the mouse has actually moved recently. + // This prevents a stationary mouse cursor from stealing focus from gamepad. + float mx, my; + if (!NativeUI::GetMouseVirtual(mx, my)) + { + ClearHover(); + m_lastHoveredId = -1; + return; + } + + // Track mouse movement — only switch to mouse device if cursor position changed + bool mouseMoved = (mx != m_lastMouseX || my != m_lastMouseY); + m_lastMouseX = mx; + m_lastMouseY = my; + + if (!mouseMoved && m_lastDevice == eDevice_Gamepad) + { + // Mouse is stationary and gamepad is active — don't update hover + return; + } + + if (mouseMoved) + m_lastDevice = eDevice_Mouse; + + int prevHover = m_lastHoveredId; + UpdateHover(mx, my); + m_lastHoveredId = m_hoveredId; + + // Play focus sound when hover enters a different element + if (m_hoveredId >= 0 && m_hoveredId != prevHover) + ui.PlayUISFX(eSFX_Focus); +#endif +} + +int FocusList::HandleMenuKey(int key, int backId, + float panelX, float panelY, + float panelW, float panelH) +{ + switch (key) + { + case ACTION_MENU_UP: + case ACTION_MENU_LEFT: + m_lastDevice = eDevice_Gamepad; + ClearHover(); // dismiss mouse hover when gamepad takes over + MovePrev(); + return RESULT_NAVIGATED; + case ACTION_MENU_DOWN: + case ACTION_MENU_RIGHT: + m_lastDevice = eDevice_Gamepad; + ClearHover(); + MoveNext(); + return RESULT_NAVIGATED; + case ACTION_MENU_OK: + { +#ifdef _WINDOWS64 + // UIController converts mouse left-click to ACTION_MENU_OK. + // Only use mouse hit-test if mouse is the active device. + if (m_lastDevice == eDevice_Mouse && + g_KBMInput.IsMouseButtonDown(KeyboardMouseInput::MOUSE_LEFT)) + { + float mx, my; + if (NativeUI::GetMouseVirtual(mx, my)) + { + int hitId; + if (HitTest(mx, my, hitId)) + { + SetFocus(hitId); + ui.PlayUISFX(eSFX_Press); + m_mouseConsumed = true; + return hitId; + } + // Click inside panel but not on element — consume silently + if (mx >= panelX && mx <= panelX + panelW && + my >= panelY && my <= panelY + panelH) + { + m_mouseConsumed = true; + return RESULT_UNHANDLED; + } + } + } +#endif + // Gamepad/keyboard press — use focused element + m_lastDevice = eDevice_Gamepad; + ui.PlayUISFX(eSFX_Press); + return GetFocused(); + } + case ACTION_MENU_CANCEL: + ui.PlayUISFX(eSFX_Back); + return backId; + default: + return RESULT_UNHANDLED; + } +} + +// ---- Link widget ------------------------------------------------------ + +void DrawLink(float x, float y, const wchar_t* text, + const char* /*url*/, bool focused, bool hovered, + float size, uint32_t align, + float* outX, float* outY, float* outW, float* outH) +{ + if (!text || !text[0] || !s_inFrame) return; + + float tw, th; + MeasureText(text, size, &tw, &th); + + // Compute draw position based on alignment + float drawX = x, drawY = y; + if (align & ALIGN_CENTER_X) drawX = x - tw * 0.5f; + else if (align & ALIGN_RIGHT) drawX = x - tw; + if (align & ALIGN_CENTER_Y) drawY = y - th * 0.5f; + else if (align & ALIGN_BOTTOM) drawY = y - th; + + uint32_t color; + float padX = 6.0f, padY = 3.0f; + + if (focused || hovered) + { + // Yellow text, no background — standard MC hover + color = 0xFFFFFF55u; + } + else + { + // Blue link + color = 0xFF4DC3FFu; + } + + // Draw the text + DrawShadowText(x, y, text, color, size, align); + + // Underline — always visible, thicker on hover/focus + float underlineY = drawY + th + 1.0f; + float underlineThick = (focused || hovered) ? 2.0f : 1.0f; + DrawRect(drawX, underlineY, tw, underlineThick, color); + + // Output bounding rect (includes padding for easier clicking) + if (outX) *outX = drawX - padX; + if (outY) *outY = drawY - padY; + if (outW) *outW = tw + padX * 2; + if (outH) *outH = th + padY * 2 + 2; +} + +void OpenURL(const char* url) +{ + if (!url || !url[0]) return; +#ifdef _WINDOWS64 + ShellExecuteA(nullptr, "open", url, nullptr, nullptr, SW_SHOWNORMAL); +#endif +} + +} // namespace NativeUI diff --git a/Minecraft.Client/Common/UI/NativeUIRenderer.h b/Minecraft.Client/Common/UI/NativeUIRenderer.h new file mode 100644 index 0000000000..113cd568f8 --- /dev/null +++ b/Minecraft.Client/Common/UI/NativeUIRenderer.h @@ -0,0 +1,346 @@ +#pragma once +#include +#include +#include + + +// NativeUI — Direct D3D11 2D rendering for SWF-free UIScene subclasses. +// +// Renders rectangles, text, and widgets via raw D3D11 draw calls, +// completely independent of the Tesselator/RenderManager pipeline. +// +// Usage: +// +// void MyScene::render(S32 w, S32 h, C4JRender::eViewportType vp) +// { +// if (!m_hasTickedOnce) return; +// NativeUI::BeginFrame(); +// +// NativeUI::DrawRect(0, 0, 1280, 720, 0xCC000000u); +// NativeUI::DrawShadowText(640, 300, L"Hello", +// 0xFFFFFFFFu, 22.0f, ALIGN_CENTER_X); +// +// NativeUI::EndFrame(); +// } +// +// Coordinate space: 1280 x 720 virtual canvas (resolution-independent). +// Color format: 0xAARRGGBB (alpha 0xFF = opaque). +// Font size: Height in virtual pixels. Bitmap font is 8 units tall; +// system scales to match. Common sizes: 8, 12, 14, 16, 22. + + +namespace NativeUI +{ + // ---- Alignment flags ------------------------------------------ + enum : uint32_t + { + ALIGN_LEFT = 0x00u, + ALIGN_RIGHT = 0x01u, + ALIGN_CENTER_X = 0x02u, + ALIGN_CENTER_Y = 0x04u, + ALIGN_BOTTOM = 0x08u, + }; + + // ---- Lifecycle ------------------------------------------------ + + void BeginFrame(); + void EndFrame(); + void Shutdown(); + + // ---- Input helpers (Windows64) ----------------------------------- + + // Convert window mouse coords to 1280x720 virtual canvas. + // Returns false if the window handle is invalid or has zero size. + bool GetMouseVirtual(float& outX, float& outY); + + // ---- Primitives ----------------------------------------------- + + // Filled axis-aligned rectangle. + void DrawRect(float x, float y, float w, float h, uint32_t color); + + // Fullscreen rectangle that covers the entire backbuffer (ignores 16:9 viewport). + // Use for dim overlays, backgrounds, etc. that must reach into pillarbox/letterbox bands. + void DrawRectFullscreen(uint32_t color); + + // Filled rectangle with rounded corners (radius in virtual pixels). + void DrawRoundedRect(float x, float y, float w, float h, + float radius, uint32_t color); + + // Rounded border (outline only, no fill). + void DrawRoundedBorder(float x, float y, float w, float h, + float radius, float thickness, uint32_t color); + + // Outlined rectangle (border only, no fill). + void DrawBorder(float x, float y, float w, float h, + float thickness, uint32_t color); + + // Horizontal line. + void DrawLine(float x, float y, float length, float thickness, + uint32_t color); + + // Vertical line. + void DrawLineV(float x, float y, float length, float thickness, + uint32_t color); + + // Filled rectangle with gradient (top to bottom). + void DrawGradientRect(float x, float y, float w, float h, + uint32_t topColor, uint32_t bottomColor); + + // Rounded rect with gradient (top to bottom). + void DrawGradientRoundedRect(float x, float y, float w, float h, + float radius, + uint32_t topColor, uint32_t bottomColor); + + // Drop shadow behind a rectangle (semi-transparent, offset). + void DrawDropShadow(float x, float y, float w, float h, + float offset = 4.0f, float spread = 6.0f, + uint32_t color = 0x60000000u); + + // Panel with rounded corners, drop shadow, and optional border. + void DrawPanel(float x, float y, float w, float h, + float radius = 8.0f, + uint32_t bgColor = 0xF0181818u, + uint32_t borderColor = 0xFF333333u, + float borderThick = 1.0f, + bool shadow = true); + + // Horizontal divider line with optional fade at edges. + void DrawDivider(float x, float y, float w, + uint32_t color = 0xFF333333u, + float thickness = 1.0f); + + // ---- Text ----------------------------------------------------- + + void DrawText(float x, float y, const wchar_t* text, + uint32_t color, float size = 14.0f, + uint32_t align = ALIGN_LEFT); + + void DrawShadowText(float x, float y, const wchar_t* text, + uint32_t color, float size = 14.0f, + uint32_t align = ALIGN_LEFT); + + float DrawTextWrapped(float x, float y, const wchar_t* text, + float maxWidth, uint32_t color, + float size = 14.0f, uint32_t align = ALIGN_LEFT); + + void MeasureText(const wchar_t* text, float size, + float* outWidth, float* outHeight); + + float LineHeight(float size); + + // ---- Clipping ------------------------------------------------- + + void PushClipRect(float x, float y, float w, float h); + void PopClipRect(); + + // ---- Widgets -------------------------------------------------- + + void DrawButton(float x, float y, float w, float h, + const wchar_t* label, bool focused, + bool hovered = false, float labelSize = 16.0f); + + // Text input box from gui/gui.png (y=46, 20px tall in atlas). + void DrawTextBox(float x, float y, float w, float h, + uint32_t tint = 0xFFFFFFFFu); + + // Clickable link text with underline. Opens URL on activation. + void DrawLink(float x, float y, const wchar_t* text, + const char* url, bool focused, bool hovered = false, + float size = 14.0f, uint32_t align = ALIGN_LEFT, + float* outX = nullptr, float* outY = nullptr, + float* outW = nullptr, float* outH = nullptr); + + void DrawProgressBar(float x, float y, float w, float h, + float progress, + uint32_t fillColor = 0xFF1A71D1u, + uint32_t trackColor = 0xFF333333u); + + void DrawSpinner(float cx, float cy, float radius, int tick, + uint32_t color = 0xFFFFFFFFu); + + void DrawCheckbox(float x, float y, float size, + bool checked, bool focused, + bool hovered = false); + + void DrawSlider(float x, float y, float w, float h, + float value, bool focused, + bool hovered = false, + uint32_t fillColor = 0xFF1A71D1u, + uint32_t trackColor = 0xFF333333u); + + void DrawTooltip(float x, float y, const wchar_t* text, + float size = 12.0f); + + // Open a URL in the system default browser (Windows only, no-op elsewhere). + void OpenURL(const char* url); + + // ---- Texture drawing ------------------------------------------ + + // Load a texture from the game's resource pack (e.g. L"/gui/container.png"). + // Returns an internal texture ID for use with Draw calls. + // Textures are cached — calling this multiple times with the same path is cheap. + int LoadTexture(const wchar_t* path); + + // Load a pre-registered texture by TEXTURE_NAME enum value. + // See Textures.h for the full list (TN_GUI_GUI, TN_TERRAIN, etc.). + int LoadTextureByName(int textureName); + + // Load a PNG file and create a D3D11 texture directly (bypasses RenderManager). + // Decodes via WIC, creates DXGI_FORMAT_R8G8B8A8_UNORM texture. + // Use this for externally sourced PNGs (e.g. Mojang skins) where the + // RenderManager's pixel format assumptions may not match. + // Returns texture ID (for use with DrawTexture/DrawTextureUV), or -1 on failure. + // Caches by path. Must be called from the main/render thread. + int LoadTextureFromFileDirect(const char* filePath, + int* outWidth = nullptr, int* outHeight = nullptr); + + // Load a texture from an arbitrary filesystem path relative to the EXE dir. + // e.g. "Common/Media/Graphics/PanelsAndTabs/Panel_MM.png" + // Returns texture ID, or -1 on failure. Caches by path. + // Also returns texture dimensions via optional out params. + int LoadTextureFromFile(const char* filePath, + int* outWidth = nullptr, int* outHeight = nullptr); + + // 9-slice panel: draws a scalable panel from 9 texture pieces. + // basePath = folder + prefix, e.g. "Common/Media/Graphics/PanelsAndTabs/Panel" + // Auto-detects naming convention: + // Convention A: _TL, _TM, _TR, _ML, _MM, _MR, _BL, _BM, _BR + // Convention B: _Top_L, _Top_M, _Top_R, _Mid_L, _Mid_M, _Mid_R, _Bot_L, _Bot_M, _Bot_R + struct NineSlice + { + int tl, tm, tr; // texture IDs: top-left, top-mid, top-right + int ml, mm, mr; // mid-left, mid-mid, mid-right + int bl, bm, br; // bot-left, bot-mid, bot-right + int cornerW, cornerH; // corner piece dimensions + bool valid; + }; + + // Load a 9-slice panel set from disk. Caches internally. + // Supports both naming conventions (auto-detected). + NineSlice LoadNineSlice(const char* basePath); + + // Draw a 9-slice panel scaled to any size. + void DrawNineSlice(float x, float y, float w, float h, + const NineSlice& ns, uint32_t tint = 0xFFFFFFFFu); + + // 3-slice horizontal strip (tabs, bars). + // basePath e.g. "Common/Media/Graphics/PanelsAndTabs/Tab" + // Expects: {basePath}_Left.png, _Middle.png, _Right.png + struct ThreeSlice + { + int left, mid, right; // texture IDs + int capW, capH; // left/right cap dimensions + bool valid; + }; + + // Load a 3-slice strip from disk. Caches internally. + ThreeSlice LoadThreeSlice(const char* basePath); + + // Draw a 3-slice strip scaled horizontally. + void DrawThreeSlice(float x, float y, float w, float h, + const ThreeSlice& ts, uint32_t tint = 0xFFFFFFFFu); + + // Draw a texture filling the given rect. tint multiplies the texture color. + // Pass 0xFFFFFFFF for no tint. textureId comes from LoadTexture/LoadTextureByName. + void DrawTexture(float x, float y, float w, float h, + int textureId, uint32_t tint = 0xFFFFFFFFu); + + // Draw a sub-region of a texture (UV rect in [0,1] space). + void DrawTextureUV(float x, float y, float w, float h, + int textureId, + float u0, float v0, float u1, float v1, + uint32_t tint = 0xFFFFFFFFu); + + // Draw a texture clipped to rounded corners (uses scissor). + void DrawTextureRounded(float x, float y, float w, float h, + int textureId, float radius, + uint32_t tint = 0xFFFFFFFFu); + + // Draw a texture scaled to fit (maintaining aspect ratio, centered). + // texW/texH = source texture dimensions in pixels. + void DrawTextureFit(float x, float y, float w, float h, + int textureId, int texW, int texH, + uint32_t tint = 0xFFFFFFFFu); + + // ---- Focus list ----------------------------------------------- + + class FocusList + { + public: + struct Entry { int id; float x, y, w, h; }; + + void Clear() { m_entries.clear(); m_hoveredId = -1; } + void Add(int id, float x, float y, float w, float h); + int GetFocused() const; + int GetHovered() const { return m_hoveredId; } + bool IsFocused(int id) const { return GetFocused() == id; } + bool IsHovered(int id) const { return m_lastDevice == eDevice_Mouse && m_hoveredId == id; } + bool IsActive(int id) const { return IsFocused(id) || IsHovered(id); } + + // For rendering: show highlight based on last-used input device. + // Gamepad last → show focus ring on focused element, ignore mouse hover. + // Mouse last → show hover highlight, suppress gamepad focus ring. + bool ShowFocus(int id) const + { + if (m_lastDevice == eDevice_Mouse) + return m_hoveredId >= 0 && m_hoveredId == id; + // Gamepad or no device: show gamepad focus, ignore hover + return IsFocused(id); + } + + void MoveNext(); + void MovePrev(); + void SetFocus(int id); + bool HitTest(float mx, float my, int& outId) const; + bool UpdateHover(float mx, float my); + void ClearHover() { m_hoveredId = -1; } + int Count() const { return (int)m_entries.size(); } + + + // Mouse hover — call once per tick from tick(). + // Updates hover state, plays hover sound. No click handling. + + void TickMouse(); + + // True while the mouse was used for the last interaction. + bool IsMouseConsumed() const { return m_mouseConsumed; } + + + // Gamepad/keyboard — standard menu key handling. + // Handles UP/DOWN/LEFT/RIGHT (navigation with sound), + // OK (returns focused id with press sound), + // CANCEL (returns backId with back sound). + // + // backId = the control id to return when CANCEL is pressed. + // + // Returns: >=0 = activated element id (OK or CANCEL) + // -1 = navigation happened (focus moved, no activation) + // -2 = key not handled (scene should set handled=false) + + static constexpr int RESULT_NAVIGATED = -1; + static constexpr int RESULT_UNHANDLED = -2; + + // panelX/Y/W/H = scene panel bounds. Used to hit-test mouse clicks + // when UIController sends ACTION_MENU_OK from a left-click. + int HandleMenuKey(int key, int backId = 0, + float panelX = 0, float panelY = 0, + float panelW = 0, float panelH = 0); + + // Input device tracking — same concept as Iggy's lastUsedDevice. + // When the gamepad navigates, mouse hover is suppressed. + // When the mouse moves, gamepad focus ring is suppressed. + enum ELastDevice { eDevice_None, eDevice_Gamepad, eDevice_Mouse }; + ELastDevice GetLastDevice() const { return m_lastDevice; } + + private: + std::vector m_entries; + int m_focusIdx = 0; + int m_hoveredId = -1; + int m_lastHoveredId = -1; // hover id from previous tick + bool m_mouseConsumed = false; // true = click in progress, don't re-fire + ELastDevice m_lastDevice = eDevice_None; + float m_lastMouseX = -1.0f; + float m_lastMouseY = -1.0f; + }; + +} // namespace NativeUI diff --git a/Minecraft.Client/Common/UI/UI.h b/Minecraft.Client/Common/UI/UI.h index a7c416f8ec..4b453ca451 100644 --- a/Minecraft.Client/Common/UI/UI.h +++ b/Minecraft.Client/Common/UI/UI.h @@ -88,6 +88,9 @@ #include "UIScene_SkinSelectMenu.h" #include "UIScene_HowToPlayMenu.h" #include "UIScene_LanguageSelector.h" +#ifndef MINECRAFT_SERVER_BUILD +#include "UIScene_MSAuth.h" +#endif #include "UIScene_HowToPlay.h" #include "UIScene_ControlsMenu.h" #include "UIScene_Credits.h" diff --git a/Minecraft.Client/Common/UI/UIController.cpp b/Minecraft.Client/Common/UI/UIController.cpp index b12ea5e739..302ccd8431 100644 --- a/Minecraft.Client/Common/UI/UIController.cpp +++ b/Minecraft.Client/Common/UI/UIController.cpp @@ -127,6 +127,27 @@ this, Iggy allows us to install a callback that will be called any time ActionScript code calls trace. */ static void RADLINK TraceCallback(void *user_callback_data, Iggy *player, char const *utf8_string, S32 length_in_bytes) { + if (length_in_bytes <= 0 || utf8_string == nullptr) return; + + // Suppress per-frame HUD repositioning trace from ActionScript. + // The SWF emits "iSceneWidth = ..." every frame — skip any line containing it. + for (S32 i = 0; i <= length_in_bytes - 4; ++i) { + if (utf8_string[i] == 'i' && utf8_string[i+1] == 'S' && + utf8_string[i+2] == 'c' && utf8_string[i+3] == 'e') + return; + } + + // Also skip whitespace-only traces (newlines, spaces) + bool hasContent = false; + for (S32 i = 0; i < length_in_bytes; ++i) { + char c = utf8_string[i]; + if (c != ' ' && c != '\t' && c != '\n' && c != '\r' && c != '\0') { + hasContent = true; + break; + } + } + if (!hasContent) return; + app.DebugPrintf(app.USER_UI, (char *)utf8_string); } diff --git a/Minecraft.Client/Common/UI/UIEnums.h b/Minecraft.Client/Common/UI/UIEnums.h index 45aff87df7..88ace538aa 100644 --- a/Minecraft.Client/Common/UI/UIEnums.h +++ b/Minecraft.Client/Common/UI/UIEnums.h @@ -128,6 +128,7 @@ enum EUIScene eUIScene_EULA, eUIScene_InGameSaveManagementMenu, eUIScene_LanguageSelector, + eUIScene_MSAuth, // Microsoft Account sign-in (device code flow) #endif // ndef _XBOX #ifdef _DEBUG_MENUS_ENABLED diff --git a/Minecraft.Client/Common/UI/UILayer.cpp b/Minecraft.Client/Common/UI/UILayer.cpp index e1c388f54f..2547b3d1d1 100644 --- a/Minecraft.Client/Common/UI/UILayer.cpp +++ b/Minecraft.Client/Common/UI/UILayer.cpp @@ -293,6 +293,11 @@ bool UILayer::NavigateToScene(int iPad, EUIScene scene, void *initData) case eUIScene_LanguageSelector: newScene = new UIScene_LanguageSelector(iPad, initData, this); break; +#ifndef MINECRAFT_SERVER_BUILD + case eUIScene_MSAuth: + newScene = new UIScene_MSAuth(iPad, initData, this); + break; +#endif case eUIScene_HowToPlay: newScene = new UIScene_HowToPlay(iPad, initData, this); break; diff --git a/Minecraft.Client/Common/UI/UIScene.cpp b/Minecraft.Client/Common/UI/UIScene.cpp index 303897a7f1..f73af8e407 100644 --- a/Minecraft.Client/Common/UI/UIScene.cpp +++ b/Minecraft.Client/Common/UI/UIScene.cpp @@ -97,13 +97,16 @@ void UIScene::reloadMovie(bool force) updateComponents(); handleReload(); - IggyDataValue result; - IggyDataValue value[1]; + if (getMovie()) + { + IggyDataValue result; + IggyDataValue value[1]; - value[0].type = IGGY_DATATYPE_number; - value[0].number = m_iFocusControl; + value[0].type = IGGY_DATATYPE_number; + value[0].number = m_iFocusControl; - IggyResult out = IggyPlayerCallMethodRS ( getMovie() , &result, IggyPlayerRootPath( getMovie() ), m_funcSetFocus , 1 , value ); + IggyResult out = IggyPlayerCallMethodRS ( getMovie() , &result, IggyPlayerRootPath( getMovie() ), m_funcSetFocus , 1 , value ); + } m_needsCacheRendered = true; m_bIsReloading = false; @@ -218,6 +221,8 @@ void UIScene::updateSafeZone() void UIScene::setSafeZone(S32 safeTop, S32 safeBottom, S32 safeLeft, S32 safeRight) { + if (!getMovie()) return; + IggyDataValue result; IggyDataValue value[4]; @@ -245,6 +250,8 @@ void UIScene::initialiseMovie() #if defined(__PSVITA__) || defined(_WINDOWS64) void UIScene::SetFocusToElement(int iID) { + if (!getMovie()) return; + IggyDataValue result; IggyDataValue value[1]; @@ -260,6 +267,9 @@ void UIScene::SetFocusToElement(int iID) bool UIScene::mapElementsAndNames() { + // Native scenes have no SWF; nothing to map. + if (!swf) return true; + m_rootPath = IggyPlayerRootPath( swf ); m_funcRemoveObject = registerFastName( L"RemoveObject" ); @@ -278,6 +288,19 @@ void UIScene::loadMovie() EnterCriticalSection(&UIController::ms_reloadSkinCS); // MGH - added to prevent crash loading Iggy movies while the skins were being reloaded wstring moviePath = getMoviePath(); + // Native (no-SWF) scenes return an empty path. Set up dimensions from + // the current screen size so render() has valid extents, mark as ticked + // so input handling is enabled, then bail out without touching Iggy. + if (moviePath.empty()) + { + m_renderWidth = m_movieWidth = (S32)ui.getScreenWidth(); + m_renderHeight = m_movieHeight = (S32)ui.getScreenHeight(); + m_loadedResolution = (ui.getScreenHeight() > 720.0f) ? eSceneResolution_1080 : eSceneResolution_720; + m_hasTickedOnce = true; + LeaveCriticalSection(&UIController::ms_reloadSkinCS); + return; + } + #ifdef __PS3__ if(RenderManager.IsWidescreen()) { @@ -461,7 +484,12 @@ void UIScene::tick() { if(m_bIsReloading) return; if(m_hasTickedOnce) m_bCanHandleInput = true; - while(IggyPlayerReadyToTick( swf )) + if (!swf) + { + // Native (no-SWF) scene: run timers without touching Iggy. + tickTimers(); + } + else while(IggyPlayerReadyToTick( swf )) { tickTimers(); for(auto & it : m_controls) @@ -655,6 +683,7 @@ IggyName UIScene::registerFastName(const wstring &name) } else { + if (!getMovie()) return 0; var = IggyPlayerCreateFastName ( getMovie() , (IggyUTF16 *)name.c_str() , -1 ); m_fastNames[name] = var; } @@ -663,6 +692,8 @@ IggyName UIScene::registerFastName(const wstring &name) void UIScene::removeControl( UIControl_Base *control, bool centreScene) { + if (!getMovie()) return; + IggyDataValue result; IggyDataValue value[2]; @@ -692,18 +723,21 @@ void UIScene::removeControl( UIControl_Base *control, bool centreScene) void UIScene::slideLeft() { + if (!getMovie()) return; IggyDataValue result; IggyResult out = IggyPlayerCallMethodRS ( getMovie() , &result, IggyPlayerRootPath( getMovie() ), m_funcSlideLeft , 0 , nullptr ); } void UIScene::slideRight() { + if (!getMovie()) return; IggyDataValue result; IggyResult out = IggyPlayerCallMethodRS ( getMovie() , &result, IggyPlayerRootPath( getMovie() ), m_funcSlideRight , 0 , nullptr ); } void UIScene::doHorizontalResizeCheck() { + if (!getMovie()) return; IggyDataValue result; IggyResult out = IggyPlayerCallMethodRS ( getMovie() , &result, IggyPlayerRootPath( getMovie() ), m_funcHorizontalResizeCheck , 0 , nullptr ); } @@ -741,12 +775,15 @@ void UIScene::setOpacity(float percent) if(m_bUpdateOpacity) m_bUpdateOpacity = false; - IggyDataValue result; - IggyDataValue value[1]; - value[0].type = IGGY_DATATYPE_number; - value[0].number = percent; + if (getMovie()) + { + IggyDataValue result; + IggyDataValue value[1]; + value[0].type = IGGY_DATATYPE_number; + value[0].number = percent; - IggyResult out = IggyPlayerCallMethodRS ( getMovie() , &result, IggyPlayerRootPath( getMovie() ), m_funcSetAlpha , 1 , value ); + IggyResult out = IggyPlayerCallMethodRS ( getMovie() , &result, IggyPlayerRootPath( getMovie() ), m_funcSetAlpha , 1 , value ); + } } } @@ -1000,7 +1037,7 @@ void UIScene::gainFocus() updateTooltips(); updateComponents(); - if(!m_bFocussedOnce) + if(!m_bFocussedOnce && getMovie()) { IggyDataValue result; IggyDataValue value[1]; diff --git a/Minecraft.Client/Common/UI/UIScene_CreateWorldMenu.cpp b/Minecraft.Client/Common/UI/UIScene_CreateWorldMenu.cpp index ef72ec163f..bf0b47503b 100644 --- a/Minecraft.Client/Common/UI/UIScene_CreateWorldMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_CreateWorldMenu.cpp @@ -3,6 +3,9 @@ #include "UIScene_CreateWorldMenu.h" #include "..\..\MinecraftServer.h" #include "..\..\Minecraft.h" +#ifdef _WINDOWS64 +#include "..\..\..\MCAuth\include\MCAuthManager.h" +#endif #include "..\..\Options.h" #include "..\..\TexturePackRepository.h" #include "..\..\TexturePack.h" @@ -520,6 +523,21 @@ void UIScene_CreateWorldMenu::checkPrivilegeCallback(LPVOID lpParam, bool hasPri void UIScene_CreateWorldMenu::StartSharedLaunchFlow() { +#ifdef _WINDOWS64 + // Block world creation if no account is configured (empty username/uuid). + { + auto session = MCAuthManager::Get().GetSlotSession(0); + if (session.username.empty() || session.uuid.empty()) + { + app.DebugPrintf("[Auth] No account configured, returning to main menu for auth\n"); + m_bIgnoreInput = false; + ui.NavigateToHomeMenu(); + ui.NavigateToScene(m_iPad, eUIScene_MSAuth); + return; + } + } +#endif + Minecraft *pMinecraft=Minecraft::GetInstance(); // Check if we need to upsell the texture pack if(m_MoreOptionsParams.dwTexturePack!=0) diff --git a/Minecraft.Client/Common/UI/UIScene_DLCMainMenu.cpp b/Minecraft.Client/Common/UI/UIScene_DLCMainMenu.cpp index 6d705765ee..4ce4addeec 100644 --- a/Minecraft.Client/Common/UI/UIScene_DLCMainMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_DLCMainMenu.cpp @@ -1,9 +1,28 @@ +#define _CRT_SECURE_NO_WARNINGS #include "stdafx.h" #include "UI.h" #if defined(__PS3__) || defined(__ORBIS__) #include "Common\Network\Sony\SonyCommerce.h" #endif #include "UIScene_DLCMainMenu.h" +#include "../../../MCAuth/include/MCAuthManager.h" +#include +#include + +#if !defined(_FINAL_BUILD) && defined(_DEBUG) +static void DLCMenuLog(const char* fmt, ...) { + char buf[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); +#ifdef _WIN32 + OutputDebugStringA(buf); +#endif +} +#else +static void DLCMenuLog(const char* /*fmt*/, ...) {} +#endif #define PLAYER_ONLINE_TIMER_ID 0 #define PLAYER_ONLINE_TIMER_TIME 100 @@ -30,13 +49,38 @@ UIScene_DLCMainMenu::UIScene_DLCMainMenu(int iPad, void *initData, UILayer *pare m_bCategoriesShown=false; #endif - if(m_loadedResolution == eSceneResolution_1080) + // Try to restore a previously saved Java auth session (background refresh). + MCAuthManager::Get().TryRestoreActiveJavaAccount(); + DLCMenuLog("[DLCMainMenu] After TryRestore: IsJavaLoggedIn=%d, State=%d\n", + (int)MCAuthManager::Get().IsJavaLoggedIn(), + (int)MCAuthManager::Get().GetState()); + + // Pre-warm: proactively refresh token if it's expiring soon + if (MCAuthManager::Get().IsJavaLoggedIn() && MCAuthManager::Get().IsTokenExpiringSoon(0)) + { + MCAuthManager::Get().RefreshSlot(0); + } + { #ifdef _DURANGO m_labelXboxStore.init(IDS_XBOX_STORE); #else - m_labelXboxStore.init( L"" ); + m_labelXboxStore.init(L""); #endif + // Show Minecraft username if already signed in, otherwise show generic label + if(MCAuthManager::Get().IsJavaLoggedIn()) + { + MCAuth::JavaSession session = MCAuthManager::Get().GetJavaSession(); + std::wstring name(session.username.begin(), session.username.end()); + DLCMenuLog("[DLCMainMenu] Constructor: Already logged in as '%s'\n", + session.username.c_str()); + m_labelXboxStore.init(name); + m_authLabelUpdated = true; + } + else + { + m_labelXboxStore.init(L"Sign in with Microsoft"); + } } #if defined(_DURANGO) @@ -130,7 +174,7 @@ void UIScene_DLCMainMenu::handlePress(F64 controlId, F64 childId) param->iPad = m_iPad; param->iType = iIndex; - // promote the DLC content request type + // promote the DLC content request type // Xbox One will have requested the marketplace content - there is only that type #ifndef _XBOX_ONE @@ -140,6 +184,12 @@ void UIScene_DLCMainMenu::handlePress(F64 controlId, F64 childId) ui.NavigateToScene(m_iPad, eUIScene_DLCOffersMenu, param); break; } + case eControl_MSSignIn: + { + // Open the Microsoft Account sign-in dialog + ui.NavigateToScene(m_iPad, eUIScene_MSAuth, nullptr, eUILayer_Popup, eUIGroup_Fullscreen); + break; + } }; } @@ -182,8 +232,25 @@ void UIScene_DLCMainMenu::handleGainFocus(bool navBack) updateTooltips(); + // Allow tick() to re-check auth state after navigating back. + m_authLabelUpdated = false; + if(navBack) { + // Refresh the sign-in button label after returning from MSAuth. + if(MCAuthManager::Get().IsJavaLoggedIn()) + { + MCAuth::JavaSession s = MCAuthManager::Get().GetJavaSession(); + std::wstring name(s.username.begin(), s.username.end()); + DLCMenuLog("[DLCMainMenu] handleGainFocus: logged in as '%s'\n", + s.username.c_str()); + m_labelXboxStore.setLabel(name); + } + else + { + m_labelXboxStore.setLabel(L"Sign in with Microsoft"); + } + // add the timer back in #if ( defined __PS3__ || defined __ORBIS__ || defined __PSVITA__ ) addTimer( PLAYER_ONLINE_TIMER_ID, PLAYER_ONLINE_TIMER_TIME ); @@ -195,6 +262,32 @@ void UIScene_DLCMainMenu::tick() { UIScene::tick(); + // Update sign-in label when background session restore completes. + if(!m_authLabelUpdated) + { + auto state = MCAuthManager::Get().GetState(); + bool loggedIn = MCAuthManager::Get().IsJavaLoggedIn(); + + if(loggedIn) + { + MCAuth::JavaSession s = MCAuthManager::Get().GetJavaSession(); + std::wstring name(s.username.begin(), s.username.end()); + DLCMenuLog("[DLCMainMenu] tick: Auth restored! username='%s', setting label\n", + s.username.c_str()); + m_labelXboxStore.setLabel(name); + m_authLabelUpdated = true; + } + else if(state == MCAuthManager::State::Idle || + state == MCAuthManager::State::Failed) + { + DLCMenuLog("[DLCMainMenu] tick: Restore finished without login (state=%d), stop polling\n", + (int)state); + // Restore attempt finished but failed — stop checking. + m_authLabelUpdated = true; + } + // else: still authenticating in background, keep polling + } + #if defined(__PS3__) || defined(__ORBIS__) || defined (__PSVITA__) if((m_bCategoriesShown==false) && (app.GetCommerceCategoriesRetrieved())) { diff --git a/Minecraft.Client/Common/UI/UIScene_DLCMainMenu.h b/Minecraft.Client/Common/UI/UIScene_DLCMainMenu.h index f23ee4b01d..abbf27d291 100644 --- a/Minecraft.Client/Common/UI/UIScene_DLCMainMenu.h +++ b/Minecraft.Client/Common/UI/UIScene_DLCMainMenu.h @@ -8,23 +8,25 @@ class UIScene_DLCMainMenu : public UIScene enum EControls { eControl_OffersList, + eControl_MSSignIn, // Microsoft Account sign-in button }; UIControl_DynamicButtonList m_buttonListOffers; - UIControl_Label m_labelOffers, m_labelXboxStore; + UIControl_Label m_labelOffers, m_labelXboxStore; + UIControl m_buttonMSSignIn; // replaces Xbox Store label on non-Durango UIControl m_Timer; UI_BEGIN_MAP_ELEMENTS_AND_NAMES(UIScene) UI_MAP_ELEMENT( m_buttonListOffers, "OffersList") UI_MAP_ELEMENT( m_labelOffers, "OffersList_Title") UI_MAP_ELEMENT( m_Timer, "Timer") - if(m_loadedResolution == eSceneResolution_1080) - { - UI_MAP_ELEMENT( m_labelXboxStore, "XboxLabel" ) - } + UI_MAP_ELEMENT( m_labelXboxStore, "XboxLabel" ) + UI_MAP_ELEMENT( m_buttonMSSignIn, "MSSignIn" ) UI_END_MAP_ELEMENTS_AND_NAMES() static int ExitDLCMainMenu(void *pParam,int iPad,C4JStorage::EMessageResult result); + bool m_authLabelUpdated = false; // stops tick() polling once label is set + #if defined(__PS3__) || defined(__ORBIS__) || defined (__PSVITA__) bool m_bCategoriesShown; #endif diff --git a/Minecraft.Client/Common/UI/UIScene_HUD.cpp b/Minecraft.Client/Common/UI/UIScene_HUD.cpp index 213caa8dc6..1dd487ea37 100644 --- a/Minecraft.Client/Common/UI/UIScene_HUD.cpp +++ b/Minecraft.Client/Common/UI/UIScene_HUD.cpp @@ -819,7 +819,13 @@ void UIScene_HUD::repositionHud(S32 tileWidth, S32 tileHeight, F32 scale, bool n S32 visibleW = static_cast(tileWidth / scale); S32 visibleH = static_cast(tileHeight / scale); - app.DebugPrintf(app.USER_SR, "Reposition HUD: tile %dx%d, scale %.3f, visible SWF %dx%d\n", tileWidth, tileHeight, scale, visibleW, visibleH ); + // Log only when values actually change to avoid per-frame spam. + static S32 s_lastVisibleW = 0, s_lastVisibleH = 0; + if (visibleW != s_lastVisibleW || visibleH != s_lastVisibleH) { + app.DebugPrintf(app.USER_SR, "Reposition HUD: tile %dx%d, scale %.3f, visible SWF %dx%d\n", tileWidth, tileHeight, scale, visibleW, visibleH ); + s_lastVisibleW = visibleW; + s_lastVisibleH = visibleH; + } IggyDataValue result; IggyDataValue value[2]; diff --git a/Minecraft.Client/Common/UI/UIScene_LoadMenu.cpp b/Minecraft.Client/Common/UI/UIScene_LoadMenu.cpp index d61a790227..5a554f8a70 100644 --- a/Minecraft.Client/Common/UI/UIScene_LoadMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_LoadMenu.cpp @@ -2,6 +2,9 @@ #include "UI.h" #include "UIScene_LoadMenu.h" #include "..\..\Minecraft.h" +#ifdef _WINDOWS64 +#include "..\..\..\MCAuth\include\MCAuthManager.h" +#endif #include "..\..\User.h" #include "..\..\TexturePackRepository.h" #include "..\..\Options.h" @@ -1080,6 +1083,26 @@ void UIScene_LoadMenu::handleTimerComplete(int id) void UIScene_LoadMenu::LaunchGame(void) { +#ifdef _WINDOWS64 + // Block world launch if no account is configured or session has no username. + // Open the auth picker so the player can sign in or create an offline account. + { + auto session = MCAuthManager::Get().GetSlotSession(0); + if (session.username.empty() || session.uuid.empty()) + { + app.DebugPrintf("[Auth] No valid account (username='%s', uuid='%s'), returning to main menu\n", + session.username.c_str(), session.uuid.c_str()); + m_bIgnoreInput = false; + // Go all the way back to the main menu then open the auth picker. + // MSAuth uses NativeUI (D3D overlay) which can't render on top of + // SWF scenes, so we must clear them first. + ui.NavigateToHomeMenu(); + ui.NavigateToScene(m_iPad, eUIScene_MSAuth); + return; + } + } +#endif + // stop the timer running that causes a check for new texture packs in TMS but not installed, since this will run all through the load game, and will crash if it tries to create an hbrush #ifdef _XBOX killTimer(CHECKFORAVAILABLETEXTUREPACKS_TIMER_ID); @@ -1632,8 +1655,31 @@ void UIScene_LoadMenu::StartGameFromSave(UIScene_LoadMenu* pClass, DWORD dwLocal #ifdef _WINDOWS64 { - extern wchar_t g_Win64UsernameW[17]; - Minecraft::GetInstance()->user->name = g_Win64UsernameW; + auto& mgr = MCAuthManager::Get(); + + // Safety net: if TryRestoreActiveJavaAccount was never called (e.g. MainMenu + // constructor skipped), ensure we at least attempt account restoration now. + mgr.TryRestoreActiveJavaAccount(); + + // Check if auth is still refreshing — if so, wait briefly (non-blocking on main thread + // is impractical here since StartGameFromSave runs synchronously before the progress + // screen; cap the wait to avoid long UI freezes). + MCAuthManager::State st = mgr.GetSlotState(0); + if (st == MCAuthManager::State::Authenticating || st == MCAuthManager::State::WaitingForCode) + { + app.DebugPrintf("[Auth] Waiting for token refresh to complete before world entry...\n"); + mgr.WaitForSlotReady(0, 3000); + app.DebugPrintf("[Auth] Auth settled: state=%d, IsLoggedIn=%d\n", + (int)mgr.GetSlotState(0), (int)mgr.IsSlotLoggedIn(0)); + } + + // Now the session is guaranteed to be populated (or failed — offline fallback) + if (mgr.IsSlotLoggedIn(0)) + { + auto session = mgr.GetSlotSession(0); + if (!session.username.empty()) + Minecraft::GetInstance()->user->name = std::wstring(session.username.begin(), session.username.end()); + } } #endif #ifndef _XBOX diff --git a/Minecraft.Client/Common/UI/UIScene_MSAuth.cpp b/Minecraft.Client/Common/UI/UIScene_MSAuth.cpp new file mode 100644 index 0000000000..38c1ed4fdc --- /dev/null +++ b/Minecraft.Client/Common/UI/UIScene_MSAuth.cpp @@ -0,0 +1,1712 @@ +#include "stdafx.h" +#include "UI.h" +#include "UIScene_MSAuth.h" +#include "NativeUIRenderer.h" +#include "../../../MCAuth/include/MCAuthManager.h" +#include "../../../MCAuth/include/MCAuth.h" +#include +#include +#include + +using namespace MSAuthUI; + +#ifdef _WINDOWS64 +#include "../../Windows64/KeyboardMouseInput.h" +#include "../../Minecraft.h" +extern KeyboardMouseInput g_KBMInput; +#endif + +// Auto-close ~3 seconds after auth result (~30 fps). +static constexpr int kAutoCloseTicks = 90; + +// Sentinel value: skin PNG downloaded to disk, awaiting texture load on main thread. +static constexpr int TEXTURE_PENDING_LOAD = -3; + +// Max account rows visible at once (scrollable beyond this). +static constexpr int kMaxVisible = 7; + +// Panel dimensions (1280x720 virtual canvas) +static constexpr float kPX = 290.0f, kPY = 85.0f, kPW = 700.0f, kPH = 550.0f; + +// Account row layout — taller rows with space for skin head +static constexpr float kRowH = 52.0f; +static constexpr float kRowGap = 4.0f; +static constexpr float kRowLeft = kPX + 20.0f; +static constexpr float kRowWidth = kPW - 40.0f; +static constexpr float kRemoveBtnW = 100.0f; +static constexpr float kAccountTextW = kRowWidth - kRemoveBtnW - 8.0f; + +// Head icon layout inside rows +static constexpr float kHeadSize = 36.0f; +static constexpr float kHeadPad = 8.0f; +static constexpr float kTextAfterHead = kHeadSize + kHeadPad * 2; + +// List area geometry (starts right after title + divider) +static constexpr float kListTop = kPY + 54.0f; +static constexpr float kListH = kMaxVisible * (kRowH + kRowGap) - kRowGap; + +// Button bar +static constexpr float kBtnY = kPY + kPH - 54.0f; +static constexpr float kBtnH = 40.0f; + +// Constructor +UIScene_MSAuth::UIScene_MSAuth(int iPad, void* initData, UILayer* parentLayer) + : UIScene(iPad, parentLayer) +{ + if (initData) + m_targetSlot = *reinterpret_cast(initData); + else + m_targetSlot = 0; + + initialiseMovie(); + SwitchToAccountList(); +} + +UIScene_MSAuth::~UIScene_MSAuth() +{ + // Invalidate any pending keyboard result so the callback (which may fire + // after this scene is destroyed) writes into the shared buffer harmlessly. + if (m_pendingKBResult) + m_pendingKBResult->valid.store(false, std::memory_order_release); +} + +// Skin texture loading (async download + disk cache) +static const char* kSkinCacheDir = "skins"; + +void UIScene_MSAuth::EnsureSkinLoaded(const std::string& uuid) +{ + if (uuid.empty()) return; + + // Already in cache? + auto it = m_skinCache.find(uuid); + if (it != m_skinCache.end()) { + auto& entry = *it->second; + // If downloaded to disk but not yet loaded as a texture, load it now (main thread) + if (entry.textureId.load() == TEXTURE_PENDING_LOAD) { + int texId = NativeUI::LoadTextureFromFileDirect(entry.filePath.c_str()); + entry.textureId.store(texId >= 0 ? texId : -1); + } + return; + } + + // Create cache entry and start background download + auto entry = std::make_shared(); + entry->textureId.store(-1); // -1 = downloading + entry->filePath = std::string(kSkinCacheDir) + "/" + uuid + ".png"; + m_skinCache[uuid] = entry; + + // Check if already cached on disk + struct stat st; + if (stat(entry->filePath.c_str(), &st) == 0 && st.st_size > 0) { + int texId = NativeUI::LoadTextureFromFileDirect(entry->filePath.c_str()); + entry->textureId.store(texId >= 0 ? texId : -1); + return; + } + + // Determine auth provider for this UUID + std::string skinProvider = "mojang"; + for (auto& acct : m_accounts) { + if (acct.uuid == uuid || MCAuth::UndashUuid(acct.uuid) == uuid) { + skinProvider = acct.authProvider; + break; + } + } + + // Background download + std::string uuidCopy = uuid; + std::thread([entry, uuidCopy, skinProvider]() { + try { + std::string error; + + std::string skinUrl; + if (skinProvider == "elyby") + skinUrl = MCAuth::ElybyFetchProfileSkinUrl(uuidCopy, error); + else + skinUrl = MCAuth::FetchProfileSkinUrl(uuidCopy, error); + if (skinUrl.empty()) { + entry->textureId.store(-1); + return; + } + + auto pngData = MCAuth::FetchSkinPngRaw(skinUrl, error); + if (pngData.empty()) { + entry->textureId.store(-1); + return; + } + + CreateDirectoryA(kSkinCacheDir, nullptr); + + std::ofstream f(entry->filePath, std::ios::binary); + if (!f) { + entry->textureId.store(-1); + return; + } + f.write(reinterpret_cast(pngData.data()), pngData.size()); + f.close(); + + // Signal main thread to load the texture (can't call D3D from here) + entry->textureId.store(TEXTURE_PENDING_LOAD); + } catch (...) { + // Network failure (no internet) — mark skin as unavailable, don't crash + entry->textureId.store(-1); + } + }).detach(); +} + +// View switching +void UIScene_MSAuth::SwitchToAccountList() +{ + m_view = eView_AccountList; + m_authFlags->done.store(false); + m_authFlags->success.store(false); + m_closeCountdown = 0; + m_scrollOffset = 0; + m_pendingRemoveIdx = -1; +} + +void UIScene_MSAuth::SwitchToDeviceCode() +{ + m_view = eView_DeviceCode; + // Create a NEW AuthFlags instance so the old thread's onComplete callback + // (which captured the old shared_ptr) cannot affect this flow. + m_authFlags = std::make_shared(); + m_closeCountdown = 0; + StartAddAccount(); +} + +static int MSAuthKeyboardCallback(LPVOID lpParam, bool bRes) +{ + // lpParam is a heap-allocated shared_ptr; take ownership and delete it. + auto* pShared = reinterpret_cast*>(lpParam); + auto pending = *pShared; + delete pShared; + if (bRes && pending && pending->valid.load(std::memory_order_acquire)) + { + uint16_t text[128]; + ZeroMemory(text, sizeof(text)); + Win64_GetKeyboardText(text, 128); + std::wstring ws(reinterpret_cast(text)); + // Pass through all printable ASCII — ely.by fields need more than alnum+underscore. + // Field-specific validation happens in tick(). + std::string result; + for (auto wc : ws) { + char c = (char)wc; + if (c >= 0x20 && c <= 0x7E) + result += c; + if (result.size() >= 128) break; + } + pending->value = result; + pending->ready.store(true, std::memory_order_release); + } + return 0; +} + +void UIScene_MSAuth::SwitchToOfflineInput() +{ + m_view = eView_OfflineInput; + m_offlineUsername.clear(); + m_offlineCursorBlink = 0; + m_textInputActive = false; +#ifdef _WINDOWS64 + g_KBMInput.ClearCharBuffer(); +#endif +} + +void UIScene_MSAuth::SwitchToElybyInput() +{ + m_view = eView_ElybyInput; + m_elybyUsername.clear(); + m_elybyPassword.clear(); + m_elyby2FACode.clear(); + m_authFlags->need2FA.store(false, std::memory_order_relaxed); + m_elybyActiveField = 0; + m_textInputActive = false; +#ifdef _WINDOWS64 + g_KBMInput.ClearCharBuffer(); +#endif +} + +void UIScene_MSAuth::SwitchToElyby2FA() +{ + m_view = eView_Elyby2FA; + m_elyby2FACode.clear(); + m_elybyActiveField = 2; + m_textInputActive = false; +#ifdef _WINDOWS64 + g_KBMInput.ClearCharBuffer(); +#endif +} + +void UIScene_MSAuth::SubmitElybyLogin() +{ + if (m_elybyUsername.empty() || m_elybyPassword.empty()) return; + m_textInputActive = false; + + auto flags = m_authFlags; + MCAuthManager::Get().BeginAddElybyAccount( + m_elybyUsername, m_elybyPassword, + [flags](bool ok, const MCAuth::JavaSession&, const std::string& error) { + if (error == "elyby_2fa_required") return; // handled by on2FA callback + flags->success.store(ok, std::memory_order_release); + flags->done.store(true, std::memory_order_release); + }, + [flags]() { + flags->need2FA.store(true, std::memory_order_release); + } + ); + m_view = eView_DeviceCode; // Show spinner while authenticating + m_closeCountdown = 0; +} + +void UIScene_MSAuth::SubmitElyby2FA() +{ + if (m_elyby2FACode.empty()) return; + m_textInputActive = false; + + // Retry with password:totp + std::string combinedPassword = m_elybyPassword + ":" + m_elyby2FACode; + + auto flags = m_authFlags; + MCAuthManager::Get().BeginAddElybyAccount( + m_elybyUsername, combinedPassword, + [flags](bool ok, const MCAuth::JavaSession&, const std::string&) { + flags->success.store(ok, std::memory_order_release); + flags->done.store(true, std::memory_order_release); + } + ); + m_view = eView_DeviceCode; // Show spinner + m_closeCountdown = 0; +} + +void UIScene_MSAuth::ConfirmOfflineAccount() +{ + if (m_offlineUsername.empty()) return; + int idx = MCAuthManager::Get().AddOfflineJavaAccount(m_offlineUsername); + if (idx >= 0) + MCAuthManager::Get().SaveJavaAccountIndex(); + SwitchToAccountList(); +} + +void UIScene_MSAuth::StartAddAccount() +{ + // Capture by value so flags outlive the scene + auto flags = m_authFlags; + MCAuthManager::Get().BeginAddJavaAccount( + nullptr, + [flags](bool ok, const MCAuth::JavaSession&, const std::string&) + { + flags->success.store(ok, std::memory_order_release); + flags->done.store(true, std::memory_order_release); + } + ); +} + + +// tick() + +void UIScene_MSAuth::tick() +{ + UIScene::tick(); + ++m_spinnerTick; + + if (m_inputGuardTicks > 0) + --m_inputGuardTicks; + + m_accounts = MCAuthManager::Get().GetJavaAccounts(); + m_activeIdx = MCAuthManager::Get().GetSlot(m_targetSlot).accountIndex; + + // Trigger skin downloads for online accounts (non-blocking) + for (auto& acct : m_accounts) { + if (!acct.isOffline && !acct.uuid.empty()) + EnsureSkinLoaded(acct.uuid); + } + + // Pick up result from virtual keyboard callback (shared_ptr pattern) + if (m_pendingKBResult && m_pendingKBResult->ready.load(std::memory_order_acquire)) + { + if (m_view == eView_ElybyInput) { + if (m_elybyActiveField == 0) + m_elybyUsername = m_pendingKBResult->value; + else + m_elybyPassword = m_pendingKBResult->value; + } else if (m_view == eView_Elyby2FA) { + m_elyby2FACode = m_pendingKBResult->value; + } else { + // Offline username: filter to alnum + underscore, max 16 chars + std::string filtered; + for (char c : m_pendingKBResult->value) { + if (isalnum((unsigned char)c) || c == '_') + filtered += c; + if (filtered.size() >= 16) break; + } + m_offlineUsername = filtered; + } + m_pendingKBResult.reset(); + } + + if (m_view == eView_OfflineInput) + { + ++m_offlineCursorBlink; + + // Deactivate text input if focus moved away from the text box + if (m_textInputActive && !m_focus.IsActive(eBtn_OfflineTextBox)) + m_textInputActive = false; + +#ifdef _WINDOWS64 + // Only consume keyboard input when the text box is active + if (m_textInputActive) + { + wchar_t ch; + while (g_KBMInput.ConsumeChar(ch)) + { + if (ch == 0x08) // backspace + { + if (!m_offlineUsername.empty()) + m_offlineUsername.pop_back(); + } + else if (ch == 0x0D) // enter — confirm and deactivate + { + m_textInputActive = false; + if (!m_offlineUsername.empty()) + ConfirmOfflineAccount(); + } + else if (ch == 0x1B) // escape — deactivate text input + { + m_textInputActive = false; + } + else if (m_offlineUsername.size() < 16) + { + char c = (char)ch; + if (isalnum((unsigned char)c) || c == '_') + m_offlineUsername += c; + } + } + } + else + { + // Drain the char buffer so stale keys don't fire when text box is re-selected + wchar_t ch; + while (g_KBMInput.ConsumeChar(ch)) {} + } +#endif + } + + // Check for ely.by 2FA request (flag lives in shared AuthFlags for async safety) + if (m_authFlags->need2FA.load(std::memory_order_acquire)) + { + m_authFlags->need2FA.store(false, std::memory_order_relaxed); + SwitchToElyby2FA(); + } + + if (m_view == eView_ElybyInput || m_view == eView_Elyby2FA) + { + ++m_offlineCursorBlink; + + // Determine which text box is active based on elybyActiveField + int activeBtn = -1; + if (m_view == eView_ElybyInput) { + if (m_elybyActiveField == 0) activeBtn = eBtn_ElybyUsername; + else if (m_elybyActiveField == 1) activeBtn = eBtn_ElybyPassword; + } else { + activeBtn = eBtn_Elyby2FACode; + } + + if (m_textInputActive && activeBtn >= 0 && !m_focus.IsActive(activeBtn)) + m_textInputActive = false; + +#ifdef _WINDOWS64 + if (m_textInputActive) + { + std::string* targetStr = nullptr; + size_t maxLen = 64; + bool allowAll = true; // allow all printable chars for password/2FA + if (m_view == eView_ElybyInput && m_elybyActiveField == 0) { + targetStr = &m_elybyUsername; maxLen = 64; allowAll = true; + } else if (m_view == eView_ElybyInput && m_elybyActiveField == 1) { + targetStr = &m_elybyPassword; maxLen = 128; allowAll = true; + } else if (m_view == eView_Elyby2FA) { + targetStr = &m_elyby2FACode; maxLen = 10; allowAll = false; + } + + if (targetStr) + { + wchar_t ch; + while (g_KBMInput.ConsumeChar(ch)) + { + if (ch == 0x08) { // backspace + if (!targetStr->empty()) targetStr->pop_back(); + } + else if (ch == 0x09) { // tab — move to next field + m_textInputActive = false; + if (m_view == eView_ElybyInput) { + m_elybyActiveField = (m_elybyActiveField + 1) % 2; + m_textInputActive = true; + g_KBMInput.ClearCharBuffer(); + } + } + else if (ch == 0x0D) { // enter + m_textInputActive = false; + if (m_view == eView_ElybyInput) + SubmitElybyLogin(); + else + SubmitElyby2FA(); + } + else if (ch == 0x1B) { + m_textInputActive = false; + } + else if (targetStr->size() < maxLen) { + char c = (char)ch; + if (allowAll && c >= 0x20 && c <= 0x7E) + *targetStr += c; + else if (!allowAll && c >= '0' && c <= '9') + *targetStr += c; + } + } + } + } + else + { + wchar_t ch; + while (g_KBMInput.ConsumeChar(ch)) {} + } +#endif + } + + if (m_view == eView_DeviceCode) + { + if (m_authFlags->done.load(std::memory_order_acquire)) + { + m_authFlags->done.store(false, std::memory_order_relaxed); + m_closeCountdown = kAutoCloseTicks; + } + + if (m_closeCountdown > 0) + { + if (--m_closeCountdown <= 0) + SwitchToAccountList(); + } + + m_cachedUri = MCAuthManager::Get().GetJavaDirectUri(); + } + + // ---- Build focus list ---- + int prevFocus = m_focus.GetFocused(); + m_focus.Clear(); + + if (m_view == eView_AccountList) + { + if (m_pendingRemoveIdx >= 0) + { + // Confirm dialog — only Yes/No buttons, No is first (default) + float dlgW = 360.0f, dlgH = 160.0f; + float dlgX = 640.0f - dlgW * 0.5f, dlgY = 360.0f - dlgH * 0.5f; + float dbtnW = 140.0f, dbtnH = 36.0f; + float dbtnY = dlgY + dlgH - 48.0f; + float gap = 16.0f; + float dbtnStartX = dlgX + (dlgW - dbtnW * 2 - gap) * 0.5f; + // No first (default selected), Yes second + m_focus.Add(eBtn_ConfirmNo, dbtnStartX, dbtnY, dbtnW, dbtnH); + m_focus.Add(eBtn_ConfirmYes, dbtnStartX + dbtnW + gap, dbtnY, dbtnW, dbtnH); + } + else + { + int visible = (int)m_accounts.size() - m_scrollOffset; + if (visible > kMaxVisible) visible = kMaxVisible; + + for (int i = 0; i < visible; ++i) + { + float y = kListTop + i * (kRowH + kRowGap); + m_focus.Add(eAccountBase + m_scrollOffset + i, kRowLeft, y, kAccountTextW, kRowH); + m_focus.Add(eBtn_RemoveBase + m_scrollOffset + i, + kRowLeft + kAccountTextW + 8.0f, y, kRemoveBtnW, kRowH); + } + + // Bottom buttons — centered quad + float totalBtnW = 160.0f + 8.0f + 120.0f + 8.0f + 160.0f + 8.0f + 140.0f; + float btnStartX = kPX + (kPW - totalBtnW) * 0.5f; + m_focus.Add(eBtn_AddAccount, btnStartX, kBtnY, 160.0f, kBtnH); + m_focus.Add(eBtn_AddElyby, btnStartX + 168.0f, kBtnY, 120.0f, kBtnH); + m_focus.Add(eBtn_AddOffline, btnStartX + 296.0f, kBtnY, 160.0f, kBtnH); + m_focus.Add(eBtn_Back, btnStartX + 464.0f, kBtnY, 140.0f, kBtnH); + } + } + else if (m_view == eView_OfflineInput) + { + // Text box — selectable input field + const float bx = kPX + 120.0f, by = kPY + 156.0f; + const float bw = kPW - 240.0f, bh = 52.0f; + m_focus.Add(eBtn_OfflineTextBox, bx, by, bw, bh); + + // Bottom buttons + float totalBtnW = 200.0f + 12.0f + 200.0f; + float btnStartX = kPX + (kPW - totalBtnW) * 0.5f; + m_focus.Add(eBtn_OfflineConfirm, btnStartX, kBtnY, 200.0f, kBtnH); + m_focus.Add(eBtn_Back, btnStartX + 212.0f, kBtnY, 200.0f, kBtnH); + } + else if (m_view == eView_ElybyInput) + { + const float bx = kPX + 120.0f, bw = kPW - 240.0f, bh = 44.0f; + m_focus.Add(eBtn_ElybyUsername, bx, kPY + 130.0f, bw, bh); + m_focus.Add(eBtn_ElybyPassword, bx, kPY + 210.0f, bw, bh); + + float totalBtnW = 160.0f + 12.0f + 160.0f; + float btnStartX = kPX + (kPW - totalBtnW) * 0.5f; + m_focus.Add(eBtn_ElybySignIn, btnStartX, kBtnY, 160.0f, kBtnH); + m_focus.Add(eBtn_ElybyCancel, btnStartX + 172.0f, kBtnY, 160.0f, kBtnH); + } + else if (m_view == eView_Elyby2FA) + { + const float bx = kPX + 160.0f, bw = kPW - 320.0f, bh = 44.0f; + m_focus.Add(eBtn_Elyby2FACode, bx, kPY + 160.0f, bw, bh); + + float totalBtnW = 160.0f + 12.0f + 160.0f; + float btnStartX = kPX + (kPW - totalBtnW) * 0.5f; + m_focus.Add(eBtn_Elyby2FASubmit, btnStartX, kBtnY, 160.0f, kBtnH); + m_focus.Add(eBtn_Elyby2FACancel, btnStartX + 172.0f, kBtnY, 160.0f, kBtnH); + } + else // eView_DeviceCode + { + if (!m_cachedUri.empty()) + { + float tw = 0, th = 0; + std::wstring wuri(m_cachedUri.begin(), m_cachedUri.end()); + NativeUI::MeasureText(wuri.c_str(), 15.0f, &tw, &th); + float padX = 8.0f, padY = 4.0f; + float linkX = 640.0f - tw * 0.5f - padX; + float linkY = kPY + 128.0f - padY; + m_focus.Add(eLink_URL, linkX, linkY, tw + padX * 2, th + padY * 2 + 2); + } + m_focus.Add(eBtn_Back, kPX + (kPW - 200.0f) * 0.5f, kBtnY, 200.0f, kBtnH); + } + + if (prevFocus >= 0) + m_focus.SetFocus(prevFocus); + + m_focus.TickMouse(); +} + + +// Helper: draw a step number circle + +static void DrawStepCircle(float cx, float cy, int number, bool completed) +{ + uint32_t circleColor = completed ? 0xFF55DD55u : 0xFF3C6E96u; + NativeUI::DrawRoundedRect(cx - 14, cy - 14, 28, 28, 14.0f, circleColor); + + wchar_t num[4]; + num[0] = L'0' + number; + num[1] = 0; + NativeUI::DrawShadowText(cx, cy, completed ? L"\u2713" : num, + 0xFFFFFFFFu, 14.0f, + NativeUI::ALIGN_CENTER_X | NativeUI::ALIGN_CENTER_Y); +} + + +// Helper: draw a Steve head placeholder (or skin head when available) + +static void DrawHeadPlaceholder(float x, float y, float size) +{ + // Outer border — dark stone grey + NativeUI::DrawRoundedRect(x, y, size, size, 3.0f, 0xFF2A2A2Au); + // Inner — slightly lighter (Steve skin tone placeholder) + NativeUI::DrawRoundedRect(x + 2, y + 2, size - 4, size - 4, 2.0f, 0xFF4A3728u); + // Eyes + float eyeSize = size * 0.12f; + float eyeY = y + size * 0.42f; + NativeUI::DrawRect(x + size * 0.25f, eyeY, eyeSize, eyeSize, 0xFFFFFFFFu); + NativeUI::DrawRect(x + size * 0.63f, eyeY, eyeSize, eyeSize, 0xFFFFFFFFu); +} + + +// Helper: draw a player head from a skin texture (face + hat overlay) + +static void DrawSkinHead(float x, float y, float size, int skinTexId) +{ + // Face layer: pixels (8,8)-(16,16) in 64x64 skin + NativeUI::DrawTextureUV(x, y, size, size, skinTexId, + 8.0f / 64.0f, 8.0f / 64.0f, + 16.0f / 64.0f, 16.0f / 64.0f); + // Hat overlay layer: pixels (40,8)-(48,16) in 64x64 skin + NativeUI::DrawTextureUV(x, y, size, size, skinTexId, + 40.0f / 64.0f, 8.0f / 64.0f, + 48.0f / 64.0f, 16.0f / 64.0f); +} + +// Skin cache type alias for static render functions +using SkinCacheMap = std::unordered_map>; + + +// Render — Account List View + +// Returns 1-based player number if account at accountIndex is assigned to another slot, 0 if free. +static int GetSlotUsingAccount(int selfSlot, int accountIndex) +{ + for (int i = 0; i < XUSER_MAX_COUNT; ++i) { + if (i == selfSlot) continue; + if (MCAuthManager::Get().GetSlot(i).accountIndex == accountIndex) + return i + 1; // 1-based player number + } + return 0; +} + +static void RenderAccountList(const NativeUI::NineSlice& panel, + const NativeUI::NineSlice& recessPanel, + const std::vector& accounts, + int activeIdx, int scrollOffset, int spinnerTick, + NativeUI::FocusList& focus, + const SkinCacheMap& skinCache, + int pendingRemoveIdx, + int targetSlot) +{ + // Main panel + if (panel.valid) + NativeUI::DrawNineSlice(kPX, kPY, kPW, kPH, panel); + else + NativeUI::DrawPanel(kPX, kPY, kPW, kPH); + + // Title + NativeUI::DrawShadowText(640.0f, kPY + 14.0f, L"Account Manager", + 0xFFFFFFFFu, 22.0f, NativeUI::ALIGN_CENTER_X); + + NativeUI::DrawDivider(kPX + 20.0f, kPY + 44.0f, kPW - 40.0f, 0x40FFFFFFu); + + // Account list area + if (accounts.empty()) + { + // Empty state — centered message + float centerY = kListTop + kListH * 0.35f; + NativeUI::DrawShadowText(640.0f, centerY, L"No accounts saved", + 0xFFAAAAAA, 18.0f, NativeUI::ALIGN_CENTER_X); + NativeUI::DrawShadowText(640.0f, centerY + 28.0f, + L"Press \"Add Account\" to sign in with Microsoft", + 0xFF777777u, 13.0f, NativeUI::ALIGN_CENTER_X); + NativeUI::DrawShadowText(640.0f, centerY + 48.0f, + L"or \"Add Offline\" to play without authentication.", + 0xFF777777u, 13.0f, NativeUI::ALIGN_CENTER_X); + } + else + { + int visible = (int)accounts.size() - scrollOffset; + if (visible > kMaxVisible) visible = kMaxVisible; + + // Recessed list background + const float recessMargin = 6.0f; + float rX = kRowLeft - recessMargin; + float rY = kListTop - recessMargin; + float rW = kRowWidth + recessMargin * 2; + float rH = kListH + recessMargin * 2; + + if (recessPanel.valid) + NativeUI::DrawNineSlice(rX, rY, rW, rH, recessPanel); + else + NativeUI::DrawRoundedRect(rX, rY, rW, rH, 4.0f, 0x60000000u); + + // Clip region for scrollable list + NativeUI::PushClipRect(kPX, kListTop, kPW, kListH); + + for (int i = 0; i < visible; ++i) + { + int idx = scrollOffset + i; + auto& acct = accounts[idx]; + float y = kListTop + i * (kRowH + kRowGap); + + bool isActive = (idx == activeIdx); + bool rowFocused = focus.ShowFocus(eAccountBase + idx); + bool rowHovered = focus.IsHovered(eAccountBase + idx); + + // Row background — MC style + uint32_t rowBg = 0x30000000u; + if (isActive) rowBg = 0x4000AA00u; + if (rowHovered) rowBg = 0x50FFFFFF; + if (rowFocused) rowBg = 0x60FFFFFF; + NativeUI::DrawRoundedRect(kRowLeft, y, kAccountTextW, kRowH, 4.0f, rowBg); + + // Focus/hover border highlight + if (rowFocused || rowHovered) + { + uint32_t borderColor = rowFocused ? 0xAAFFFFFFu : 0x60FFFFFFu; + NativeUI::DrawRoundedBorder(kRowLeft, y, kAccountTextW, kRowH, + 4.0f, 1.0f, borderColor); + } + + // Active indicator — green left bar + if (isActive) + NativeUI::DrawRoundedRect(kRowLeft, y, 4.0f, kRowH, 2.0f, 0xFF55DD55u); + + // Player head — skin texture for online, Steve placeholder for offline + float headX = kRowLeft + kHeadPad; + float headY = y + (kRowH - kHeadSize) * 0.5f; + bool headDrawn = false; + + if (!acct.isOffline && !acct.uuid.empty()) { + auto skinIt = skinCache.find(acct.uuid); + if (skinIt != skinCache.end()) { + int texId = skinIt->second->textureId.load(); + if (texId >= 0) { + DrawSkinHead(headX, headY, kHeadSize, texId); + headDrawn = true; + } + } + } + + if (!headDrawn) { + if (acct.isOffline) + DrawHeadPlaceholder(headX, headY, kHeadSize); + else { + // Loading placeholder — dark square with spinner + NativeUI::DrawRoundedRect(headX, headY, kHeadSize, kHeadSize, 3.0f, 0xFF222222u); + } + } + + // Username — always after head area + float textX = kRowLeft + kTextAfterHead; + float textY = y + 8.0f; + std::wstring wname; + if (acct.username.empty()) + wname = L"(refreshing...)"; + else + wname = std::wstring(acct.username.begin(), acct.username.end()); + + uint32_t nameColor = (rowFocused || rowHovered) ? 0xFFFFFF55u : 0xFFFFFFFFu; + NativeUI::DrawShadowText(textX, textY, wname.c_str(), + nameColor, 16.0f, NativeUI::ALIGN_LEFT); + + // Subtitle line: type badge + UUID + float subY = textY + 21.0f; + + // Type badge pill + { + const wchar_t* badge; + uint32_t pillBg, pillTxt; + if (acct.isOffline) { + badge = L"Offline"; pillBg = 0x50AA8800u; pillTxt = 0xFFCCBB55u; + } else if (acct.authProvider == "elyby") { + badge = L"Ely.by"; pillBg = 0x5000AAAAu; pillTxt = 0xFF77CCCCu; + } else { + badge = L"Microsoft"; pillBg = 0x5000AA00u; pillTxt = 0xFF77CC77u; + } + + float tw = 0, th = 0; + NativeUI::MeasureText(badge, 9.0f, &tw, &th); + NativeUI::DrawRoundedRect(textX, subY, tw + 8.0f, th + 4.0f, 3.0f, pillBg); + NativeUI::DrawText(textX + 4.0f, subY + 2.0f, badge, + pillTxt, 9.0f, NativeUI::ALIGN_LEFT); + + // UUID after the pill + if (!acct.uuid.empty()) + { + std::wstring wuuid(acct.uuid.begin(), acct.uuid.end()); + if (wuuid.size() > 13) + wuuid = wuuid.substr(0, 8) + L"..."; + NativeUI::DrawText(textX + tw + 16.0f, subY + 2.0f, wuuid.c_str(), + 0xFF666666u, 9.0f, NativeUI::ALIGN_LEFT); + } + } + + // Active badge or "Player N" badge on the right + int usedByPlayer = GetSlotUsingAccount(targetSlot, idx); + if (isActive) + { + float aw = 0, ah = 0; + NativeUI::MeasureText(L"ACTIVE", 10.0f, &aw, &ah); + float ax = kRowLeft + kAccountTextW - aw - 12.0f; + float ay = y + (kRowH - ah - 4.0f) * 0.5f; + NativeUI::DrawRoundedRect(ax, ay, aw + 8.0f, ah + 4.0f, 3.0f, 0x6055DD55u); + NativeUI::DrawText(ax + 4.0f, ay + 2.0f, L"ACTIVE", + 0xFF55DD55u, 10.0f, NativeUI::ALIGN_LEFT); + } + else if (usedByPlayer > 0) + { + wchar_t label[24]; + swprintf(label, 24, L"Player %d", usedByPlayer); + float aw = 0, ah = 0; + NativeUI::MeasureText(label, 10.0f, &aw, &ah); + float ax = kRowLeft + kAccountTextW - aw - 12.0f; + float ay = y + (kRowH - ah - 4.0f) * 0.5f; + NativeUI::DrawRoundedRect(ax, ay, aw + 8.0f, ah + 4.0f, 3.0f, 0x60DD8855u); + NativeUI::DrawText(ax + 4.0f, ay + 2.0f, label, + 0xFFDD8855u, 10.0f, NativeUI::ALIGN_LEFT); + } + + // Remove button + float rmX = kRowLeft + kAccountTextW + 8.0f; + bool rmFocused = focus.ShowFocus(eBtn_RemoveBase + idx); + bool rmHovered = focus.IsHovered(eBtn_RemoveBase + idx); + NativeUI::DrawButton(rmX, y + 6.0f, kRemoveBtnW, kRowH - 12.0f, L"Remove", + rmFocused, rmHovered, 12.0f); + } + + NativeUI::PopClipRect(); + + // Scroll arrow indicators — side by side, below the list on the right + { + static int sTexUp = -2; + static int sTexDown = -2; + if (sTexUp == -2) sTexUp = NativeUI::LoadTextureFromFileDirect("Common/Media/Graphics/scrollUp.png"); + if (sTexDown == -2) sTexDown = NativeUI::LoadTextureFromFileDirect("Common/Media/Graphics/scrollDown.png"); + + // Arrows are 32x22 native, render at ~1.2x scale + const float arrowW = 38.0f, arrowH = 26.0f; + const float gap = 4.0f; + const float arrowY = kListTop + kListH + 4.0f; + const float arrowX = kPX + kPW - 20.0f - arrowW * 2 - gap; + + bool canUp = scrollOffset > 0; + bool canDown = scrollOffset + kMaxVisible < (int)accounts.size(); + + if (canUp && sTexUp >= 0) + NativeUI::DrawTexture(arrowX, arrowY, arrowW, arrowH, sTexUp); + if (canDown && sTexDown >= 0) + NativeUI::DrawTexture(arrowX + arrowW + gap, arrowY, arrowW, arrowH, sTexDown); + } + } + + // Divider above buttons + NativeUI::DrawDivider(kPX + 20.0f, kBtnY - 12.0f, kPW - 40.0f, 0x30FFFFFFu); + + // Bottom buttons — centered quad + float totalBtnW = 160.0f + 8.0f + 120.0f + 8.0f + 160.0f + 8.0f + 140.0f; + float btnStartX = kPX + (kPW - totalBtnW) * 0.5f; + NativeUI::DrawButton(btnStartX, kBtnY, 160.0f, kBtnH, L"Microsoft", + focus.ShowFocus(eBtn_AddAccount), focus.IsHovered(eBtn_AddAccount)); + NativeUI::DrawButton(btnStartX + 168.0f, kBtnY, 120.0f, kBtnH, L"Ely.by", + focus.ShowFocus(eBtn_AddElyby), focus.IsHovered(eBtn_AddElyby)); + NativeUI::DrawButton(btnStartX + 296.0f, kBtnY, 160.0f, kBtnH, L"Offline", + focus.ShowFocus(eBtn_AddOffline), focus.IsHovered(eBtn_AddOffline)); + NativeUI::DrawButton(btnStartX + 464.0f, kBtnY, 140.0f, kBtnH, L"Done", + focus.ShowFocus(eBtn_Back), focus.IsHovered(eBtn_Back)); + + // ---- Remove confirmation dialog ---- + if (pendingRemoveIdx >= 0 && pendingRemoveIdx < (int)accounts.size()) + { + // Dim everything behind the dialog + NativeUI::DrawRect(kPX, kPY, kPW, kPH, 0xA0000000u); + + // Dialog panel — same 9-slice as the main panel (authentic MC look) + float dlgW = 400.0f, dlgH = 170.0f; + float dlgX = 640.0f - dlgW * 0.5f, dlgY = 360.0f - dlgH * 0.5f; + + if (panel.valid) + NativeUI::DrawNineSlice(dlgX, dlgY, dlgW, dlgH, panel); + else + NativeUI::DrawPanel(dlgX, dlgY, dlgW, dlgH); + + // Title + NativeUI::DrawShadowText(640.0f, dlgY + 16.0f, L"Remove Account?", + 0xFFFFFFFFu, 18.0f, NativeUI::ALIGN_CENTER_X); + + NativeUI::DrawDivider(dlgX + 16.0f, dlgY + 42.0f, dlgW - 32.0f, 0x40FFFFFFu); + + // Account name being removed + std::wstring removeName(accounts[pendingRemoveIdx].username.begin(), + accounts[pendingRemoveIdx].username.end()); + if (removeName.empty()) removeName = L"(unknown)"; + + std::wstring removeMsg = L"\"" + removeName + L"\""; + NativeUI::DrawShadowText(640.0f, dlgY + 54.0f, removeMsg.c_str(), + 0xFFFFFF55u, 16.0f, NativeUI::ALIGN_CENTER_X); + + NativeUI::DrawShadowText(640.0f, dlgY + 78.0f, L"will be removed from this device.", + 0xFFFFFFFFu, 13.0f, NativeUI::ALIGN_CENTER_X); + + // Buttons: [Cancel] [Remove] — Cancel is default (left, first in focus list) + float dbtnW = 160.0f, dbtnH = 36.0f; + float dbtnY = dlgY + dlgH - 52.0f; + float gap = 12.0f; + float dbtnStartX = dlgX + (dlgW - dbtnW * 2 - gap) * 0.5f; + + NativeUI::DrawButton(dbtnStartX, dbtnY, dbtnW, dbtnH, L"Cancel", + focus.ShowFocus(eBtn_ConfirmNo), focus.IsHovered(eBtn_ConfirmNo)); + NativeUI::DrawButton(dbtnStartX + dbtnW + gap, dbtnY, dbtnW, dbtnH, L"Remove", + focus.ShowFocus(eBtn_ConfirmYes), focus.IsHovered(eBtn_ConfirmYes)); + } +} + + +// Render — Device Code View + +static void RenderDeviceCode(const NativeUI::NineSlice& panel, + int spinnerTick, int closeCountdown, + bool authSuccess, + NativeUI::FocusList& focus, + MCAuthManager& auth) +{ + if (panel.valid) + NativeUI::DrawNineSlice(kPX, kPY, kPW, kPH, panel); + else + NativeUI::DrawPanel(kPX, kPY, kPW, kPH); + + NativeUI::DrawShadowText(640.0f, kPY + 14.0f, L"Sign In with Microsoft", + 0xFFFFFFFFu, 22.0f, NativeUI::ALIGN_CENTER_X); + + NativeUI::DrawDivider(kPX + 20.0f, kPY + 44.0f, kPW - 40.0f, 0x40FFFFFFu); + + auto state = auth.GetState(); + + // --- Success state --- + if (state == MCAuthManager::State::Success && (closeCountdown > 0 || authSuccess)) + { + // Big checkmark + NativeUI::DrawRoundedRect(640.0f - 30, kPY + 140.0f, 60, 60, 30.0f, 0xFF55DD55u); + NativeUI::DrawShadowText(640.0f, kPY + 170.0f, L"\u2713", + 0xFFFFFFFFu, 32.0f, + NativeUI::ALIGN_CENTER_X | NativeUI::ALIGN_CENTER_Y); + + NativeUI::DrawShadowText(640.0f, kPY + 218.0f, L"Signed in successfully!", + 0xFF55DD55u, 22.0f, NativeUI::ALIGN_CENTER_X); + + MCAuth::JavaSession s = auth.GetJavaSession(); + if (!s.username.empty()) + { + const std::wstring wname(s.username.begin(), s.username.end()); + NativeUI::DrawShadowText(640.0f, kPY + 252.0f, wname.c_str(), + 0xFFFFFFFFu, 18.0f, NativeUI::ALIGN_CENTER_X); + } + + // Progress bar for auto-close countdown + float progress = closeCountdown > 0 ? (float)closeCountdown / kAutoCloseTicks : 1.0f; + NativeUI::DrawProgressBar(kPX + 100.0f, kPY + 290.0f, kPW - 200.0f, 6.0f, + progress, 0xFF55DD55u, 0xFF222222u); + NativeUI::DrawText(640.0f, kPY + 302.0f, L"Returning to account list...", + 0xFF888888u, 11.0f, NativeUI::ALIGN_CENTER_X); + } + // --- Failure state --- + else if (state == MCAuthManager::State::Failed) + { + // Red X + NativeUI::DrawRoundedRect(640.0f - 30, kPY + 140.0f, 60, 60, 30.0f, 0xFFDD5555u); + NativeUI::DrawShadowText(640.0f, kPY + 170.0f, L"X", + 0xFFFFFFFFu, 28.0f, + NativeUI::ALIGN_CENTER_X | NativeUI::ALIGN_CENTER_Y); + + NativeUI::DrawShadowText(640.0f, kPY + 218.0f, L"Sign-in failed", + 0xFFDD5555u, 22.0f, NativeUI::ALIGN_CENTER_X); + + std::string err = auth.GetLastError(); + if (!err.empty()) + { + const std::wstring werr(err.begin(), err.end()); + NativeUI::DrawTextWrapped(640.0f, kPY + 252.0f, werr.c_str(), + kPW - 120.0f, 0xFF888888u, 12.0f, + NativeUI::ALIGN_CENTER_X); + } + } + // --- Waiting for code / Authenticating --- + else if (state == MCAuthManager::State::WaitingForCode || + state == MCAuthManager::State::Authenticating) + { + const std::string code = auth.GetJavaDeviceCode(); + const std::string uri = auth.GetJavaDirectUri(); + + if (code.empty()) + { + // Still loading + NativeUI::DrawSpinner(640.0f, kPY + 200.0f, 24.0f, spinnerTick); + NativeUI::DrawShadowText(640.0f, kPY + 240.0f, L"Connecting to Microsoft...", + 0xFFAAAAAA, 14.0f, NativeUI::ALIGN_CENTER_X); + } + else + { + // Step 1: Visit URL + float stepY1 = kPY + 66.0f; + DrawStepCircle(kPX + 50.0f, stepY1 + 10.0f, 1, + state == MCAuthManager::State::Authenticating); + + NativeUI::DrawShadowText(kPX + 76.0f, stepY1 - 2.0f, + L"Open this link on any device:", + 0xFFCCCCCCu, 13.0f, NativeUI::ALIGN_LEFT); + + if (!uri.empty()) + { + const std::wstring wuri(uri.begin(), uri.end()); + NativeUI::DrawLink(640.0f, kPY + 128.0f, wuri.c_str(), + uri.c_str(), + focus.ShowFocus(eLink_URL), + focus.IsHovered(eLink_URL), + 15.0f, NativeUI::ALIGN_CENTER_X); + } + + // Step 2: Enter code + float stepY2 = kPY + 168.0f; + DrawStepCircle(kPX + 50.0f, stepY2 + 10.0f, 2, false); + + NativeUI::DrawShadowText(kPX + 76.0f, stepY2 - 2.0f, + L"Enter this code:", + 0xFFCCCCCCu, 13.0f, NativeUI::ALIGN_LEFT); + + // Code box — prominent centered display + const float bx = kPX + 120.0f, by2 = kPY + 200.0f; + const float bw = kPW - 240.0f, bh = 68.0f; + NativeUI::DrawTextBox(bx, by2, bw, bh); + + const std::wstring wcode(code.begin(), code.end()); + NativeUI::DrawShadowText(640.0f, by2 + 14.0f, wcode.c_str(), + 0xFFFFFFFFu, 32.0f, NativeUI::ALIGN_CENTER_X); + + // Status indicator + float statusY = kPY + 290.0f; + NativeUI::DrawSpinner(600.0f, statusY + 6.0f, 8.0f, spinnerTick); + const wchar_t* status = (state == MCAuthManager::State::Authenticating) + ? L"Authenticating..." + : L"Waiting for you to sign in..."; + NativeUI::DrawText(616.0f, statusY, status, + 0xFF888888u, 12.0f, NativeUI::ALIGN_LEFT); + } + } + else + { + // Idle / initial state + NativeUI::DrawSpinner(640.0f, kPY + 220.0f, 20.0f, spinnerTick); + } + + // Divider above button + NativeUI::DrawDivider(kPX + 20.0f, kBtnY - 12.0f, kPW - 40.0f, 0x30FFFFFFu); + + // Cancel button — centered + NativeUI::DrawButton(kPX + (kPW - 200.0f) * 0.5f, kBtnY, 200.0f, kBtnH, + L"Cancel", + focus.ShowFocus(eBtn_Back), focus.IsHovered(eBtn_Back)); +} + + +// Render — Offline Username Input View + +static void RenderOfflineInput(const NativeUI::NineSlice& panel, + const std::string& username, + int cursorBlink, + NativeUI::FocusList& focus, + bool textInputActive) +{ + if (panel.valid) + NativeUI::DrawNineSlice(kPX, kPY, kPW, kPH, panel); + else + NativeUI::DrawPanel(kPX, kPY, kPW, kPH); + + NativeUI::DrawShadowText(640.0f, kPY + 14.0f, L"Add Offline Account", + 0xFFFFFFFFu, 22.0f, NativeUI::ALIGN_CENTER_X); + + NativeUI::DrawDivider(kPX + 20.0f, kPY + 44.0f, kPW - 40.0f, 0x40FFFFFFu); + + // Description + NativeUI::DrawShadowText(640.0f, kPY + 68.0f, + L"Choose a username for offline play", + 0xFFDDDDDDu, 16.0f, NativeUI::ALIGN_CENTER_X); + NativeUI::DrawShadowText(640.0f, kPY + 92.0f, + L"Letters, numbers and underscores only (max 16)", + 0xFFAAAAAAu, 13.0f, NativeUI::ALIGN_CENTER_X); + + // Username label + NativeUI::DrawShadowText(640.0f, kPY + 134.0f, L"Username", + 0xFFDDDDDDu, 14.0f, NativeUI::ALIGN_CENTER_X); + + // Text input box — wider, centered + const float bx = kPX + 120.0f, by = kPY + 156.0f; + const float bw = kPW - 240.0f, bh = 52.0f; + + bool tbFocused = focus.ShowFocus(eBtn_OfflineTextBox); + bool tbHovered = focus.IsHovered(eBtn_OfflineTextBox); + + // Draw text box with focus highlight + NativeUI::DrawTextBox(bx, by, bw, bh, + (tbFocused || textInputActive) ? 0xFFCCCCFFu : 0xFFFFFFFFu); + + if (tbFocused || tbHovered) + NativeUI::DrawRoundedBorder(bx - 1, by - 1, bw + 2, bh + 2, 2.0f, 1.5f, + textInputActive ? 0xFFFFFF55u : 0xAAFFFFFFu); + + // Display the typed username + std::wstring display(username.begin(), username.end()); + + // Only show blinking cursor when text input is active + if (textInputActive && (cursorBlink / 25) % 2 == 0) + display += L"_"; + + if (display.empty()) + { + // Placeholder text + const wchar_t* hint = textInputActive + ? L"Type a username..." + : L"Select to type a username"; + NativeUI::DrawText(bx + 14.0f, by + 14.0f, hint, + 0xFF555555u, 18.0f, NativeUI::ALIGN_LEFT); + } + + NativeUI::DrawShadowText(bx + 14.0f, by + 14.0f, display.c_str(), + 0xFFFFFFFFu, 20.0f, NativeUI::ALIGN_LEFT); + + // Character counter + { + char hint[32]; + snprintf(hint, sizeof(hint), "%d / 16", (int)username.size()); + std::wstring whint(hint, hint + strlen(hint)); + uint32_t hintColor = username.size() >= 14 ? 0xFFDD8855u : 0xFF666666u; + NativeUI::DrawText(bx + bw - 8.0f, by + bh + 6.0f, whint.c_str(), + hintColor, 10.0f, NativeUI::ALIGN_RIGHT); + } + + // Steve head preview + float previewY = kPY + 240.0f; + if (!username.empty()) + { + DrawHeadPlaceholder(640.0f - 24.0f, previewY, 48.0f); + std::wstring wname(username.begin(), username.end()); + NativeUI::DrawShadowText(640.0f, previewY + 56.0f, wname.c_str(), + 0xFFFFFFFFu, 14.0f, NativeUI::ALIGN_CENTER_X); + NativeUI::DrawText(640.0f, previewY + 74.0f, L"Offline Account", + 0xFFAAAA55u, 10.0f, NativeUI::ALIGN_CENTER_X); + } + + // Divider above buttons + NativeUI::DrawDivider(kPX + 20.0f, kBtnY - 12.0f, kPW - 40.0f, 0x30FFFFFFu); + + // Bottom buttons — centered pair + float totalBtnW = 200.0f + 12.0f + 200.0f; + float btnStartX = kPX + (kPW - totalBtnW) * 0.5f; + NativeUI::DrawButton(btnStartX, kBtnY, 200.0f, kBtnH, L"Confirm", + focus.ShowFocus(eBtn_OfflineConfirm), + focus.IsHovered(eBtn_OfflineConfirm)); + NativeUI::DrawButton(btnStartX + 212.0f, kBtnY, 200.0f, kBtnH, L"Cancel", + focus.ShowFocus(eBtn_Back), focus.IsHovered(eBtn_Back)); +} + + +// Render — Ely.by Login Input View + +static void RenderElybyInput(const NativeUI::NineSlice& panel, + const std::string& username, + const std::string& password, + int cursorBlink, + NativeUI::FocusList& focus, + bool textInputActive, + int activeField) +{ + if (panel.valid) + NativeUI::DrawNineSlice(kPX, kPY, kPW, kPH, panel); + else + NativeUI::DrawPanel(kPX, kPY, kPW, kPH); + + NativeUI::DrawShadowText(640.0f, kPY + 14.0f, L"Sign In with Ely.by", + 0xFFFFFFFFu, 22.0f, NativeUI::ALIGN_CENTER_X); + NativeUI::DrawDivider(kPX + 20.0f, kPY + 44.0f, kPW - 40.0f, 0x40FFFFFFu); + + NativeUI::DrawShadowText(640.0f, kPY + 62.0f, + L"Enter your ely.by username and password", + 0xFFDDDDDDu, 14.0f, NativeUI::ALIGN_CENTER_X); + + const float bx = kPX + 120.0f, bw = kPW - 240.0f, bh = 44.0f; + + // Username field + NativeUI::DrawShadowText(bx, kPY + 112.0f, L"Username / Email", + 0xFFDDDDDDu, 12.0f, NativeUI::ALIGN_LEFT); + { + bool tbFocused = focus.ShowFocus(eBtn_ElybyUsername); + bool tbHovered = focus.IsHovered(eBtn_ElybyUsername); + bool isActive = textInputActive && activeField == 0; + NativeUI::DrawTextBox(bx, kPY + 130.0f, bw, bh, + isActive ? 0xFFCCCCFFu : 0xFFFFFFFFu); + if (tbFocused || tbHovered) + NativeUI::DrawRoundedBorder(bx - 1, kPY + 129.0f, bw + 2, bh + 2, 2.0f, 1.5f, + isActive ? 0xFFFFFF55u : 0xAAFFFFFFu); + std::wstring display(username.begin(), username.end()); + if (isActive && (cursorBlink / 25) % 2 == 0) display += L"_"; + if (display.empty()) + NativeUI::DrawText(bx + 10.0f, kPY + 142.0f, L"Username or email...", + 0xFF555555u, 16.0f, NativeUI::ALIGN_LEFT); + NativeUI::DrawShadowText(bx + 10.0f, kPY + 142.0f, display.c_str(), + 0xFFFFFFFFu, 16.0f, NativeUI::ALIGN_LEFT); + } + + // Password field + NativeUI::DrawShadowText(bx, kPY + 192.0f, L"Password", + 0xFFDDDDDDu, 12.0f, NativeUI::ALIGN_LEFT); + { + bool tbFocused = focus.ShowFocus(eBtn_ElybyPassword); + bool tbHovered = focus.IsHovered(eBtn_ElybyPassword); + bool isActive = textInputActive && activeField == 1; + NativeUI::DrawTextBox(bx, kPY + 210.0f, bw, bh, + isActive ? 0xFFCCCCFFu : 0xFFFFFFFFu); + if (tbFocused || tbHovered) + NativeUI::DrawRoundedBorder(bx - 1, kPY + 209.0f, bw + 2, bh + 2, 2.0f, 1.5f, + isActive ? 0xFFFFFF55u : 0xAAFFFFFFu); + // Render dots for password + std::wstring dots(password.size(), L'\u2022'); + if (isActive && (cursorBlink / 25) % 2 == 0) dots += L"_"; + if (dots.empty()) + NativeUI::DrawText(bx + 10.0f, kPY + 222.0f, L"Password...", + 0xFF555555u, 16.0f, NativeUI::ALIGN_LEFT); + NativeUI::DrawShadowText(bx + 10.0f, kPY + 222.0f, dots.c_str(), + 0xFFFFFFFFu, 16.0f, NativeUI::ALIGN_LEFT); + } + + // Divider above buttons + NativeUI::DrawDivider(kPX + 20.0f, kBtnY - 12.0f, kPW - 40.0f, 0x30FFFFFFu); + + // Buttons + float totalBtnW = 160.0f + 12.0f + 160.0f; + float btnStartX = kPX + (kPW - totalBtnW) * 0.5f; + NativeUI::DrawButton(btnStartX, kBtnY, 160.0f, kBtnH, L"Sign In", + focus.ShowFocus(eBtn_ElybySignIn), focus.IsHovered(eBtn_ElybySignIn)); + NativeUI::DrawButton(btnStartX + 172.0f, kBtnY, 160.0f, kBtnH, L"Cancel", + focus.ShowFocus(eBtn_ElybyCancel), focus.IsHovered(eBtn_ElybyCancel)); +} + + +// Render — Ely.by 2FA Dialog View + +static void RenderElyby2FA(const NativeUI::NineSlice& panel, + const std::string& code, + int cursorBlink, + NativeUI::FocusList& focus, + bool textInputActive) +{ + if (panel.valid) + NativeUI::DrawNineSlice(kPX, kPY, kPW, kPH, panel); + else + NativeUI::DrawPanel(kPX, kPY, kPW, kPH); + + NativeUI::DrawShadowText(640.0f, kPY + 14.0f, L"Two-Factor Authentication", + 0xFFFFFFFFu, 22.0f, NativeUI::ALIGN_CENTER_X); + NativeUI::DrawDivider(kPX + 20.0f, kPY + 44.0f, kPW - 40.0f, 0x40FFFFFFu); + + NativeUI::DrawShadowText(640.0f, kPY + 80.0f, + L"Your account is protected with 2FA.", + 0xFFDDDDDDu, 14.0f, NativeUI::ALIGN_CENTER_X); + NativeUI::DrawShadowText(640.0f, kPY + 100.0f, + L"Enter the code from your authenticator app.", + 0xFFAAAAAAu, 13.0f, NativeUI::ALIGN_CENTER_X); + + // TOTP code field + NativeUI::DrawShadowText(640.0f, kPY + 140.0f, L"Authenticator Code", + 0xFFDDDDDDu, 12.0f, NativeUI::ALIGN_CENTER_X); + + const float bx = kPX + 160.0f, bw = kPW - 320.0f, bh = 44.0f; + { + bool tbFocused = focus.ShowFocus(eBtn_Elyby2FACode); + bool tbHovered = focus.IsHovered(eBtn_Elyby2FACode); + NativeUI::DrawTextBox(bx, kPY + 160.0f, bw, bh, + textInputActive ? 0xFFCCCCFFu : 0xFFFFFFFFu); + if (tbFocused || tbHovered) + NativeUI::DrawRoundedBorder(bx - 1, kPY + 159.0f, bw + 2, bh + 2, 2.0f, 1.5f, + textInputActive ? 0xFFFFFF55u : 0xAAFFFFFFu); + std::wstring display(code.begin(), code.end()); + if (textInputActive && (cursorBlink / 25) % 2 == 0) display += L"_"; + if (display.empty()) + NativeUI::DrawText(bx + 10.0f, kPY + 172.0f, L"Enter code...", + 0xFF555555u, 18.0f, NativeUI::ALIGN_LEFT); + NativeUI::DrawShadowText(640.0f, kPY + 172.0f, display.c_str(), + 0xFFFFFFFFu, 20.0f, NativeUI::ALIGN_CENTER_X); + } + + // Divider above buttons + NativeUI::DrawDivider(kPX + 20.0f, kBtnY - 12.0f, kPW - 40.0f, 0x30FFFFFFu); + + float totalBtnW = 160.0f + 12.0f + 160.0f; + float btnStartX = kPX + (kPW - totalBtnW) * 0.5f; + NativeUI::DrawButton(btnStartX, kBtnY, 160.0f, kBtnH, L"Submit", + focus.ShowFocus(eBtn_Elyby2FASubmit), focus.IsHovered(eBtn_Elyby2FASubmit)); + NativeUI::DrawButton(btnStartX + 172.0f, kBtnY, 160.0f, kBtnH, L"Cancel", + focus.ShowFocus(eBtn_Elyby2FACancel), focus.IsHovered(eBtn_Elyby2FACancel)); +} + + +// render() + +void UIScene_MSAuth::render(S32 /*width*/, S32 /*height*/, + C4JRender::eViewportType /*viewport*/) +{ + if (!m_hasTickedOnce) return; + + if (!m_panelLoaded) + { + m_panel = NativeUI::LoadNineSlice( + "Common/Media/Graphics/PanelsAndTabs/Panel"); + m_recessPanel = NativeUI::LoadNineSlice( + "Common/Media/Graphics/PanelsAndTabs/Panel_Recess"); + m_panelLoaded = true; + } + + NativeUI::BeginFrame(); + + // Dim the entire screen + NativeUI::DrawRectFullscreen(0xB0000000u); + + if (m_view == eView_AccountList) + { + RenderAccountList(m_panel, m_recessPanel, m_accounts, m_activeIdx, + m_scrollOffset, m_spinnerTick, m_focus, m_skinCache, + m_pendingRemoveIdx, m_targetSlot); + } + else if (m_view == eView_OfflineInput) + { + RenderOfflineInput(m_panel, m_offlineUsername, m_offlineCursorBlink, m_focus, + m_textInputActive); + } + else if (m_view == eView_ElybyInput) + { + RenderElybyInput(m_panel, m_elybyUsername, m_elybyPassword, + m_offlineCursorBlink, m_focus, m_textInputActive, + m_elybyActiveField); + } + else if (m_view == eView_Elyby2FA) + { + RenderElyby2FA(m_panel, m_elyby2FACode, m_offlineCursorBlink, m_focus, + m_textInputActive); + } + else + { + RenderDeviceCode(m_panel, m_spinnerTick, m_closeCountdown, + m_authFlags->success.load(std::memory_order_relaxed), + m_focus, MCAuthManager::Get()); + } + + NativeUI::EndFrame(); +} + + +// Input + +void UIScene_MSAuth::updateTooltips() +{ + ui.SetTooltips(m_iPad, IDS_TOOLTIPS_SELECT, IDS_TOOLTIPS_BACK); +} + +void UIScene_MSAuth::handleInput(int iPad, int key, bool repeat, + bool pressed, bool released, bool& handled) +{ + handled = true; + + if (!pressed) return; + + if (m_inputGuardTicks > 0) return; + + if (m_view == eView_OfflineInput && key == ACTION_MENU_CANCEL) + { + ui.PlayUISFX(eSFX_Back); + if (m_textInputActive) + m_textInputActive = false; + else if (!m_offlineUsername.empty()) + m_offlineUsername.clear(); + else + handlePress((F64)eBtn_Back, 0.0); + return; + } + + if ((m_view == eView_ElybyInput || m_view == eView_Elyby2FA) && key == ACTION_MENU_CANCEL) + { + ui.PlayUISFX(eSFX_Back); + if (m_textInputActive) + m_textInputActive = false; + else + SwitchToAccountList(); + return; + } + + // Account list scroll: intercept UP/DOWN when focused on edge rows + // to scroll the list instead of jumping to the buttons. + if (m_view == eView_AccountList && m_pendingRemoveIdx < 0 + && (int)m_accounts.size() > kMaxVisible) + { + int foc = m_focus.GetFocused(); + // Get the account index if focused on an account row or its remove button + int focAcctIdx = -1; + if (foc >= eAccountBase) + focAcctIdx = foc - eAccountBase; + else if (foc >= eBtn_RemoveBase && foc < eAccountBase) + focAcctIdx = foc - eBtn_RemoveBase; + + if (focAcctIdx >= 0) + { + int lastVisible = m_scrollOffset + kMaxVisible - 1; + + if ((key == ACTION_MENU_DOWN || key == ACTION_MENU_RIGHT) + && focAcctIdx >= lastVisible + && m_scrollOffset + kMaxVisible < (int)m_accounts.size()) + { + ++m_scrollOffset; + ui.PlayUISFX(eSFX_Focus); + return; + } + if ((key == ACTION_MENU_UP || key == ACTION_MENU_LEFT) + && focAcctIdx <= m_scrollOffset + && m_scrollOffset > 0) + { + --m_scrollOffset; + ui.PlayUISFX(eSFX_Focus); + return; + } + } + } + +#ifdef _WINDOWS64 + // Mouse wheel scroll for account list + if (m_view == eView_AccountList && m_pendingRemoveIdx < 0) + { + int wheel = g_KBMInput.GetMouseWheel(); + if (wheel != 0) + { + int maxScroll = (int)m_accounts.size() - kMaxVisible; + if (maxScroll > 0) + { + m_scrollOffset -= wheel; + if (m_scrollOffset < 0) m_scrollOffset = 0; + if (m_scrollOffset > maxScroll) m_scrollOffset = maxScroll; + } + } + } +#endif + + int backId = (m_pendingRemoveIdx >= 0) ? eBtn_ConfirmNo : eBtn_Back; + int result = m_focus.HandleMenuKey(key, backId, kPX, kPY, kPW, kPH); + + if (result == NativeUI::FocusList::RESULT_UNHANDLED) + { + return; + } + + if (result == NativeUI::FocusList::RESULT_NAVIGATED) + { + return; + } + + handlePress((F64)result, 0.0); +} + +void UIScene_MSAuth::handlePress(F64 controlId, F64 /*childId*/) +{ + int id = static_cast(controlId); + + // ---- Remove confirmation dialog ---- + if (m_pendingRemoveIdx >= 0) + { + if (id == eBtn_ConfirmYes) + { + int idx = m_pendingRemoveIdx; + m_pendingRemoveIdx = -1; + + // Verify the index still refers to the same account (stale-index safety) + auto accounts = MCAuthManager::Get().GetJavaAccounts(); + if (idx < 0 || idx >= (int)accounts.size() || accounts[idx].uuid != m_pendingRemoveUuid) { + m_pendingRemoveUuid.clear(); + return; // list changed — abort removal silently + } + m_pendingRemoveUuid.clear(); + + MCAuthManager::Get().RemoveJavaAccount(idx); + MCAuthManager::Get().SaveJavaAccountIndex(); + + accounts = MCAuthManager::Get().GetJavaAccounts(); + int newActive = MCAuthManager::Get().GetActiveJavaAccountIndex(); + if (!accounts.empty() && newActive >= 0) + MCAuthManager::Get().SetAccountForSlot(m_targetSlot, newActive); + } + else // ConfirmNo, Back, or any other key → cancel + { + m_pendingRemoveIdx = -1; + } + return; + } + + if (id == eBtn_Back) + { + if (m_view == eView_DeviceCode || m_view == eView_OfflineInput || + m_view == eView_ElybyInput || m_view == eView_Elyby2FA) + { + SwitchToAccountList(); + } + else + { +#ifdef _WINDOWS64 + if (m_targetSlot > 0) + { + Minecraft* mc = Minecraft::GetInstance(); + if (mc) mc->setSplitAuthCancelled(m_targetSlot); + } +#endif + ui.SetTooltips(m_iPad, -1, -1); + ui.NavigateBack(m_iPad); + } + return; + } + + if (m_view == eView_ElybyInput) + { + if (id == eBtn_ElybyUsername || id == eBtn_ElybyPassword) + { +#ifdef _WINDOWS64 + bool useGamepadKeyboard = + (m_focus.GetLastDevice() == NativeUI::FocusList::eDevice_Gamepad); + if (!useGamepadKeyboard) + { + m_elybyActiveField = (id == eBtn_ElybyUsername) ? 0 : 1; + m_textInputActive = true; + g_KBMInput.ClearCharBuffer(); + } + else + { + m_elybyActiveField = (id == eBtn_ElybyUsername) ? 0 : 1; + m_pendingKBResult = std::make_shared(); + UIKeyboardInitData kbData; + kbData.title = (id == eBtn_ElybyUsername) ? L"Ely.by Username" : L"Password"; + kbData.defaultText = L""; + kbData.maxChars = 128; + kbData.callback = &MSAuthKeyboardCallback; + kbData.lpParam = new std::shared_ptr(m_pendingKBResult); + ui.NavigateToScene(m_iPad, eUIScene_Keyboard, &kbData, + eUILayer_Fullscreen, eUIGroup_Fullscreen); + } +#endif + } + else if (id == eBtn_ElybySignIn) + { + m_textInputActive = false; + SubmitElybyLogin(); + } + else if (id == eBtn_ElybyCancel) + { + m_textInputActive = false; + SwitchToAccountList(); + } + return; + } + + if (m_view == eView_Elyby2FA) + { + if (id == eBtn_Elyby2FACode) + { +#ifdef _WINDOWS64 + bool useGamepadKeyboard = + (m_focus.GetLastDevice() == NativeUI::FocusList::eDevice_Gamepad); + if (!useGamepadKeyboard) + { + m_textInputActive = true; + g_KBMInput.ClearCharBuffer(); + } + else + { + m_pendingKBResult = std::make_shared(); + UIKeyboardInitData kbData; + kbData.title = L"2FA Code"; + kbData.defaultText = L""; + kbData.maxChars = 10; + kbData.callback = &MSAuthKeyboardCallback; + kbData.lpParam = new std::shared_ptr(m_pendingKBResult); + ui.NavigateToScene(m_iPad, eUIScene_Keyboard, &kbData, + eUILayer_Fullscreen, eUIGroup_Fullscreen); + } +#endif + } + else if (id == eBtn_Elyby2FASubmit) + { + m_textInputActive = false; + SubmitElyby2FA(); + } + else if (id == eBtn_Elyby2FACancel) + { + m_textInputActive = false; + SwitchToAccountList(); + } + return; + } + + if (m_view == eView_OfflineInput) + { + if (id == eBtn_OfflineTextBox) + { +#ifdef _WINDOWS64 + bool useGamepadKeyboard = + (m_focus.GetLastDevice() == NativeUI::FocusList::eDevice_Gamepad); + + if (!useGamepadKeyboard) + { + // KBM (keyboard/mouse): activate inline text input + m_textInputActive = true; + g_KBMInput.ClearCharBuffer(); + } + else + { + // Gamepad: open the game's virtual keyboard scene + m_pendingKBResult = std::make_shared(); + UIKeyboardInitData kbData; + kbData.title = L"Offline Username"; + kbData.defaultText = m_offlineUsername.empty() ? L"" + : std::wstring(m_offlineUsername.begin(), m_offlineUsername.end()).c_str(); + kbData.maxChars = 16; + kbData.callback = &MSAuthKeyboardCallback; + kbData.lpParam = new std::shared_ptr(m_pendingKBResult); + ui.NavigateToScene(m_iPad, eUIScene_Keyboard, &kbData, + eUILayer_Fullscreen, eUIGroup_Fullscreen); + } +#endif + } + else if (id == eBtn_OfflineConfirm) + { + m_textInputActive = false; + ConfirmOfflineAccount(); + } + return; + } + + if (m_view == eView_AccountList) + { + if (id == eBtn_AddAccount) + { + SwitchToDeviceCode(); + } + else if (id == eBtn_AddElyby) + { + SwitchToElybyInput(); + } + else if (id == eBtn_AddOffline) + { + SwitchToOfflineInput(); + } + else if (id >= eBtn_RemoveBase && id < eAccountBase) + { + // Show confirmation dialog instead of removing immediately. + // Capture the UUID so we can verify at confirm time that the + // index still refers to the same account (stale-index safety). + int removeIdx = id - eBtn_RemoveBase; + if (removeIdx >= 0 && removeIdx < (int)m_accounts.size()) { + m_pendingRemoveIdx = removeIdx; + m_pendingRemoveUuid = m_accounts[removeIdx].uuid; + } + } + else if (id >= eAccountBase) + { + int idx = id - eAccountBase; + if (MCAuthManager::Get().IsAccountInUseByOtherSlot(m_targetSlot, idx)) + { + app.DebugPrintf("[MSAuth] Account %d already in use by another player slot\n", idx); + } + else + { + MCAuthManager::Get().SetAccountForSlot(m_targetSlot, idx); + +#ifdef _WINDOWS64 + if (m_targetSlot > 0) + { + Minecraft* mc = Minecraft::GetInstance(); + if (mc) mc->setSplitAuthCompleted(m_targetSlot); + ui.SetTooltips(m_iPad, -1, -1); + ui.NavigateBack(m_iPad); + } +#endif + } + } + } + else if (m_view == eView_DeviceCode) + { + if (id == eLink_URL && !m_cachedUri.empty()) + NativeUI::OpenURL(m_cachedUri.c_str()); + } +} + + +// Mouse click (Windows64 only) + +#ifdef _WINDOWS64 +bool UIScene_MSAuth::handleMouseClick(F32 /*x*/, F32 /*y*/) +{ + return m_focus.IsMouseConsumed(); +} +#endif diff --git a/Minecraft.Client/Common/UI/UIScene_MSAuth.h b/Minecraft.Client/Common/UI/UIScene_MSAuth.h new file mode 100644 index 0000000000..838ee6f145 --- /dev/null +++ b/Minecraft.Client/Common/UI/UIScene_MSAuth.h @@ -0,0 +1,158 @@ +#pragma once +/* + * UIScene_MSAuth — Microsoft Account Manager. + * + * Two views: + * 1. Account List — shows saved accounts, select/remove/add. + * 2. Device Code — device code flow for adding a new account. + * + * Native overlay (no SWF). + */ + +#include "UIScene.h" +#include "NativeUIRenderer.h" +#include "../../../MCAuth/include/MCAuthManager.h" +#include +#include +#include +#include + +// Control IDs for UIScene_MSAuth focus list. +// File-scope so static render helpers in the .cpp can reference them directly. +namespace MSAuthUI +{ + enum EControls { + eBtn_Back = 0, + eBtn_AddAccount = 1, + eBtn_AddOffline = 2, + eBtn_OfflineConfirm = 3, + eBtn_ConfirmYes = 4, + eBtn_ConfirmNo = 5, + eBtn_OfflineTextBox = 6, + eBtn_AddElyby = 7, + eBtn_ElybyUsername = 8, + eBtn_ElybyPassword = 9, + eBtn_ElybySignIn = 10, + eBtn_ElybyCancel = 11, + eBtn_Elyby2FACode = 12, + eBtn_Elyby2FASubmit = 13, + eBtn_Elyby2FACancel = 14, + eBtn_RemoveBase = 50, // 50 + index = remove button for account i + eAccountBase = 100, // 100 + index = account row for account i + eLink_URL = 200, + }; +} + +class UIScene_MSAuth : public UIScene +{ +private: + // View mode + enum EView { eView_AccountList, eView_DeviceCode, eView_OfflineInput, eView_ElybyInput, eView_Elyby2FA }; + EView m_view = eView_AccountList; + + // Shared flags survive scene destruction (prevent UAF from async callback) + struct AuthFlags { + std::atomic done { false }; + std::atomic success{ false }; + std::atomic need2FA{ false }; + }; + std::shared_ptr m_authFlags = std::make_shared(); + + int m_closeCountdown = 0; + int m_spinnerTick = 0; + NativeUI::NineSlice m_panel; + NativeUI::NineSlice m_recessPanel; + bool m_panelLoaded = false; + NativeUI::FocusList m_focus; + std::string m_cachedUri; + + // Cached account list (refreshed each tick) + std::vector m_accounts; + int m_activeIdx = -1; + + // Scroll offset for account list + int m_scrollOffset = 0; + + void StartAddAccount(); + void SwitchToAccountList(); + void SwitchToDeviceCode(); + void SwitchToOfflineInput(); + void SwitchToElybyInput(); + void SwitchToElyby2FA(); + void ConfirmOfflineAccount(); + void SubmitElybyLogin(); + void SubmitElyby2FA(); + + // Offline username input state + std::string m_offlineUsername; + int m_offlineCursorBlink = 0; + bool m_textInputActive = false; // true = text box has focus and is accepting keyboard input + + // Ely.by login state + std::string m_elybyUsername; + std::string m_elybyPassword; + std::string m_elyby2FACode; + // (2FA flag lives in m_authFlags->need2FA for async safety) + int m_elybyActiveField = 0; // 0=username, 1=password, 2=2fa code + + // Target slot for splitscreen (0 = primary player, 1-3 = splitscreen). + // When > 0, account selection binds to that slot instead of slot 0. + int m_targetSlot = 0; + + // Input guard: ignore input for the first N ticks after opening to avoid + // processing the button press that triggered the scene to open. + int m_inputGuardTicks = 6; + + // Remove confirmation dialog (-1 = not showing, >=0 = account index pending removal) + int m_pendingRemoveIdx = -1; + std::string m_pendingRemoveUuid; // UUID captured at click time for stale-index safety + +public: + // Shared buffer for virtual keyboard callback (public so the file-static + // callback function can access it; prevents UAF if scene is destroyed + // while the keyboard is open on a different UI group). + struct PendingKeyboardResult { + std::string value; + std::atomic ready{false}; + std::atomic valid{true}; + }; + + // Skin texture cache entry (public so static render helpers can access it) + struct SkinEntry { + std::atomic textureId{-2}; // -2=not started, -1=downloading/failed, -3=file ready, >=0=texture ID + std::string filePath; + }; + +private: + std::shared_ptr m_pendingKBResult; + + // Skin texture cache (key = UUID string) + std::unordered_map> m_skinCache; + void EnsureSkinLoaded(const std::string& uuid); + +public: + UIScene_MSAuth(int iPad, void* initData, UILayer* parentLayer); + ~UIScene_MSAuth(); + + virtual EUIScene getSceneType() override { return eUIScene_MSAuth; } + virtual wstring getMoviePath() override { return L""; } + virtual bool hidesLowerScenes() override { return true; } + virtual bool blocksInput() override { return true; } + virtual bool hasFocus(int iPad) override { return bHasFocus; } + virtual bool needsReloaded() override { return false; } + + virtual void updateTooltips() override; + virtual void tick() override; + virtual void render(S32 width, S32 height, + C4JRender::eViewportType viewport) override; + + virtual void handleInput(int iPad, int key, bool repeat, + bool pressed, bool released, + bool& handled) override; + + virtual void handlePress(F64 controlId, F64 childId) override; + +#ifdef _WINDOWS64 + virtual bool handleMouseClick(F32 x, F32 y) override; +#endif +}; diff --git a/Minecraft.Client/Common/UI/UIScene_MainMenu.cpp b/Minecraft.Client/Common/UI/UIScene_MainMenu.cpp index 93f1edf119..740f428531 100644 --- a/Minecraft.Client/Common/UI/UIScene_MainMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_MainMenu.cpp @@ -1,4 +1,5 @@ #include "stdafx.h" +#include "../../../MCAuth/include/MCAuthManager.h" #include "..\..\..\Minecraft.World\Mth.h" #include "..\..\..\Minecraft.World\StringHelpers.h" #include "..\..\..\Minecraft.World\Random.h" @@ -43,16 +44,10 @@ UIScene_MainMenu::UIScene_MainMenu(int iPad, void *initData, UILayer *parentLaye m_buttons[static_cast(eControl_Leaderboards)].init(IDS_LEADERBOARDS,eControl_Leaderboards); m_buttons[static_cast(eControl_Achievements)].init( (UIString)IDS_ACHIEVEMENTS,eControl_Achievements); m_buttons[static_cast(eControl_HelpAndOptions)].init(IDS_HELP_AND_OPTIONS,eControl_HelpAndOptions); - if(ProfileManager.IsFullVersion()) - { - m_bTrialVersion=false; - m_buttons[static_cast(eControl_UnlockOrDLC)].init(IDS_DOWNLOADABLECONTENT,eControl_UnlockOrDLC); - } - else - { - m_bTrialVersion=true; - m_buttons[static_cast(eControl_UnlockOrDLC)].init(IDS_UNLOCK_FULL_GAME,eControl_UnlockOrDLC); - } + m_bTrialVersion = !ProfileManager.IsFullVersion(); + // Try to restore a saved auth session (loads accounts + refreshes active in background). + MCAuthManager::Get().TryRestoreActiveJavaAccount(); + m_buttons[static_cast(eControl_UnlockOrDLC)].init(L"Account Manager", eControl_UnlockOrDLC); #ifndef _DURANGO m_buttons[static_cast(eControl_Exit)].init(app.GetString(IDS_EXIT_GAME),eControl_Exit); @@ -181,8 +176,7 @@ void UIScene_MainMenu::handleGainFocus(bool navBack) if(navBack && ProfileManager.IsFullVersion()) { - // Replace the Unlock Full Game with Downloadable Content - m_buttons[static_cast(eControl_UnlockOrDLC)].setLabel(IDS_DOWNLOADABLECONTENT); + m_buttons[static_cast(eControl_UnlockOrDLC)].setLabel(L"Account Manager"); } #if TO_BE_IMPLEMENTED @@ -355,9 +349,7 @@ void UIScene_MainMenu::handlePress(F64 controlId, F64 childId) case eControl_UnlockOrDLC: //CD - Added for audio ui.PlayUISFX(eSFX_Press); - - m_eAction=eAction_RunUnlockOrDLC; - signInReturnedFunc = &UIScene_MainMenu::UnlockFullGame_SignInReturned; + ui.NavigateToScene(m_iPad, eUIScene_MSAuth); break; case eControl_Exit: //CD - Added for audio @@ -1858,6 +1850,7 @@ void UIScene_MainMenu::tick() { UIScene::tick(); + if ( (eNavigateWhenReady >= 0) ) { @@ -2129,7 +2122,7 @@ void UIScene_MainMenu::LoadTrial(void) void UIScene_MainMenu::handleUnlockFullVersion() { - m_buttons[static_cast(eControl_UnlockOrDLC)].setLabel(IDS_DOWNLOADABLECONTENT,true); + m_buttons[static_cast(eControl_UnlockOrDLC)].setLabel(L"Account Manager", true); } diff --git a/Minecraft.Client/Common/UI/UIScene_QuadrantSignin.cpp b/Minecraft.Client/Common/UI/UIScene_QuadrantSignin.cpp index 7667656148..6dd2d5c166 100644 --- a/Minecraft.Client/Common/UI/UIScene_QuadrantSignin.cpp +++ b/Minecraft.Client/Common/UI/UIScene_QuadrantSignin.cpp @@ -2,6 +2,9 @@ #include "UI.h" #include "UIScene_QuadrantSignin.h" #include "..\..\Minecraft.h" +#ifdef _WINDOWS64 +#include "..\..\..\MCAuth\include\MCAuthManager.h" +#endif #if defined(__ORBIS__) #include "Common\Network\Sony\SonyHttp.h" #endif @@ -122,6 +125,23 @@ void UIScene_QuadrantSignin::handleInput(int iPad, int key, bool repeat, bool pr app.DebugPrintf("Signed in pad pressed\n"); ProfileManager.CancelProfileAvatarRequest(); +#ifdef _WINDOWS64 + // Block if no MCAuth account is configured (empty username/uuid). + // Redirect to the auth picker instead of proceeding with the join. + { + auto session = MCAuthManager::Get().GetSlotSession(0); + if (session.username.empty() || session.uuid.empty()) + { + app.DebugPrintf("[Auth] No account configured, redirecting to auth picker\n"); + m_bIgnoreInput = false; + navigateBack(); + ui.NavigateToHomeMenu(); + ui.NavigateToScene(m_iPad, eUIScene_MSAuth); + break; + } + } +#endif + #ifdef _XBOX_ONE // On Durango, if we don't navigate forward here, then when we are on the main menu, it (re)gains focus & that causes our users to get cleared ui.NavigateToScene(m_iPad, eUIScene_Timer); diff --git a/Minecraft.Client/Common/UI/UIScene_SkinSelectMenu.cpp b/Minecraft.Client/Common/UI/UIScene_SkinSelectMenu.cpp index a3482a24d1..2a271c678c 100644 --- a/Minecraft.Client/Common/UI/UIScene_SkinSelectMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_SkinSelectMenu.cpp @@ -24,6 +24,7 @@ const WCHAR *UIScene_SkinSelectMenu::wchDefaultNamesA[]= L"Prisoner Steve", L"Cyclist Steve", L"Boxer Steve", + L"Mojang Skin", }; UIScene_SkinSelectMenu::UIScene_SkinSelectMenu(int iPad, void *initData, UILayer *parentLayer) : UIScene(iPad, parentLayer) @@ -407,8 +408,16 @@ void UIScene_SkinSelectMenu::InputActionOK(unsigned int iPad) switch(m_packIndex) { case SKIN_SELECT_PACK_DEFAULT: - app.SetPlayerSkin(iPad, m_skinIndex); - app.SetPlayerCape(iPad, 0); + if (m_skinIndex == eDefaultSkins_MojangSkin && !app.m_mojangSkinKey[m_iPad].empty()) + { + app.SetPlayerMojangSkin(iPad); + app.SetPlayerCape(iPad, 0); + } + else + { + app.SetPlayerSkin(iPad, m_skinIndex); + app.SetPlayerCape(iPad, 0); + } m_currentSkinPath = app.GetPlayerSkinName(iPad); m_originalSkinId = app.GetPlayerSkinId(iPad); setCharacterSelected(true); @@ -684,8 +693,17 @@ void UIScene_SkinSelectMenu::handleSkinIndexChanged() { skinName = app.GetString(IDS_DEFAULT_SKINS); } + else if( m_skinIndex == eDefaultSkins_MojangSkin ) + { + skinName = wchDefaultNamesA[m_skinIndex]; + // Use the Mojang skin from memory textures + if (!app.m_mojangSkinKey[m_iPad].empty()) + { + m_selectedSkinPath = app.m_mojangSkinKey[m_iPad]; + } + } else - { + { skinName = wchDefaultNamesA[m_skinIndex]; } @@ -693,10 +711,16 @@ void UIScene_SkinSelectMenu::handleSkinIndexChanged() { setCharacterSelected(true); } + // Hide Mojang skin entry if no Mojang skin is available + if( m_skinIndex == eDefaultSkins_MojangSkin && app.m_mojangSkinKey[m_iPad].empty() ) + { + m_characters[eCharacter_Current].setVisible(false); + } + else + { + m_characters[eCharacter_Current].setVisible(true); + } setCharacterLocked(false); - setCharacterLocked(false); - - m_characters[eCharacter_Current].setVisible(true); m_controlSkinNamePlate.setVisible( true ); break; @@ -837,7 +861,7 @@ void UIScene_SkinSelectMenu::handleSkinIndexChanged() backupTexture = TN_MOB_CHAR; } else - { + { otherSkinPath = L""; otherCapePath = L""; othervAdditionalSkinBoxes=nullptr; @@ -845,16 +869,18 @@ void UIScene_SkinSelectMenu::handleSkinIndexChanged() { case SKIN_SELECT_PACK_DEFAULT: backupTexture = getTextureId(nextIndex); + if(nextIndex == eDefaultSkins_MojangSkin && !app.m_mojangSkinKey[m_iPad].empty()) + otherSkinPath = app.m_mojangSkinKey[m_iPad]; break; case SKIN_SELECT_PACK_FAVORITES: if(uiCurrentFavoriteC>0) - { + { // get the pack number from the skin id swprintf(chars, 256, L"dlcskin%08d.png", app.GetPlayerFavoriteSkin(m_iPad,nextIndex)); - Pack=app.m_dlcManager.getPackContainingSkin(chars); + Pack=app.m_dlcManager.getPackContainingSkin(chars); if(Pack) - { + { skinFile = Pack->getSkinFile(chars); otherSkinPath = skinFile->getPath(); @@ -916,14 +942,16 @@ void UIScene_SkinSelectMenu::handleSkinIndexChanged() { case SKIN_SELECT_PACK_DEFAULT: backupTexture = getTextureId(previousIndex); + if(previousIndex == eDefaultSkins_MojangSkin && !app.m_mojangSkinKey[m_iPad].empty()) + otherSkinPath = app.m_mojangSkinKey[m_iPad]; break; case SKIN_SELECT_PACK_FAVORITES: if(uiCurrentFavoriteC>0) - { + { // get the pack number from the skin id swprintf(chars, 256, L"dlcskin%08d.png", app.GetPlayerFavoriteSkin(m_iPad,previousIndex)); - Pack=app.m_dlcManager.getPackContainingSkin(chars); + Pack=app.m_dlcManager.getPackContainingSkin(chars); if(Pack) { skinFile = Pack->getSkinFile(chars); @@ -993,6 +1021,9 @@ TEXTURE_NAME UIScene_SkinSelectMenu::getTextureId(int skinIndex) case eDefaultSkins_Skin7: texture = TN_MOB_CHAR7; break; + case eDefaultSkins_MojangSkin: + texture = TN_MOB_CHAR; // fallback if memory texture not loaded yet + break; }; return texture; @@ -1017,14 +1048,18 @@ int UIScene_SkinSelectMenu::getNextSkinIndex(DWORD sourceIndex) default: ++nextSkin; - if(m_packIndex == SKIN_SELECT_PACK_DEFAULT && nextSkin >= eDefaultSkins_Count) + if(m_packIndex == SKIN_SELECT_PACK_DEFAULT) { - nextSkin = eDefaultSkins_ServerSelected; + // Skip Mojang skin entry if no Mojang skin is available + if(nextSkin == eDefaultSkins_MojangSkin && app.m_mojangSkinKey[m_iPad].empty()) + ++nextSkin; + if(nextSkin >= eDefaultSkins_Count) + nextSkin = eDefaultSkins_ServerSelected; } else if(m_currentPack != nullptr && nextSkin>=m_currentPack->getSkinCount()) { nextSkin = 0; - } + } break; } @@ -1046,7 +1081,7 @@ int UIScene_SkinSelectMenu::getPreviousSkinIndex(DWORD sourceIndex) else { --previousSkin; - } + } break; default: if(previousSkin==0) @@ -1054,6 +1089,9 @@ int UIScene_SkinSelectMenu::getPreviousSkinIndex(DWORD sourceIndex) if(m_packIndex == SKIN_SELECT_PACK_DEFAULT) { previousSkin = eDefaultSkins_Count - 1; + // Skip Mojang skin entry if no Mojang skin is available + if(previousSkin == eDefaultSkins_MojangSkin && app.m_mojangSkinKey[m_iPad].empty()) + --previousSkin; } else if(m_currentPack != nullptr) { @@ -1063,7 +1101,10 @@ int UIScene_SkinSelectMenu::getPreviousSkinIndex(DWORD sourceIndex) else { --previousSkin; - } + // Skip Mojang skin entry if no Mojang skin is available + if(m_packIndex == SKIN_SELECT_PACK_DEFAULT && previousSkin == eDefaultSkins_MojangSkin && app.m_mojangSkinKey[m_iPad].empty()) + --previousSkin; + } break; } diff --git a/Minecraft.Client/Durango/Network/PlatformNetworkManagerDurango.cpp b/Minecraft.Client/Durango/Network/PlatformNetworkManagerDurango.cpp index 5e3a08245d..f8e90a4372 100644 --- a/Minecraft.Client/Durango/Network/PlatformNetworkManagerDurango.cpp +++ b/Minecraft.Client/Durango/Network/PlatformNetworkManagerDurango.cpp @@ -478,7 +478,7 @@ bool CPlatformNetworkManagerDurango::_LeaveGame(bool bMigrateHost, bool bLeaveRo return true; } -void CPlatformNetworkManagerDurango::HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, unsigned char publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, unsigned char privateSlots /*= 0*/) +void CPlatformNetworkManagerDurango::HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, int publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, int privateSlots /*= 0*/) { // #ifdef _XBOX // 4J Stu - We probably did this earlier as well, but just to be sure! @@ -495,7 +495,7 @@ void CPlatformNetworkManagerDurango::HostGame(int localUsersMask, bool bOnlineGa //#endif } -void CPlatformNetworkManagerDurango::_HostGame(int usersMask, unsigned char publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, unsigned char privateSlots /*= 0*/) +void CPlatformNetworkManagerDurango::_HostGame(int usersMask, int publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, int privateSlots /*= 0*/) { memset(&m_hostGameSessionData,0,sizeof(m_hostGameSessionData)); m_hostGameSessionData.netVersion = MINECRAFT_NET_VERSION; diff --git a/Minecraft.Client/Durango/Network/PlatformNetworkManagerDurango.h b/Minecraft.Client/Durango/Network/PlatformNetworkManagerDurango.h index 5626b01204..62b516fcba 100644 --- a/Minecraft.Client/Durango/Network/PlatformNetworkManagerDurango.h +++ b/Minecraft.Client/Durango/Network/PlatformNetworkManagerDurango.h @@ -44,7 +44,7 @@ class CPlatformNetworkManagerDurango : public CPlatformNetworkManager, IDQRNetwo virtual void SendInviteGUI(int quadrant); virtual bool IsAddingPlayer(); - virtual void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, unsigned char publicSlots = MINECRAFT_NET_MAX_PLAYERS, unsigned char privateSlots = 0); + virtual void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, int publicSlots = MINECRAFT_NET_MAX_PLAYERS, int privateSlots = 0); virtual int JoinGame(FriendSessionInfo *searchResult, int localUsersMask, int primaryUserIndex ); virtual void CancelJoinGame(); virtual bool SetLocalGame(bool isLocal); @@ -64,7 +64,7 @@ class CPlatformNetworkManagerDurango : public CPlatformNetworkManager, IDQRNetwo private: bool isSystemPrimaryPlayer(DQRNetworkPlayer *pDQRPlayer); virtual bool _LeaveGame(bool bMigrateHost, bool bLeaveRoom); - virtual void _HostGame(int dwUsersMask, unsigned char publicSlots = MINECRAFT_NET_MAX_PLAYERS, unsigned char privateSlots = 0); + virtual void _HostGame(int dwUsersMask, int publicSlots = MINECRAFT_NET_MAX_PLAYERS, int privateSlots = 0); virtual bool _StartGame(); DQRNetworkManager * m_pDQRNet; // pointer to SQRNetworkManager interface diff --git a/Minecraft.Client/Durango/XML/xmlFilesCallback.h b/Minecraft.Client/Durango/XML/xmlFilesCallback.h index 1614934044..02bc323157 100644 --- a/Minecraft.Client/Durango/XML/xmlFilesCallback.h +++ b/Minecraft.Client/Durango/XML/xmlFilesCallback.h @@ -19,7 +19,7 @@ class xmlMojangCallback : public ATG::ISAXCallback WCHAR wNameXUID[32] = L""; WCHAR wNameSkin[32] = L""; WCHAR wNameCloak[32] = L""; - PlayerUID xuid=0LL; + PlayerUID xuid = INVALID_XUID; if (NameLen >31) @@ -47,7 +47,11 @@ class xmlMojangCallback : public ATG::ISAXCallback { ZeroMemory(wTemp,sizeof(WCHAR)*35); wcsncpy_s( wTemp, pAttributes[i].strValue, pAttributes[i].ValueLen); - xuid=_wcstoui64(wTemp,nullptr,10); + { + char narrow[64] = {}; + wcstombs_s(nullptr, narrow, wTemp, _TRUNCATE); + xuid = PlayerUID::fromDashed(std::string(narrow)); + } } } else if (_wcsicmp(wAttName,L"cape")==0) @@ -68,7 +72,7 @@ class xmlMojangCallback : public ATG::ISAXCallback } // if the xuid hasn't been defined, then we can't use the data - if(xuid!=0LL) + if(xuid.isValid()) { return CConsoleMinecraftApp::RegisterMojangData(wNameXUID , xuid, wNameSkin, wNameCloak ); } diff --git a/Minecraft.Client/Extrax64Stubs.cpp b/Minecraft.Client/Extrax64Stubs.cpp index 0147896ca9..5909f3d729 100644 --- a/Minecraft.Client/Extrax64Stubs.cpp +++ b/Minecraft.Client/Extrax64Stubs.cpp @@ -21,7 +21,8 @@ #include "Windows64\Social\SocialManager.h" #include "Windows64\Sentient\DynamicConfigurations.h" #include "Windows64\Network\WinsockNetLayer.h" -#include "Windows64\Windows64_Xuid.h" +#include "Windows64\Windows64_Uuid.h" +#include "..\MCAuth\include\MCAuthManager.h" #elif defined __PSVITA__ #include "PSVita\Sentient\SentientManager.h" #include "StatsCounter.h" @@ -172,11 +173,7 @@ void PIXSetMarkerDeprecated(int a, const char* b, ...) {} bool IsEqualXUID(PlayerUID a, PlayerUID b) { -#if defined(__PS3__) || defined(__ORBIS__) || defined (__PSVITA__) || defined(_DURANGO) return (a == b); -#else - return false; -#endif } void XMemCpy(void* a, const void* b, size_t s) { memcpy(a, b, s); } @@ -214,12 +211,7 @@ bool IQNetPlayer::IsGuest() { return false; } bool IQNetPlayer::IsLocal() { return !m_isRemote; } PlayerUID IQNetPlayer::GetXuid() { - // Compatibility model: - // - Preferred path: use per-player resolved XUID populated from login/add-player flow. - // - Fallback path: keep legacy base+smallId behavior for peers/saves still on old scheme. - if (m_resolvedXuid != INVALID_XUID) - return m_resolvedXuid; - return (PlayerUID)(0xe000d45248242f2e + m_smallId); + return m_resolvedXuid; } LPCWSTR IQNetPlayer::GetGamertag() { return m_gamertag; } int IQNetPlayer::GetSessionIndex() { return m_smallId; } @@ -254,16 +246,31 @@ void Win64_SetupRemoteQNetPlayer(IQNetPlayer * player, BYTE smallId, bool isHost static bool Win64_IsActivePlayer(IQNetPlayer* p, DWORD index); +static std::wstring GetAuthUsername(int slot) +{ + auto& mgr = MCAuthManager::Get(); + if (mgr.IsSlotLoggedIn(slot)) + { + auto session = mgr.GetSlotSession(slot); + if (!session.username.empty()) + return std::wstring(session.username.begin(), session.username.end()); + } + if (slot > 0) + { + wchar_t buf[32]; + swprintf_s(buf, 32, L"Player(%d)", slot + 1); + return buf; + } + return L"Player"; +} + HRESULT IQNet::AddLocalPlayerByUserIndex(DWORD dwUserIndex) { if (dwUserIndex >= MINECRAFT_NET_MAX_PLAYERS) return E_FAIL; m_player[dwUserIndex].m_isRemote = false; m_player[dwUserIndex].m_isHostPlayer = false; - // Give the joining player a distinct gamertag - extern wchar_t g_Win64UsernameW[17]; - if (dwUserIndex == 0) - wcscpy_s(m_player[0].m_gamertag, 32, g_Win64UsernameW); - else - swprintf_s(m_player[dwUserIndex].m_gamertag, 32, L"%s(%d)", g_Win64UsernameW, dwUserIndex + 1); + // Give the joining player the auth username for their slot + std::wstring authName = GetAuthUsername(dwUserIndex); + wcscpy_s(m_player[dwUserIndex].m_gamertag, 32, authName.c_str()); if (dwUserIndex >= s_playerCount) s_playerCount = dwUserIndex + 1; return S_OK; @@ -323,7 +330,10 @@ IQNetPlayer* IQNet::GetPlayerByIndex(DWORD dwPlayerIndex) found++; } } - return &m_player[0]; + // Don't silently fall back to player[0] — callers must handle nullptr. + // Returning player[0] here caused the TAB list to show duplicates: + // any out-of-range index would alias to the host player. + return nullptr; } IQNetPlayer* IQNet::GetPlayerBySmallId(BYTE SmallId) { @@ -335,6 +345,11 @@ IQNetPlayer* IQNet::GetPlayerBySmallId(BYTE SmallId) } IQNetPlayer* IQNet::GetPlayerByXuid(PlayerUID xuid) { + // Guard against INVALID_XUID lookups — every unresolved slot has INVALID_XUID, + // so searching for it would alias to the first active slot (usually the host). + if (xuid == INVALID_XUID) + return nullptr; + for (DWORD i = 0; i < s_playerCount; i++) { if (!Win64_IsActivePlayer(&m_player[i], i)) @@ -343,8 +358,7 @@ IQNetPlayer* IQNet::GetPlayerByXuid(PlayerUID xuid) if (m_player[i].GetXuid() == xuid) return &m_player[i]; } - // Keep existing stub behavior: return host slot instead of nullptr on miss. - return &m_player[0]; + return nullptr; } DWORD IQNet::GetPlayerCount() { @@ -362,8 +376,7 @@ void IQNet::HostGame() { _iQNetStubState = QNET_STATE_SESSION_STARTING; s_isHosting = true; - // Host slot keeps legacy XUID so old host player data remains addressable. - m_player[0].m_resolvedXuid = Win64Xuid::GetLegacyEmbeddedHostXuid(); + m_player[0].m_resolvedXuid = INVALID_XUID; // Will be set by auth handshake } void IQNet::ClientJoinGame() { @@ -394,10 +407,9 @@ void IQNet::EndGame() m_player[i].m_gamertag[0] = 0; m_player[i].SetCustomDataValue(0); } - // Restore local player 0's gamertag so re-joining works correctly - extern wchar_t g_Win64UsernameW[17]; m_player[0].m_isHostPlayer = true; - wcscpy_s(m_player[0].m_gamertag, 32, g_Win64UsernameW); + std::wstring authName0 = GetAuthUsername(0); + wcscpy_s(m_player[0].m_gamertag, 32, authName0.c_str()); } DWORD MinecraftDynamicConfigurations::GetTrialTime() { return DYNAMIC_CONFIG_DEFAULT_TRIAL_TIME; } @@ -634,14 +646,8 @@ void C_4JProfile::SetPrimaryPlayerChanged(bool bVal) {} bool C_4JProfile::QuerySigninStatus(void) { return true; } void C_4JProfile::GetXUID(int iPad, PlayerUID * pXuid, bool bOnlineXuid) { -#ifdef _WINDOWS64 - // Each pad gets a unique XUID derived from the persistent uid.dat value. - // Pad 0 uses the base XUID directly. Pads 1-3 get a deterministic hash - // of (base + pad) to produce fully independent IDs with no overlap risk. - *pXuid = Win64Xuid::DeriveXuidForPad(Win64Xuid::ResolvePersistentXuid(), iPad); -#else - * pXuid = 0xe000d45248242f2e + iPad; -#endif + // Stub — auth manager assigns UUID during login + *pXuid = INVALID_XUID; } BOOL C_4JProfile::AreXUIDSEqual(PlayerUID xuid1, PlayerUID xuid2) { return xuid1 == xuid2; } BOOL C_4JProfile::XUIDIsGuest(PlayerUID xuid) { return false; } @@ -669,7 +675,6 @@ char fakeGamerTag[32] = "PlayerName"; void SetFakeGamertag(char* name) { strcpy_s(fakeGamerTag, name); } #else char* C_4JProfile::GetGamertag(int iPad) { - extern char g_Win64Username[17]; if (iPad > 0 && iPad < XUSER_MAX_COUNT && IQNet::m_player[iPad].m_gamertag[0] != 0 && !IQNet::m_player[iPad].m_isRemote) { @@ -677,14 +682,33 @@ char* C_4JProfile::GetGamertag(int iPad) { WideCharToMultiByte(CP_ACP, 0, IQNet::m_player[iPad].m_gamertag, -1, s_padGamertag[iPad], 17, nullptr, nullptr); return s_padGamertag[iPad]; } - return g_Win64Username; + // MCAuth session is the source-of-truth for player names + static char s_authGamertag[XUSER_MAX_COUNT][17]; + auto& mgr = MCAuthManager::Get(); + if (mgr.IsSlotLoggedIn(iPad)) + { + auto session = mgr.GetSlotSession(iPad); + if (!session.username.empty()) + { + strncpy_s(s_authGamertag[iPad], sizeof(s_authGamertag[iPad]), session.username.c_str(), _TRUNCATE); + return s_authGamertag[iPad]; + } + } + static char s_defaultTag[] = "Player"; + return s_defaultTag; } wstring C_4JProfile::GetDisplayName(int iPad) { - extern wchar_t g_Win64UsernameW[17]; if (iPad > 0 && iPad < XUSER_MAX_COUNT && IQNet::m_player[iPad].m_gamertag[0] != 0 && !IQNet::m_player[iPad].m_isRemote) return IQNet::m_player[iPad].m_gamertag; - return g_Win64UsernameW; + auto& mgr = MCAuthManager::Get(); + if (mgr.IsSlotLoggedIn(iPad)) + { + auto session = mgr.GetSlotSession(iPad); + if (!session.username.empty()) + return std::wstring(session.username.begin(), session.username.end()); + } + return L"Player"; } #endif bool C_4JProfile::IsFullVersion() { return s_bProfileIsFullVersion; } diff --git a/Minecraft.Client/Font.h b/Minecraft.Client/Font.h index 58bceb4c09..21ced52bf8 100644 --- a/Minecraft.Client/Font.h +++ b/Minecraft.Client/Font.h @@ -93,6 +93,32 @@ class Font void setEnforceUnicodeSheet(bool enforceUnicodeSheet); void setBidirectional(bool bidirectional); + // Bind the font atlas texture so external code (e.g. NativeUI) can sample + // the white texel at UV (0,0) for solid-colour rectangles. + void bindFontTexture() { textures->bindTexture(m_textureLocation); } + + // Expose font metrics for NativeUI D3D11 text renderer. + int getCols() const { return m_cols; } + int getRows() const { return m_rows; } + int getCharWidth() const { return m_charWidth; } + int getCharHeight() const { return m_charHeight; } + int getCharPixelWidth(wchar_t c) const { return charWidths[MapCharacterConst(c)]; } + wchar_t mapChar(wchar_t c) const { return static_cast(MapCharacterConst(c)); } + ResourceLocation* getTextureLocation() const { return m_textureLocation; } + Textures* getTextures() const { return textures; } + +private: + int MapCharacterConst(wchar_t c) const + { + if (!m_charMap.empty() && c != L' ') + { + auto it = m_charMap.find(c); + return (it != m_charMap.end()) ? it->second : 0; + } + return c; + } +public: + // 4J-PB - check for invalid player name - Japanese local name bool AllCharactersValid(const wstring &str); }; diff --git a/Minecraft.Client/LocalPlayer.cpp b/Minecraft.Client/LocalPlayer.cpp index 7988416ad9..bac323f740 100644 --- a/Minecraft.Client/LocalPlayer.cpp +++ b/Minecraft.Client/LocalPlayer.cpp @@ -88,7 +88,7 @@ LocalPlayer::LocalPlayer(Minecraft *minecraft, Level *level, User *user, int dim this->name = user->name; //wprintf(L"Created LocalPlayer with name %ls\n", name.c_str() ); // check to see if this player's xuid is in the list of special players - MOJANG_DATA *pMojangData=app.GetMojangDataForXuid(getOnlineXuid()); + MOJANG_DATA *pMojangData=app.GetMojangDataForXuid(getXuid()); if(pMojangData) { customTextureUrl=pMojangData->wchSkin; diff --git a/Minecraft.Client/Minecraft.cpp b/Minecraft.Client/Minecraft.cpp index 1ba432fd04..36e632172a 100644 --- a/Minecraft.Client/Minecraft.cpp +++ b/Minecraft.Client/Minecraft.cpp @@ -1,5 +1,8 @@ #include "stdafx.h" #include "Minecraft.h" +#ifdef _WINDOWS64 +#include "..\MCAuth\include\MCAuthManager.h" +#endif #include "Common/UI/UIScene.h" #include "GameMode.h" #include "Timer.h" @@ -54,7 +57,7 @@ #include "..\Minecraft.World\net.minecraft.world.level.dimension.h" #include "..\Minecraft.World\net.minecraft.world.item.h" #include "..\Minecraft.World\Minecraft.World.h" -#include "Windows64\Windows64_Xuid.h" +#include "Windows64\Windows64_Uuid.h" #include "ClientConnection.h" #include "..\Minecraft.World\HellRandomLevelSource.h" #include "..\Minecraft.World\net.minecraft.world.entity.animal.h" @@ -1040,25 +1043,8 @@ shared_ptr Minecraft::createExtraLocalPlayer(int idx, co //localitemInHandRenderers[idx] = new ItemInHandRenderer(this); localplayers[idx] = localgameModes[idx]->createPlayer(level); - PlayerUID playerXUIDOffline = INVALID_XUID; - PlayerUID playerXUIDOnline = INVALID_XUID; - ProfileManager.GetXUID(idx,&playerXUIDOffline,false); - ProfileManager.GetXUID(idx,&playerXUIDOnline,true); -#ifdef _WINDOWS64 - // Compatibility rule for Win64 id migration - // host keeps legacy host XUID, non-host uses persistent uid.dat XUID. - INetworkPlayer *localNetworkPlayer = g_NetworkManager.GetLocalPlayerByUserIndex(idx); - if(localNetworkPlayer != nullptr && localNetworkPlayer->IsHost()) - { - playerXUIDOffline = Win64Xuid::GetLegacyEmbeddedHostXuid(); - } - else - { - playerXUIDOffline = Win64Xuid::ResolvePersistentXuid(); - } -#endif - localplayers[idx]->setXuid(playerXUIDOffline); - localplayers[idx]->setOnlineXuid(playerXUIDOnline); + PlayerUID playerXUID = GameUUID::generateOffline(std::string(localplayers[idx]->name.begin(), localplayers[idx]->name.end())); + localplayers[idx]->setXuid(playerXUID); localplayers[idx]->setIsGuest(ProfileManager.IsGuest(idx)); localplayers[idx]->m_displayName = ProfileManager.GetDisplayName(idx); @@ -1110,7 +1096,7 @@ void Minecraft::storeExtraLocalPlayer(int idx) void Minecraft::removeLocalPlayerIdx(int idx) { bool updateXui = true; - if(localgameModes[idx] != nullptr) + if(localgameModes[idx] != nullptr && localplayers[idx] != nullptr) { if( getLevel( localplayers[idx]->dimension )->isClientSide ) { @@ -1156,6 +1142,13 @@ void Minecraft::removeLocalPlayerIdx(int idx) } localplayers[idx] = nullptr; +#ifdef _WINDOWS64 + // Reset splitscreen join state so the slot can be reused + resetSplitJoinState(idx); + // Release the auth account slot so it's no longer shown as "Player N" in the picker + MCAuthManager::Get().ClearSlot(idx); +#endif + if( idx == ProfileManager.GetPrimaryPad() ) { // We should never try to remove the Primary player in this way @@ -1189,7 +1182,25 @@ void Minecraft::createPrimaryLocalPlayer(int iPad) localgameModes[iPad] = gameMode; localplayers[iPad] = player; //gameRenderer->itemInHandRenderer = localitemInHandRenderers[iPad]; - // Give them the gamertag if they're signed in + +#ifdef _WINDOWS64 + // Auth handshake already set user->name; don't overwrite. + { + auto& mgr = MCAuthManager::Get(); + if (mgr.IsSlotLoggedIn(iPad)) + { + auto session = mgr.GetSlotSession(iPad); + if (!session.username.empty()) + { + // Only update user->name for primary player (it's a shared global) + if (iPad == 0) + user->name = std::wstring(session.username.begin(), session.username.end()); + return; + } + } + } +#endif + // Fallback: use ProfileManager gamertag (console platforms, or no auth session) if(ProfileManager.IsSignedIn(ProfileManager.GetPrimaryPad())) { user->name = convStringToWstring( ProfileManager.GetGamertag(ProfileManager.GetPrimaryPad()) ); @@ -1203,14 +1214,19 @@ void Minecraft::applyFrameMouseLook() // for the 20Hz game tick. Apply the same delta to both xRot/yRot AND xRotO/yRotO // so the render interpolation instantly reflects the change without waiting for a tick. if (level == nullptr) return; + if (!g_KBMInput.IsMouseGrabbed()) return; + + // If any pad has a blocking UI open, skip mouse look but do NOT drain + // the delta accumulators — they are used by KeyboardMouseInput::Tick() + // to populate m_mouseDeltaX/Y which the inventory cursor reads. + for (int p = 0; p < XUSER_MAX_COUNT; ++p) + if (ui.GetMenuDisplayed(p)) return; for (int i = 0; i < XUSER_MAX_COUNT; i++) { if (localplayers[i] == nullptr) continue; int iPad = localplayers[i]->GetXboxPad(); if (iPad != 0) continue; // Mouse only applies to pad 0 - - if (!g_KBMInput.IsMouseGrabbed()) continue; if (localgameModes[iPad] == nullptr) continue; float rawDx, rawDy; @@ -1629,6 +1645,46 @@ void Minecraft::run_middle() s_prevXButtons[i] = xCurButtons; } bool startJustPressed = s_startPressLatch[i] > 0; + // State machine: block the join flow when auth UI is open + if (m_splitJoinState[i] == ESplitJoinState::AuthUI) + { + s_startPressLatch[i] = 0; + continue; + } + // AuthDone: account selected, join immediately without waiting for button press + if (i > 0 && m_splitJoinState[i] == ESplitJoinState::AuthDone) + { + app.DebugPrintf("Splitscreen: slot %d auth done, joining immediately\n", i); + resetSplitJoinState(i); + ui.HidePressStart(); + + // Ensure the auth session is ready before joining. + // If still authenticating, defer to next frame instead of blocking. + { + auto slotState = MCAuthManager::Get().GetSlotState(i); + if (slotState == MCAuthManager::State::Authenticating || + slotState == MCAuthManager::State::WaitingForCode) + { + app.DebugPrintf("Splitscreen: slot %d auth still in flight, deferring join\n", i); + setSplitAuthCompleted(i); // re-enter AuthDone state, retry next frame + continue; + } + } + + if (level->isClientSide) + { + bool success = addLocalPlayer(i); + if (!success) + app.DebugPrintf("Splitscreen: addLocalPlayer(%d) failed\n", i); + } + else + { + shared_ptr pl = localplayers[i]; + if (pl == nullptr) + createExtraLocalPlayer(i, (convStringToWstring(ProfileManager.GetGamertag(i))).c_str(), i, level->dimension->id); + } + continue; + } bool tryJoin = !pause && !ui.IsIgnorePlayerJoinMenuDisplayed(ProfileManager.GetPrimaryPad()) && g_NetworkManager.SessionHasSpace() && xCurButtons != 0 && g_KBMInput.IsWindowFocused(); #else bool tryJoin = !pause && !ui.IsIgnorePlayerJoinMenuDisplayed(ProfileManager.GetPrimaryPad()) && g_NetworkManager.SessionHasSpace() && RenderManager.IsHiDef() && InputManager.ButtonPressed(i); @@ -1695,17 +1751,35 @@ void Minecraft::run_middle() #endif if( level->isClientSide ) { - bool success=addLocalPlayer(i); - - if(!success) +#ifdef _WINDOWS64 + // Splitscreen: state machine for secondary controller join. + // Idle → open account picker → AuthUI + // AuthDone → Joining → addLocalPlayer → Idle + if (i > 0 && m_splitJoinState[i] == ESplitJoinState::Idle) { - app.DebugPrintf("Bringing up the sign in ui\n"); - ProfileManager.RequestSignInUI(false, g_NetworkManager.IsLocalGame(), true, false,true,&Minecraft::InGame_SignInReturned, this,i); + // First press: open the account picker + static int s_splitscreenSlots[XUSER_MAX_COUNT] = {}; + s_splitscreenSlots[i] = i; + app.DebugPrintf("Splitscreen: opening account picker for slot %d\n", i); + setSplitAuthOpened(i); + s_startPressLatch[i] = 0; + // Use Popup layer + Fullscreen group so auth UI renders above + // all other players' HUDs (avoids z-order issue with per-pad groups). + ui.NavigateToScene(i, eUIScene_MSAuth, &s_splitscreenSlots[i], + eUILayer_Popup, eUIGroup_Fullscreen); } - else + else if (i == 0) +#endif { + bool success=addLocalPlayer(i); + + if(!success) + { + app.DebugPrintf("Bringing up the sign in ui\n"); + ProfileManager.RequestSignInUI(false, g_NetworkManager.IsLocalGame(), true, false,true,&Minecraft::InGame_SignInReturned, this,i); + } #ifdef __ORBIS__ - if(g_NetworkManager.IsLocalGame() == false) + else if(!g_NetworkManager.IsLocalGame()) { bool chatRestricted = false; ProfileManager.GetChatAndContentRestrictions(i,false,&chatRestricted,nullptr,nullptr); @@ -2375,25 +2449,23 @@ void Minecraft::tick(bool bFirst, bool bUpdateTextures) } #ifdef _WINDOWS64 - // Mouse grab/release only for the primary (KBM) player — splitscreen - // players use controllers and must never fight over the cursor state. + // Mouse grab/release for the primary (KBM) player. + // Only ungrab when the PRIMARY player's own UI is blocking — other + // players' menus (splitscreen) should not steal KBM focus. if (iPad == ProfileManager.GetPrimaryPad()) { - if ((screen != nullptr || ui.GetMenuDisplayed(iPad)) && g_KBMInput.IsMouseGrabbed()) - { + int primaryPad = ProfileManager.GetPrimaryPad(); + bool primaryUIBlocking = (screen != nullptr) || ui.GetMenuDisplayed(primaryPad); + + if (primaryUIBlocking && g_KBMInput.IsMouseGrabbed()) g_KBMInput.SetMouseGrabbed(false); - } + else if (!primaryUIBlocking && !g_KBMInput.IsMouseGrabbed() && g_KBMInput.IsWindowFocused()) + g_KBMInput.SetMouseGrabbed(true); } #endif if (screen == nullptr && !ui.GetMenuDisplayed(iPad) ) { -#ifdef _WINDOWS64 - if (iPad == ProfileManager.GetPrimaryPad() && !g_KBMInput.IsMouseGrabbed() && g_KBMInput.IsWindowFocused()) - { - g_KBMInput.SetMouseGrabbed(true); - } -#endif // 4J-PB - add some tooltips if required int iA=-1, iB=-1, iX, iY=IDS_CONTROLS_INVENTORY, iLT=-1, iRT=-1, iLB=-1, iRB=-1, iLS=-1, iRS=-1; @@ -4359,32 +4431,8 @@ void Minecraft::setLevel(MultiPlayerLevel *level, int message /*=-1*/, shared_pt player = gameMode->createPlayer(level); - PlayerUID playerXUIDOffline = INVALID_XUID; - PlayerUID playerXUIDOnline = INVALID_XUID; - ProfileManager.GetXUID(iPrimaryPlayer,&playerXUIDOffline,false); - ProfileManager.GetXUID(iPrimaryPlayer,&playerXUIDOnline,true); -#ifdef __PSVITA__ - if(CGameNetworkManager::usingAdhocMode() && playerXUIDOnline.getOnlineID()[0] == 0) - { - // player doesn't have an online UID, set it from the player name - playerXUIDOnline.setForAdhoc(); - } -#endif -#ifdef _WINDOWS64 - // On Windows, the implementation has been changed to use a per-client pseudo XUID based on `uid.dat`. - // To maintain player data compatibility with existing worlds, the world host (the first player) will use the previous embedded pseudo XUID. - INetworkPlayer *localNetworkPlayer = g_NetworkManager.GetLocalPlayerByUserIndex(iPrimaryPlayer); - if(localNetworkPlayer != nullptr && localNetworkPlayer->IsHost()) - { - playerXUIDOffline = Win64Xuid::GetLegacyEmbeddedHostXuid(); - } - else - { - playerXUIDOffline = Win64Xuid::ResolvePersistentXuid(); - } -#endif - player->setXuid(playerXUIDOffline); - player->setOnlineXuid(playerXUIDOnline); + PlayerUID playerXUID = GameUUID::generateOffline(std::string(player->name.begin(), player->name.end())); + player->setXuid(playerXUID); player->m_displayName = ProfileManager.GetDisplayName(iPrimaryPlayer); @@ -4457,6 +4505,7 @@ void Minecraft::setLevel(MultiPlayerLevel *level, int message /*=-1*/, shared_pt if( m_pendingLocalConnections[i] != nullptr ) m_pendingLocalConnections[i]->close(); m_pendingLocalConnections[i] = nullptr; localplayers[i] = nullptr; + delete localgameModes[i]; localgameModes[i] = nullptr; } } @@ -4561,24 +4610,11 @@ void Minecraft::respawnPlayer(int iPad, int dimension, int newEntityId) EDefaultSkins skin = localPlayer->getPlayerDefaultSkin(); player = localgameModes[iPad]->createPlayer(level); - PlayerUID playerXUIDOffline = INVALID_XUID; - PlayerUID playerXUIDOnline = INVALID_XUID; - ProfileManager.GetXUID(iTempPad,&playerXUIDOffline,false); - ProfileManager.GetXUID(iTempPad,&playerXUIDOnline,true); -#ifdef _WINDOWS64 - // Same compatibility rule as create/init paths. - INetworkPlayer *localNetworkPlayer = g_NetworkManager.GetLocalPlayerByUserIndex(iTempPad); - if(localNetworkPlayer != nullptr && localNetworkPlayer->IsHost()) - { - playerXUIDOffline = Win64Xuid::GetLegacyEmbeddedHostXuid(); - } - else - { - playerXUIDOffline = Win64Xuid::ResolvePersistentXuid(); - } -#endif - player->setXuid(playerXUIDOffline); - player->setOnlineXuid(playerXUIDOnline); + // Carry UUID from old player, or generate offline UUID + PlayerUID playerXUID = (localPlayer != nullptr && localPlayer->getXuid().isValid()) + ? localPlayer->getXuid() + : GameUUID::generateOffline(std::string(player->name.begin(), player->name.end())); + player->setXuid(playerXUID); player->setIsGuest( ProfileManager.IsGuest(iTempPad) ); player->m_displayName = ProfileManager.GetDisplayName(iPad); @@ -4590,6 +4626,8 @@ void Minecraft::respawnPlayer(int iPad, int dimension, int newEntityId) player->setCustomSkin(localPlayer->getCustomSkin()); player->setPlayerDefaultSkin( skin ); player->setCustomCape(localPlayer->getCustomCape()); + // Preserve Mojang skin texture key across respawn. + player->customTextureUrl = localPlayer->customTextureUrl; player->m_sessionTimeStart = localPlayer->m_sessionTimeStart; player->m_dimensionTimeStart = localPlayer->m_dimensionTimeStart; player->setPlayerGamePrivilege(Player::ePlayerGamePrivilege_All, localPlayer->getAllPlayerGamePrivileges()); diff --git a/Minecraft.Client/Minecraft.h b/Minecraft.Client/Minecraft.h index 2c5203d827..6745f47f23 100644 --- a/Minecraft.Client/Minecraft.h +++ b/Minecraft.Client/Minecraft.h @@ -110,6 +110,28 @@ class Minecraft DisconnectPacket::eDisconnectReason m_connectionFailedReason[XUSER_MAX_COUNT]; ClientConnection *m_pendingLocalConnections[XUSER_MAX_COUNT]; +#ifdef _WINDOWS64 + // Splitscreen join state machine per slot. + // Centralizes the auth UI lifecycle that was previously tracked + // via two independent booleans (m_splitscreenAuthPending/Done). + enum class ESplitJoinState : uint8_t { + Idle, // slot free, ready for Start press to initiate join + AuthUI, // account picker UI is open, block game loop join + AuthDone, // user picked an account, game loop should call addLocalPlayer + }; + ESplitJoinState m_splitJoinState[XUSER_MAX_COUNT] = {}; + + void setSplitAuthOpened(int i) { m_splitJoinState[i] = ESplitJoinState::AuthUI; } + void setSplitAuthCompleted(int i){ m_splitJoinState[i] = ESplitJoinState::AuthDone; } + void setSplitAuthCancelled(int i){ m_splitJoinState[i] = ESplitJoinState::Idle; } + void resetSplitJoinState(int i) { m_splitJoinState[i] = ESplitJoinState::Idle; } + bool isAnySplitAuthUIOpen() const { + for (int i = 0; i < XUSER_MAX_COUNT; ++i) + if (m_splitJoinState[i] == ESplitJoinState::AuthUI) return true; + return false; + } +#endif + bool addLocalPlayer(int idx); // Re-arrange the screen and start the connection void addPendingLocalConnection(int idx, ClientConnection *connection); void connectionDisconnected(int idx, DisconnectPacket::eDisconnectReason reason) { m_connectionFailed[idx] = true; m_connectionFailedReason[idx] = reason; } @@ -179,7 +201,7 @@ class Minecraft int rightClickDelay; public: // 4J- this should really be in localplayer - StatsCounter* stats[4]; + StatsCounter* stats[XUSER_MAX_COUNT]; private: wstring connectToIp; diff --git a/Minecraft.Client/MinecraftServer.cpp b/Minecraft.Client/MinecraftServer.cpp index 1e3ed74eff..5ec7eb34ad 100644 --- a/Minecraft.Client/MinecraftServer.cpp +++ b/Minecraft.Client/MinecraftServer.cpp @@ -40,6 +40,12 @@ #endif #include "..\Minecraft.World\ConsoleSaveFileOriginal.h" #include "..\Minecraft.World\Socket.h" +#ifdef _WINDOWS64 +#include "..\MCAuth\include\MCAuthManager.h" +#endif +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) +#include "..\Minecraft.Server\ServerLogger.h" +#endif #include "..\Minecraft.World\net.minecraft.world.entity.h" #include "ProgressRenderer.h" #include "ServerPlayer.h" @@ -644,8 +650,49 @@ bool MinecraftServer::initServer(int64_t seed, NetworkGameInitData *initData, DW // 4J - Unused //localIp = settings->getString(L"server-ip", L""); - //onlineMode = settings->getBoolean(L"online-mode", true); - //motd = settings->getString(L"motd", L"A Minecraft Server"); + if (ShouldUseDedicatedServerProperties()) + { + onlineMode = GetDedicatedServerBool(settings, L"online-mode", true); + { + wstring wProvider = GetDedicatedServerString(settings, L"auth-provider", L"mojang"); + authProvider = std::string(wProvider.begin(), wProvider.end()); + if (authProvider != "mojang" && authProvider != "elyby") + authProvider = "mojang"; + } + app.DebugPrintf("[Auth] Dedicated server: onlineMode=%d, authProvider=%s (from server.properties)\n", + (int)onlineMode, authProvider.c_str()); +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) + ServerRuntime::LogInfof("auth", "Dedicated server: onlineMode=%d, authProvider=%s (from server.properties)", + (int)onlineMode, authProvider.c_str()); +#endif + } + else + { +#ifdef _WINDOWS64 + // Embedded server (singleplayer/LAN): derive from whether we have an online account + auto& mgr = MCAuthManager::Get(); + bool isLoggedIn = mgr.IsJavaLoggedIn(); + bool hasToken = !mgr.GetJavaSession().accessToken.empty(); + onlineMode = isLoggedIn && hasToken; + // Derive auth provider from active account + { + auto accounts = mgr.GetJavaAccounts(); + int acctIdx = mgr.GetSlot(0).accountIndex.load(); + if (acctIdx >= 0 && acctIdx < (int)accounts.size()) + authProvider = accounts[acctIdx].authProvider; + else + authProvider = "mojang"; + } + app.DebugPrintf("[Auth] Embedded server: IsJavaLoggedIn=%d, hasAccessToken=%d -> onlineMode=%d, authProvider=%s\n", + (int)isLoggedIn, (int)hasToken, (int)onlineMode, authProvider.c_str()); +#else + // Console embedded servers: auth handled by platform (PSN/Xbox Live), not MCAuth + onlineMode = false; + authProvider = "mojang"; + app.DebugPrintf("[Auth] Embedded server (console): onlineMode=false\n"); +#endif + } + //motd = settings->getString(L"motd", L"A Minecraft Server"); //motd.replace('�', '$'); setAnimals(GetDedicatedServerBool(settings, L"spawn-animals", true)); diff --git a/Minecraft.Client/MinecraftServer.h b/Minecraft.Client/MinecraftServer.h index 1ed5db9d7c..936c7d19d0 100644 --- a/Minecraft.Client/MinecraftServer.h +++ b/Minecraft.Client/MinecraftServer.h @@ -113,6 +113,7 @@ class MinecraftServer : public ConsoleInputSource CRITICAL_SECTION m_consoleInputCS; public: bool onlineMode; + std::string authProvider = "mojang"; // "mojang" or "elyby" bool animals; bool npcs; bool pvp; diff --git a/Minecraft.Client/PS3/XML/xmlFilesCallback.h b/Minecraft.Client/PS3/XML/xmlFilesCallback.h index b6c1bcf044..d0658ac4c8 100644 --- a/Minecraft.Client/PS3/XML/xmlFilesCallback.h +++ b/Minecraft.Client/PS3/XML/xmlFilesCallback.h @@ -19,7 +19,7 @@ class xmlMojangCallback : public ATG::ISAXCallback WCHAR wNameXUID[32] = L""; WCHAR wNameSkin[32] = L""; WCHAR wNameCloak[32] = L""; - PlayerUID xuid=0LL; + PlayerUID xuid = INVALID_XUID; if (NameLen >31) @@ -47,7 +47,11 @@ class xmlMojangCallback : public ATG::ISAXCallback { ZeroMemory(wTemp,sizeof(WCHAR)*35); wcsncpy_s( wTemp, pAttributes[i].strValue, pAttributes[i].ValueLen); - xuid=_wcstoui64(wTemp,NULL,10); + { + char narrow[64] = {}; + wcstombs_s(nullptr, narrow, wTemp, _TRUNCATE); + xuid = PlayerUID::fromDashed(std::string(narrow)); + } } } else if (_wcsicmp(wAttName,L"cape")==0) @@ -68,7 +72,7 @@ class xmlMojangCallback : public ATG::ISAXCallback } // if the xuid hasn't been defined, then we can't use the data - if(xuid!=0LL) + if(xuid.isValid()) { return CConsoleMinecraftApp::RegisterMojangData(wNameXUID , xuid, wNameSkin, wNameCloak ); } diff --git a/Minecraft.Client/PendingConnection.cpp b/Minecraft.Client/PendingConnection.cpp index f24086c1b6..89b7725ec9 100644 --- a/Minecraft.Client/PendingConnection.cpp +++ b/Minecraft.Client/PendingConnection.cpp @@ -8,14 +8,21 @@ #include "PlayerList.h" #include "MinecraftServer.h" #include "..\Minecraft.World\net.minecraft.network.h" +#include "..\Minecraft.World\net.minecraft.network.packet.h" #include "..\Minecraft.World\pos.h" #include "..\Minecraft.World\net.minecraft.world.level.dimension.h" #include "..\Minecraft.World\net.minecraft.world.level.storage.h" #include "..\Minecraft.World\net.minecraft.world.item.h" #include "..\Minecraft.World\SharedConstants.h" +#include "..\Minecraft.World\GameUUID.h" #include "Settings.h" +#ifdef _WINDOWS64 +#include "..\..\MCAuth\include\MCAuth.h" +#include +#endif #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) #include "..\Minecraft.Server\ServerLogManager.h" +#include "..\Minecraft.Server\ServerLogger.h" #include "..\Minecraft.Server\Access\Access.h" #include "..\Minecraft.World\Socket.h" #endif @@ -23,6 +30,25 @@ // #include "PS3\Network\NetworkPlayerSony.h" // #endif +// Auth logging: use INFO-level server logger on dedicated server, DebugPrintf on client +#if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) +static void AuthLog(const char* fmt, ...) +{ + char buf[2048]; + va_list args; + va_start(args, fmt); + vsnprintf_s(buf, sizeof(buf), _TRUNCATE, fmt, args); + va_end(args); + // Strip trailing newline for server logger (it adds its own) + size_t len = strlen(buf); + while (len > 0 && (buf[len-1] == '\n' || buf[len-1] == '\r')) buf[--len] = '\0'; + ServerRuntime::LogInfof("auth", "%s", buf); +} +#define AUTH_LOG(fmt, ...) AuthLog(fmt, ##__VA_ARGS__) +#else +#define AUTH_LOG(fmt, ...) app.DebugPrintf(fmt, ##__VA_ARGS__) +#endif + Random *PendingConnection::random = new Random(); #ifdef _WINDOWS64 @@ -47,6 +73,43 @@ namespace } #endif +struct UgcGatherResult +{ + PlayerUID *xuids; + DWORD cappedCount; + BYTE friendsOnlyBits; + BYTE cappedHostIndex; + char uniqueMapName[14]; +}; + +static UgcGatherResult GatherUgcData() +{ + UgcGatherResult r = {}; + StorageManager.GetSaveUniqueFilename(r.uniqueMapName); + + r.xuids = new PlayerUID[MINECRAFT_NET_MAX_PLAYERS]; + DWORD count = 0; + DWORD hostIndex = 0; + + PlayerList *playerList = MinecraftServer::getInstance()->getPlayers(); + for (auto& player : playerList->players) + { + if (player != nullptr && player->connection->m_xuid != INVALID_XUID) + { + if (player->connection->m_friendsOnlyUGC) + r.friendsOnlyBits |= (1 << count); + r.xuids[count] = player->connection->m_xuid; + if (player->connection->getNetworkPlayer() != nullptr && player->connection->getNetworkPlayer()->IsHost()) + hostIndex = count; + ++count; + } + } + + r.cappedCount = (count > 255u) ? 255u : count; + r.cappedHostIndex = (hostIndex >= 255u) ? 254 : static_cast(hostIndex); + return r; +} + PendingConnection::PendingConnection(MinecraftServer *server, Socket *socket, const wstring& id) { // 4J - added initialisers @@ -63,6 +126,8 @@ PendingConnection::PendingConnection(MinecraftServer *server, Socket *socket, co PendingConnection::~PendingConnection() { + if (m_authVerifyResult) + m_authVerifyResult->cancelled.store(true, std::memory_order_release); delete connection; } @@ -73,6 +138,51 @@ void PendingConnection::tick() this->handleAcceptedLogin(acceptedLogin); acceptedLogin = nullptr; } + +#ifdef _WINDOWS64 + // Poll async Mojang verification result + if (m_authState == eAuth_Verifying && m_authVerifyResult && m_authVerifyResult->ready.load(std::memory_order_acquire)) + { + std::lock_guard lock(m_authVerifyResult->mutex); + + if (m_authVerifyResult->success) + { + m_authUsername = m_authVerifyResult->username; + m_authUuid = MCAuth::DashUuid(m_authVerifyResult->uuid); + AUTH_LOG("[Auth] %s verification SUCCESS for '%s' (uuid=%s)\n", + m_authScheme.c_str(), m_authUsername.c_str(), m_authUuid.c_str()); + + // Store skin data for later use in placeNewPlayer + m_authSkinData = std::move(m_authVerifyResult->skinData); + if (!m_authSkinData.empty()) + { + if (m_authScheme == "elyby") + m_authSkinUrl = MCAuth::MakeElybySkinKey(m_authVerifyResult->uuid); + else + m_authSkinUrl = MCAuth::MakeSkinKey(m_authVerifyResult->uuid); + AUTH_LOG("[Auth] Downloaded %s skin for %s (%zu bytes)\n", + m_authScheme.c_str(), m_authUsername.c_str(), m_authSkinData.size()); + } + + wstring wUuid(m_authUuid.begin(), m_authUuid.end()); + wstring wName(m_authUsername.begin(), m_authUsername.end()); + // Send skin key + bytes inline so the client doesn't need a separate download + wstring wSkinKey(m_authSkinUrl.begin(), m_authSkinUrl.end()); + connection->send(make_shared(true, wUuid, wName, L"", wSkinKey, m_authSkinData)); + m_authState = eAuth_WaitingAck; + } + else + { + AUTH_LOG("[Auth] %s verification FAILED for pending connection (error=%s), disconnecting\n", + m_authScheme.c_str(), m_authVerifyResult->errorDetail.c_str()); + connection->send(make_shared(false, L"", L"", L"Authentication failed")); + connection->sendAndQuit(); + m_authState = eAuth_Done; + done = true; + } + } +#endif + if (_tick++ == MAX_TICKS_BEFORE_LOGIN) { disconnect(DisconnectPacket::eDisconnect_LoginTooLong); @@ -85,9 +195,23 @@ void PendingConnection::tick() void PendingConnection::disconnect(DisconnectPacket::eDisconnectReason reason) { - // try { // 4J - removed try/catch - // logger.info("Disconnecting " + getName() + ": " + reason); - app.DebugPrintf("Pending connection disconnect: %d\n", reason ); + if (m_authVerifyResult) + m_authVerifyResult->cancelled.store(true, std::memory_order_release); + + const char* reasonStr = "unknown"; + switch (reason) + { + case DisconnectPacket::eDisconnect_Closed: reasonStr = "Closed"; break; + case DisconnectPacket::eDisconnect_Kicked: reasonStr = "Kicked"; break; + case DisconnectPacket::eDisconnect_LoginTooLong: reasonStr = "LoginTooLong"; break; + case DisconnectPacket::eDisconnect_OutdatedServer: reasonStr = "OutdatedServer"; break; + case DisconnectPacket::eDisconnect_OutdatedClient: reasonStr = "OutdatedClient"; break; + case DisconnectPacket::eDisconnect_ServerFull: reasonStr = "ServerFull"; break; + case DisconnectPacket::eDisconnect_AuthFailed: reasonStr = "AuthFailed"; break; + case DisconnectPacket::eDisconnect_UnexpectedPacket: reasonStr = "UnexpectedPacket"; break; + default: break; + } + AUTH_LOG("[Auth] Pending connection disconnect: reason=%d (%s)\n", reason, reasonStr); connection->send(std::make_shared(reason)); connection->sendAndQuit(); done = true; @@ -119,56 +243,72 @@ void PendingConnection::handlePreLogin(shared_ptr packet) void PendingConnection::sendPreLoginResponse() { // 4J Stu - Calculate the players with UGC privileges set - PlayerUID *ugcXuids = new PlayerUID[MINECRAFT_NET_MAX_PLAYERS]; - DWORD ugcXuidCount = 0; - DWORD hostIndex = 0; - BYTE ugcFriendsOnlyBits = 0; - char szUniqueMapName[14]; + UgcGatherResult ugc = GatherUgcData(); + connection->send(std::make_shared(L"-", ugc.xuids, ugc.cappedCount, ugc.friendsOnlyBits, server->m_ugcPlayersVersion, ugc.uniqueMapName, app.GetGameHostOption(eGameHostOption_All), ugc.cappedHostIndex, server->m_texturePackId)); - StorageManager.GetSaveUniqueFilename(szUniqueMapName); - - PlayerList *playerList = MinecraftServer::getInstance()->getPlayers(); - for(auto& player : playerList->players) + // --- Auth handshake --- + // Generate random serverId (20 hex chars) { - // If the offline Xuid is invalid but the online one is not then that's guest which we should ignore - // If the online Xuid is invalid but the offline one is not then we are definitely an offline game so dont care about UGC - - // PADDY - this is failing when a local player with chat restrictions joins an online game - - if( player != nullptr && player->connection->m_offlineXUID != INVALID_XUID && player->connection->m_onlineXUID != INVALID_XUID ) - { - if( player->connection->m_friendsOnlyUGC ) - { - ugcFriendsOnlyBits |= (1<connection->m_onlineXUID; - - if( player->connection->getNetworkPlayer() != nullptr && player->connection->getNetworkPlayer()->IsHost() ) hostIndex = ugcXuidCount; - - ++ugcXuidCount; - } + static const char hex[] = "0123456789abcdef"; + m_serverId.resize(20); + for (int i = 0; i < 20; i++) + m_serverId[i] = hex[random->nextInt(16)]; } -#if 0 - if (false)// server->onlineMode) // 4J - removed + vector schemes; + if (server->onlineMode) { - loginKey = L"TOIMPLEMENT"; // 4J - todo Long.toHexString(random.nextLong()); - connection->send( shared_ptr( new PreLoginPacket(loginKey, ugcXuids, ugcXuidCount, ugcFriendsOnlyBits, server->m_ugcPlayersVersion, szUniqueMapName,app.GetGameHostOption(eGameHostOption_All),hostIndex) ) ); + if (server->authProvider == "elyby") + { + schemes.push_back(L"elyby"); + AUTH_LOG("[Auth] Server onlineMode=true, authProvider=elyby, offering scheme: [elyby]\n"); + } + else + { + schemes.push_back(L"mojang"); + AUTH_LOG("[Auth] Server onlineMode=true, authProvider=mojang, offering scheme: [mojang]\n"); + } } else -#endif { - DWORD cappedCount = (ugcXuidCount > 255u) ? 255u : ugcXuidCount; - BYTE cappedHostIndex = (hostIndex >= 255u) ? 254 : static_cast(hostIndex); - connection->send(std::make_shared(L"-", ugcXuids, cappedCount, ugcFriendsOnlyBits, server->m_ugcPlayersVersion, szUniqueMapName, app.GetGameHostOption(eGameHostOption_All), cappedHostIndex, server->m_texturePackId)); + schemes.push_back(L"mojang"); + schemes.push_back(L"offline"); + AUTH_LOG("[Auth] Server onlineMode=false, offering schemes: [mojang, offline]\n"); } + + wstring wServerId(m_serverId.begin(), m_serverId.end()); + connection->send(make_shared(schemes, wServerId)); + m_authState = eAuth_WaitingResponse; + AUTH_LOG("[Auth] Sent AuthSchemePacket to client, serverId=%s\n", m_serverId.c_str()); } void PendingConnection::handleLogin(shared_ptr packet) { // printf("Server: handleLogin\n"); //name = packet->userName; + + // Reject login if auth handshake has not completed. + // eAuth_None means no auth handshake occurred — reject to prevent auth bypass. + if (m_authState == eAuth_WaitingAck) + { + // LoginPacket completes the auth handshake + wstring wName(m_authUsername.begin(), m_authUsername.end()); + name = wName; + m_authState = eAuth_Done; + AUTH_LOG("[Auth] Auth handshake complete for '%s' (implicit ack via LoginPacket)\n", m_authUsername.c_str()); + } + else if (m_authState == eAuth_Done) + { + // Already completed — allow through + } + else + { + // eAuth_None, eAuth_WaitingResponse, eAuth_Verifying — all invalid + AUTH_LOG("[Auth] Received LoginPacket before auth completed (state=%d), disconnecting\n", (int)m_authState); + disconnect(DisconnectPacket::eDisconnect_AuthFailed); + return; + } + if (packet->clientVersion != SharedConstants::NETWORK_PROTOCOL_VERSION) { app.DebugPrintf("Client version is %d not equal to %d\n", packet->clientVersion, SharedConstants::NETWORK_PROTOCOL_VERSION); @@ -184,56 +324,43 @@ void PendingConnection::handleLogin(shared_ptr packet) } //if (true)// 4J removed !server->onlineMode) - bool sentDisconnect = false; - - // Use the same Xuid choice as handleAcceptedLogin (offline first, online fallback). - // - PlayerUID loginXuid = packet->m_offlineXuid; - if (loginXuid == INVALID_XUID) loginXuid = packet->m_onlineXuid; - - bool duplicateXuid = false; - if (loginXuid != INVALID_XUID && server->getPlayers()->getPlayer(loginXuid) != nullptr) + PlayerUID loginXuid = packet->m_xuid; + // INVALID_XUID may indicate a truncated packet (readPlayerUID returns {0,0} on EOF) + if (loginXuid == INVALID_XUID) { - duplicateXuid = true; - } - else if (packet->m_onlineXuid != INVALID_XUID && - packet->m_onlineXuid != loginXuid && - server->getPlayers()->getPlayer(packet->m_onlineXuid) != nullptr) - { - duplicateXuid = true; + AUTH_LOG("[Auth] Warning: received INVALID_XUID in LoginPacket — possible truncated packet or offline client\n"); } - bool bannedXuid = false; - if (loginXuid != INVALID_XUID) - { - bannedXuid = server->getPlayers()->isXuidBanned(loginXuid); - } - if (!bannedXuid && packet->m_onlineXuid != INVALID_XUID && packet->m_onlineXuid != loginXuid) + bool duplicateXuid = (loginXuid != INVALID_XUID && server->getPlayers()->getPlayer(loginXuid) != nullptr); + + bool bannedXuid = (loginXuid != INVALID_XUID && server->getPlayers()->isXuidBanned(loginXuid)); + // Also check ban against server-verified auth UUID to prevent bypass via INVALID_XUID + if (!bannedXuid && !m_authUuid.empty()) { - bannedXuid = server->getPlayers()->isXuidBanned(packet->m_onlineXuid); + GameUUID authUid = GameUUID::fromDashed(m_authUuid); + if (authUid.isValid()) + { + bannedXuid = server->getPlayers()->isXuidBanned(authUid); + } } bool whitelistSatisfied = true; #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) if (ServerRuntime::Access::IsWhitelistEnabled()) { - whitelistSatisfied = false; - if (loginXuid != INVALID_XUID) - { - whitelistSatisfied = ServerRuntime::Access::IsPlayerWhitelisted(loginXuid); - } - if (!whitelistSatisfied && packet->m_onlineXuid != INVALID_XUID && packet->m_onlineXuid != loginXuid) + whitelistSatisfied = (loginXuid != INVALID_XUID && ServerRuntime::Access::IsPlayerWhitelisted(loginXuid)); + if (!whitelistSatisfied && !m_authUuid.empty()) { - whitelistSatisfied = ServerRuntime::Access::IsPlayerWhitelisted(packet->m_onlineXuid); + GameUUID authUid = GameUUID::fromDashed(m_authUuid); + if (authUid.isValid()) + { + whitelistSatisfied = ServerRuntime::Access::IsPlayerWhitelisted(authUid); + } } } #endif - if( sentDisconnect ) - { - // Do nothing - } - else if (bannedXuid) + if (bannedXuid) { #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) ServerRuntime::ServerLogManager::OnRejectedPlayerLogin(GetPendingConnectionSmallId(connection), name, ServerRuntime::ServerLogManager::eLoginRejectReason_BannedXuid); @@ -289,31 +416,6 @@ void PendingConnection::handleLogin(shared_ptr packet) { handleAcceptedLogin(packet); } - //else - { - //4J - removed -#if 0 - new Thread() { - public void run() { - try { - String key = loginKey; - URL url = new URL("http://www.minecraft.net/game/checkserver.jsp?user=" + URLEncoder.encode(packet.userName, "UTF-8") + "&serverId=" + URLEncoder.encode(key, "UTF-8")); - BufferedReader br = new BufferedReader(new InputStreamReader(url.openStream())); - String msg = br.readLine(); - br.close(); - if (msg.equals("YES")) { - acceptedLogin = packet; - } else { - disconnect("Failed to verify username!"); - } - } catch (Exception e) { - disconnect("Failed to verify username! [internal error " + e + "]"); - e.printStackTrace(); - } - } - }.start(); -#endif - } } @@ -321,28 +423,294 @@ void PendingConnection::handleAcceptedLogin(shared_ptr packet) { if(packet->m_ugcPlayersVersion != server->m_ugcPlayersVersion) { - // Send the pre-login packet again with the new list of players - sendPreLoginResponse(); + // UGC version mismatch — resend pre-login info but do NOT restart auth handshake. + // Only resend the PreLoginPacket with updated UGC player list; the auth state + // (m_authState, m_authUsername, m_authUuid) remains valid from the completed handshake. + UgcGatherResult ugc = GatherUgcData(); + connection->send(std::make_shared(L"-", ugc.xuids, ugc.cappedCount, ugc.friendsOnlyBits, server->m_ugcPlayersVersion, ugc.uniqueMapName, app.GetGameHostOption(eGameHostOption_All), ugc.cappedHostIndex, server->m_texturePackId)); return; } - // Guests use the online xuid, everyone else uses the offline one - PlayerUID playerXuid = packet->m_offlineXuid; - if(playerXuid == INVALID_XUID) playerXuid = packet->m_onlineXuid; + PlayerUID playerXuid = packet->m_xuid; + // Fallback: generate offline UUID from player name if still invalid + if (playerXuid == INVALID_XUID) + { + if (name.empty()) + { + AUTH_LOG("[Auth] Rejecting login with empty name and no UUID\n"); + disconnect(DisconnectPacket::eDisconnect_AuthFailed); + return; + } + playerXuid = GameUUID::generateOffline(std::string(name.begin(), name.end())); + } - shared_ptr playerEntity = server->getPlayers()->getPlayerForLogin(this, name, playerXuid,packet->m_onlineXuid); + shared_ptr playerEntity = server->getPlayers()->getPlayerForLogin(this, name, playerXuid); if (playerEntity != nullptr) { + // Use server-verified auth UUID instead of client-supplied packet->m_mojangUuid + if (!m_authUuid.empty()) + { + GameUUID verifiedUuid = GameUUID::fromDashed(m_authUuid); + if (verifiedUuid.isValid()) + { + playerEntity->setXuid(verifiedUuid); + } + } + #if defined(_WINDOWS64) && defined(MINECRAFT_SERVER_BUILD) ServerRuntime::ServerLogManager::OnAcceptedPlayerLogin(GetPendingConnectionSmallId(connection), name); #endif server->getPlayers()->placeNewPlayer(connection, playerEntity, packet); + + // Override with Mojang skin after placeNewPlayer has set the DLC skin + if (!m_authSkinData.empty() && !m_authSkinUrl.empty()) + { + wstring wSkinKey(m_authSkinUrl.begin(), m_authSkinUrl.end()); + // AddMemoryTextureFile takes ownership of the pointer — must be heap-allocated + DWORD skinSize = (DWORD)m_authSkinData.size(); + PBYTE skinBuf = new BYTE[skinSize]; + memcpy(skinBuf, m_authSkinData.data(), skinSize); + app.AddMemoryTextureFile(wSkinKey, skinBuf, skinSize); + playerEntity->customTextureUrl = wSkinKey; + app.DebugPrintf("Set Mojang skin for player %s: %s (%zu bytes)\n", + m_authUsername.c_str(), m_authSkinUrl.c_str(), m_authSkinData.size()); + + // Send Mojang skin to all other clients + for (auto& otherPlayer : server->getPlayers()->players) + { + if (otherPlayer != nullptr && otherPlayer != playerEntity && otherPlayer->connection != nullptr) + { + // Send the skin PNG data so the other client has it in memory + PBYTE otherBuf = new BYTE[skinSize]; + memcpy(otherBuf, m_authSkinData.data(), skinSize); + otherPlayer->connection->send( + std::make_shared(wSkinKey, otherBuf, skinSize)); + + // Notify the other client that this player now uses this skin + otherPlayer->connection->send( + std::make_shared(playerEntity, wSkinKey)); + } + } + + // Send existing Mojang skins to the new joiner + for (auto& otherPlayer : server->getPlayers()->players) + { + if (otherPlayer != nullptr && otherPlayer != playerEntity && + !otherPlayer->customTextureUrl.empty() && + (otherPlayer->customTextureUrl.substr(0, 6) == L"mojang" || + otherPlayer->customTextureUrl.substr(0, 5) == L"elyby")) + { + PBYTE existingData = nullptr; + DWORD existingSize = 0; + app.GetMemFileDetails(otherPlayer->customTextureUrl, &existingData, &existingSize); + if (existingData != nullptr && existingSize > 0) + { + PBYTE copyBuf = new BYTE[existingSize]; + memcpy(copyBuf, existingData, existingSize); + playerEntity->connection->send( + std::make_shared(otherPlayer->customTextureUrl, copyBuf, existingSize)); + playerEntity->connection->send( + std::make_shared(otherPlayer, otherPlayer->customTextureUrl)); + } + } + } + } + connection = nullptr; // We've moved responsibility for this over to the new PlayerConnection, nullptr so we don't delete our reference to it here in our dtor } + else + { + disconnect(DisconnectPacket::eDisconnect_ServerFull); + return; + } done = true; } +void PendingConnection::handleAuthResponse(shared_ptr packet) +{ + if (m_authState != eAuth_WaitingResponse) + { + AUTH_LOG("[Auth] Received AuthResponse in unexpected state %d, disconnecting\n", (int)m_authState); + disconnect(DisconnectPacket::eDisconnect_UnexpectedPacket); + return; + } + + // Convert wstring fields to std::string for MCAuth + string scheme(packet->chosenScheme.begin(), packet->chosenScheme.end()); + string username(packet->username.begin(), packet->username.end()); + string uuid(packet->mojangUuid.begin(), packet->mojangUuid.end()); + + AUTH_LOG("[Auth] Received AuthResponse: scheme='%s', username='%s', uuid='%s'\n", + scheme.c_str(), username.c_str(), uuid.c_str()); + + if (username.empty()) + { + AUTH_LOG("[Auth] REJECTED: empty username\n"); + disconnect(DisconnectPacket::eDisconnect_AuthFailed); + return; + } + + m_authScheme = scheme; + +#ifdef _WINDOWS64 + if (scheme == "mojang") + { + AUTH_LOG("[Auth] Starting Mojang session verification for '%s'...\n", username.c_str()); + // Verify with Mojang sessionserver in a background thread + // shared_ptr so the result outlives PendingConnection if destroyed + m_authState = eAuth_Verifying; + m_authVerifyResult = std::make_shared(); + + string serverId = m_serverId; + auto sharedResult = m_authVerifyResult; // capture shared_ptr by value + + std::thread([username, serverId, sharedResult]() { + try { + if (sharedResult->cancelled.load(std::memory_order_acquire)) + return; + + string error; + auto result = MCAuth::HasJoined(username, serverId, error); + + // Client may have disconnected + if (sharedResult->cancelled.load(std::memory_order_acquire)) + return; + + std::vector skinBytes; + if (result.success && !result.skinUrl.empty()) + { + string skinError; + skinBytes = MCAuth::FetchSkinPng(result.skinUrl, skinError); + } + + if (sharedResult->cancelled.load(std::memory_order_acquire)) + return; + + // Prevents data race with tick() + { + std::lock_guard lock(sharedResult->mutex); + sharedResult->success = result.success; + if (result.success) + { + sharedResult->username = result.username; + sharedResult->uuid = result.uuid; + sharedResult->skinUrl = result.skinUrl; + sharedResult->skinData = std::move(skinBytes); + } + else + { + sharedResult->errorDetail = error; + } + } + // Signal main thread + sharedResult->ready.store(true, std::memory_order_release); + } + catch (...) { + std::lock_guard lock(sharedResult->mutex); + sharedResult->success = false; + sharedResult->errorDetail = "unknown exception"; + sharedResult->ready.store(true, std::memory_order_release); + } + }).detach(); + } + else if (scheme == "elyby") + { + AUTH_LOG("[Auth] Starting ely.by session verification for '%s'...\n", username.c_str()); + m_authState = eAuth_Verifying; + m_authVerifyResult = std::make_shared(); + + string capturedServerId = m_serverId; + auto sharedResult = m_authVerifyResult; + + std::thread([username, capturedServerId, sharedResult]() { + try { + if (sharedResult->cancelled.load(std::memory_order_acquire)) + return; + + string error; + auto result = MCAuth::ElybyHasJoined(username, capturedServerId, error); + + if (sharedResult->cancelled.load(std::memory_order_acquire)) + return; + + std::vector skinBytes; + if (result.success && !result.skinUrl.empty()) + { + string skinError; + skinBytes = MCAuth::FetchSkinPng(result.skinUrl, skinError); + } + + if (sharedResult->cancelled.load(std::memory_order_acquire)) + return; + + { + std::lock_guard lock(sharedResult->mutex); + sharedResult->success = result.success; + if (result.success) + { + sharedResult->username = result.username; + sharedResult->uuid = result.uuid; + sharedResult->skinUrl = result.skinUrl; + sharedResult->skinData = std::move(skinBytes); + } + else + { + sharedResult->errorDetail = error; + } + } + sharedResult->ready.store(true, std::memory_order_release); + } + catch (const std::exception& ex) { + std::lock_guard lock(sharedResult->mutex); + sharedResult->success = false; + sharedResult->errorDetail = std::string("exception: ") + ex.what(); + sharedResult->ready.store(true, std::memory_order_release); + } + catch (...) { + std::lock_guard lock(sharedResult->mutex); + sharedResult->success = false; + sharedResult->errorDetail = "unknown exception"; + sharedResult->ready.store(true, std::memory_order_release); + } + }).detach(); + } + else +#endif + if (scheme == "offline") + { + if (server->onlineMode) + { + AUTH_LOG("[Auth] REJECTED: Client chose 'offline' but server requires online-mode (onlineMode=true)\n"); + connection->send(make_shared( + false, L"", L"", L"Server requires Microsoft authentication")); + connection->sendAndQuit(); + m_authState = eAuth_Done; + done = true; + return; + } + + // Server-generated UUID prevents UUID forgery + GameUUID offlineGuid = GameUUID::generateOffline(username); + std::string offlineUuid = offlineGuid.toDashed(); + AUTH_LOG("[Auth] Accepted offline auth for '%s' (server-assigned uuid=%s, client-sent uuid=%s)\n", + username.c_str(), offlineUuid.c_str(), uuid.c_str()); + m_authUsername = username; + m_authUuid = offlineUuid; + + wstring wUuid(offlineUuid.begin(), offlineUuid.end()); + wstring wName(username.begin(), username.end()); + connection->send(make_shared(true, wUuid, wName, L"")); + m_authState = eAuth_WaitingAck; + } + else + { + AUTH_LOG("[Auth] REJECTED: Unknown auth scheme '%s', disconnecting\n", scheme.c_str()); + disconnect(DisconnectPacket::eDisconnect_AuthFailed); + } +} + + void PendingConnection::onDisconnect(DisconnectPacket::eDisconnectReason reason, void *reasonObjects) { // logger.info(getName() + " lost connection"); diff --git a/Minecraft.Client/PendingConnection.h b/Minecraft.Client/PendingConnection.h index e8a493b097..2440c596f1 100644 --- a/Minecraft.Client/PendingConnection.h +++ b/Minecraft.Client/PendingConnection.h @@ -1,10 +1,15 @@ #pragma once #include "..\Minecraft.World\PacketListener.h" +#include +#include +#include +#include class MinecraftServer; class Socket; class LoginPacket; class Connection; class Random; +class AuthResponsePacket; using namespace std; class PendingConnection : public PacketListener @@ -27,6 +32,32 @@ class PendingConnection : public PacketListener shared_ptr acceptedLogin; wstring loginKey; + // Auth handshake state + enum eAuthState { eAuth_None, eAuth_WaitingResponse, eAuth_Verifying, eAuth_WaitingAck, eAuth_Done }; + eAuthState m_authState = eAuth_None; + std::string m_serverId; // random hex challenge + std::string m_authUsername; // username from auth response + std::string m_authUuid; // uuid from auth response (dashed) + std::string m_authScheme; // "mojang" or "offline" + + // Thread-safe verification result (heap-allocated so detached thread cannot UAF) + struct AuthVerifyResult { + std::atomic ready{false}; + std::atomic cancelled{false}; + std::mutex mutex; // protects fields below + bool success = false; + std::string username; + std::string uuid; // undashed from HasJoined + std::string skinUrl; // Mojang skin texture URL + std::vector skinData; // downloaded skin PNG bytes + std::string errorDetail; // diagnostic info on failure + }; + std::shared_ptr m_authVerifyResult; + + // Skin data received from Mojang after auth verification + std::string m_authSkinUrl; // texture key for this player's skin + std::vector m_authSkinData; // raw PNG bytes + public: PendingConnection(MinecraftServer *server, Socket *socket, const wstring& id); ~PendingConnection(); @@ -35,6 +66,7 @@ class PendingConnection : public PacketListener virtual void handlePreLogin(shared_ptr packet); virtual void handleLogin(shared_ptr packet); virtual void handleAcceptedLogin(shared_ptr packet); + virtual void handleAuthResponse(shared_ptr packet); virtual void onDisconnect(DisconnectPacket::eDisconnectReason reason, void *reasonObjects); virtual void handleGetInfo(shared_ptr packet); virtual void handleKeepAlive(shared_ptr packet); diff --git a/Minecraft.Client/PlayerConnection.cpp b/Minecraft.Client/PlayerConnection.cpp index 1fb7c39888..009f4abb3c 100644 --- a/Minecraft.Client/PlayerConnection.cpp +++ b/Minecraft.Client/PlayerConnection.cpp @@ -80,8 +80,7 @@ PlayerConnection::PlayerConnection(MinecraftServer *server, Connection *connecti m_bWasKicked = false; m_friendsOnlyUGC = false; - m_offlineXUID = INVALID_XUID; - m_onlineXUID = INVALID_XUID; + m_xuid = INVALID_XUID; m_bHasClientTickedOnce = false; m_logSmallId = 0; @@ -1632,7 +1631,6 @@ bool PlayerConnection::isDisconnected() void PlayerConnection::handleDebugOptions(shared_ptr packet) { #ifdef _DEBUG - // Player player = dynamic_pointer_cast( player->shared_from_this() ); player->SetDebugOptions(packet->m_uiVal); #endif } diff --git a/Minecraft.Client/PlayerConnection.h b/Minecraft.Client/PlayerConnection.h index 0284bc6a2f..ea750a184c 100644 --- a/Minecraft.Client/PlayerConnection.h +++ b/Minecraft.Client/PlayerConnection.h @@ -18,8 +18,7 @@ class PlayerConnection : public PacketListener, public ConsoleInputSource bool done; CRITICAL_SECTION done_cs; - // 4J Stu - Added this so that we can manage UGC privileges - PlayerUID m_offlineXUID, m_onlineXUID; + PlayerUID m_xuid; bool m_friendsOnlyUGC; private: diff --git a/Minecraft.Client/PlayerList.cpp b/Minecraft.Client/PlayerList.cpp index ba82ec6acd..cbfd302af7 100644 --- a/Minecraft.Client/PlayerList.cpp +++ b/Minecraft.Client/PlayerList.cpp @@ -18,7 +18,8 @@ #include "..\Minecraft.World\ArrayWithLength.h" #include "..\Minecraft.World\net.minecraft.network.packet.h" #include "..\Minecraft.World\net.minecraft.network.h" -#include "Windows64\Windows64_Xuid.h" +#include "..\Minecraft.World\GameUUID.h" +#include "Windows64\Windows64_Uuid.h" #ifdef _WINDOWS64 #include "Windows64\Network\WinsockNetLayer.h" #endif @@ -90,6 +91,9 @@ bool PlayerList::placeNewPlayer(Connection *connection, shared_ptr bool newPlayer = playerTag == nullptr; + // UUID is now set by PendingConnection::handleAcceptedLogin using the + // server-verified m_authUuid — do NOT trust packet->m_mojangUuid (client-supplied). + player->setLevel(server->getLevel(player->dimension)); player->gameMode->setLevel(static_cast(player->level)); @@ -107,7 +111,7 @@ bool PlayerList::placeNewPlayer(Connection *connection, shared_ptr { if( networkPlayer != nullptr ) { - ((NetworkPlayerSony *)networkPlayer)->SetUID( packet->m_onlineXuid ); + ((NetworkPlayerSony *)networkPlayer)->SetUID( packet->m_xuid ); } } #endif @@ -213,7 +217,7 @@ bool PlayerList::placeNewPlayer(Connection *connection, shared_ptr wprintf(L"Sending texture packet to get custom skin %ls from player %ls\n",player->customTextureUrl2.c_str(), player->name.c_str()); #endif playerConnection->send(std::make_shared( - player->customTextureUrl, + player->customTextureUrl2, nullptr, static_cast(0) )); @@ -244,8 +248,7 @@ bool PlayerList::placeNewPlayer(Connection *connection, shared_ptr // 4J Added to store UGC settings playerConnection->m_friendsOnlyUGC = packet->m_friendsOnlyUGC; - playerConnection->m_offlineXUID = packet->m_offlineXuid; - playerConnection->m_onlineXUID = packet->m_onlineXuid; + playerConnection->m_xuid = packet->m_xuid; // This player is now added to the list, so incrementing this value invalidates all previous PreLogin packets if(packet->m_friendsOnlyUGC) ++server->m_ugcPlayersVersion; @@ -536,7 +539,15 @@ if (player->riding != nullptr) { players.erase(it); } - //broadcastAll(shared_ptr( new PlayerInfoPacket(player->name, false, 9999) ) ); + // Notify all remaining clients that this player left. + // The original PlayerInfoPacket removal was commented out (repurposed by 4J). + // Send a RemoveEntitiesPacket so clients remove the player entity from their world + // and from the tab/player list display. + { + intArray ids(1); + ids[0] = player->entityId; + broadcastAll(std::make_shared(ids)); + } removePlayerFromReceiving(player); player->connection = nullptr; // Must remove reference to connection, or else there is a circular dependency @@ -547,7 +558,7 @@ if (player->riding != nullptr) saveAll(nullptr,false); } -shared_ptr PlayerList::getPlayerForLogin(PendingConnection *pendingConnection, const wstring& userName, PlayerUID xuid, PlayerUID onlineXuid) +shared_ptr PlayerList::getPlayerForLogin(PendingConnection *pendingConnection, const wstring& userName, PlayerUID xuid) { if (players.size() >= (unsigned int)maxPlayers) { @@ -556,27 +567,7 @@ shared_ptr PlayerList::getPlayerForLogin(PendingConnection *pendin } shared_ptr player = std::make_shared(server, server->getLevel(0), userName, new ServerPlayerGameMode(server->getLevel(0))); player->gameMode->player = player; // 4J added as had to remove this assignment from ServerPlayer ctor - player->setXuid( xuid ); // 4J Added - player->setOnlineXuid( onlineXuid ); // 4J Added -#ifdef _WINDOWS64 - { - // Use packet-supplied identity from LoginPacket. - // Do not recompute from name here: mixed-version clients must stay compatible. - INetworkPlayer* np = pendingConnection->connection->getSocket()->getPlayer(); - if (np != nullptr) - { - player->setOnlineXuid(np->GetUID()); - - // Backward compatibility: when Minecraft.Client is hosting, keep the first - // host player on the legacy embedded host XUID (base + 0). - // This preserves pre-migration host playerdata in existing worlds. - if (np->IsHost()) - { - player->setXuid(Win64Xuid::GetLegacyEmbeddedHostXuid()); - } - } - } -#endif + player->setXuid( xuid ); // Work out the base server player settings INetworkPlayer *networkPlayer = pendingConnection->connection->getSocket()->getPlayer(); if(networkPlayer != nullptr && !networkPlayer->IsHost()) @@ -664,7 +655,6 @@ shared_ptr PlayerList::respawn(shared_ptr serverPlay DWORD playerIndex = serverPlayer->getPlayerIndex(); PlayerUID playerXuid = serverPlayer->getXuid(); - PlayerUID playerOnlineXuid = serverPlayer->getOnlineXuid(); shared_ptr player = std::make_shared(server, server->getLevel(serverPlayer->dimension), serverPlayer->getName(), new ServerPlayerGameMode(server->getLevel(serverPlayer->dimension))); player->connection = serverPlayer->connection; @@ -676,7 +666,6 @@ shared_ptr PlayerList::respawn(shared_ptr serverPlay } player->gameMode->player = player; // 4J added as had to remove this assignment from ServerPlayer ctor player->setXuid( playerXuid ); // 4J Added - player->setOnlineXuid( playerOnlineXuid ); // 4J Added // 4J Stu - Don't reuse the id. If we do, then the player can be re-added after being removed, but the add packet gets sent before the remove packet //player->entityId = serverPlayer->entityId; @@ -686,6 +675,8 @@ shared_ptr PlayerList::respawn(shared_ptr serverPlay player->setPlayerIndex( playerIndex ); player->setCustomSkin( serverPlayer->getCustomSkin() ); player->setCustomCape( serverPlayer->getCustomCape() ); + player->customTextureUrl = serverPlayer->customTextureUrl; + player->customTextureUrl2 = serverPlayer->customTextureUrl2; player->setPlayerGamePrivilege(Player::ePlayerGamePrivilege_All, serverPlayer->getAllPlayerGamePrivileges()); player->gameMode->setGameRules( serverPlayer->gameMode->getGameRules() ); player->dimension = targetDimension; @@ -1012,6 +1003,12 @@ void PlayerList::tick() } #ifdef _WINDOWS64 + // Kill any zombie PendingConnections that still reference this smallId. + // Their Socket::getPlayer() resolves by smallId, so if we recycle the id + // before they are gone, a stale timeout would disconnect the new player. + if (server->connection != nullptr) + server->connection->closePendingConnectionsBySmallId(smallId); + // The old Connection's read/write threads are now dead (disconnect waits // for them). Safe to recycle the smallId — no stale write thread can // resolve getPlayer() to a new connection that reuses this slot. @@ -1039,7 +1036,7 @@ void PlayerList::tick() for(unsigned int i = 0; i < players.size(); i++) { shared_ptr p = players.at(i); - PlayerUID playersXuid = p->getOnlineXuid(); + PlayerUID playersXuid = p->getXuid(); if (p != nullptr && ProfileManager.AreXUIDSEqual(playersXuid, xuid ) ) { player = p; @@ -1049,7 +1046,7 @@ void PlayerList::tick() if (player != nullptr) { - m_bannedXuids.push_back( player->getOnlineXuid() ); + m_bannedXuids.push_back( player->getXuid() ); // 4J Stu - If we have kicked a player, make sure that they have no privileges if they later try to join the world when trust players is off player->enableAllPlayerPrivileges( false ); player->connection->setWasKicked(); @@ -1062,7 +1059,7 @@ void PlayerList::tick() LeaveCriticalSection(&m_kickPlayersCS); // Check our receiving players, and if they are dead see if we can replace them - for(unsigned int dim = 0; dim < 2; ++dim) + for(unsigned int dim = 0; dim < DIMENSION_COUNT; ++dim) { for(unsigned int i = 0; i < receiveAllPlayers[dim].size(); ++i) { @@ -1160,7 +1157,7 @@ shared_ptr PlayerList::getPlayer(PlayerUID uid) for (unsigned int i = 0; i < players.size(); i++) { shared_ptr p = players[i]; - if (p->getXuid() == uid || p->getOnlineXuid() == uid) // 4J - used to be case insensitive (using equalsIgnoreCase) - imagine we'll be shifting to XUIDs anyway + if (p->getXuid() == uid) { return p; } diff --git a/Minecraft.Client/PlayerList.h b/Minecraft.Client/PlayerList.h index a4ae9c5d7a..64e1e26c3b 100644 --- a/Minecraft.Client/PlayerList.h +++ b/Minecraft.Client/PlayerList.h @@ -52,7 +52,8 @@ class PlayerList int sendAllPlayerInfoIn; // 4J Added to maintain which players in which dimensions can receive all packet types - vector > receiveAllPlayers[3]; + static const int DIMENSION_COUNT = 3; // overworld / nether / end — keep in sync with LevelData + vector > receiveAllPlayers[DIMENSION_COUNT]; private: shared_ptr findAlivePlayerOnSystem(shared_ptr currentPlayer); @@ -81,7 +82,7 @@ class PlayerList void add(shared_ptr player); void move(shared_ptr player); void remove(shared_ptr player); - shared_ptr getPlayerForLogin(PendingConnection *pendingConnection, const wstring& userName, PlayerUID xuid, PlayerUID OnlineXuid); + shared_ptr getPlayerForLogin(PendingConnection *pendingConnection, const wstring& userName, PlayerUID xuid); shared_ptr respawn(shared_ptr serverPlayer, int targetDimension, bool keepAllPlayerData); void toggleDimension(shared_ptr player, int targetDimension); void repositionAcrossDimension(shared_ptr entity, int lastDimension, ServerLevel *oldLevel, ServerLevel *newLevel); diff --git a/Minecraft.Client/ServerConnection.cpp b/Minecraft.Client/ServerConnection.cpp index 0f96e0325d..df86b5c5f3 100644 --- a/Minecraft.Client/ServerConnection.cpp +++ b/Minecraft.Client/ServerConnection.cpp @@ -44,6 +44,23 @@ void ServerConnection::handleConnection(shared_ptr uc) LeaveCriticalSection(&pending_cs); } +void ServerConnection::closePendingConnectionsBySmallId(BYTE smallId) +{ + EnterCriticalSection(&pending_cs); + for (auto& pc : pending) + { + if (pc && !pc->done && pc->connection != nullptr) + { + Socket *pcSocket = pc->connection->getSocket(); + if (pcSocket != nullptr && pcSocket->getSmallId() == smallId) + { + pc->done = true; + } + } + } + LeaveCriticalSection(&pending_cs); +} + void ServerConnection::stop() { std::vector > pendingSnapshot; diff --git a/Minecraft.Client/ServerConnection.h b/Minecraft.Client/ServerConnection.h index 9ef8097303..1641acd773 100644 --- a/Minecraft.Client/ServerConnection.h +++ b/Minecraft.Client/ServerConnection.h @@ -40,6 +40,7 @@ class ServerConnection public: void stop(); void tick(); + void closePendingConnectionsBySmallId(BYTE smallId); // 4J Added bool addPendingTextureRequest(const wstring &textureName); diff --git a/Minecraft.Client/Textures.h b/Minecraft.Client/Textures.h index 1fca56106a..3c2d86e1c9 100644 --- a/Minecraft.Client/Textures.h +++ b/Minecraft.Client/Textures.h @@ -290,8 +290,10 @@ class Textures public: void clearLastBoundId(); -private: +public: int loadTexture(TEXTURE_NAME texId, const wstring& resourceName); + // Narrow public accessor for ad-hoc path-based texture loading (used by NativeUIRenderer) + int loadTextureByPath(const wstring& resourceName) { return loadTexture(TN_COUNT, resourceName); } public: int loadTexture(int idx); // 4J added int getTexture(BufferedImage *img, C4JRender::eTextureFormat format = C4JRender::TEXTURE_FORMAT_RxGyBzAw, bool mipmap = true); diff --git a/Minecraft.Client/TrackedEntity.cpp b/Minecraft.Client/TrackedEntity.cpp index 3aa33248d2..88f9dd635b 100644 --- a/Minecraft.Client/TrackedEntity.cpp +++ b/Minecraft.Client/TrackedEntity.cpp @@ -546,13 +546,12 @@ void TrackedEntity::updatePlayer(EntityTracker *tracker, shared_ptr living = dynamic_pointer_cast(e); ServersideAttributeMap *attributeMap = static_cast(living->getAttributes()); - unordered_set *attributes = attributeMap->getSyncableAttributes(); + std::unique_ptr> attributes(attributeMap->getSyncableAttributes()); if (!attributes->empty()) { - sp->connection->send(std::make_shared(e->entityId, attributes)); + sp->connection->send(std::make_shared(e->entityId, attributes.get())); } - delete attributes; } if (trackDelta && !isAddMobPacket) @@ -652,14 +651,11 @@ shared_ptr TrackedEntity::getAddEntityPacket() shared_ptr player = dynamic_pointer_cast(e); PlayerUID xuid = INVALID_XUID; - PlayerUID OnlineXuid = INVALID_XUID; if( player != nullptr ) { xuid = player->getXuid(); - OnlineXuid = player->getOnlineXuid(); } - // 4J Added yHeadRotp param to fix #102563 - TU12: Content: Gameplay: When one of the Players is idle for a few minutes his head turns 180 degrees. - return std::make_shared(player, xuid, OnlineXuid, xp, yp, zp, yRotp, xRotp, yHeadRotp); + return std::make_shared(player, xuid, xp, yp, zp, yRotp, xRotp, yHeadRotp); } else if (e->instanceof(eTYPE_MINECART)) { diff --git a/Minecraft.Client/Windows64/Windows64_App.cpp b/Minecraft.Client/Windows64/Windows64_App.cpp index 369e29094f..c7afdf279b 100644 --- a/Minecraft.Client/Windows64/Windows64_App.cpp +++ b/Minecraft.Client/Windows64/Windows64_App.cpp @@ -1,6 +1,7 @@ #include "stdafx.h" #include "..\Common\Consoles_App.h" #include "..\User.h" +#include "..\..\MCAuth\include\MCAuthManager.h" #include "..\..\Minecraft.Client\Minecraft.h" #include "..\..\Minecraft.Client\MinecraftServer.h" #include "..\..\Minecraft.Client\PlayerList.h" @@ -75,8 +76,22 @@ void CConsoleMinecraftApp::TemporaryCreateGameStart() Minecraft *pMinecraft=Minecraft::GetInstance(); app.ReleaseSaveThumbnail(); ProfileManager.SetLockedProfile(0); - extern wchar_t g_Win64UsernameW[17]; - pMinecraft->user->name = g_Win64UsernameW; + // Wait for auth to settle, then set user->name from session. + { + auto& mgr = MCAuthManager::Get(); + MCAuthManager::State st = mgr.GetSlotState(0); + if (st == MCAuthManager::State::Authenticating || st == MCAuthManager::State::WaitingForCode) + { + app.DebugPrintf("[Auth] Waiting for token refresh before world entry...\n"); + mgr.WaitForSlotReady(0, 15000); + } + if (mgr.IsSlotLoggedIn(0)) + { + auto session = mgr.GetSlotSession(0); + if (!session.username.empty()) + pMinecraft->user->name = std::wstring(session.username.begin(), session.username.end()); + } + } app.ApplyGameSettingsChanged(0); ////////////////////////////////////////////////////////////////////////////////////////////// From CScene_MultiGameJoinLoad::OnInit @@ -132,6 +147,8 @@ void CConsoleMinecraftApp::TemporaryCreateGameStart() C4JThread* thread = new C4JThread(loadingParams->func, loadingParams->lpParam, "RunNetworkGame"); thread->Run(); + + delete loadingParams; } int CConsoleMinecraftApp::GetLocalTMSFileIndex(WCHAR *wchTMSFile,bool bFilenameIncludesExtension,eFileExtensionType eEXT) diff --git a/Minecraft.Client/Windows64/Windows64_Minecraft.cpp b/Minecraft.Client/Windows64/Windows64_Minecraft.cpp index 81430ffcc7..e6d978698c 100644 --- a/Minecraft.Client/Windows64/Windows64_Minecraft.cpp +++ b/Minecraft.Client/Windows64/Windows64_Minecraft.cpp @@ -46,7 +46,7 @@ #include "Common/PostProcesser.h" #include "..\GameRenderer.h" #include "Network\WinsockNetLayer.h" -#include "Windows64_Xuid.h" +#include "Windows64_Uuid.h" #include "Common/UI/UI.h" // Forward-declare the internal Renderer class and its global instance from 4J_Render_PC_d.lib. @@ -1302,6 +1302,13 @@ int APIENTRY _tWinMain(_In_ HINSTANCE hInstance, UNREFERENCED_PARAMETER(hPrevInstance); UNREFERENCED_PARAMETER(lpCmdLine); + // Allocate a console window so DebugPrintf output is visible when + // launching from a terminal (or attach to the parent console). + if (!AttachConsole(ATTACH_PARENT_PROCESS)) + AllocConsole(); + freopen("CONOUT$", "w", stdout); + freopen("CONOUT$", "w", stderr); + // 4J-Win64: set CWD to exe dir so asset paths resolve correctly { char szExeDir[MAX_PATH] = {}; @@ -1319,50 +1326,19 @@ int APIENTRY _tWinMain(_In_ HINSTANCE hInstance, g_rScreenWidth = GetSystemMetrics(SM_CXSCREEN); g_rScreenHeight = GetSystemMetrics(SM_CYSCREEN); - // Load username from username.txt - char exePath[MAX_PATH] = {}; - GetModuleFileNameA(nullptr, exePath, MAX_PATH); - char *lastSlash = strrchr(exePath, '\\'); - if (lastSlash) - { - *(lastSlash + 1) = '\0'; - } - - char filePath[MAX_PATH] = {}; - _snprintf_s(filePath, sizeof(filePath), _TRUNCATE, "%susername.txt", exePath); - - FILE *f = nullptr; - if (fopen_s(&f, filePath, "r") == 0 && f) - { - char buf[128] = {}; - if (fgets(buf, sizeof(buf), f)) - { - int len = static_cast(strlen(buf)); - while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r' || buf[len - 1] == ' ')) - { - buf[--len] = '\0'; - } - - if (len > 0) - { - strncpy_s(g_Win64Username, sizeof(g_Win64Username), buf, _TRUNCATE); - } - } - fclose(f); - } + // username.txt is DEPRECATED — player identity comes from MCAuthManager. + // The -name launch arg is still supported as a pre-auth default. - // Load stuff from launch options, including username + // Load stuff from launch options, including -name override const Win64LaunchOptions launchOptions = ParseLaunchOptions(); ApplyScreenMode(launchOptions.screenMode); - // Ensure uid.dat exists from startup (before any multiplayer/login path). - Win64Xuid::ResolvePersistentXuid(); + // Player identity is resolved by MCAuthManager during login — no local uid.dat needed. - // If no username, let's fall back + // If no username, fall back to "Player" if (g_Win64Username[0] == 0) { - // Default username will be "Player" - strncpy_s(g_Win64Username, sizeof(g_Win64Username), "Player", _TRUNCATE); + strncpy_s(g_Win64Username, sizeof(g_Win64Username), "Player", _TRUNCATE); } MultiByteToWideChar(CP_ACP, 0, g_Win64Username, -1, g_Win64UsernameW, 17); @@ -1644,7 +1620,18 @@ int APIENTRY _tWinMain(_In_ HINSTANCE hInstance, { pMinecraft->applyFrameMouseLook(); // Per-frame mouse look (before ticks + render) pMinecraft->run_middle(); - app.SetAppPaused( g_NetworkManager.IsLocalGame() && g_NetworkManager.GetPlayerCount() == 1 && ui.IsPauseMenuDisplayed(ProfileManager.GetPrimaryPad()) ); + { + bool shouldPause = false; + // Freeze ticks ONLY in local single player with pause menu open. + // Never freeze online — the server keeps running regardless. + if (g_NetworkManager.IsLocalGame() && g_NetworkManager.GetPlayerCount() == 1 + && ui.IsPauseMenuDisplayed(ProfileManager.GetPrimaryPad())) + shouldPause = true; + // Also freeze ticks in local game when account picker is open + if (g_NetworkManager.IsLocalGame() && pMinecraft->isAnySplitAuthUIOpen()) + shouldPause = true; + app.SetAppPaused(shouldPause); + } } else { diff --git a/Minecraft.Client/Windows64/Windows64_UIController.cpp b/Minecraft.Client/Windows64/Windows64_UIController.cpp index 3929aa0496..dcb56c10b9 100644 --- a/Minecraft.Client/Windows64/Windows64_UIController.cpp +++ b/Minecraft.Client/Windows64/Windows64_UIController.cpp @@ -4,6 +4,8 @@ // Temp #include "..\Minecraft.h" #include "..\Textures.h" +#include "..\Font.h" +#include "..\Tesselator.h" #define _ENABLEIGGY diff --git a/Minecraft.Client/Windows64/Windows64_Uuid.h b/Minecraft.Client/Windows64/Windows64_Uuid.h new file mode 100644 index 0000000000..1543bb0619 --- /dev/null +++ b/Minecraft.Client/Windows64/Windows64_Uuid.h @@ -0,0 +1,42 @@ +#pragma once + +#ifdef _WINDOWS64 + +#include +#include +#include "..\..\Minecraft.World\GameUUID.h" + +// Player identity is always assigned by the auth manager (Mojang/offline). +// No local persistence (uuid.dat) is used — the auth handshake is the source of truth. + +namespace Win64Uuid +{ + inline GameUUID DeriveUuidForPad(GameUUID baseUuid, int iPad) + { + if (iPad == 0) + return baseUuid; + + // Deterministic per-pad UUID derived from the base. + uint64_t mixHi = baseUuid.hi ^ (0x9E3779B97F4A7C15ULL * (uint64_t)(iPad + 1)); + uint64_t mixLo = baseUuid.lo ^ (0xBF58476D1CE4E5B9ULL * (uint64_t)(iPad + 1)); + + mixHi = (mixHi ^ (mixHi >> 30)) * 0xBF58476D1CE4E5B9ULL; + mixHi = (mixHi ^ (mixHi >> 27)) * 0x94D049BB133111EBULL; + mixHi = mixHi ^ (mixHi >> 31); + + mixLo = (mixLo ^ (mixLo >> 30)) * 0xBF58476D1CE4E5B9ULL; + mixLo = (mixLo ^ (mixLo >> 27)) * 0x94D049BB133111EBULL; + mixLo = mixLo ^ (mixLo >> 31); + + // Mark as UUID v4 variant 1 + mixHi = (mixHi & ~0x000000000000F000ULL) | 0x0000000000004000ULL; + mixLo = (mixLo & ~0xC000000000000000ULL) | 0x8000000000000000ULL; + + GameUUID derived; + derived.hi = mixHi; + derived.lo = mixLo; + return derived; + } +} + +#endif diff --git a/Minecraft.Client/Windows64/Windows64_Xuid.h b/Minecraft.Client/Windows64/Windows64_Xuid.h deleted file mode 100644 index f5fd62b90b..0000000000 --- a/Minecraft.Client/Windows64/Windows64_Xuid.h +++ /dev/null @@ -1,237 +0,0 @@ -#pragma once - -#ifdef _WINDOWS64 - -#include -#include -#include -#include -#include -#include - -namespace Win64Xuid -{ - inline PlayerUID GetLegacyEmbeddedBaseXuid() - { - return (PlayerUID)0xe000d45248242f2eULL; - } - - inline PlayerUID GetLegacyEmbeddedHostXuid() - { - // Legacy behavior used "embedded base + smallId"; host was always smallId 0. - // We intentionally keep this value for host/self compatibility with pre-migration worlds. - return GetLegacyEmbeddedBaseXuid(); - } - - inline bool IsLegacyEmbeddedRange(PlayerUID xuid) - { - // Old Win64 XUIDs were not persistent and always lived in this narrow base+smallId range. - // Treat them as legacy/non-persistent so uid.dat values never collide with old slot IDs. - const PlayerUID base = GetLegacyEmbeddedBaseXuid(); - return xuid >= base && xuid < (base + MINECRAFT_NET_MAX_PLAYERS); - } - - inline bool IsPersistedUidValid(PlayerUID xuid) - { - return xuid != INVALID_XUID && !IsLegacyEmbeddedRange(xuid); - } - - - // ./uid.dat - inline bool BuildUidFilePath(char* outPath, size_t outPathSize) - { - if (outPath == NULL || outPathSize == 0) - return false; - - outPath[0] = 0; - - char exePath[MAX_PATH] = {}; - DWORD len = GetModuleFileNameA(NULL, exePath, MAX_PATH); - if (len == 0 || len >= MAX_PATH) - return false; - - char* lastSlash = strrchr(exePath, '\\'); - if (lastSlash != NULL) - { - *(lastSlash + 1) = 0; - } - - if (strcpy_s(outPath, outPathSize, exePath) != 0) - return false; - if (strcat_s(outPath, outPathSize, "uid.dat") != 0) - return false; - - return true; - } - - inline bool ReadUid(PlayerUID* outXuid) - { - if (outXuid == NULL) - return false; - - char path[MAX_PATH] = {}; - if (!BuildUidFilePath(path, MAX_PATH)) - return false; - - FILE* f = NULL; - if (fopen_s(&f, path, "rb") != 0 || f == NULL) - return false; - - char buffer[128] = {}; - size_t readBytes = fread(buffer, 1, sizeof(buffer) - 1, f); - fclose(f); - - if (readBytes == 0) - return false; - - // Compatibility: earlier experiments may have written raw 8-byte uid.dat. - if (readBytes == sizeof(uint64_t)) - { - uint64_t raw = 0; - memcpy(&raw, buffer, sizeof(raw)); - PlayerUID parsed = (PlayerUID)raw; - if (IsPersistedUidValid(parsed)) - { - *outXuid = parsed; - return true; - } - } - - buffer[readBytes] = 0; - char* begin = buffer; - while (*begin == ' ' || *begin == '\t' || *begin == '\r' || *begin == '\n') - { - ++begin; - } - - errno = 0; - char* end = NULL; - uint64_t raw = _strtoui64(begin, &end, 0); - if (begin == end || errno != 0) - return false; - - while (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n') - { - ++end; - } - if (*end != 0) - return false; - - PlayerUID parsed = (PlayerUID)raw; - if (!IsPersistedUidValid(parsed)) - return false; - - *outXuid = parsed; - return true; - } - - inline bool WriteUid(PlayerUID xuid) - { - char path[MAX_PATH] = {}; - if (!BuildUidFilePath(path, MAX_PATH)) - return false; - - FILE* f = NULL; - if (fopen_s(&f, path, "wb") != 0 || f == NULL) - return false; - - int written = fprintf_s(f, "0x%016llX\n", (unsigned long long)xuid); - fclose(f); - return written > 0; - } - - inline uint64_t Mix64(uint64_t x) - { - x += 0x9E3779B97F4A7C15ULL; - x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ULL; - x = (x ^ (x >> 27)) * 0x94D049BB133111EBULL; - return x ^ (x >> 31); - } - - inline PlayerUID GeneratePersistentUid() - { - // Avoid rand_s dependency: mix several Win64 runtime values into a 64-bit seed. - FILETIME ft = {}; - GetSystemTimeAsFileTime(&ft); - uint64_t t = (((uint64_t)ft.dwHighDateTime) << 32) | ft.dwLowDateTime; - - LARGE_INTEGER qpc = {}; - QueryPerformanceCounter(&qpc); - - uint64_t seed = t; - seed ^= (uint64_t)qpc.QuadPart; - seed ^= ((uint64_t)GetCurrentProcessId() << 32); - seed ^= (uint64_t)GetCurrentThreadId(); - seed ^= (uint64_t)GetTickCount(); - seed ^= (uint64_t)(size_t)&qpc; - seed ^= (uint64_t)(size_t)GetModuleHandleA(NULL); - - uint64_t raw = Mix64(seed) ^ Mix64(seed + 0xA0761D6478BD642FULL); - raw ^= 0x8F4B2D6C1A93E705ULL; - raw |= 0x8000000000000000ULL; - - PlayerUID xuid = (PlayerUID)raw; - if (!IsPersistedUidValid(xuid)) - { - raw ^= 0x0100000000000001ULL; - xuid = (PlayerUID)raw; - } - - if (!IsPersistedUidValid(xuid)) - { - // Last-resort deterministic fallback for pathological cases. - xuid = (PlayerUID)0xD15EA5E000000001ULL; - } - - return xuid; - } - - inline PlayerUID DeriveXuidForPad(PlayerUID baseXuid, int iPad) - { - if (iPad == 0) - return baseXuid; - - // Deterministic per-pad XUID: hash the base XUID with the pad number. - // Produces a fully unique 64-bit value with no risk of overlap. - // Suggested by rtm516 to avoid adjacent-integer collisions from the old "+ iPad" approach. - uint64_t raw = Mix64((uint64_t)baseXuid + (uint64_t)iPad); - raw |= 0x8000000000000000ULL; // keep high bit set like all our XUIDs - - PlayerUID xuid = (PlayerUID)raw; - if (!IsPersistedUidValid(xuid)) - { - raw ^= 0x0100000000000001ULL; - xuid = (PlayerUID)raw; - } - if (!IsPersistedUidValid(xuid)) - xuid = (PlayerUID)(0xD15EA5E000000001ULL + iPad); - - return xuid; - } - - inline PlayerUID ResolvePersistentXuid() - { - // Process-local cache: uid.dat is immutable during runtime and this path is hot. - static bool s_cached = false; - static PlayerUID s_xuid = INVALID_XUID; - - if (s_cached) - return s_xuid; - - PlayerUID fileXuid = INVALID_XUID; - if (ReadUid(&fileXuid)) - { - s_xuid = fileXuid; - s_cached = true; - return s_xuid; - } - - // First launch on this client: generate once and persist to uid.dat. - s_xuid = GeneratePersistentUid(); - WriteUid(s_xuid); - s_cached = true; - return s_xuid; - } -} - -#endif diff --git a/Minecraft.Client/Xbox/Network/PlatformNetworkManagerXbox.cpp b/Minecraft.Client/Xbox/Network/PlatformNetworkManagerXbox.cpp index f586f4470b..7443ec4fd5 100644 --- a/Minecraft.Client/Xbox/Network/PlatformNetworkManagerXbox.cpp +++ b/Minecraft.Client/Xbox/Network/PlatformNetworkManagerXbox.cpp @@ -748,7 +748,7 @@ bool CPlatformNetworkManagerXbox::_LeaveGame(bool bMigrateHost, bool bLeaveRoom) return true; } -void CPlatformNetworkManagerXbox::HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, unsigned char publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, unsigned char privateSlots /*= 0*/) +void CPlatformNetworkManagerXbox::HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, int publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, int privateSlots /*= 0*/) { // #ifdef _XBOX // 4J Stu - We probably did this earlier as well, but just to be sure! @@ -763,7 +763,7 @@ void CPlatformNetworkManagerXbox::HostGame(int localUsersMask, bool bOnlineGame, //#endif } -void CPlatformNetworkManagerXbox::_HostGame(int usersMask, unsigned char publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, unsigned char privateSlots /*= 0*/) +void CPlatformNetworkManagerXbox::_HostGame(int usersMask, int publicSlots /*= MINECRAFT_NET_MAX_PLAYERS*/, int privateSlots /*= 0*/) { HRESULT hr; // Create a session using the standard game type, in multiplayer game mode, diff --git a/Minecraft.Client/Xbox/Network/PlatformNetworkManagerXbox.h b/Minecraft.Client/Xbox/Network/PlatformNetworkManagerXbox.h index 2784726c7b..ea88a743f6 100644 --- a/Minecraft.Client/Xbox/Network/PlatformNetworkManagerXbox.h +++ b/Minecraft.Client/Xbox/Network/PlatformNetworkManagerXbox.h @@ -47,7 +47,7 @@ class CPlatformNetworkManagerXbox : public CPlatformNetworkManager, public IQNet virtual void SendInviteGUI(int quadrant); virtual bool IsAddingPlayer(); - virtual void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, unsigned char publicSlots = MINECRAFT_NET_MAX_PLAYERS, unsigned char privateSlots = 0); + virtual void HostGame(int localUsersMask, bool bOnlineGame, bool bIsPrivate, int publicSlots = MINECRAFT_NET_MAX_PLAYERS, int privateSlots = 0); virtual int JoinGame(FriendSessionInfo *searchResult, int localUsersMask, int primaryUserIndex ); virtual bool SetLocalGame(bool isLocal); virtual bool IsLocalGame() { return m_bIsOfflineGame; } @@ -66,7 +66,7 @@ class CPlatformNetworkManagerXbox : public CPlatformNetworkManager, public IQNet private: bool isSystemPrimaryPlayer(IQNetPlayer *pQNetPlayer); virtual bool _LeaveGame(bool bMigrateHost, bool bLeaveRoom); - virtual void _HostGame(int dwUsersMask, unsigned char publicSlots = MINECRAFT_NET_MAX_PLAYERS, unsigned char privateSlots = 0); + virtual void _HostGame(int dwUsersMask, int publicSlots = MINECRAFT_NET_MAX_PLAYERS, int privateSlots = 0); virtual bool _StartGame(); IQNet * m_pIQNet; // pointer to QNet interface diff --git a/Minecraft.Client/Xbox/XML/xmlFilesCallback.h b/Minecraft.Client/Xbox/XML/xmlFilesCallback.h index 7571706e3a..6df0e385d3 100644 --- a/Minecraft.Client/Xbox/XML/xmlFilesCallback.h +++ b/Minecraft.Client/Xbox/XML/xmlFilesCallback.h @@ -19,7 +19,7 @@ class xmlMojangCallback : public ATG::ISAXCallback WCHAR wNameXUID[32] = L""; WCHAR wNameSkin[32] = L""; WCHAR wNameCloak[32] = L""; - PlayerUID xuid=0LL; + PlayerUID xuid = INVALID_XUID; if (NameLen >31) @@ -47,7 +47,12 @@ class xmlMojangCallback : public ATG::ISAXCallback { ZeroMemory(wTemp,sizeof(WCHAR)*35); wcsncpy_s( wTemp, pAttributes[i].strValue, pAttributes[i].ValueLen); - xuid=_wcstoui64(wTemp,nullptr,10); + // Parse UUID from wide string (dashed format) + { + char narrow[64] = {}; + wcstombs_s(nullptr, narrow, wTemp, _TRUNCATE); + xuid = PlayerUID::fromDashed(std::string(narrow)); + } } } else if (_wcsicmp(wAttName,L"cape")==0) @@ -68,7 +73,7 @@ class xmlMojangCallback : public ATG::ISAXCallback } // if the xuid hasn't been defined, then we can't use the data - if(xuid!=0LL) + if(xuid.isValid()) { return CConsoleMinecraftApp::RegisterMojangData(wNameXUID , xuid, wNameSkin, wNameCloak ); } diff --git a/Minecraft.Client/cmake/sources/Windows.cmake b/Minecraft.Client/cmake/sources/Windows.cmake index 7fc07abd89..f467d63759 100644 --- a/Minecraft.Client/cmake/sources/Windows.cmake +++ b/Minecraft.Client/cmake/sources/Windows.cmake @@ -19,6 +19,8 @@ set(_MINECRAFT_CLIENT_WINDOWS_COMMON_NETWORK source_group("Common/Network" FILES ${_MINECRAFT_CLIENT_WINDOWS_COMMON_NETWORK}) set(_MINECRAFT_CLIENT_WINDOWS_COMMON_UI + "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/NativeUIRenderer.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/NativeUIRenderer.h" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UI.h" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIBitmapFont.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIBitmapFont.h" @@ -167,6 +169,8 @@ set(_MINECRAFT_CLIENT_WINDOWS_COMMON_UI_SCENES_FRONTEND_MENU_SCREENS "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_LoadOrJoinMenu.h" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_MainMenu.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_MainMenu.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_MSAuth.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_MSAuth.h" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_NewUpdateMessage.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_NewUpdateMessage.h" "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_SaveMessage.cpp" @@ -354,6 +358,7 @@ set(_MINECRAFT_CLIENT_WINDOWS_WINDOWS64 "${BASE_DIR}/Minecraft_Macros.h" "${BASE_DIR}/PostProcesser.cpp" "${BASE_DIR}/Windows64_Minecraft.cpp" + "${BASE_DIR}/Windows64_Uuid.h" ) source_group("Windows64" FILES ${_MINECRAFT_CLIENT_WINDOWS_WINDOWS64}) diff --git a/Minecraft.Server/Access/Access.cpp b/Minecraft.Server/Access/Access.cpp index 5767a9555c..050220cbde 100644 --- a/Minecraft.Server/Access/Access.cpp +++ b/Minecraft.Server/Access/Access.cpp @@ -14,13 +14,6 @@ namespace ServerRuntime { namespace { - /** - * **Access State** - * - * These features are used extensively from various parts of the code, so safe read/write handling is implemented - * Stores the published BAN manager snapshot plus a writer gate for clone-and-publish updates - * 公開中のBanManagerスナップショットと更新直列化用ロックを保持する - */ struct AccessState { std::mutex stateLock; @@ -32,20 +25,12 @@ namespace ServerRuntime AccessState g_accessState; - /** - * Copies the currently published manager pointer so readers can work without holding the publish mutex - * 公開中のBanManager共有ポインタを複製取得する - */ static std::shared_ptr GetBanManagerSnapshot() { std::lock_guard stateLock(g_accessState.stateLock); return g_accessState.banManager; } - /** - * Replaces the shared manager pointer with a fully prepared snapshot in one short critical section - * 準備完了したBanManagerスナップショットを短いロックで公開する - */ static void PublishBanManagerSnapshot(const std::shared_ptr &banManager) { std::lock_guard stateLock(g_accessState.stateLock); @@ -71,10 +56,7 @@ namespace ServerRuntime { return ""; } - - char buffer[32] = {}; - sprintf_s(buffer, sizeof(buffer), "0x%016llx", (unsigned long long)xuid); - return buffer; + return xuid.toDashed(); } bool TryParseXuid(const std::string &text, PlayerUID *outXuid) @@ -84,13 +66,13 @@ namespace ServerRuntime return false; } - unsigned long long parsed = 0; - if (!StringUtils::TryParseUnsignedLongLong(text, &parsed) || parsed == 0ULL) + PlayerUID parsed = PlayerUID::fromDashed(text); + if (!parsed.isValid()) { return false; } - *outXuid = (PlayerUID)parsed; + *outXuid = parsed; return true; } diff --git a/Minecraft.Server/CMakeLists.txt b/Minecraft.Server/CMakeLists.txt index 52e5826eb4..c0633172ee 100644 --- a/Minecraft.Server/CMakeLists.txt +++ b/Minecraft.Server/CMakeLists.txt @@ -37,6 +37,7 @@ set_target_properties(Minecraft.Server PROPERTIES target_link_libraries(Minecraft.Server PRIVATE Minecraft.World + MCAuth d3d11 d3dcompiler XInput9_1_0 diff --git a/Minecraft.Server/Common/AccessStorageUtils.h b/Minecraft.Server/Common/AccessStorageUtils.h index c5d3477c70..438ed48a7c 100644 --- a/Minecraft.Server/Common/AccessStorageUtils.h +++ b/Minecraft.Server/Common/AccessStorageUtils.h @@ -2,6 +2,7 @@ #include "FileUtils.h" #include "StringUtils.h" +#include "..\..\Minecraft.World\GameUUID.h" #include "..\vendor\nlohmann\json.hpp" @@ -53,19 +54,21 @@ namespace ServerRuntime return ""; } - unsigned long long numericXuid = 0; - if (StringUtils::TryParseUnsignedLongLong(trimmed, &numericXuid)) + // Try parsing as a dashed 128-bit UUID (standard format) + GameUUID parsed = GameUUID::fromDashed(trimmed); + if (parsed.isValid()) { - if (numericXuid == 0ULL) - { - return ""; - } - - char buffer[32] = {}; - sprintf_s(buffer, sizeof(buffer), "0x%016llx", numericXuid); - return buffer; + return parsed.toDashed(); } + // Try parsing as an undashed 128-bit UUID + parsed = GameUUID::fromUndashed(trimmed); + if (parsed.isValid()) + { + return parsed.toDashed(); + } + + // Fallback: lowercase whatever was passed (e.g. player names in legacy files) return StringUtils::ToLowerAscii(trimmed); } diff --git a/Minecraft.Server/Console/commands/ban/CliCommandBan.cpp b/Minecraft.Server/Console/commands/ban/CliCommandBan.cpp index f9855c0cb9..2fedca71f3 100644 --- a/Minecraft.Server/Console/commands/ban/CliCommandBan.cpp +++ b/Minecraft.Server/Console/commands/ban/CliCommandBan.cpp @@ -36,9 +36,7 @@ namespace ServerRuntime return; } - // Keep both identity variants because the dedicated server checks login and online XUIDs separately. AppendUniqueXuid(player->getXuid(), out); - AppendUniqueXuid(player->getOnlineXuid(), out); } } diff --git a/Minecraft.Server/Console/commands/pardon/CliCommandPardon.cpp b/Minecraft.Server/Console/commands/pardon/CliCommandPardon.cpp index d1e995e90b..b49b826d26 100644 --- a/Minecraft.Server/Console/commands/pardon/CliCommandPardon.cpp +++ b/Minecraft.Server/Console/commands/pardon/CliCommandPardon.cpp @@ -82,10 +82,6 @@ namespace ServerRuntime { AppendUniqueXuid(onlineTarget->getXuid(), &xuidsToRemove); } - if (ServerRuntime::Access::IsPlayerBanned(onlineTarget->getOnlineXuid())) - { - AppendUniqueXuid(onlineTarget->getOnlineXuid(), &xuidsToRemove); - } } std::vector entries; diff --git a/Minecraft.Server/ServerProperties.cpp b/Minecraft.Server/ServerProperties.cpp index d6ba64e7e2..efee626f19 100644 --- a/Minecraft.Server/ServerProperties.cpp +++ b/Minecraft.Server/ServerProperties.cpp @@ -80,7 +80,9 @@ static const ServerPropertyDefault kServerPropertyDefaults[] = { "spawn-monsters", "true" }, { "spawn-npcs", "true" }, { "tnt", "true" }, - { "trust-players", "true" } + { "trust-players", "true" }, + { "online-mode", "true" }, + { "auth-provider", "mojang" } }; static std::string BoolToString(bool value) @@ -398,6 +400,7 @@ static bool WriteServerPropertiesFile(const char *filePath, const std::unordered std::string text; text += "# Minecraft server properties\n"; text += "# Auto-generated and normalized when missing\n"; + text += "# Currently supported auth-provider: [mojang, elyby]\n"; std::map sortedProperties(properties.begin(), properties.end()); for (std::map::const_iterator it = sortedProperties.begin(); it != sortedProperties.end(); ++it) @@ -826,7 +829,7 @@ ServerPropertiesConfig LoadServerPropertiesConfig() config.autosaveIntervalSeconds = ReadNormalizedIntProperty(&merged, "autosave-interval", kDefaultAutosaveIntervalSeconds, 5, 3600, &shouldWrite); config.difficulty = ReadNormalizedIntProperty(&merged, "difficulty", 1, 0, 3, &shouldWrite); - config.gameMode = ReadNormalizedIntProperty(&merged, "gamemode", 0, 0, 1, &shouldWrite); + config.gameMode = ReadNormalizedIntProperty(&merged, "gamemode", 0, 0, 2, &shouldWrite); config.worldSize = ReadNormalizedWorldSizeProperty( &merged, "world-size", @@ -861,6 +864,14 @@ ServerPropertiesConfig LoadServerPropertiesConfig() config.doTileDrops = ReadNormalizedBoolProperty(&merged, "do-tile-drops", true, &shouldWrite); config.naturalRegeneration = ReadNormalizedBoolProperty(&merged, "natural-regeneration", true, &shouldWrite); config.doDaylightCycle = ReadNormalizedBoolProperty(&merged, "do-daylight-cycle", true, &shouldWrite); + config.onlineMode = ReadNormalizedBoolProperty(&merged, "online-mode", true, &shouldWrite); + config.authProvider = ReadNormalizedStringProperty(&merged, "auth-provider", "mojang", 16, &shouldWrite); + if (config.authProvider != "mojang" && config.authProvider != "elyby") + { + config.authProvider = "mojang"; + merged["auth-provider"] = "mojang"; + shouldWrite = true; + } config.maxBuildHeight = ReadNormalizedIntProperty(&merged, "max-build-height", 256, 64, 256, &shouldWrite); config.motd = ReadNormalizedStringProperty(&merged, "motd", "A Minecraft Server", 255, &shouldWrite); diff --git a/Minecraft.Server/ServerProperties.h b/Minecraft.Server/ServerProperties.h index 3bb5aca802..6b7f8c13e8 100644 --- a/Minecraft.Server/ServerProperties.h +++ b/Minecraft.Server/ServerProperties.h @@ -74,6 +74,11 @@ namespace ServerRuntime bool naturalRegeneration; bool doDaylightCycle; + /** `online-mode` — require Mojang session verification */ + bool onlineMode; + /** `auth-provider` — which auth provider to use: "mojang" or "elyby" */ + std::string authProvider = "mojang"; + /** other MinecraftServer runtime settings */ int maxBuildHeight; std::string levelType; diff --git a/Minecraft.Server/Windows64/ServerMain.cpp b/Minecraft.Server/Windows64/ServerMain.cpp index a8d5fc66b2..9a4e045f3a 100644 --- a/Minecraft.Server/Windows64/ServerMain.cpp +++ b/Minecraft.Server/Windows64/ServerMain.cpp @@ -410,6 +410,8 @@ int main(int argc, char **argv) LogInfof("startup", "LAN advertise: %s", serverProperties.lanAdvertise ? "enabled" : "disabled"); LogInfof("startup", "Whitelist: %s", serverProperties.whiteListEnabled ? "enabled" : "disabled"); LogInfof("startup", "Spawn protection radius: %d", serverProperties.spawnProtectionRadius); + LogInfof("startup", "Online mode: %s", serverProperties.onlineMode ? "enabled" : "disabled"); + LogInfof("startup", "Auth provider: %s", serverProperties.authProvider.c_str()); #ifdef _LARGE_WORLDS LogInfof( "startup", @@ -589,7 +591,7 @@ int main(int argc, char **argv) param->dedicatedNoLocalHostPlayer = true; LogStartupStep("starting hosted network game thread"); - g_NetworkManager.HostGame(0, true, false, (unsigned char)config.maxPlayers, 0); + g_NetworkManager.HostGame(0, true, false, config.maxPlayers, 0); g_NetworkManager.FakeLocalPlayerJoined(); C4JThread *startThread = new C4JThread(&CGameNetworkManager::RunNetworkGameThreadProc, (LPVOID)param, "RunNetworkGame"); diff --git a/Minecraft.World/AddPlayerPacket.cpp b/Minecraft.World/AddPlayerPacket.cpp index fbee1fc524..0beb718c17 100644 --- a/Minecraft.World/AddPlayerPacket.cpp +++ b/Minecraft.World/AddPlayerPacket.cpp @@ -32,7 +32,7 @@ AddPlayerPacket::~AddPlayerPacket() if(unpack != nullptr) delete unpack; } -AddPlayerPacket::AddPlayerPacket(shared_ptr player, PlayerUID xuid, PlayerUID OnlineXuid,int xp, int yp, int zp, int yRotp, int xRotp, int yHeadRotp) +AddPlayerPacket::AddPlayerPacket(shared_ptr player, PlayerUID xuid, int xp, int yp, int zp, int yRotp, int xRotp, int yHeadRotp) { id = player->entityId; name = player->getName(); @@ -54,7 +54,6 @@ AddPlayerPacket::AddPlayerPacket(shared_ptr player, PlayerUID xuid, Play carriedItem = itemInstance == nullptr ? 0 : itemInstance->id; this->xuid = xuid; - this->OnlineXuid = OnlineXuid; m_playerIndex = static_cast(player->getPlayerIndex()); m_skinId = player->getCustomSkin(); m_capeId = player->getCustomCape(); @@ -72,18 +71,17 @@ void AddPlayerPacket::read(DataInputStream *dis) //throws IOException y = dis->readInt(); z = dis->readInt(); yRot = dis->readByte(); - xRot = dis->readByte(); + xRot = dis->readByte(); yHeadRot = dis->readByte(); // 4J Added carriedItem = dis->readShort(); xuid = dis->readPlayerUID(); - OnlineXuid = dis->readPlayerUID(); m_playerIndex = dis->readByte(); INT skinId = dis->readInt(); - m_skinId = *(DWORD *)&skinId; + m_skinId = static_cast(skinId); INT capeId = dis->readInt(); - m_capeId = *(DWORD *)&capeId; + m_capeId = static_cast(capeId); INT privileges = dis->readInt(); - m_uiGamePrivileges = *(unsigned int *)&privileges; + m_uiGamePrivileges = static_cast(privileges); MemSect(1); unpack = SynchedEntityData::unpack(dis); MemSect(0); @@ -101,7 +99,6 @@ void AddPlayerPacket::write(DataOutputStream *dos) //throws IOException dos->writeByte(yHeadRot); // 4J Added dos->writeShort(carriedItem); dos->writePlayerUID(xuid); - dos->writePlayerUID(OnlineXuid); dos->writeByte(m_playerIndex); dos->writeInt(m_skinId); dos->writeInt(m_capeId); @@ -117,7 +114,7 @@ void AddPlayerPacket::handle(PacketListener *listener) int AddPlayerPacket::getEstimatedSize() { - int iSize= sizeof(int) + Player::MAX_NAME_LENGTH + sizeof(int) + sizeof(int) + sizeof(int) + sizeof(BYTE) + sizeof(BYTE) +sizeof(short) + sizeof(PlayerUID) + sizeof(PlayerUID) + sizeof(int) + sizeof(BYTE) + sizeof(unsigned int) + sizeof(byte); + int iSize= sizeof(int) + Player::MAX_NAME_LENGTH + sizeof(int) + sizeof(int) + sizeof(int) + sizeof(BYTE) + sizeof(BYTE) +sizeof(short) + sizeof(PlayerUID) + sizeof(int) + sizeof(BYTE) + sizeof(unsigned int) + sizeof(byte); if( entityData != nullptr ) { @@ -132,7 +129,7 @@ int AddPlayerPacket::getEstimatedSize() return iSize; } -vector > *AddPlayerPacket::getUnpackedData() +vector > *AddPlayerPacket::getUnpackedData() { if (unpack == nullptr) { diff --git a/Minecraft.World/AddPlayerPacket.h b/Minecraft.World/AddPlayerPacket.h index af90c97dfe..3f01d6bd87 100644 --- a/Minecraft.World/AddPlayerPacket.h +++ b/Minecraft.World/AddPlayerPacket.h @@ -20,7 +20,6 @@ class AddPlayerPacket : public Packet, public enable_shared_from_this player, PlayerUID xuid, PlayerUID OnlineXuid,int xp, int yp, int zp, int yRotp, int xRotp, int yHeadRotp); + AddPlayerPacket(shared_ptr player, PlayerUID xuid, int xp, int yp, int zp, int yRotp, int xRotp, int yHeadRotp); virtual void read(DataInputStream *dis); virtual void write(DataOutputStream *dos); diff --git a/Minecraft.World/AuthResponsePacket.cpp b/Minecraft.World/AuthResponsePacket.cpp new file mode 100644 index 0000000000..2575b5cfdb --- /dev/null +++ b/Minecraft.World/AuthResponsePacket.cpp @@ -0,0 +1,54 @@ +#include "stdafx.h" +#include "InputOutputStream.h" +#include "PacketListener.h" +#include "AuthResponsePacket.h" + +AuthResponsePacket::AuthResponsePacket() +{ +} + +AuthResponsePacket::AuthResponsePacket(const wstring& chosenScheme, const wstring& mojangUuid, const wstring& username) +{ + this->chosenScheme = chosenScheme; + this->mojangUuid = mojangUuid; + this->username = username; +} + +void AuthResponsePacket::read(DataInputStream *dis) +{ + chosenScheme = readUtf(dis, 32); + mojangUuid = readUtf(dis, 64); + username = readUtf(dis, 16); // MC usernames are max 16 chars + + if (chosenScheme != L"mojang" && chosenScheme != L"offline" && chosenScheme != L"elyby") + chosenScheme = L""; + + // Validate username: only alphanumeric + underscore (standard MC rules) + for (wchar_t c : username) + { + if (!((c >= L'a' && c <= L'z') || (c >= L'A' && c <= L'Z') || + (c >= L'0' && c <= L'9') || c == L'_')) + { + username = L""; + break; + } + } +} + +void AuthResponsePacket::write(DataOutputStream *dos) +{ + writeUtf(chosenScheme, dos); + writeUtf(mojangUuid, dos); + writeUtf(username, dos); +} + +void AuthResponsePacket::handle(PacketListener *listener) +{ + listener->handleAuthResponse(shared_from_this()); +} + +int AuthResponsePacket::getEstimatedSize() +{ + return static_cast(3 * sizeof(short) + + (chosenScheme.length() + mojangUuid.length() + username.length()) * sizeof(wchar_t)); +} diff --git a/Minecraft.World/AuthResponsePacket.h b/Minecraft.World/AuthResponsePacket.h new file mode 100644 index 0000000000..af9a740094 --- /dev/null +++ b/Minecraft.World/AuthResponsePacket.h @@ -0,0 +1,22 @@ +#pragma once +#include "Packet.h" +using namespace std; + +class AuthResponsePacket : public Packet, public enable_shared_from_this +{ +public: + wstring chosenScheme; // "mojang" or "offline" + wstring mojangUuid; // dashed format + wstring username; // chosen username + + AuthResponsePacket(); + AuthResponsePacket(const wstring& chosenScheme, const wstring& mojangUuid, const wstring& username); + + virtual void read(DataInputStream *dis); + virtual void write(DataOutputStream *dos); + virtual void handle(PacketListener *listener); + virtual int getEstimatedSize(); + + static shared_ptr create() { return make_shared(); } + virtual int getId() { return 171; } +}; diff --git a/Minecraft.World/AuthResultPacket.cpp b/Minecraft.World/AuthResultPacket.cpp new file mode 100644 index 0000000000..fd488470ac --- /dev/null +++ b/Minecraft.World/AuthResultPacket.cpp @@ -0,0 +1,76 @@ +#include "stdafx.h" +#include "InputOutputStream.h" +#include "PacketListener.h" +#include "AuthResultPacket.h" + +AuthResultPacket::AuthResultPacket() +{ + success = false; +} + +AuthResultPacket::AuthResultPacket(bool success, const wstring& assignedUuid, const wstring& assignedUsername, + const wstring& errorMessage, const wstring& skinKey, + std::vector skinData) +{ + this->success = success; + this->assignedUuid = assignedUuid; + this->assignedUsername = assignedUsername; + this->errorMessage = errorMessage; + this->skinKey = skinKey; + this->skinData = std::move(skinData); +} + +void AuthResultPacket::read(DataInputStream *dis) +{ + success = dis->readBoolean(); + assignedUuid = readUtf(dis, 64); + assignedUsername = readUtf(dis, 64); + errorMessage = readUtf(dis, 256); + skinKey = readUtf(dis, 256); + + // Read inline skin data (int length + raw bytes) + // Cap to 32KB — a valid 64x64 RGBA skin PNG is ~4KB compressed. + int skinSize = dis->readInt(); + if (skinSize > 0 && skinSize <= 32768) + { + skinData.resize(static_cast(skinSize)); + for (int i = 0; i < skinSize; i++) + skinData[i] = dis->readByte(); + } + else + { + skinData.clear(); + // Consume declared bytes to keep stream synchronized (same fix as readUtf) + if (skinSize > 0) + { + for (int i = 0; i < skinSize; i++) + dis->readByte(); + } + } +} + +void AuthResultPacket::write(DataOutputStream *dos) +{ + dos->writeBoolean(success); + writeUtf(assignedUuid, dos); + writeUtf(assignedUsername, dos); + writeUtf(errorMessage, dos); + writeUtf(skinKey, dos); + + int skinSize = static_cast(skinData.size()); + dos->writeInt(skinSize); + for (int i = 0; i < skinSize; i++) + dos->writeByte(skinData[i]); +} + +void AuthResultPacket::handle(PacketListener *listener) +{ + listener->handleAuthResult(shared_from_this()); +} + +int AuthResultPacket::getEstimatedSize() +{ + return static_cast(sizeof(bool) + 4 * sizeof(short) + + (assignedUuid.length() + assignedUsername.length() + errorMessage.length() + skinKey.length()) * sizeof(wchar_t) + + sizeof(int) + skinData.size()); +} diff --git a/Minecraft.World/AuthResultPacket.h b/Minecraft.World/AuthResultPacket.h new file mode 100644 index 0000000000..475ae35383 --- /dev/null +++ b/Minecraft.World/AuthResultPacket.h @@ -0,0 +1,28 @@ +#pragma once +#include "Packet.h" +#include +using namespace std; + +class AuthResultPacket : public Packet, public enable_shared_from_this +{ +public: + bool success; + wstring assignedUuid; // final UUID assigned by server (dashed) + wstring assignedUsername; // final username + wstring errorMessage; // empty if success + wstring skinKey; // memory-texture key (e.g. "mojang_skin_{uuid}.png") + std::vector skinData; // raw PNG bytes of the Mojang skin (already cropped to 64x32) + + AuthResultPacket(); + AuthResultPacket(bool success, const wstring& assignedUuid, const wstring& assignedUsername, + const wstring& errorMessage, const wstring& skinKey = L"", + std::vector skinData = {}); + + virtual void read(DataInputStream *dis); + virtual void write(DataOutputStream *dos); + virtual void handle(PacketListener *listener); + virtual int getEstimatedSize(); + + static shared_ptr create() { return make_shared(); } + virtual int getId() { return 172; } +}; diff --git a/Minecraft.World/AuthSchemePacket.cpp b/Minecraft.World/AuthSchemePacket.cpp new file mode 100644 index 0000000000..396a6980f7 --- /dev/null +++ b/Minecraft.World/AuthSchemePacket.cpp @@ -0,0 +1,56 @@ +#include "stdafx.h" +#include "InputOutputStream.h" +#include "PacketListener.h" +#include "AuthSchemePacket.h" + +AuthSchemePacket::AuthSchemePacket() +{ +} + +AuthSchemePacket::AuthSchemePacket(const vector& schemes, const wstring& serverId) +{ + this->schemes = schemes; + this->serverId = serverId; +} + +void AuthSchemePacket::read(DataInputStream *dis) +{ + int count = dis->readInt(); + if (count < 0 || count > 16) count = 0; // sane upper bound for auth schemes + schemes.clear(); + schemes.reserve(static_cast(count)); + for (int i = 0; i < count; i++) + { + wstring scheme = readUtf(dis, 32); + // Only accept known scheme names; discard unknown/empty + if (scheme == L"mojang" || scheme == L"offline" || scheme == L"elyby") + schemes.push_back(std::move(scheme)); + } + serverId = readUtf(dis, 64); +} + +void AuthSchemePacket::write(DataOutputStream *dos) +{ + dos->writeInt(static_cast(schemes.size())); + for (auto& s : schemes) + { + writeUtf(s, dos); + } + writeUtf(serverId, dos); +} + +void AuthSchemePacket::handle(PacketListener *listener) +{ + listener->handleAuthScheme(shared_from_this()); +} + +int AuthSchemePacket::getEstimatedSize() +{ + int size = sizeof(int); + for (auto& s : schemes) + { + size += sizeof(short) + static_cast(s.length()) * sizeof(wchar_t); + } + size += sizeof(short) + static_cast(serverId.length()) * sizeof(wchar_t); + return size; +} diff --git a/Minecraft.World/AuthSchemePacket.h b/Minecraft.World/AuthSchemePacket.h new file mode 100644 index 0000000000..d1a8da21c1 --- /dev/null +++ b/Minecraft.World/AuthSchemePacket.h @@ -0,0 +1,22 @@ +#pragma once +#include "Packet.h" +using namespace std; + +class AuthSchemePacket : public Packet, public enable_shared_from_this +{ +public: + // Schemes: "mojang", "offline" + vector schemes; + wstring serverId; // random hex challenge (20 chars), empty if offline-only + + AuthSchemePacket(); + AuthSchemePacket(const vector& schemes, const wstring& serverId); + + virtual void read(DataInputStream *dis); + virtual void write(DataOutputStream *dos); + virtual void handle(PacketListener *listener); + virtual int getEstimatedSize(); + + static shared_ptr create() { return make_shared(); } + virtual int getId() { return 170; } +}; diff --git a/Minecraft.World/Connection.cpp b/Minecraft.World/Connection.cpp index d445026678..6fc9cda2d4 100644 --- a/Minecraft.World/Connection.cpp +++ b/Minecraft.World/Connection.cpp @@ -189,18 +189,23 @@ bool Connection::writeTick() return didSomething; // try { - if (!outgoing.empty() && (fakeLag == 0 || System::currentTimeMillis() - outgoing.front()->createTime >= fakeLag)) { shared_ptr packet; + bool hasPacket = false; EnterCriticalSection(&writeLock); - - packet = outgoing.front(); - outgoing.pop(); - estimatedRemaining -= packet->getEstimatedSize() + 1; - + if (!outgoing.empty() && (fakeLag == 0 || System::currentTimeMillis() - outgoing.front()->createTime >= fakeLag)) + { + packet = outgoing.front(); + outgoing.pop(); + estimatedRemaining -= packet->getEstimatedSize() + 1; + hasPacket = true; + } LeaveCriticalSection(&writeLock); + if (hasPacket) + { + Packet::writePacket(packet, bufferedDos); @@ -231,24 +236,30 @@ bool Connection::writeTick() writeSizes[packet->getId()] += packet->getEstimatedSize() + 1; didSomething = true; } + } - if ((slowWriteDelay-- <= 0) && !outgoing_slow.empty() && (fakeLag == 0 || System::currentTimeMillis() - outgoing_slow.front()->createTime >= fakeLag)) { - shared_ptr packet; - - //synchronized (writeLock) { + shared_ptr slowPacket; + bool hasSlowPacket = false; EnterCriticalSection(&writeLock); - - packet = outgoing_slow.front(); - outgoing_slow.pop(); - estimatedRemaining -= packet->getEstimatedSize() + 1; - + bool slowReady = (slowWriteDelay <= 0) && !outgoing_slow.empty() + && (fakeLag == 0 || System::currentTimeMillis() - outgoing_slow.front()->createTime >= fakeLag); + if (slowReady) + { + slowPacket = outgoing_slow.front(); + outgoing_slow.pop(); + estimatedRemaining -= slowPacket->getEstimatedSize() + 1; + hasSlowPacket = true; + } LeaveCriticalSection(&writeLock); + if (slowWriteDelay > 0) slowWriteDelay--; // decrement only when positive, matching original (slowWriteDelay-- <= 0) + if (hasSlowPacket) + { // If the shouldDelay flag is still set at this point then we want to write it to QNet as a single packet with priority flags // Otherwise just buffer the packet with other outgoing packets as the java game did - if(packet->shouldDelay) + if(slowPacket->shouldDelay) { // Flush any buffered data BEFORE writing directly to the socket. // bufferedDos and sos->writeWithFlags both write to the same underlying @@ -258,7 +269,7 @@ bool Connection::writeTick() // the TCP stream on the receiving end. bufferedDos->flush(); - Packet::writePacket(packet, byteArrayDos); + Packet::writePacket(slowPacket, byteArrayDos); // 4J Stu - Changed this so that rather than writing to the network stream through a buffered stream we want to: // a) Only push whole "game" packets to QNet, rather than amalgamated chunks of data that may include many packets, and partial packets @@ -273,34 +284,32 @@ bool Connection::writeTick() } else { - Packet::writePacket(packet, bufferedDos); + Packet::writePacket(slowPacket, bufferedDos); } #ifndef _CONTENT_PACKAGE // 4J Added for debugging - if( !socket->isLocal() ) + if( !socket->isLocal() ) { int playerId = 0; - if( !socket->isLocal() ) + Socket *socket = getSocket(); + if( socket ) { - Socket *socket = getSocket(); - if( socket ) + INetworkPlayer *player = socket->getPlayer(); + if( player ) { - INetworkPlayer *player = socket->getPlayer(); - if( player ) - { - playerId = player->GetSmallId(); - } + playerId = player->GetSmallId(); } - Packet::recordOutgoingPacket(packet,playerId); } + Packet::recordOutgoingPacket(slowPacket,playerId); } -#endif +#endif - writeSizes[packet->getId()] += packet->getEstimatedSize() + 1; + writeSizes[slowPacket->getId()] += slowPacket->getEstimatedSize() + 1; slowWriteDelay = 0; didSomething = true; } + } /* 4J JEV, removed try/catch } catch (Exception e) { if (!disconnected) handleException(e); @@ -384,22 +393,9 @@ void Connection::close(DisconnectPacket::eDisconnectReason reason, ...) disconnectReason = reason;//va_arg( input, const wstring ); - vector objs = vector(); - void *i = nullptr; - while (i != nullptr) - { - i = va_arg( input, void* ); - objs.push_back(i); - } - - if( objs.size() ) - { - disconnectReasonObjects = &objs[0]; - } - else - { - disconnectReasonObjects = nullptr; - } + // unused, clear for safety + disconnectReasonObjects = nullptr; + va_end(input); // int count = 0, sum = 0, i = first; // va_list marker; diff --git a/Minecraft.World/DataInput.h b/Minecraft.World/DataInput.h index f61eceb515..6731f702ed 100644 --- a/Minecraft.World/DataInput.h +++ b/Minecraft.World/DataInput.h @@ -17,6 +17,6 @@ class DataInput virtual short readShort() = 0; virtual wchar_t readChar() = 0; virtual wstring readUTF() = 0; - virtual PlayerUID readPlayerUID() = 0; // 4J Added + virtual PlayerUID readPlayerUID() = 0; virtual int skipBytes(int n) = 0; }; diff --git a/Minecraft.World/DataInputStream.cpp b/Minecraft.World/DataInputStream.cpp index 0cd21a7e90..a97f271d36 100644 --- a/Minecraft.World/DataInputStream.cpp +++ b/Minecraft.World/DataInputStream.cpp @@ -83,7 +83,9 @@ void DataInputStream::close() //the boolean value read. bool DataInputStream::readBoolean() { - return stream->read() != 0; + int val = stream->read(); + if (val == -1) return false; + return val != 0; } //Reads and returns one input byte. The byte is treated as a signed value in the range -128 through 127, inclusive. @@ -92,12 +94,16 @@ bool DataInputStream::readBoolean() //the 8-bit value read. byte DataInputStream::readByte() { - return static_cast(stream->read()); + int val = stream->read(); + if (val == -1) return 0; + return static_cast(val); } unsigned char DataInputStream::readUnsignedByte() { - return static_cast(stream->read()); + int val = stream->read(); + if (val == -1) return 0; + return static_cast(val); } //Reads two input bytes and returns a char value. Let a be the first byte read and b be the second byte. The value returned is: @@ -110,6 +116,7 @@ wchar_t DataInputStream::readChar() { int a = stream->read(); int b = stream->read(); + if (a == -1 || b == -1) return 0; return static_cast((a << 8) | (b & 0xff)); } @@ -201,6 +208,7 @@ int DataInputStream::readInt() int b = stream->read(); int c = stream->read(); int d = stream->read(); + if (a == -1 || b == -1 || c == -1 || d == -1) return 0; int bits = (((a & 0xff) << 24) | ((b & 0xff) << 16) | ((c & 0xff) << 8) | (d & 0xff)); return bits; @@ -231,15 +239,17 @@ int64_t DataInputStream::readLong() int64_t f = stream->read(); int64_t g = stream->read(); int64_t h = stream->read(); - - int64_t bits = (((a & 0xff) << 56) | - ((b & 0xff) << 48) | - ((c & 0xff) << 40) | - ((d & 0xff) << 32) | - ((e & 0xff) << 24) | - ((f & 0xff) << 16) | - ((g & 0xff) << 8) | - ((h & 0xff))); + if (a == -1 || b == -1 || c == -1 || d == -1 || + e == -1 || f == -1 || g == -1 || h == -1) return 0; + + int64_t bits = (((a & 0xffLL) << 56) | + ((b & 0xffLL) << 48) | + ((c & 0xffLL) << 40) | + ((d & 0xffLL) << 32) | + ((e & 0xffLL) << 24) | + ((f & 0xffLL) << 16) | + ((g & 0xffLL) << 8) | + ((h & 0xffLL))); return bits; } @@ -254,6 +264,7 @@ short DataInputStream::readShort() { int a = stream->read(); int b = stream->read(); + if (a == -1 || b == -1) return 0; return static_cast((a << 8) | (b & 0xff)); } @@ -261,6 +272,7 @@ unsigned short DataInputStream::readUnsignedShort() { int a = stream->read(); int b = stream->read(); + if (a == -1 || b == -1) return 0; return static_cast((a << 8) | (b & 0xff)); } @@ -519,19 +531,12 @@ int DataInputStream::readUTFChar() return returnValue; } -// 4J Added PlayerUID DataInputStream::readPlayerUID() { - PlayerUID returnValue; -#if defined(__PS3__) || defined(__ORBIS__) || defined(__PSVITA__) - for(int idPos=0;idPoswrite( a ); + written += 1; } //Converts the double argument to a long using the doubleToLongBits method in class Double, @@ -81,8 +82,6 @@ void DataOutputStream::writeDouble(double a) int64_t bits = Double::doubleToLongBits( a ); writeLong( bits ); - // TODO 4J Stu - Error handling? - written += 8; } //Converts the float argument to an int using the floatToIntBits method in class Float, @@ -95,8 +94,6 @@ void DataOutputStream::writeFloat(float a) int bits = Float::floatToIntBits( a ); writeInt( bits ); - // TODO 4J Stu - Error handling? - written += 4; } //Writes an int to the underlying output stream as four bytes, high byte first. If no exception is thrown, the counter written is incremented by 4. @@ -127,7 +124,7 @@ void DataOutputStream::writeLong(int64_t a) stream->write( (a >> 8) & 0xff ); stream->write( a & 0xff ); // TODO 4J Stu - Error handling? - written += 4; + written += 8; } //Writes a short to the underlying output stream as two bytes, high byte first. @@ -262,15 +259,8 @@ void DataOutputStream::writeUTF(const wstring& str) delete[] bytearr.data; } -// 4J Added void DataOutputStream::writePlayerUID(PlayerUID player) { -#if defined(__PS3__) || defined(__ORBIS__) || defined (__PSVITA__) - for(int idPos=0;idPos>2] &= ~( 2 << offset ); + dimensions[id>>2] &= ~( 3 << offset ); switch(dimension) { case 0: // Overworld @@ -78,11 +78,11 @@ void _MapDataMappings::setMapping(int id, PlayerUID xuid, int dimension) } } -// Old version the only used 1 bit for dimension indexing +// Old version that only used 1 bit for dimension indexing (legacy 64-bit XUIDs) _MapDataMappings_old::_MapDataMappings_old() { #ifndef _DURANGO - ZeroMemory(xuids,sizeof(PlayerUID)*MAXIMUM_MAP_SAVE_DATA); + ZeroMemory(xuids,sizeof(uint64_t)*MAXIMUM_MAP_SAVE_DATA); #endif ZeroMemory(dimensions,sizeof(byte)*(MAXIMUM_MAP_SAVE_DATA/8)); } @@ -92,7 +92,7 @@ int _MapDataMappings_old::getDimension(int id) return dimensions[id>>3] & (128 >> (id%8) ) ? -1 : 0; } -void _MapDataMappings_old::setMapping(int id, PlayerUID xuid, int dimension) +void _MapDataMappings_old::setMapping(int id, uint64_t xuid, int dimension) { xuids[id] = xuid; if( dimension == 0 ) @@ -140,7 +140,6 @@ void DirectoryLevelStorage::PlayerMappings::writeMappings(DataOutputStream *dos) dos->writeInt(m_mappings.size()); for (const auto& it : m_mappings ) { - app.DebugPrintf(" -- %lld (0x%016llx) = %d\n", it.first, it.first, it.second); dos->writeLong(it.first); dos->writeInt(it.second); } @@ -149,12 +148,12 @@ void DirectoryLevelStorage::PlayerMappings::writeMappings(DataOutputStream *dos) void DirectoryLevelStorage::PlayerMappings::readMappings(DataInputStream *dis) { const int count = dis->readInt(); - for(unsigned int i = 0; i < count; ++i) + const unsigned int safeCount = (count < 0 || count > 10000) ? 0 : static_cast(count); + for(unsigned int i = 0; i < safeCount; ++i) { int64_t index = dis->readLong(); const int id = dis->readInt(); m_mappings[index] = id; - app.DebugPrintf(" -- %lld (0x%016llx) = %d\n", index, index, id); } } #endif @@ -280,15 +279,10 @@ LevelData *DirectoryLevelStorage::prepareLevel() ByteArrayInputStream bais(data); DataInputStream dis(&bais); const int count = dis.readInt(); - app.DebugPrintf("Loading %d mappings\n", count); - for(unsigned int i = 0; i < count; ++i) + const unsigned int safeCount = (count < 0 || count > 10000) ? 0 : static_cast(count); + for(unsigned int i = 0; i < safeCount; ++i) { PlayerUID playerUid = dis.readPlayerUID(); -#ifdef _WINDOWS64 - app.DebugPrintf(" -- %d\n", playerUid); -#else - app.DebugPrintf(" -- %ls\n", playerUid.toString().c_str()); -#endif m_playerMappings[playerUid].readMappings(&dis); } dis.readFully(m_usedMappings); @@ -296,7 +290,9 @@ LevelData *DirectoryLevelStorage::prepareLevel() if(getSaveFile()->getSaveVersion() < END_DIMENSION_MAP_MAPPINGS_SAVE_VERSION) { + // Very old format: 64-bit XUIDs + 1-bit dimension indexing MapDataMappings_old oldMapDataMappings; + ZeroMemory(&oldMapDataMappings, sizeof(oldMapDataMappings)); getSaveFile()->readFile( fileEntry, &oldMapDataMappings, // data buffer sizeof(MapDataMappings_old), // number of bytes to read @@ -306,17 +302,58 @@ LevelData *DirectoryLevelStorage::prepareLevel() for(unsigned int i = 0; i < MAXIMUM_MAP_SAVE_DATA; ++i) { - m_saveableMapDataMappings.setMapping(i,oldMapDataMappings.xuids[i],oldMapDataMappings.getDimension(i)); + // Migrate 64-bit XUID to 128-bit UUID: put old value in hi, lo=0 + GameUUID migrated; + migrated.hi = oldMapDataMappings.xuids[i]; + migrated.lo = 0; + m_saveableMapDataMappings.setMapping(i, migrated, oldMapDataMappings.getDimension(i)); } } else { - getSaveFile()->readFile( fileEntry, - &m_saveableMapDataMappings, // data buffer - sizeof(MapDataMappings), // number of bytes to read - &NumberOfBytesRead // number of bytes read - ); - assert( NumberOfBytesRead == sizeof(MapDataMappings) ); + // Try to detect whether this is old 64-bit or new 128-bit format + // by comparing file size against expected struct sizes + DWORD fileSize = fileEntry->getFileSize(); + DWORD expectedNew = sizeof(MapDataMappings); + DWORD expectedLegacy64 = sizeof(MapDataMappings_legacy64); + + if (fileSize == expectedNew) + { + // New 128-bit format + getSaveFile()->readFile( fileEntry, + &m_saveableMapDataMappings, + sizeof(MapDataMappings), + &NumberOfBytesRead + ); + assert( NumberOfBytesRead == sizeof(MapDataMappings) ); + } + else if (fileSize == expectedLegacy64 || fileSize > 0) + { + // Legacy 64-bit format — migrate + MapDataMappings_legacy64 legacy; + ZeroMemory(&legacy, sizeof(legacy)); + getSaveFile()->readFile( fileEntry, + &legacy, + (fileSize < sizeof(legacy)) ? fileSize : sizeof(legacy), + &NumberOfBytesRead + ); + + for(unsigned int i = 0; i < MAXIMUM_MAP_SAVE_DATA; ++i) + { + GameUUID migrated; + migrated.hi = legacy.xuids[i]; + migrated.lo = 0; + // Decode raw 2-bit value to Minecraft dimension ID + int rawDim = (legacy.dimensions[i>>2] >> (2*(i%4))) & 3; + int dimension = 0; + switch (rawDim) { + case 1: dimension = -1; break; // Nether + case 2: dimension = 1; break; // End + default: break; + } + m_saveableMapDataMappings.setMapping(i, migrated, dimension); + } + } } memcpy(&m_mapDataMappings,&m_saveableMapDataMappings,sizeof(MapDataMappings)); @@ -398,7 +435,7 @@ void DirectoryLevelStorage::save(shared_ptr player) #elif defined(_DURANGO) ConsoleSavePath realFile = ConsoleSavePath( playerDir.getName() + player->getXuid().toString() + L".dat" ); #else - const ConsoleSavePath realFile = ConsoleSavePath( playerDir.getName() + std::to_wstring( player->getXuid() ) + L".dat" ); + const ConsoleSavePath realFile = ConsoleSavePath( playerDir.getName() + player->getXuid().toWDashed() + L".dat" ); #endif // If saves are disabled (e.g. because we are writing the save buffer to disk) then cache this player data if(StorageManager.GetSaveDisabled()) @@ -447,7 +484,7 @@ CompoundTag *DirectoryLevelStorage::loadPlayerDataTag(PlayerUID xuid) #elif defined(_DURANGO) ConsoleSavePath realFile = ConsoleSavePath( playerDir.getName() + xuid.toString() + L".dat" ); #else - const ConsoleSavePath realFile = ConsoleSavePath( playerDir.getName() + std::to_wstring( xuid ) + L".dat" ); + const ConsoleSavePath realFile = ConsoleSavePath( playerDir.getName() + xuid.toWDashed() + L".dat" ); #endif const auto it = m_cachedSaveData.find(realFile.getName()); if(it != m_cachedSaveData.end() ) @@ -640,6 +677,28 @@ int DirectoryLevelStorage::getAuxValueForMap(PlayerUID xuid, int dimension, int mapId = i; } } + + // Fallback: match migrated entries where lo==0 (old 64-bit XUID stored as {xuid, 0}) + // and update them in-place to the player's full new UUID. + if (!foundMapping) + { + for(unsigned int i = 0; i < MAXIMUM_MAP_SAVE_DATA; ++i) + { + if(m_mapDataMappings.xuids[i].hi == xuid.hi && + m_mapDataMappings.xuids[i].lo == 0 && + xuid.lo != 0 && + m_mapDataMappings.getDimension(i) == dimension) + { + // Update the migrated entry to the full UUID + m_mapDataMappings.setMapping(i, xuid, dimension); + m_saveableMapDataMappings.setMapping(i, xuid, dimension); + foundMapping = true; + mapId = i; + break; + } + } + } + if( !foundMapping && mapId >= 0 && mapId < MAXIMUM_MAP_SAVE_DATA ) { m_mapDataMappings.setMapping(mapId, xuid, dimension); @@ -681,14 +740,8 @@ void DirectoryLevelStorage::saveMapIdLookup() ByteArrayOutputStream baos; DataOutputStream dos(&baos); dos.writeInt(m_playerMappings.size()); - app.DebugPrintf("Saving %d mappings\n", m_playerMappings.size()); for ( auto& it : m_playerMappings ) { -#ifdef _WINDOWS64 - app.DebugPrintf(" -- %d\n", it.first); -#else - app.DebugPrintf(" -- %ls\n", it.first.toString().c_str()); -#endif dos.writePlayerUID(it.first); it.second.writeMappings(&dos); } diff --git a/Minecraft.World/DirectoryLevelStorage.h b/Minecraft.World/DirectoryLevelStorage.h index 6d305ba3f4..239af9b1a0 100644 --- a/Minecraft.World/DirectoryLevelStorage.h +++ b/Minecraft.World/DirectoryLevelStorage.h @@ -30,9 +30,7 @@ using namespace std; #include "ConsoleSavePath.h" class ConsoleSaveFile; -// 4J Stu - Added this which we will write out as a file. Map id's are stored in itemInstances -// as the auxValue, so we can have at most 65536 maps. As we currently have a limit of 80 players -// with 3 maps each we should not hit this limit. +// Map data mappings — current format uses 128-bit GameUUID typedef struct _MapDataMappings { PlayerUID xuids[MAXIMUM_MAP_SAVE_DATA]; @@ -43,15 +41,22 @@ typedef struct _MapDataMappings void setMapping(int id, PlayerUID xuid, int dimension); } MapDataMappings; -// Old version the only used 1 bit for dimension indexing +// Legacy binary format with 64-bit XUIDs — used ONLY for reading old save files +typedef struct _MapDataMappings_legacy64 +{ + uint64_t xuids[MAXIMUM_MAP_SAVE_DATA]; + byte dimensions[MAXIMUM_MAP_SAVE_DATA/4]; +} MapDataMappings_legacy64; + +// Even older format with 1-bit dimension indexing and 64-bit XUIDs typedef struct _MapDataMappings_old { - PlayerUID xuids[MAXIMUM_MAP_SAVE_DATA]; + uint64_t xuids[MAXIMUM_MAP_SAVE_DATA]; byte dimensions[MAXIMUM_MAP_SAVE_DATA/8]; _MapDataMappings_old(); int getDimension(int id); - void setMapping(int id, PlayerUID xuid, int dimension); + void setMapping(int id, uint64_t xuid, int dimension); } MapDataMappings_old; class DirectoryLevelStorage : public LevelStorage, public PlayerIO @@ -83,11 +88,7 @@ class DirectoryLevelStorage : public LevelStorage, public PlayerIO void writeMappings(DataOutputStream *dos); void readMappings(DataInputStream *dis); }; -#if defined(__PS3__) || defined(__ORBIS__) || defined(__PSVITA__) || defined(_DURANGO) unordered_map m_playerMappings; -#else - unordered_map m_playerMappings; -#endif byteArray m_usedMappings; #else MapDataMappings m_mapDataMappings; diff --git a/Minecraft.World/DisconnectPacket.h b/Minecraft.World/DisconnectPacket.h index 3c96a429b1..e78e915d5e 100644 --- a/Minecraft.World/DisconnectPacket.h +++ b/Minecraft.World/DisconnectPacket.h @@ -32,24 +32,25 @@ class DisconnectPacket : public Packet, public enable_shared_from_this +#include +#include + +#ifdef _WIN32 +#include +#include +#pragma comment(lib, "bcrypt.lib") +#endif + +static uint8_t hexVal(char c) +{ + if (c >= '0' && c <= '9') return (uint8_t)(c - '0'); + if (c >= 'a' && c <= 'f') return (uint8_t)(c - 'a' + 10); + if (c >= 'A' && c <= 'F') return (uint8_t)(c - 'A' + 10); + return 0; +} + +std::string GameUUID::toDashed() const +{ + // Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + char buf[37]; + sprintf_s(buf, sizeof(buf), + "%08x-%04x-%04x-%04x-%012llx", + (unsigned int)(hi >> 32), + (unsigned int)((hi >> 16) & 0xFFFF), + (unsigned int)(hi & 0xFFFF), + (unsigned int)(lo >> 48), + (unsigned long long)(lo & 0x0000FFFFFFFFFFFFULL)); + return std::string(buf); +} + +std::string GameUUID::toUndashed() const +{ + char buf[33]; + sprintf_s(buf, sizeof(buf), + "%016llx%016llx", + (unsigned long long)hi, + (unsigned long long)lo); + return std::string(buf); +} + +std::wstring GameUUID::toWDashed() const +{ + std::string s = toDashed(); + return std::wstring(s.begin(), s.end()); +} + +GameUUID GameUUID::fromDashed(const std::string& s) +{ + std::string undashed; + undashed.reserve(32); + for (size_t i = 0; i < s.size(); i++) + { + if (s[i] != '-') + undashed.push_back(s[i]); + } + return fromUndashed(undashed); +} + +GameUUID GameUUID::fromUndashed(const std::string& s) +{ + GameUUID uuid; + if (s.size() < 32) + return uuid; + + uuid.hi = 0; + for (int i = 0; i < 16; i++) + { + uuid.hi = (uuid.hi << 4) | hexVal(s[i]); + } + uuid.lo = 0; + for (int i = 16; i < 32; i++) + { + uuid.lo = (uuid.lo << 4) | hexVal(s[i]); + } + return uuid; +} + +// ---- UUID v4 (random) ---- + +GameUUID GameUUID::generateV4() +{ + GameUUID uuid; + uint8_t bytes[16] = {}; + +#ifdef _WIN32 + NTSTATUS status = BCryptGenRandom(NULL, bytes, sizeof(bytes), BCRYPT_USE_SYSTEM_PREFERRED_RNG); + if (!BCRYPT_SUCCESS(status)) + { + // Fallback: weak but avoids uninitialized memory + for (int i = 0; i < 16; i++) bytes[i] = (uint8_t)(rand() & 0xFF); + } +#else + // Fallback: mix runtime entropy + static uint64_t counter = 0; + uint64_t seed = (uint64_t)time(NULL) ^ (++counter * 6364136223846793005ULL); + for (int i = 0; i < 16; i++) + { + seed = seed * 6364136223846793005ULL + 1442695040888963407ULL; + bytes[i] = (uint8_t)(seed >> 56); + } +#endif + + // Set version 4 (bits 48-51 of hi = 0100) + bytes[6] = (bytes[6] & 0x0F) | 0x40; + // Set variant 1 (bits 0-1 of byte 8 = 10) + bytes[8] = (bytes[8] & 0x3F) | 0x80; + + uuid.hi = 0; + for (int i = 0; i < 8; i++) + uuid.hi = (uuid.hi << 8) | bytes[i]; + + uuid.lo = 0; + for (int i = 8; i < 16; i++) + uuid.lo = (uuid.lo << 8) | bytes[i]; + + return uuid; +} + +// ---- UUID v3 (MD5 name-based) for offline players ---- +// Matches Java's UUID.nameUUIDFromBytes() used by Mojang for offline UUIDs + +// Minimal MD5 implementation (RFC 1321) for offline UUID generation +namespace +{ + struct MD5State + { + uint32_t state[4]; + uint64_t count; + uint8_t buffer[64]; + }; + + static const uint32_t md5_T[64] = { + 0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501, + 0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821, + 0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8, + 0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a, + 0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70, + 0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665, + 0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1, + 0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391 + }; + + static const int md5_S[64] = { + 7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22, + 5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20, + 4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23, + 6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21 + }; + + static inline uint32_t rotl32(uint32_t x, int n) { return (x << n) | (x >> (32 - n)); } + + static void md5_transform(uint32_t state[4], const uint8_t block[64]) + { + uint32_t M[16]; + for (int i = 0; i < 16; i++) + M[i] = (uint32_t)block[i * 4] | ((uint32_t)block[i * 4 + 1] << 8) | + ((uint32_t)block[i * 4 + 2] << 16) | ((uint32_t)block[i * 4 + 3] << 24); + + uint32_t a = state[0], b = state[1], c = state[2], d = state[3]; + + for (int i = 0; i < 64; i++) + { + uint32_t f; + int g; + if (i < 16) { f = (b & c) | (~b & d); g = i; } + else if (i < 32) { f = (d & b) | (~d & c); g = (5 * i + 1) % 16; } + else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) % 16; } + else { f = c ^ (b | ~d); g = (7 * i) % 16; } + + uint32_t temp = d; + d = c; + c = b; + b = b + rotl32(a + f + md5_T[i] + M[g], md5_S[i]); + a = temp; + } + + state[0] += a; state[1] += b; state[2] += c; state[3] += d; + } + + static void md5(const uint8_t* data, size_t len, uint8_t digest[16]) + { + uint32_t state[4] = { 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476 }; + + size_t i = 0; + for (; i + 64 <= len; i += 64) + md5_transform(state, data + i); + + uint8_t block[64] = {}; + size_t remain = len - i; + if (remain > 0) + memcpy(block, data + i, remain); + + block[remain] = 0x80; + + if (remain >= 56) + { + md5_transform(state, block); + memset(block, 0, 64); + } + + uint64_t bitLen = (uint64_t)len * 8; + for (int b = 0; b < 8; b++) + block[56 + b] = (uint8_t)(bitLen >> (b * 8)); + + md5_transform(state, block); + + for (int b = 0; b < 4; b++) + { + digest[b * 4 + 0] = (uint8_t)(state[b]); + digest[b * 4 + 1] = (uint8_t)(state[b] >> 8); + digest[b * 4 + 2] = (uint8_t)(state[b] >> 16); + digest[b * 4 + 3] = (uint8_t)(state[b] >> 24); + } + } +} + +GameUUID GameUUID::generateOffline(const std::string& playerName) +{ + // Matches Mojang's offline UUID: UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes("UTF-8")) + std::string input = "OfflinePlayer:" + playerName; + + uint8_t digest[16]; + md5((const uint8_t*)input.c_str(), input.size(), digest); + + // Set version 3 + digest[6] = (digest[6] & 0x0F) | 0x30; + // Set variant 1 + digest[8] = (digest[8] & 0x3F) | 0x80; + + GameUUID uuid; + uuid.hi = 0; + for (int i = 0; i < 8; i++) + uuid.hi = (uuid.hi << 8) | digest[i]; + uuid.lo = 0; + for (int i = 8; i < 16; i++) + uuid.lo = (uuid.lo << 8) | digest[i]; + + return uuid; +} diff --git a/Minecraft.World/GameUUID.h b/Minecraft.World/GameUUID.h new file mode 100644 index 0000000000..16c0e544bf --- /dev/null +++ b/Minecraft.World/GameUUID.h @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include +#include + +struct GameUUID { + uint64_t hi = 0; + uint64_t lo = 0; + + bool isValid() const { return hi != 0 || lo != 0; } + + // String conversion + std::string toDashed() const; + std::string toUndashed() const; + std::wstring toWDashed() const; + static GameUUID fromDashed(const std::string& s); + static GameUUID fromUndashed(const std::string& s); + + // Generation + static GameUUID generateV4(); + static GameUUID generateOffline(const std::string& playerName); + + // Comparison + bool operator==(const GameUUID& o) const { return hi == o.hi && lo == o.lo; } + bool operator!=(const GameUUID& o) const { return !(*this == o); } + bool operator<(const GameUUID& o) const { return hi < o.hi || (hi == o.hi && lo < o.lo); } + + // Hash support for unordered containers + struct Hash { + size_t operator()(const GameUUID& u) const { + size_t h = std::hash{}(u.hi); + h ^= std::hash{}(u.lo) + 0x9e3779b9 + (h << 6) + (h >> 2); + return h; + } + }; +}; + +inline const GameUUID INVALID_UUID = {}; + +// Stream extraction for _fromString compatibility +inline std::wistream& operator>>(std::wistream& is, GameUUID& uuid) +{ + std::wstring ws; + is >> ws; + std::string s(ws.begin(), ws.end()); + uuid = GameUUID::fromDashed(s); + return is; +} + +// Specialise std::hash so unordered_map works without explicit Hash argument +namespace std { + template<> struct hash { + size_t operator()(const GameUUID& u) const { + return GameUUID::Hash{}(u); + } + }; +} diff --git a/Minecraft.World/LevelData.h b/Minecraft.World/LevelData.h index 9c8d08ed81..c5c1e2a7db 100644 --- a/Minecraft.World/LevelData.h +++ b/Minecraft.World/LevelData.h @@ -77,8 +77,9 @@ class LevelData { DIMENSION_NETHER=-1, DIMENSION_OVERWORLD=0, - DIMENSION_END=1 + DIMENSION_END=1, }; + static const int DIMENSION_COUNT = 3; protected: virtual void setTagData(CompoundTag *tag); // 4J - removed CompoundTag *playerTag diff --git a/Minecraft.World/LoginPacket.cpp b/Minecraft.World/LoginPacket.cpp index 79cdfbae1d..5dc982d4ae 100644 --- a/Minecraft.World/LoginPacket.cpp +++ b/Minecraft.World/LoginPacket.cpp @@ -10,7 +10,6 @@ LoginPacket::LoginPacket() { - this->userName = L""; this->clientVersion = 0; this->seed = 0; this->dimension = 0; @@ -20,8 +19,7 @@ LoginPacket::LoginPacket() this->difficulty = 1; - this->m_offlineXuid = INVALID_XUID; - this->m_onlineXuid = INVALID_XUID; + this->m_xuid = INVALID_XUID; m_friendsOnlyUGC = false; m_ugcPlayersVersion = 0; m_multiplayerInstanceId = 0; @@ -37,7 +35,7 @@ LoginPacket::LoginPacket() } // Client -> Server -LoginPacket::LoginPacket(const wstring& userName, int clientVersion, PlayerUID offlineXuid, PlayerUID onlineXuid, bool friendsOnlyUGC, DWORD ugcPlayersVersion, DWORD skinId, DWORD capeId, bool isGuest) +LoginPacket::LoginPacket(const wstring& userName, int clientVersion, PlayerUID uuid, bool friendsOnlyUGC, DWORD ugcPlayersVersion, DWORD skinId, DWORD capeId, bool isGuest) { this->userName = userName; this->clientVersion = clientVersion; @@ -49,8 +47,7 @@ LoginPacket::LoginPacket(const wstring& userName, int clientVersion, PlayerUID o this->difficulty = 1; - this->m_offlineXuid = offlineXuid; - this->m_onlineXuid = onlineXuid; + this->m_xuid = uuid; m_friendsOnlyUGC = friendsOnlyUGC; m_ugcPlayersVersion = ugcPlayersVersion; m_multiplayerInstanceId = 0; @@ -78,8 +75,7 @@ LoginPacket::LoginPacket(const wstring& userName, int clientVersion, LevelType * this->difficulty = difficulty; - this->m_offlineXuid = INVALID_XUID; - this->m_onlineXuid = INVALID_XUID; + this->m_xuid = INVALID_XUID; m_friendsOnlyUGC = false; m_ugcPlayersVersion = 0; m_multiplayerInstanceId = multiplayerInstanceId; @@ -109,17 +105,17 @@ void LoginPacket::read(DataInputStream *dis) //throws IOException dimension = dis->readByte(); mapHeight = dis->readByte(); maxPlayers = dis->readByte(); - m_offlineXuid = dis->readPlayerUID(); - m_onlineXuid = dis->readPlayerUID(); + m_xuid = dis->readPlayerUID(); + m_friendsOnlyUGC = dis->readBoolean(); m_ugcPlayersVersion = dis->readInt(); difficulty = dis->readByte(); m_multiplayerInstanceId = dis->readInt(); m_playerIndex = dis->readByte(); INT skinId = dis->readInt(); - m_playerSkinId = *(DWORD *)&skinId; + m_playerSkinId = static_cast(skinId); INT capeId = dis->readInt(); - m_playerCapeId = *(DWORD *)&capeId; + m_playerCapeId = static_cast(capeId); m_isGuest = dis->readBoolean(); m_newSeaLevel = dis->readBoolean(); m_uiGamePrivileges = dis->readInt(); @@ -127,6 +123,12 @@ void LoginPacket::read(DataInputStream *dis) //throws IOException m_xzSize = dis->readShort(); m_hellScale = dis->read(); #endif + // Protocol version 80+ + if (clientVersion >= 80) + { + m_mojangUuid = readUtf(dis, 64); + } + app.DebugPrintf("LoginPacket::read - Difficulty = %d\n",difficulty); } @@ -148,8 +150,7 @@ void LoginPacket::write(DataOutputStream *dos) //throws IOException dos->writeByte(dimension); dos->writeByte(mapHeight); dos->writeByte(maxPlayers); - dos->writePlayerUID(m_offlineXuid); - dos->writePlayerUID(m_onlineXuid); + dos->writePlayerUID(m_xuid); dos->writeBoolean(m_friendsOnlyUGC); dos->writeInt(m_ugcPlayersVersion); dos->writeByte(difficulty); @@ -164,6 +165,11 @@ void LoginPacket::write(DataOutputStream *dos) //throws IOException dos->writeShort(m_xzSize); dos->write(m_hellScale); #endif + // Protocol version 80+ + if (clientVersion >= 80) + { + writeUtf(m_mojangUuid, dos); + } } void LoginPacket::handle(PacketListener *listener) @@ -179,5 +185,5 @@ int LoginPacket::getEstimatedSize() length = static_cast(m_pLevelType->getGeneratorName().length()); } - return static_cast(sizeof(int) + userName.length() + 4 + 6 + sizeof(int64_t) + sizeof(char) + sizeof(int) + (2 * sizeof(PlayerUID)) + 1 + sizeof(char) + sizeof(BYTE) + sizeof(bool) + sizeof(bool) + length + sizeof(unsigned int)); + return static_cast(sizeof(int) + userName.length() + 4 + 6 + sizeof(int64_t) + sizeof(char) + sizeof(int) + sizeof(PlayerUID) + 1 + sizeof(char) + sizeof(BYTE) + sizeof(bool) + sizeof(bool) + length + sizeof(unsigned int) + 2 + m_mojangUuid.length() * 2); } diff --git a/Minecraft.World/LoginPacket.h b/Minecraft.World/LoginPacket.h index 02a62b60b9..054d6e2afe 100644 --- a/Minecraft.World/LoginPacket.h +++ b/Minecraft.World/LoginPacket.h @@ -11,19 +11,21 @@ class LoginPacket : public Packet, public enable_shared_from_this wstring userName; int64_t seed; char dimension; - PlayerUID m_offlineXuid, m_onlineXuid; // 4J Added - char difficulty; // 4J Added - bool m_friendsOnlyUGC; // 4J Added - DWORD m_ugcPlayersVersion; // 4J Added - INT m_multiplayerInstanceId; //4J Added for sentient - BYTE m_playerIndex; // 4J Added - DWORD m_playerSkinId, m_playerCapeId; // 4J Added - bool m_isGuest; // 4J Added - bool m_newSeaLevel; // 4J Added + PlayerUID m_xuid; + char difficulty; + bool m_friendsOnlyUGC; + DWORD m_ugcPlayersVersion; + INT m_multiplayerInstanceId; + BYTE m_playerIndex; + DWORD m_playerSkinId, m_playerCapeId; + bool m_isGuest; + bool m_newSeaLevel; LevelType *m_pLevelType; unsigned int m_uiGamePrivileges; - int m_xzSize; // 4J Added - int m_hellScale; // 4J Added + int m_xzSize; + int m_hellScale; + + wstring m_mojangUuid; // from auth handshake (empty if legacy flow) // 1.8.2 int gameType; @@ -32,7 +34,7 @@ class LoginPacket : public Packet, public enable_shared_from_this LoginPacket(); LoginPacket(const wstring& userName, int clientVersion, LevelType *pLevelType, int64_t seed, int gameType, char dimension, BYTE mapHeight, BYTE maxPlayers, char difficulty, INT m_multiplayerInstanceId, BYTE playerIndex, bool newSeaLevel, unsigned int uiGamePrivileges, int xzSize, int hellScale); // Server -> Client - LoginPacket(const wstring& userName, int clientVersion, PlayerUID offlineXuid, PlayerUID onlineXuid, bool friendsOnlyUGC, DWORD ugcPlayersVersion, DWORD skinId, DWORD capeId, bool isGuest); // Client -> Server + LoginPacket(const wstring& userName, int clientVersion, PlayerUID uuid, bool friendsOnlyUGC, DWORD ugcPlayersVersion, DWORD skinId, DWORD capeId, bool isGuest); // Client -> Server virtual void read(DataInputStream *dis); virtual void write(DataOutputStream *dos); diff --git a/Minecraft.World/Packet.cpp b/Minecraft.World/Packet.cpp index 05bf932dcb..46051e6dc3 100644 --- a/Minecraft.World/Packet.cpp +++ b/Minecraft.World/Packet.cpp @@ -131,6 +131,11 @@ void Packet::staticCtor() map(166, true, true, false, false, typeid(XZPacket), XZPacket::create); map(167, false, true, false, false, typeid(GameCommandPacket), GameCommandPacket::create); + // Auth handshake packets + map(170, true, false, true, false, typeid(AuthSchemePacket), AuthSchemePacket::create); // server->client + map(171, false, true, false, false, typeid(AuthResponsePacket), AuthResponsePacket::create); // client->server + map(172, true, false, true, false, typeid(AuthResultPacket), AuthResultPacket::create); // server->client + map(200, true, false, true, false, typeid(AwardStatPacket), AwardStatPacket::create); map(201, true, true, false, false, typeid(PlayerInfoPacket), PlayerInfoPacket::create); // TODO New for 1.8.2 - Repurposed by 4J map(202, true, true, true, false, typeid(PlayerAbilitiesPacket), PlayerAbilitiesPacket::create); @@ -331,10 +336,9 @@ shared_ptr Packet::readPacket(DataInputStream *dis, bool isServer) // th id = dis->read(); if (id == -1) return nullptr; - // Track last few good packets for diagnosing TCP desync + // diagnostic: inspect in debugger on TCP desync static thread_local int s_lastIds[8] = {}; static thread_local int s_lastIdPos = 0; - static thread_local int s_packetCount = 0; if ((isServer && serverReceivedPackets.find(id) == serverReceivedPackets.end()) || (!isServer && clientReceivedPackets.find(id) == clientReceivedPackets.end())) { @@ -346,7 +350,6 @@ shared_ptr Packet::readPacket(DataInputStream *dis, bool isServer) // th s_lastIds[s_lastIdPos] = id; s_lastIdPos = (s_lastIdPos + 1) % 8; - s_packetCount++; packet->read(dis); // } @@ -388,30 +391,36 @@ void Packet::writePacket(shared_ptr packet, DataOutputStream *dos) // th void Packet::writeUtf(const wstring& value, DataOutputStream *dos) // throws IOException TODO 4J JEV, should this declare a throws? { -#if 0 - if (value.length() > Short::MAX_VALUE) + // Clamp string length to prevent short overflow and stream desynchronization + size_t len = value.length(); + if (len > 32767) len = 32767; + + dos->writeShort(static_cast(len)); + for (size_t i = 0; i < len; i++) { - throw new IOException(L"String too big"); + dos->writeChar(value[i]); } -#endif - - dos->writeShort(static_cast(value.length())); - dos->writeChars(value); } wstring Packet::readUtf(DataInputStream *dis, int maxLength) // throws IOException TODO 4J JEV, should this declare a throws? { short stringLength = dis->readShort(); - if (stringLength > maxLength || stringLength <= 0) + if (stringLength <= 0) { - return L""; - // throw new IOException( stream.str() ); + return L""; } - if (stringLength < 0) + if (stringLength > 32767) { - assert(false); - // throw new IOException(L"Received string length is less than zero! Weird string!"); + // Unreasonably large — stream is likely corrupted, don't try to consume + return L""; + } + if (stringLength > maxLength) + { + // Consume the string data to keep the stream synchronized + for (int i = 0; i < stringLength; i++) + dis->readChar(); + return L""; } wstring builder = L""; @@ -514,17 +523,26 @@ shared_ptr Packet::readItem(DataInputStream *dis) { shared_ptr item = nullptr; int id = dis->readShort(); - if (id >= 0 && id < 32000) // todo: should turn Item::ITEM_NUM_COUNT into a global define + if (id == -1) { - int count = dis->readByte(); - int damage = dis->readShort(); + // No item — sentinel value, no additional data to consume + return nullptr; + } + + // Always consume count, damage, and NBT tag to keep stream synchronized + int count = dis->readByte(); + int damage = dis->readShort(); + CompoundTag *tag = readNbt(dis); + if (id >= 0 && id < 32000) + { item = std::make_shared(id, count, damage); - // 4J Stu - Always read/write the tag - //if (Item.items[id].canBeDepleted() || Item.items[id].shouldOverrideMultiplayerNBT()) - { - item->tag = readNbt(dis); - } + item->tag = tag; + } + else + { + // Invalid item ID — data consumed but item discarded + delete tag; } return item; @@ -576,6 +594,13 @@ void Packet::writeNbt(CompoundTag *tag, DataOutputStream *dos) else { byteArray buff = NbtIo::compress(tag); + if (buff.length > 32767) + { + // NBT too large for wire format — write -1 (null) to keep stream in sync + dos->writeShort(-1); + delete[] buff.data; + return; + } dos->writeShort(static_cast(buff.length)); dos->write(buff); delete [] buff.data; diff --git a/Minecraft.World/PacketListener.cpp b/Minecraft.World/PacketListener.cpp index 09fbb05251..f1b0178844 100644 --- a/Minecraft.World/PacketListener.cpp +++ b/Minecraft.World/PacketListener.cpp @@ -491,3 +491,20 @@ void PacketListener::handleGameCommand(shared_ptr packet) { onUnhandledPacket( (shared_ptr ) packet); } + +// Auth handshake +void PacketListener::handleAuthScheme(shared_ptr packet) +{ + onUnhandledPacket( (shared_ptr ) packet); +} + +void PacketListener::handleAuthResponse(shared_ptr packet) +{ + onUnhandledPacket( (shared_ptr ) packet); +} + +void PacketListener::handleAuthResult(shared_ptr packet) +{ + onUnhandledPacket( (shared_ptr ) packet); +} + diff --git a/Minecraft.World/PacketListener.h b/Minecraft.World/PacketListener.h index f63b6bc7fd..ef36530b77 100644 --- a/Minecraft.World/PacketListener.h +++ b/Minecraft.World/PacketListener.h @@ -95,6 +95,11 @@ class LevelParticlesPacket; class UpdateAttributesPacket; class TileEditorOpenPacket; +// Auth handshake +class AuthSchemePacket; +class AuthResponsePacket; +class AuthResultPacket; + // 4J Added class CraftItemPacket; class TradeItemPacket; @@ -227,4 +232,9 @@ class PacketListener virtual void handleKickPlayer(shared_ptr packet); virtual void handleXZ(shared_ptr packet); virtual void handleGameCommand(shared_ptr packet); + + // Auth handshake + virtual void handleAuthScheme(shared_ptr packet); + virtual void handleAuthResponse(shared_ptr packet); + virtual void handleAuthResult(shared_ptr packet); }; diff --git a/Minecraft.World/Player.cpp b/Minecraft.World/Player.cpp index 00c7148e41..e050d40da7 100644 --- a/Minecraft.World/Player.cpp +++ b/Minecraft.World/Player.cpp @@ -138,9 +138,7 @@ Player::Player(Level *level, const wstring &name) : LivingEntity( level ) m_dwSkinId = 0; m_dwCapeId = 0; - // 4J Added m_xuid = INVALID_XUID; - m_OnlineXuid = INVALID_XUID; //m_bShownOnMaps = true; setShowOnMaps(app.GetGameHostOption(eGameHostOption_Gamertags)!=0?true:false); m_bIsGuest = false; @@ -759,16 +757,7 @@ unsigned int Player::getSkinAnimOverrideBitmask(DWORD skinId) return bitmask; } -void Player::setXuid(PlayerUID xuid) -{ - m_xuid = xuid; -#ifdef _XBOX_ONE - // 4J Stu - For XboxOne (and probably in the future all other platforms) we store a UUID for the player to use as the owner key for tamed animals - // This should just be a string version of the xuid - setUUID( xuid.toString() ); -#endif -} void Player::setCustomCape(DWORD capeId) { @@ -784,7 +773,7 @@ void Player::setCustomCape(DWORD capeId) } else { - MOJANG_DATA *pMojangData=app.GetMojangDataForXuid(getOnlineXuid()); + MOJANG_DATA *pMojangData=app.GetMojangDataForXuid(getXuid()); if(pMojangData) { // Cape @@ -898,7 +887,7 @@ void Player::ChangePlayerSkin() void Player::prepareCustomTextures() { - MOJANG_DATA *pMojangData=app.GetMojangDataForXuid(getOnlineXuid()); + MOJANG_DATA *pMojangData=app.GetMojangDataForXuid(getXuid()); if(pMojangData) { @@ -1094,11 +1083,6 @@ void Player::die(DamageSource *source) setPos(x, y, z); yd = 0.1f; - // 4J - TODO need to use a xuid - if ( app.isXuidNotch( m_xuid ) ) - { - drop(std::make_shared(Item::apple, 1), true); - } if (!level->getGameRules()->getBoolean(GameRules::RULE_KEEPINVENTORY)) { inventory->dropAll(); @@ -1297,6 +1281,12 @@ void Player::readAdditionalSaveData(CompoundTag *entityTag) // 4J Added m_uiGamePrivileges = entityTag->getInt(L"GamePrivileges"); + + if (entityTag->contains(L"GameUuidHi")) + { + m_xuid.hi = (uint64_t)entityTag->getLong(L"GameUuidHi"); + m_xuid.lo = (uint64_t)entityTag->getLong(L"GameUuidLo"); + } } void Player::addAdditonalSaveData(CompoundTag *entityTag) @@ -1328,6 +1318,12 @@ void Player::addAdditonalSaveData(CompoundTag *entityTag) // 4J Added entityTag->putInt(L"GamePrivileges",m_uiGamePrivileges); + if (m_xuid.isValid()) + { + entityTag->putLong(L"GameUuidHi", (int64_t)m_xuid.hi); + entityTag->putLong(L"GameUuidLo", (int64_t)m_xuid.lo); + } + } bool Player::openContainer(shared_ptr container) diff --git a/Minecraft.World/Player.h b/Minecraft.World/Player.h index 2e223a1e55..a6ce85a21a 100644 --- a/Minecraft.World/Player.h +++ b/Minecraft.World/Player.h @@ -8,6 +8,7 @@ using namespace std; #include "PlayerEnderChestContainer.h" #include "CommandSender.h" #include "ScoreHolder.h" +#include "GameUUID.h" class AbstractContainerMenu; class Stats; @@ -412,11 +413,9 @@ class Player : public LivingEntity, public CommandSender, public ScoreHolder static wstring getCapePathFromId(DWORD capeId); static unsigned int getSkinAnimOverrideBitmask(DWORD skinId); - // 4J Added - void setXuid(PlayerUID xuid); - PlayerUID getXuid() { return m_xuid; } - void setOnlineXuid(PlayerUID xuid) { m_OnlineXuid = xuid; } - PlayerUID getOnlineXuid() { return m_OnlineXuid; } + // Player identity — single 128-bit UUID + void setXuid(PlayerUID xuid) { m_xuid = xuid; } + PlayerUID getXuid() const { return m_xuid; } void setPlayerIndex(DWORD dwIndex) { m_playerIndex = dwIndex; } DWORD getPlayerIndex() { return m_playerIndex; } @@ -430,7 +429,6 @@ class Player : public LivingEntity, public CommandSender, public ScoreHolder virtual void sendMessage(const wstring& message, ChatPacket::EChatPacketMessage type = ChatPacket::e_ChatCustom, int customData = -1, const wstring& additionalMessage = L"") { } private: PlayerUID m_xuid; - PlayerUID m_OnlineXuid; protected: bool m_bShownOnMaps; diff --git a/Minecraft.World/PlayerIO.h b/Minecraft.World/PlayerIO.h index 2b13cab9c1..8aabcbbae7 100644 --- a/Minecraft.World/PlayerIO.h +++ b/Minecraft.World/PlayerIO.h @@ -7,7 +7,7 @@ using namespace std; class Player; -class PlayerIO +class PlayerIO { public: virtual void save(shared_ptr player) = 0; @@ -19,4 +19,4 @@ class PlayerIO virtual void saveMapIdLookup() = 0; virtual void deleteMapFilesForPlayer(shared_ptr player) = 0; virtual void saveAllCachedData() = 0; -}; \ No newline at end of file +}; diff --git a/Minecraft.World/PreLoginPacket.cpp b/Minecraft.World/PreLoginPacket.cpp index ddcfe19733..b7002b5e79 100644 --- a/Minecraft.World/PreLoginPacket.cpp +++ b/Minecraft.World/PreLoginPacket.cpp @@ -80,7 +80,7 @@ void PreLoginPacket::read(DataInputStream *dis) //throws IOException m_hostIndex = dis->readByte(); INT texturePackId = dis->readInt(); - m_texturePackId = *(DWORD *)&texturePackId; + m_texturePackId = static_cast(texturePackId); // Set the name of the map so we can check it for players banned lists app.SetUniqueMapName((char *)m_szUniqueSaveName); @@ -115,7 +115,9 @@ void PreLoginPacket::handle(PacketListener *listener) listener->handlePreLogin(shared_from_this()); } -int PreLoginPacket::getEstimatedSize() +int PreLoginPacket::getEstimatedSize() { - return 4 + 4 + static_cast(loginKey.length()) + 4 +14 + 4 + 1 + 4; + return 2 + 2 + static_cast(loginKey.length()) + 1 + 4 + 1 + + (static_cast(m_dwPlayerCount) * static_cast(sizeof(PlayerUID))) + + 14 + 4 + 1 + 4; } diff --git a/Minecraft.World/SharedConstants.h b/Minecraft.World/SharedConstants.h index a8924e47c0..9d10659869 100644 --- a/Minecraft.World/SharedConstants.h +++ b/Minecraft.World/SharedConstants.h @@ -7,7 +7,7 @@ class SharedConstants public: static void staticCtor(); static const wstring VERSION_STRING; - static const int NETWORK_PROTOCOL_VERSION = 78; + static const int NETWORK_PROTOCOL_VERSION = 80; // Bumped: unified single UUID per player on the wire static const bool INGAME_DEBUG_OUTPUT = false; // NOT texture resolution. How many sub-blocks each block face is made up of. diff --git a/Minecraft.World/cmake/sources/Common.cmake b/Minecraft.World/cmake/sources/Common.cmake index 8a4d683303..e199d7e1cb 100644 --- a/Minecraft.World/cmake/sources/Common.cmake +++ b/Minecraft.World/cmake/sources/Common.cmake @@ -1,5 +1,6 @@ set(_MINECRAFT_WORLD_COMMON_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/EntityDiagram.cd" + "${CMAKE_CURRENT_SOURCE_DIR}/resource.h" ) source_group("" FILES ${_MINECRAFT_WORLD_COMMON_ROOT}) @@ -151,6 +152,8 @@ set(_MINECRAFT_WORLD_COMMON_NET_MINECRAFT "${CMAKE_CURRENT_SOURCE_DIR}/Direction.h" "${CMAKE_CURRENT_SOURCE_DIR}/Facing.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Facing.h" + "${CMAKE_CURRENT_SOURCE_DIR}/GameUUID.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/GameUUID.h" "${CMAKE_CURRENT_SOURCE_DIR}/Pos.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/Pos.h" "${CMAKE_CURRENT_SOURCE_DIR}/SharedConstants.cpp" @@ -245,6 +248,12 @@ set(_MINECRAFT_WORLD_COMMON_NET_MINECRAFT_NETWORK source_group("net/minecraft/network" FILES ${_MINECRAFT_WORLD_COMMON_NET_MINECRAFT_NETWORK}) set(_MINECRAFT_WORLD_COMMON_NET_MINECRAFT_NETWORK_PACKET + "${CMAKE_CURRENT_SOURCE_DIR}/AuthResponsePacket.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/AuthResponsePacket.h" + "${CMAKE_CURRENT_SOURCE_DIR}/AuthResultPacket.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/AuthResultPacket.h" + "${CMAKE_CURRENT_SOURCE_DIR}/AuthSchemePacket.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/AuthSchemePacket.h" "${CMAKE_CURRENT_SOURCE_DIR}/AddEntityPacket.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/AddEntityPacket.h" "${CMAKE_CURRENT_SOURCE_DIR}/AddExperienceOrbPacket.cpp" diff --git a/Minecraft.World/net.minecraft.network.packet.h b/Minecraft.World/net.minecraft.network.packet.h index c1d52d1473..260023d5d5 100644 --- a/Minecraft.World/net.minecraft.network.packet.h +++ b/Minecraft.World/net.minecraft.network.packet.h @@ -109,3 +109,8 @@ #include "XZPacket.h" #include "GameCommandPacket.h" +// Auth handshake +#include "AuthSchemePacket.h" +#include "AuthResponsePacket.h" +#include "AuthResultPacket.h" + diff --git a/Minecraft.World/resource.h b/Minecraft.World/resource.h new file mode 100644 index 0000000000..d7fc1ae259 --- /dev/null +++ b/Minecraft.World/resource.h @@ -0,0 +1,14 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Minecraft.World.rc + +// Valori predefiniti successivi per i nuovi oggetti +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 101 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/Minecraft.World/x64headers/extraX64.h b/Minecraft.World/x64headers/extraX64.h index 4af047b5b9..acfac0016a 100644 --- a/Minecraft.World/x64headers/extraX64.h +++ b/Minecraft.World/x64headers/extraX64.h @@ -5,7 +5,7 @@ #include #include "..\..\Minecraft.Client\SkinBox.h" - +#include "..\GameUUID.h" #include @@ -63,7 +63,7 @@ typedef ULONGLONG SessionID; typedef ULONGLONG GameSessionUID; typedef DQRNetworkManager::SessionInfo INVITE_INFO; #else -typedef ULONGLONG PlayerUID; +typedef GameUUID PlayerUID; typedef ULONGLONG SessionID; typedef PlayerUID GameSessionUID; class INVITE_INFO; @@ -205,7 +205,7 @@ class IQNetPlayer bool IsHost(); bool IsGuest(); bool IsLocal(); - PlayerUID GetXuid(); + PlayerUID GetXuid(); LPCWSTR GetGamertag(); int GetSessionIndex(); bool IsTalking(); @@ -344,11 +344,8 @@ void XSetThreadProcessor(HANDLE a, int b); const int QNET_SENDDATA_LOW_PRIORITY = 0; const int QNET_SENDDATA_SECONDARY = 0; -#if defined(__PS3__) || defined(__ORBIS__) || defined(_DURANGO) || defined(__PSVITA__) -#define INVALID_XUID PlayerUID() -#else -const int INVALID_XUID = 0; -#endif +// Legacy alias — use INVALID_UUID from GameUUID.h +#define INVALID_XUID INVALID_UUID // const int MOJANG_DATA = 0; // typedef struct _STRING_VERIFY_RESPONSE diff --git a/cmake/CopyAssets.cmake b/cmake/CopyAssets.cmake index a78c9170cf..4bd14ffbb7 100644 --- a/cmake/CopyAssets.cmake +++ b/cmake/CopyAssets.cmake @@ -14,8 +14,8 @@ function(setup_asset_folder_copy TARGET_NAME ASSET_FOLDER_PAIRS) ) # Global folder exclusions applied to every folder copy + # Graphics/ must not be excluded — NativeUI loads PNGs from disk set(ASSET_EXCLUDE_FOLDERS - "Graphics" ) # Exclude platform-specific arc media files diff --git a/cmake/CopyFileScript.cmake b/cmake/CopyFileScript.cmake index a6b2f8397b..c14e4c4c9c 100644 --- a/cmake/CopyFileScript.cmake +++ b/cmake/CopyFileScript.cmake @@ -4,6 +4,8 @@ # COPY_SOURCE – pipe-separated list of source file paths # COPY_DEST – destination directory +cmake_policy(SET CMP0057 NEW) + if(NOT COPY_SOURCE OR NOT COPY_DEST) message(FATAL_ERROR "COPY_SOURCE and COPY_DEST must be set.") endif() diff --git a/docs/auth-system.md b/docs/auth-system.md new file mode 100644 index 0000000000..454ce9b314 --- /dev/null +++ b/docs/auth-system.md @@ -0,0 +1,359 @@ +# Authentication System - MinecraftConsoles + +## Overview + +3-packet handshake between client and server, after PreLogin and before LoginPacket. Three schemes: **Mojang** (online verification via sessionserver.mojang.com), **ely.by** (online verification via authserver.ely.by), and **offline** (server-generated UUID from username). + +Replaces the old system that relied solely on console platform XUIDs (Xbox Live, PSN) and had no identity verification for the Windows x64 build. + +--- + +## Packet Architecture + +| Packet | ID | Direction | Description | +|---|---|---|---| +| `AuthSchemePacket` | 170 | Server -> Client | Server offers auth schemes + challenge | +| `AuthResponsePacket` | 171 | Client -> Server | Client picks a scheme and sends credentials | +| `AuthResultPacket` | 172 | Server -> Client | Server confirms/rejects + assigns UUID and skin | + +The `LoginPacket` serves as the handshake acknowledgement — no dedicated ACK packet needed. + +### Handshake Flow + +``` +CLIENT SERVER + | | + | ---- PreLoginPacket (netcode version) ----> | + | <--- PreLoginPacket (UGC, save info) ------ | + | | + | <--- AuthSchemePacket (170) --------------- | schemes=["mojang"|"elyby"|"offline"], serverId="a1b2c3..." + | | + | ---- AuthResponsePacket (171) ------------> | scheme="mojang", uuid, username + | | + | [Server verifies with Mojang on a | + | background thread + downloads skin] | + | | + | <--- AuthResultPacket (172) --------------- | success=true, assignedUuid, assignedUsername, skinData + | | + | ---- LoginPacket (1) ---------------------> | Completes auth handshake + | | + | <--- LoginPacket (1) --------------------- | Server responds with world data + | | +``` + +--- + +## Packet Details + +### AuthSchemePacket (ID 170) + +**Direction:** Server -> Client + +Sent right after PreLogin response. Contains supported auth schemes and a random challenge. + +```cpp +class AuthSchemePacket : public Packet { + vector schemes; // "mojang", "elyby", "offline" + wstring serverId; // random hex challenge (20 chars) +}; +``` + +**Wire format:** +| Field | Type | Notes | +|---|---|---| +| `count` | int32 | Number of schemes (max 16) | +| `schemes[i]` | UTF string | Scheme name (max 32 chars) | +| `serverId` | UTF string | Hex challenge (max 64 chars) | + +**Read validation:** +- Scheme count capped at 16 +- Only `"mojang"`, `"elyby"`, and `"offline"` accepted; unknown strings silently discarded + +**Server behavior:** +- `onlineMode=true`: offers only `["mojang"]` +- `onlineMode=true, authProvider="mojang"`: offers `["mojang"]` +- `onlineMode=true, authProvider="elyby"`: offers `["elyby"]` +- `onlineMode=false`: offers `["mojang", "offline"]` + +--- + +### AuthResponsePacket (ID 171) + +**Direction:** Client -> Server + +Client picks a scheme and sends credentials. + +```cpp +class AuthResponsePacket : public Packet { + wstring chosenScheme; // "mojang", "elyby", or "offline" + wstring mojangUuid; // dashed 128-bit UUID + wstring username; // player username (max 16 chars) +}; +``` + +**Wire format:** +| Field | Type | Notes | +|---|---|---| +| `chosenScheme` | UTF string | max 32 chars, validated against known values | +| `mojangUuid` | UTF string | max 64 chars | +| `username` | UTF string | max 16 chars | + +**Read validation:** +- Scheme reset to `""` if not `"mojang"`, `"elyby"`, or `"offline"` +- Username validated char by char: only `[a-zA-Z0-9_]`. Invalid char clears the entire username + +**Client side ("mojang" scheme):** +1. Calls `MCAuth::JoinServer(token, uuid, serverId)` to `sessionserver.mojang.com/session/minecraft/join` +2. On success, sends AuthResponsePacket with scheme `"mojang"` +3. On failure, disconnects with `eDisconnect_AuthFailed` + +**Client side ("elyby" scheme):** +1. Calls `MCAuth::ElybyJoinServer(token, uuid, serverId)` to `authserver.ely.by/session/minecraft/join` +2. On success, sends AuthResponsePacket with scheme `"elyby"` +3. On failure, disconnects with `eDisconnect_AuthFailed` + +**Client side ("offline" scheme):** +1. Sends AuthResponsePacket directly with UUID and username from the local session + +--- + +### AuthResultPacket (ID 172) + +**Direction:** Server -> Client + +Server communicates the verification result and assigns the player's final identity. + +```cpp +class AuthResultPacket : public Packet { + bool success; + wstring assignedUuid; // final UUID assigned by the server + wstring assignedUsername; // final username + wstring errorMessage; // empty if success=true + wstring skinKey; // memory-texture key (e.g. "mojang_skin_{uuid}.png") + vector skinData; // raw PNG skin bytes +}; +``` + +**Wire format:** +| Field | Type | Notes | +|---|---|---| +| `success` | bool | 1 byte | +| `assignedUuid` | UTF string | max 64 chars | +| `assignedUsername` | UTF string | max 64 chars | +| `errorMessage` | UTF string | max 256 chars | +| `skinKey` | UTF string | max 256 chars | +| `skinSize` | int32 | skin data length | +| `skinData` | byte[] | raw PNG (max 32KB) | + +**Skin validation:** +- Max 32KB (a 64x64 RGBA skin PNG is ~4KB compressed) +- Excess bytes consumed to keep stream synchronized but not stored +- Client validates with `MCAuth::ValidateSkinPng()` (PNG magic, 64x32 or 64x64 dimensions) + +--- + +## Server-Side Verification Flow + +### "mojang" Scheme (Online Mode) + +``` +PendingConnection::handleAuthResponse() + | + +--> m_authState = eAuth_Verifying + +--> Spawns detached std::thread: + | + +--> MCAuth::HasJoined(username, serverId) + | [GET sessionserver.mojang.com/session/minecraft/hasJoined] + | + +--> On success: MCAuth::FetchSkinPng(skinUrl) + | [Downloads skin PNG from Mojang CDN] + | + +--> Writes result to AuthVerifyResult (mutex-protected) + +--> ready.store(true) + +PendingConnection::tick() [main thread] + | + +--> Checks m_authVerifyResult->ready + +--> On success: + | +--> Sends AuthResultPacket(success=true, uuid, username, skinKey, skinData) + | +--> m_authState = eAuth_WaitingAck + | + +--> On failure: + +--> Sends AuthResultPacket(success=false, errorMessage) + +--> Disconnects client +``` + +### "offline" Scheme + +``` +PendingConnection::handleAuthResponse() + | + +--> Checks that server->onlineMode == false + +--> Generates offline UUID: GameUUID::generateOffline(username) + | [UUID v3, namespace "OfflinePlayer:" + username] + +--> Sends AuthResultPacket(success=true, offlineUuid, username) + +--> m_authState = eAuth_WaitingAck +``` + +In offline mode, the server generates the UUID itself from the username. The client-sent UUID is ignored, preventing UUID forgery. + +--- + +## State Machine (Server - PendingConnection) + +``` +eAuth_None + | + +--> sendPreLoginResponse() sends AuthSchemePacket + | + v +eAuth_WaitingResponse + | + +--> handleAuthResponse() received + | + v +eAuth_Verifying (only for "mojang" scheme) + | + +--> tick() detects result ready + | + v +eAuth_WaitingAck + | + +--> handleLogin() received + | + v +eAuth_Done + | + +--> handleLogin() proceeds normally +``` + +If a `LoginPacket` arrives in state `eAuth_None`, `eAuth_WaitingResponse`, or `eAuth_Verifying`, the connection is closed with `eDisconnect_AuthFailed`. + +--- + +## Channel Security + +The game protocol is **plaintext** — no AES, no TLS on the game socket. `SharedKeyPacket` (vanilla Java's encryption setup) is disabled. This is fine because no secrets travel over the game channel. + +What travels on the game socket (plaintext): +- UUID, username, skin PNG, serverId challenge, gamestate + +What **never** travels on the game socket: +- Access tokens, refresh tokens, session tokens + +The Mojang access token stays local on the client. It's sent only to `sessionserver.mojang.com` via HTTPS (`WINHTTP_FLAG_SECURE` in `MCAuthHttp.cpp`). The game server never sees it — it verifies the player by calling `HasJoined` on Mojang's side, also over HTTPS. + +The `serverId` challenge is random per connection (20 hex chars, generated in `PendingConnection`). A captured serverId is useless — Mojang consumes the `(username, serverId)` binding on the first `JoinServer` call. Replay is not possible. + +On console platforms (Xbox/QNet, PS/SQR), the transport layer provides its own integrity. On Windows64 it's raw TCP — an attacker on the same network can see gamestate (positions, chat, inventory), but that's a cheat/sniffing concern, not an auth one. Same situation as vanilla Java pre-1.7. + +--- + +## Mojang Skin Handling + +The server downloads skins and distributes them inline: + +1. Server downloads the skin during verification (background thread) +2. AuthResultPacket contains the raw PNG bytes +3. Client registers the texture in memory via `app.AddMemoryTextureFile()` +4. Server distributes the skin to all connected clients via `TextureAndGeometryPacket` +5. New clients receive existing skins at join time + +--- + +## MCAuth Library + +The authentication backend lives in `MCAuth/`. + +### JavaAuthManager +- Login via Device Code Flow (OAuth 2.0) — user visits `microsoft.com/link` +- Automatic token refresh +- Token save/load to disk (JSON) + +### Verification Functions +| Function | Usage | +|---|---| +| `JoinServer()` | Client: POST to sessionserver to announce join | +| `HasJoined()` | Server: GET to sessionserver to verify identity | +| `FetchSkinPng()` | Downloads skin PNG from CDN | +| `FetchProfileSkinUrl()` | Gets skin URL from Mojang profile | +| `ValidateSkinPng()` | Validates PNG (magic bytes + 64x32 or 64x64 dimensions) | + +### UUID Utilities +| Function | Usage | +|---|---| +| `GenerateOfflineUuid()` | UUID v3 from username (vanilla-compatible) | +| `DashUuid()` / `UndashUuid()` | UUID format conversion | +| `MakeSkinKey()` | Generates memory texture key (e.g. `"mojang_skin_069a79f4.png"`) | + +### MCAuthManager (Singleton) +- Multi-account with splitscreen support (up to `XUSER_MAX_COUNT` slots) +- Each slot has its own `JavaAuthManager` and independent session +- Background workers: detached threads capturing `shared_ptr`, invalidated by generation counter +- Cancellation via `RequestCancel()` +- Account persistence in `java_accounts.json` + +--- + +## Thread Safety + +### AuthVerifyResult (PendingConnection) +```cpp +struct AuthVerifyResult { + std::atomic ready{false}; + std::atomic cancelled{false}; + std::mutex mutex; + bool success; + std::string username, uuid, skinUrl; + std::vector skinData; +}; +``` + +- Background thread writes under mutex, then `ready.store(true, memory_order_release)` +- Main thread checks `ready` with `memory_order_acquire`, then reads under mutex +- `cancelled` flag lets PendingConnection destructor abort unnecessary work +- `shared_ptr` prevents use-after-free if PendingConnection is destroyed before the thread finishes + +### MCAuthManager Slots +- Each slot has its own mutex +- Atomic `generation` counter invalidates stale workers +- Workers capture `shared_ptr` by value — if the slot is reset, the old engine stays alive until the thread exits + +--- + +## Files + +### Packets (Minecraft.World/) +| File | Description | +|---|---| +| `AuthSchemePacket.h/.cpp` | Scheme offer packet | +| `AuthResponsePacket.h/.cpp` | Client response packet | +| `AuthResultPacket.h/.cpp` | Result packet + skin data | +| `DisconnectPacket.h` | `eDisconnect_AuthFailed = 31` | +| `PacketListener.h` | Auth packet handlers | +| `GameUUID.h/.cpp` | UUID utilities (fromDashed, generateOffline) | + +### Server (Minecraft.Client/) +| File | Description | +|---|---| +| `PendingConnection.h/.cpp` | Auth state machine, Mojang verification, skin distribution | +| `PlayerList.cpp` | Ban check against auth UUID | + +### Client (Minecraft.Client/) +| File | Description | +|---|---| +| `ClientConnection.h/.cpp` | handleAuthScheme, handleAuthResult, sendLoginPacketAfterAuth | +| `UIScene_MSAuth.h/.cpp` | Account management UI (list, device code, offline) | + +### Auth Library (MCAuth/) +| File | Description | +|---|---| +| `MCAuth.h` | Public API: login, verification, skin, UUID | +| `MCAuthManager.h` | Multi-account singleton with splitscreen support | +| `MCAuth.cpp` | UUID utilities, skin validation | +| `MCAuthJava.cpp` | Java Edition auth flow (MSA -> XBL -> Mojang) | +| `MCAuthElyby.cpp` | Ely.by Yggdrasil auth (login, refresh, validate, token persistence) | +| `MCAuthSession.cpp` | Session verification (JoinServer, HasJoined, Elyby variants) | +| `MCAuthHttp.cpp/.h` | HTTP client (WinHTTP) | +| `MCAuthCrypto.cpp/.h` | Base64, SHA, JSON parsing | +| `MCAuthPlatform.cpp` | File paths, persistence | diff --git a/include/Common/BuildVer.h b/include/Common/BuildVer.h index eaa77d26ac..beef61ff85 100644 --- a/include/Common/BuildVer.h +++ b/include/Common/BuildVer.h @@ -1,7 +1,7 @@ #pragma once #define VER_PRODUCTBUILD 560 -#define VER_PRODUCTVERSION_STR_W L"DEV (unknown version)" +#define VER_PRODUCTVERSION_STR_W L"f896332-dev" #define VER_FILEVERSION_STR_W VER_PRODUCTVERSION_STR_W -#define VER_BRANCHVERSION_STR_W L"UNKNOWN BRANCH" +#define VER_BRANCHVERSION_STR_W L"MrTheShy/MinecraftConsoles/main" #define VER_NETWORK VER_PRODUCTBUILD