MinecraftConsoles/Minecraft.Client/Common/UI/UIScene_MSAuth.cpp
MrTheShy 4f2352361a Add Mojang/Ely.by/offline authentication system
The old Windows64 port had no real player identity — it used hardcoded
fake XUIDs, so anyone could impersonate anyone. This replaces that with
proper auth supporting Mojang, Ely.by, and offline accounts.

MCAuth library (new, MCAuth/):
  Mojang auth via MSA device code flow (XBL, SISU, MC services),
  Ely.by via Yggdrasil with 2FA, offline UUID generation matching
  Java Edition (MD5 v3 from "OfflinePlayer:<name>"). Multi-account
  manager with background token refresh, per-slot sessions, and
  on-disk token persistence. Server-side session verification via
  Mojang/Ely.by hasJoined API. Skin fetching and PNG validation
  from texture servers.

Network protocol (version bumped to 80):
  Three new packets (AuthScheme, AuthResponse, AuthResult) implement
  a server-driven auth handshake before login completes. Player
  identity migrated from 64-bit XUID to 128-bit GameUUID backed by
  two uint64 fields (hi/lo). readPlayerUID/writePlayerUID now
  serialize 16 bytes on the wire. Old and new clients cannot connect
  to each other — version mismatch is rejected at PreLogin.

Save migration:
  Map data mappings auto-migrate from old format: the old 64-bit
  XUID is placed in hi, lo is set to 0 as a sentinel. On first
  access by the real player, the sentinel entry is upgraded in-place
  to the full 128-bit UUID. Format detection is by file size (2080,
  2112, or 4160 bytes). Player .dat filenames inside saveData.ms
  change from decimal XUID to dashed UUID — old saves need manual
  entry renaming in the archive.

UI:
  NativeUIRenderer: immediate-mode drawing system (quads, text,
  9-slice panels, scrollbars, focus lists) for rendering auth
  screens without Flash/Scaleform. UIScene_MSAuth handles device
  code display, Ely.by credential input with 2FA, per-account
  skin head preview, and multi-account add/remove/switch.

Server:
  online-mode and auth-provider (mojang/elyby) in server.properties.
  Whitelist and ban checks validate against the server-verified UUID.
  Incompatible auth scheme logs which provider the server expects
  vs what the client is using.

Also fixes a pre-existing exploit where any client could send a
DebugOptionsPacket to grant themselves CraftAnything and other debug
privileges on any server — now requires OP status server-side.
2026-03-23 01:14:23 +01:00

1713 lines
64 KiB
C++

#include "stdafx.h"
#include "UI.h"
#include "UIScene_MSAuth.h"
#include "NativeUIRenderer.h"
#include "../../../MCAuth/include/MCAuthManager.h"
#include "../../../MCAuth/include/MCAuth.h"
#include <thread>
#include <fstream>
#include <sys/stat.h>
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<int*>(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<SkinEntry>();
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<const char*>(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<AuthFlags>();
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<std::shared_ptr<UIScene_MSAuth::PendingKeyboardResult>*>(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<wchar_t*>(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<std::string, std::shared_ptr<UIScene_MSAuth::SkinEntry>>;
// 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<MCAuthManager::JavaAccountInfo>& 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<int>(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<PendingKeyboardResult>();
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<PendingKeyboardResult>(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<PendingKeyboardResult>();
UIKeyboardInitData kbData;
kbData.title = L"2FA Code";
kbData.defaultText = L"";
kbData.maxChars = 10;
kbData.callback = &MSAuthKeyboardCallback;
kbData.lpParam = new std::shared_ptr<PendingKeyboardResult>(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<PendingKeyboardResult>();
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<PendingKeyboardResult>(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