5 Commits
Author SHA1 Message Date
paste fe78e6141b add readme 2026-07-17 00:13:13 -05:00
paste 09475b4698 Save CharacterOrder on CHARACTER_LIST_UPDATE 2026-07-17 00:13:13 -05:00
paste 51c987ccf2 fixed wrong character being selected upon logout 2026-07-17 00:13:13 -05:00
paste b460fb2be7 Add MPQ release pipeline (packaged by mpq-packager on v* tag) 2026-07-17 00:13:13 -05:00
paste 9f387a94f1 Add account manager, character reordering, and glue Lua console
Three features layered onto the Turtle WoW baseline:

- Account manager (AccountLogin.lua / .xml): saved-account list backed
  by Windows Credential Manager via ClassicAPI. Pass-through to
  LoginWithSavedAccount; per-account 'last used' timestamp; password
  field hides itself when the typed name matches a saved entry and a
  'Change password' button reveals it on demand.
- Character reordering (CharacterSelect.lua / .xml): drag-to-reorder
  the character list, persisted via ClassicAPI's
  GetSavedCharacterOrder / SetSavedCharacterOrder.
- Glue Lua console (GlueConsole.lua / .xml, new files): in-glue RunScript
  console reachable from the title and char-select screens.

Currently no key opens the console — the backtick binding was removed
pending a ClassicAPI EditBox arrow/key script handler (see TODO.md section 96).
2026-07-17 00:13:13 -05:00
9 changed files with 2109 additions and 227 deletions
+63
View File
@@ -0,0 +1,63 @@
# Build patch-Z.mpq from this repo and attach it to a Gitea release on a v* tag.
# Packaged by paste/mpq-packager (pinned @v1).
#
# octowow.st is served under /git but Gitea generates URLs without it, which
# breaks actions/checkout and `uses:` resolution; and bind mounts don't work
# under act_runner (inner docker uses the host daemon). So we clone manually
# and run the packager via docker build + docker cp. Simplify once the server
# ROOT_URL is fixed.
name: Release MPQ
on:
push:
tags:
- "v*"
jobs:
package:
runs-on: ubuntu-latest
steps:
- name: Checkout (manual; instance under /git)
run: |
git init -q .
git remote add origin "https://gitea:${{ secrets.GITHUB_TOKEN }}@octowow.st/git/${{ github.repository }}.git"
git fetch -q --depth 1 origin "${{ github.ref }}"
git checkout -q FETCH_HEAD
- name: Build packager image
run: |
git clone -q --depth 1 --branch v1 \
"https://gitea:${{ secrets.GITHUB_TOKEN }}@octowow.st/git/paste/mpq-packager.git" /tmp/packager
docker build -q -t mpq-packager /tmp/packager
- name: Build patch MPQ
run: |
set -euo pipefail
cid=$(docker create \
-e INPUT_MANIFEST=mpq.yaml \
-e INPUT_VERSION=${{ github.ref_name }} \
-e INPUT_OUT_DIR=/work/dist \
-e GITHUB_WORKSPACE=/work \
mpq-packager)
docker cp . "$cid:/work"
docker start -a "$cid"
docker cp "$cid:/work/dist/patch-Z.mpq" ./patch-Z.mpq
docker rm "$cid" >/dev/null
ls -l patch-Z.mpq
- name: Create release and upload asset
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
API="https://octowow.st/git/api/v1/repos/${{ github.repository }}"
TAG="${{ github.ref_name }}"
RID=$(curl -fsSL -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
curl -fsSL -X POST "$API/releases/$RID/assets?name=patch-Z.mpq" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@patch-Z.mpq"
echo "Released $TAG with patch-Z.mpq"
+373 -58
View File
@@ -1,3 +1,254 @@
-- Autologin: account selection list backed by Windows Credential
-- Manager via ClassicAPI. Passwords live encrypted in the OS
-- credential vault and are never exposed to Lua — `LoginWithSavedAccount`
-- dispatches the login from C so plaintext never crosses the
-- C↔Lua boundary on the way out.
--
-- `GetSavedAccountName` / `SetSavedAccountName` now holds just the
-- last-used account name (no password, no character map). On
-- `Autologin_Load` we use it to pre-select the same account in the
-- list. The first save after this update overwrites whatever the
-- legacy plaintext-password format left in WTF.
Autologin_Table = {};
Autologin_SelectedIdx = nil;
Autologin_CurrentPage = 0;
Autologin_PageSize = 4;
Autologin_LimitReached = false;
-- When the typed account name matches a saved vault entry we hide the
-- password field (and slide the Login button up) since LoginWithSavedAccount
-- will use the vault credential anyway. PASSWORD_FIELD_FORCED lets the
-- "Change password" button re-reveal the field without changing the name
-- — once true, only an account re-selection or a fresh OnShow resets it.
Autologin_PasswordFieldForced = false;
local function HasSavedPassword(name)
if ( not name or name == "" ) then return false; end
local accounts = GetSavedAccounts();
for i = 1, table.getn(accounts) do
-- GetSavedAccounts now returns { {name=..., lastUsed=...}, ... }.
if ( accounts[i].name == name ) then return true; end
end
return false;
end
-- Best-effort extraction of an account name from whatever's currently
-- in GetSavedAccountName(). Under the new scheme we write just the
-- bare name, but legacy installs have "NAME PASSWORD CHAR;..." — we
-- accept either by taking the first non-space token before any space
-- or semicolon. Returns nil if nothing usable.
local function ParseLastAccount(raw)
if ( not raw or raw == "" ) then return nil; end
local _s, _e, n = string.find(raw, "^([^;%s]+)");
return n;
end
function Autologin_Load()
Autologin_Table = {};
local lastUsedName = GetSavedAccountName()
-- GetSavedAccounts() now returns { {name=..., lastUsed=epochSeconds}, ... }
-- where lastUsed is the Windows-tracked LastWritten timestamp on
-- the vault entry (auto-refreshed by LoginWithSavedAccount).
local accounts = GetSavedAccounts();
Autologin_SelectedIdx = nil;
for i = 1, table.getn(accounts) do
local entry = accounts[i];
table.insert(Autologin_Table, {
name = entry.name,
lastUsed = entry.lastUsed or 0,
});
if entry.name == lastUsedName then Autologin_SelectedIdx = i end
end
if ( Autologin_SelectedIdx ) then
Autologin_CurrentPage =
math.floor((Autologin_SelectedIdx - 1) / Autologin_PageSize);
else
Autologin_CurrentPage = 0;
end
end
-- `name` is the new last-used account, or "" to forget. Always-overwrite
-- semantics mean any leftover plaintext from the legacy format is
-- replaced on the first successful login after this update ships.
function Autologin_Save(name)
SetSavedAccountName(name or "");
end
function Autologin_SelectAccount(idx)
local i = Autologin_CurrentPage * Autologin_PageSize + idx;
local r = Autologin_Table[i];
if ( not r ) then return; end
Autologin_PasswordFieldForced = false;
AccountLoginAccountEdit:SetText(r.name);
-- Clear the password field — we no longer have plaintext to
-- repopulate it with. Empty + a known account name signals
-- "use the saved credential" in Autologin_OnLogin below.
AccountLoginPasswordEdit:SetText("");
-- Don't rely on SetText → OnTextChanged → Autologin_OnNameUpdate
-- firing — call directly so the visible highlight reliably moves
-- to the clicked row. OnNameUpdate is idempotent if the chain
-- does fire afterward.
Autologin_OnNameUpdate(r.name);
end
function Autologin_OnNameUpdate(name)
Autologin_SelectedIdx = nil;
for i = 1, table.getn(Autologin_Table) do
if ( Autologin_Table[i].name == name ) then Autologin_SelectedIdx = i; end
end
if ( Autologin_SelectedIdx ) then
Autologin_CurrentPage = math.floor((Autologin_SelectedIdx - 1) / Autologin_PageSize);
end
Autologin_UpdateUI();
Autologin_UpdatePasswordVisibility();
end
-- Show the password edit only when we need it: either the typed name
-- isn't a saved vault entry, or the user clicked "Change password" to
-- force the field open. When hidden, the Login button slides up into
-- the password edit's slot so the form doesn't have a gap.
function Autologin_UpdatePasswordVisibility()
local name = AccountLoginAccountEdit:GetText() or "";
if ( Autologin_PasswordFieldForced or not HasSavedPassword(name) ) then
AccountLoginPasswordEdit:Show();
AccountLoginLoginButton:ClearAllPoints();
AccountLoginLoginButton:SetPoint("TOP", 8, -519);
AutologinChangePasswordButton:Hide();
else
AccountLoginPasswordEdit:SetText("");
AccountLoginPasswordEdit:Hide();
AccountLoginLoginButton:ClearAllPoints();
AccountLoginLoginButton:SetPoint("TOP", AccountLoginPasswordEdit, "TOP", 0, 0);
AutologinChangePasswordButton:Show();
end
end
function Autologin_ChangePassword()
Autologin_PasswordFieldForced = true;
Autologin_UpdatePasswordVisibility();
AccountLogin_FocusPassword();
end
-- Formats `epochSeconds` as a "Last used: <date>" line for the row's
-- second text slot. Falls back to "Never" for entries with no
-- timestamp (theoretically only possible for credentials saved by
-- an older build of this DLL that didn't preserve LastWritten).
local function FormatLastUsed(epochSeconds)
if ( not epochSeconds or epochSeconds == 0 ) then
return "Last used: never";
end
return "Last used: " .. date("%m/%d/%Y", epochSeconds);
end
function Autologin_UpdateUI()
local skip = Autologin_CurrentPage * Autologin_PageSize;
for i = 1, Autologin_PageSize do
getglobal("AutologinAccountButton" .. i):UnlockHighlight();
if ( skip + i > table.getn(Autologin_Table) ) then
getglobal("AutologinAccountButton" .. i):Hide();
else
local r = Autologin_Table[skip + i];
getglobal("AutologinAccountButton" .. i):Show();
getglobal("AutologinAccountButton" .. i .. "ButtonTextName"):SetText(r.name);
getglobal("AutologinAccountButton" .. i .. "ButtonTextPassword"):SetText(
FormatLastUsed(r.lastUsed));
-- Character autoselect was removed alongside the
-- plaintext store; the row's character line is always
-- blank now.
getglobal("AutologinAccountButton" .. i .. "ButtonTextCharacter"):SetText("");
if ( Autologin_SelectedIdx == skip + i ) then
getglobal("AutologinAccountButton" .. i):LockHighlight();
end
end
end
-- AutologinSizeWarning was tied to the 128-byte SavedAccount cap
-- for the legacy "name pw char;name pw char;..." format. With
-- only the last-used account name persisted, the cap is
-- effectively unreachable; keep the widget hidden.
getglobal("AutologinSizeWarning"):Hide();
end
function Autologin_OnLogin()
local name = AccountLoginAccountEdit:GetText();
local password = AccountLoginPasswordEdit:GetText();
if ( name == nil or name == "" ) then return; end
if ( password ~= nil and password ~= "" ) then
-- New credentials or overwrite of an existing entry.
-- SaveAccount + LoginWithSavedAccount means the plaintext
-- password lives in this Lua scope only briefly, and is
-- never persisted to SavedVariables.
SaveAccount(name, password);
Autologin_Save(name);
Autologin_Load();
Autologin_UpdateUI();
LoginWithSavedAccount(name);
elseif ( HasSavedPassword(name) ) then
-- Selected a saved account and didn't retype: use the
-- vault entry directly.
Autologin_Save(name);
Autologin_Load();
Autologin_UpdateUI();
LoginWithSavedAccount(name);
end
-- Empty password + unknown account = no-op; the engine would
-- have shown an error dialog, but skipping the call keeps the
-- user on the login screen without an interrupting popup.
end
function AutologinAccountButton_OnClick()
Autologin_SelectAccount(this:GetID());
end
function AutologinAccountButton_OnDoubleClick()
Autologin_SelectAccount(this:GetID());
AccountLogin_Login();
end
function Autologin_RemoveAccount()
if ( not Autologin_SelectedIdx ) then return; end
local removed = Autologin_Table[Autologin_SelectedIdx];
if ( removed ) then
DeleteAccount(removed.name);
-- If the removed account was also the last-used, forget it
-- so next launch doesn't try to pre-select a missing entry.
if ( ParseLastAccount(GetSavedAccountName()) == removed.name ) then
Autologin_Save("");
end
end
-- Rebuild from the authoritative list (the vault).
Autologin_Load();
Autologin_PasswordFieldForced = false;
AccountLoginAccountEdit:SetText("");
AccountLoginPasswordEdit:SetText("");
if ( Autologin_CurrentPage > 0 and Autologin_CurrentPage * Autologin_PageSize > table.getn(Autologin_Table) - 1 ) then
Autologin_CurrentPage = Autologin_CurrentPage - 1;
end
Autologin_UpdateUI();
end
function Autologin_NextPage()
if ( (Autologin_CurrentPage + 1) * Autologin_PageSize > table.getn(Autologin_Table) - 1 ) then return; end
Autologin_CurrentPage = Autologin_CurrentPage + 1;
Autologin_UpdateUI();
end
function Autologin_PrevPage()
if ( Autologin_CurrentPage == 0 ) then return; end
Autologin_CurrentPage = Autologin_CurrentPage - 1;
Autologin_UpdateUI();
end
FADE_IN_TIME = 2; FADE_IN_TIME = 2;
DEFAULT_TOOLTIP_COLOR = {0.8, 0.8, 0.8, 0.09, 0.09, 0.09}; DEFAULT_TOOLTIP_COLOR = {0.8, 0.8, 0.8, 0.09, 0.09, 0.09};
MAX_PIN_LENGTH = 10; MAX_PIN_LENGTH = 10;
@@ -6,11 +257,13 @@ function AccountLogin_OnLoad()
this:SetSequence(0); this:SetSequence(0);
this:SetCamera(0); this:SetCamera(0);
TOSFrame.noticeType = "EULA";
this:RegisterEvent("SHOW_SERVER_ALERT"); this:RegisterEvent("SHOW_SERVER_ALERT");
this:RegisterEvent("SHOW_SURVEY_NOTIFICATION"); this:RegisterEvent("SHOW_SURVEY_NOTIFICATION");
local versionType, buildType, version, internalVersion, date = GetBuildInfo(); local versionType, buildType, version, internalVersion, date = GetBuildInfo();
AccountLoginVersion:SetText(format(VERSION_TEMPLATE, versionType, version, internalVersion, buildType, date)); AccountLoginVersion:SetText(format(TEXT(VERSION_TEMPLATE), versionType, version, internalVersion, buildType, date));
-- Color edit box backdrops -- Color edit box backdrops
local backdropColor = DEFAULT_TOOLTIP_COLOR; local backdropColor = DEFAULT_TOOLTIP_COLOR;
@@ -18,15 +271,14 @@ function AccountLogin_OnLoad()
AccountLoginAccountEdit:SetBackdropColor(backdropColor[4], backdropColor[5], backdropColor[6]); AccountLoginAccountEdit:SetBackdropColor(backdropColor[4], backdropColor[5], backdropColor[6]);
AccountLoginPasswordEdit:SetBackdropBorderColor(backdropColor[1], backdropColor[2], backdropColor[3]); AccountLoginPasswordEdit:SetBackdropBorderColor(backdropColor[1], backdropColor[2], backdropColor[3]);
AccountLoginPasswordEdit:SetBackdropColor(backdropColor[4], backdropColor[5], backdropColor[6]); AccountLoginPasswordEdit:SetBackdropColor(backdropColor[4], backdropColor[5], backdropColor[6]);
VirtualKeypadText:SetBackdropBorderColor(backdropColor[1], backdropColor[2], backdropColor[3]);
VirtualKeypadText:SetBackdropColor(backdropColor[4], backdropColor[5], backdropColor[6]);
end end
function AccountLogin_OnShow() function AccountLogin_OnShow()
CurrentGlueMusic = "Sound\\Music\\GlueScreenMusic\\wow_main_theme.mp3"; CurrentGlueMusic = "Sound\\Music\\GlueScreenMusic\\wow_main_theme.mp3";
AcceptTOS(); -- Try to show the EULA or the TOS
AcceptEULA(); -- AccountLogin_ShowUserAgreements();
local serverName = GetServerName(); local serverName = GetServerName();
if(serverName) then if(serverName) then
AccountLoginRealmName:SetText(serverName); AccountLoginRealmName:SetText(serverName);
@@ -34,15 +286,30 @@ function AccountLogin_OnShow()
AccountLoginRealmName:Hide() AccountLoginRealmName:Hide()
end end
local accountName = GetSavedAccountName(); -- Autologin OnShow. Load sets `Autologin_SelectedIdx` from the
AccountLoginAccountEdit:SetText(accountName); -- last-used account name (via GetSavedAccountName), so honor that
AccountLoginPasswordEdit:SetText(""); -- here instead of unconditionally picking row 1.
Autologin_Load();
Autologin_PasswordFieldForced = false;
if ( table.getn(Autologin_Table) ~= 0 ) then
local r = Autologin_Table[Autologin_SelectedIdx or 1];
if ( r ) then
AccountLoginAccountEdit:SetText(r.name);
AccountLoginPasswordEdit:SetText("");
Autologin_OnNameUpdate(r.name);
end
end
Autologin_UpdateUI();
Autologin_UpdatePasswordVisibility();
if ( accountName == "" ) then if ( table.getn(Autologin_Table) == 0 ) then
AccountLogin_FocusAccountName(); AccountLogin_FocusAccountName();
else elseif ( AccountLoginPasswordEdit:IsVisible() ) then
AccountLogin_FocusPassword(); AccountLogin_FocusPassword();
end end
-- If the password field is hidden (saved-account autologin), don't
-- grab focus — Enter will still trigger Login via the parent frame's
-- OnKeyDown handler, and the user can click the name field to edit.
end end
function AccountLogin_FocusPassword() function AccountLogin_FocusPassword()
@@ -66,7 +333,7 @@ function AccountLogin_OnKeyDown()
else else
AccountLogin_Exit(); AccountLogin_Exit();
end end
elseif ( arg1 == "ENTER" ) then elseif ( arg1 == "ENTER" ) then
if ( not TOSAccepted() ) then if ( not TOSAccepted() ) then
return; return;
@@ -93,44 +360,17 @@ end
function AccountLogin_Login() function AccountLogin_Login()
PlaySound("gsLogin"); PlaySound("gsLogin");
DefaultServerLogin(AccountLoginAccountEdit:GetText(), AccountLoginPasswordEdit:GetText()); Autologin_OnLogin();
AccountLoginPasswordEdit:SetText("");
if ( AccountLoginSaveAccountName:GetChecked() ) then
SetSavedAccountName(AccountLoginAccountEdit:GetText());
else
SetSavedAccountName("");
end
end end
function AccountLogin_Turtle_Armory_Website() function AccountLogin_ManageAccount()
PlaySound("gsLoginNewAccount"); PlaySound("gsLoginNewAccount");
LaunchURL(TURTLE_ARMORY_WEBSITE); LaunchURL(AUTH_NO_TIME_URL);
end end
function AccountLogin_Turtle_Website() function AccountLogin_LaunchCommunitySite()
PlaySound("gsLoginNewAccount"); PlaySound("gsLoginNewAccount");
LaunchURL(AUTH_TURTLE_WEBSITE); LaunchURL(COMMUNITY_URL);
end
function AccountLogin_Turtle_Knowledge_Database()
PlaySound("gsLoginNewAccount");
LaunchURL(TURTLE_KNOWLEDGE_DATABASE_WEBSITE);
end
function AccountLogin_Turtle_Community_Forum()
PlaySound("gsLoginNewAccount");
LaunchURL(TURTLE_COMMUNITY_FORUM_WEBSITE);
end
function AccountLogin_Turtle_Discord()
PlaySound("gsLoginNewAccount");
LaunchURL(TURTLE_DISCORD_WEBSITE);
end
function AccountLogin_Turtle_Reddit()
PlaySound("gsLoginNewAccount");
LaunchURL(TURTLE_REDDIT_WEBSITE);
end end
function AccountLogin_Credits() function AccountLogin_Credits()
@@ -172,12 +412,73 @@ function AccountLogin_SurveyNotificationDone(accepted)
AccountLoginUI:Show(); AccountLoginUI:Show();
end end
function AccountLogin_ShowUserAgreements()
TOSScrollFrame:Hide();
EULAScrollFrame:Hide();
ScanningScrollFrame:Hide();
ContestScrollFrame:Hide();
TOSText:Hide();
EULAText:Hide();
ScanningText:Hide();
if ( not EULAAccepted() ) then
if ( ShowEULANotice() ) then
TOSNotice:SetText(EULA_NOTICE);
TOSNotice:Show();
end
AccountLoginUI:Hide();
TOSFrame.noticeType = "EULA";
TOSFrameTitle:SetText(EULA_FRAME_TITLE);
TOSFrameHeader:SetWidth(TOSFrameTitle:GetWidth() + 310);
EULAScrollFrame:Show();
EULAText:Show();
TOSFrame:Show();
elseif ( not TOSAccepted() ) then
if ( ShowTOSNotice() ) then
TOSNotice:SetText(TOS_NOTICE);
TOSNotice:Show();
end
AccountLoginUI:Hide();
TOSFrame.noticeType = "TOS";
TOSFrameTitle:SetText(TOS_FRAME_TITLE);
TOSFrameHeader:SetWidth(TOSFrameTitle:GetWidth() + 310);
TOSScrollFrame:Show();
TOSText:Show();
TOSFrame:Show();
elseif ( not ScanningAccepted() and SHOW_SCANNING_AGREEMENT ) then
if ( ShowScanningNotice() ) then
TOSNotice:SetText(SCANNING_NOTICE);
TOSNotice:Show();
end
AccountLoginUI:Hide();
TOSFrame.noticeType = "SCAN";
TOSFrameTitle:SetText(SCAN_FRAME_TITLE);
TOSFrameHeader:SetWidth(TOSFrameTitle:GetWidth() + 310);
ScanningScrollFrame:Show();
ScanningText:Show();
TOSFrame:Show();
elseif ( not ContestAccepted() and SHOW_CONTEST_AGREEMENT ) then
if ( ShowContestNotice() ) then
TOSNotice:SetText(CONTEST_NOTICE);
TOSNotice:Show();
end
AccountLoginUI:Hide();
TOSFrame.noticeType = "CONTEST";
TOSFrameTitle:SetText(CONTEST_FRAME_TITLE);
TOSFrameHeader:SetWidth(TOSFrameTitle:GetWidth() + 310);
ContestScrollFrame:Show();
ContestText:Show();
TOSFrame:Show();
else
AccountLoginUI:Show();
TOSFrame:Hide();
end
end
-- Virtual keypad functions -- Virtual keypad functions
local buttonText = {}
function VirtualKeypadFrame_OnEvent(event) function VirtualKeypadFrame_OnEvent(event)
if ( event == "PLAYER_ENTER_PIN" ) then if ( event == "PLAYER_ENTER_PIN" ) then
for i=1, 10 do for i=1, 10 do
buttonText[i] = _G["arg"..i] getglobal("VirtualKeypadButton"..i):SetText(getglobal("arg"..i));
end end
end end
-- Randomize location to prevent hacking (yeah right) -- Randomize location to prevent hacking (yeah right)
@@ -185,8 +486,8 @@ function VirtualKeypadFrame_OnEvent(event)
local yPadding = 10; local yPadding = 10;
local xPos = random(xPadding, GlueParent:GetWidth()-VirtualKeypadFrame:GetWidth()-xPadding); local xPos = random(xPadding, GlueParent:GetWidth()-VirtualKeypadFrame:GetWidth()-xPadding);
local yPos = random(yPadding, GlueParent:GetHeight()-VirtualKeypadFrame:GetHeight()-yPadding); local yPos = random(yPadding, GlueParent:GetHeight()-VirtualKeypadFrame:GetHeight()-yPadding);
--VirtualKeypadFrame:SetPoint("TOPLEFT", GlueParent, "TOPLEFT", xPos, -yPos); VirtualKeypadFrame:SetPoint("TOPLEFT", GlueParent, "TOPLEFT", xPos, -yPos);
VirtualKeypadFrame:Show(); VirtualKeypadFrame:Show();
VirtualKeypad_UpdateButtons(); VirtualKeypad_UpdateButtons();
end end
@@ -196,31 +497,45 @@ function VirtualKeypadButton_OnClick()
if ( not text ) then if ( not text ) then
text = ""; text = "";
end end
VirtualKeypadText:SetText(text.."*");
VirtualKeypadFrame.PIN = VirtualKeypadFrame.PIN..this:GetID(); VirtualKeypadFrame.PIN = VirtualKeypadFrame.PIN..this:GetID();
VirtualKeypadText:SetText(VirtualKeypadFrame.PIN);
VirtualKeypad_UpdateButtons(); VirtualKeypad_UpdateButtons();
end end
function VirtualKeypadOkayButton_OnClick() function VirtualKeypadOkayButton_OnClick()
local PIN = VirtualKeypadText:GetText(); local PIN = VirtualKeypadFrame.PIN;
local numNumbers = strlen(PIN); local numNumbers = strlen(PIN);
if numNumbers < 6 then return end
local pinNumber = {}; local pinNumber = {};
for i=1, MAX_PIN_LENGTH do for i=1, MAX_PIN_LENGTH do
if ( i <= numNumbers ) then if ( i <= numNumbers ) then
pinNumber[i] = nil; pinNumber[i] = strsub(PIN,i,i);
for j=1, 10 do
if tonumber(buttonText[j]) == tonumber(strsub(PIN,i,i)) then
pinNumber[i] = j-1;
end
end
else else
pinNumber[i] = nil; pinNumber[i] = nil;
end end
end end
PINEntered(pinNumber[1], pinNumber[2], pinNumber[3], pinNumber[4], pinNumber[5], pinNumber[6], pinNumber[7], pinNumber[8], pinNumber[9], pinNumber[10]); PINEntered(pinNumber[1] , pinNumber[2], pinNumber[3], pinNumber[4], pinNumber[5], pinNumber[6], pinNumber[7], pinNumber[8], pinNumber[9], pinNumber[10]);
VirtualKeypadFrame:Hide(); VirtualKeypadFrame:Hide();
end end
function VirtualKeypad_UpdateButtons() function VirtualKeypad_UpdateButtons()
local numNumbers = strlen(VirtualKeypadFrame.PIN);
if ( numNumbers >= 4 and numNumbers <= MAX_PIN_LENGTH ) then
VirtualKeypadOkayButton:Enable();
else
VirtualKeypadOkayButton:Disable();
end
if ( numNumbers == 0 ) then
VirtualKeypadBackButton:Disable();
else
VirtualKeypadBackButton:Enable();
end
if ( numNumbers >= MAX_PIN_LENGTH ) then
for i=1, MAX_PIN_LENGTH do
getglobal("VirtualKeypadButton"..i):Disable();
end
else
for i=1, MAX_PIN_LENGTH do
getglobal("VirtualKeypadButton"..i):Enable();
end
end
end end
+1015 -102
View File
File diff suppressed because it is too large Load Diff
+329 -43
View File
@@ -6,6 +6,23 @@ CHARACTER_ROTATION_CONSTANT = 0.6;
MAX_CHARACTERS_DISPLAYED = 10; MAX_CHARACTERS_DISPLAYED = 10;
MAX_CHARACTERS_PER_REALM = 10; MAX_CHARACTERS_PER_REALM = 10;
AUTO_DRAG_TIME = 0.5;
-- Per-account character autoselect was removed alongside the plaintext
-- credential store; the AutologinSaveCharacterButton checkbox in
-- CharacterSelect.xml is now permanently hidden, and the two functions
-- below are no-op wrappers around `EnterWorld` to keep the existing
-- call sites working.
function Autologin_OnCharactersLoad()
if ( AutologinSaveCharacterButton ) then
AutologinSaveCharacterButton:Hide();
end
end
function Autologin_EnterWorld()
EnterWorld();
end
function CharacterSelect_OnLoad() function CharacterSelect_OnLoad()
this:SetSequence(0); this:SetSequence(0);
this:SetCamera(0); this:SetCamera(0);
@@ -14,6 +31,12 @@ function CharacterSelect_OnLoad()
this.selectedIndex = 0; this.selectedIndex = 0;
this.selectLast = 0; this.selectLast = 0;
this.currentModel = ""; this.currentModel = "";
this.translationTable = {};
this.orderChanged = nil;
this.pressDownButton = nil;
this.pressDownTime = 0;
this.draggedIndex = nil;
this.suppressNextClick = nil;
this:RegisterEvent("ADDON_LIST_UPDATE"); this:RegisterEvent("ADDON_LIST_UPDATE");
this:RegisterEvent("CHARACTER_LIST_UPDATE"); this:RegisterEvent("CHARACTER_LIST_UPDATE");
this:RegisterEvent("UPDATE_SELECTED_CHARACTER"); this:RegisterEvent("UPDATE_SELECTED_CHARACTER");
@@ -36,7 +59,7 @@ function CharacterSelect_OnLoad()
local backdropColor = DEFAULT_TOOLTIP_COLOR; local backdropColor = DEFAULT_TOOLTIP_COLOR;
CharacterSelectCharacterFrame:SetBackdropBorderColor(backdropColor[1], backdropColor[2], backdropColor[3]); CharacterSelectCharacterFrame:SetBackdropBorderColor(backdropColor[1], backdropColor[2], backdropColor[3]);
CharacterSelectCharacterFrame:SetBackdropColor(backdropColor[4], backdropColor[5], backdropColor[6], 0.85); CharacterSelectCharacterFrame:SetBackdropColor(backdropColor[4], backdropColor[5], backdropColor[6], 0.85);
end end
function CharacterSelect_OnShow() function CharacterSelect_OnShow()
@@ -49,7 +72,7 @@ function CharacterSelect_OnShow()
local serverType = ""; local serverType = "";
if ( serverName ) then if ( serverName ) then
if( not connected ) then if( not connected ) then
serverName = serverName.."\n("..SERVER_DOWN..")"; serverName = serverName.."\n("..TEXT(SERVER_DOWN)..")";
end end
if ( isPVP ) then if ( isPVP ) then
if ( isRP ) then if ( isRP ) then
@@ -81,7 +104,7 @@ function CharacterSelect_OnShow()
else else
local billingTimeLeft = GetBillingTimeRemaining(); local billingTimeLeft = GetBillingTimeRemaining();
-- Set default text for the payment plan -- Set default text for the payment plan
local billingText = _G["BILLING_TEXT"..paymentPlan]; local billingText = getglobal("BILLING_TEXT"..paymentPlan);
if ( paymentPlan == 1 ) then if ( paymentPlan == 1 ) then
-- Recurring account -- Recurring account
billingTimeLeft = ceil(billingTimeLeft/(60 * 24)); billingTimeLeft = ceil(billingTimeLeft/(60 * 24));
@@ -92,7 +115,7 @@ function CharacterSelect_OnShow()
-- Free account -- Free account
if ( billingTimeLeft < (24 * 60) ) then if ( billingTimeLeft < (24 * 60) ) then
billingText = format(BILLING_FREE_TIME_EXPIRE, billingTimeLeft.." "..GetText("MINUTES_ABBR", nil, billingTimeLeft)); billingText = format(BILLING_FREE_TIME_EXPIRE, billingTimeLeft.." "..GetText("MINUTES_ABBR", nil, billingTimeLeft));
end end
elseif ( paymentPlan == 3 ) then elseif ( paymentPlan == 3 ) then
-- Fixed but not recurring -- Fixed but not recurring
if ( isGameRoom == 1 ) then if ( isGameRoom == 1 ) then
@@ -107,7 +130,7 @@ function CharacterSelect_OnShow()
billingText = BILLING_FIXED_LASTDAY; billingText = BILLING_FIXED_LASTDAY;
else else
billingText = format(billingText, MinutesToTime(billingTimeLeft)); billingText = format(billingText, MinutesToTime(billingTimeLeft));
end end
end end
elseif ( paymentPlan == 4 ) then elseif ( paymentPlan == 4 ) then
-- Usage plan -- Usage plan
@@ -178,14 +201,26 @@ function CharacterSelect_OnEvent()
if ( event == "ADDON_LIST_UPDATE" ) then if ( event == "ADDON_LIST_UPDATE" ) then
UpdateAddonButton(); UpdateAddonButton();
elseif ( event == "CHARACTER_LIST_UPDATE" ) then elseif ( event == "CHARACTER_LIST_UPDATE" ) then
CharacterSelect_RebuildTranslationTable();
CharacterOrder_Apply();
CharacterOrder_Save();
if ( this.pendingSelectedCharID ) then
this.selectedIndex = GetIndexFromCharID(this.pendingSelectedCharID);
this.pendingSelectedCharID = nil;
end
UpdateCharacterList(); UpdateCharacterList();
CharSelectCharacterName:SetText(GetCharacterInfo(this.selectedIndex)); CharSelectCharacterName:SetText(GetCharacterInfo(GetCharIDFromIndex(this.selectedIndex)));
Autologin_OnCharactersLoad();
elseif ( event == "UPDATE_SELECTED_CHARACTER" ) then elseif ( event == "UPDATE_SELECTED_CHARACTER" ) then
if ( arg1 == 0 ) then if ( arg1 == 0 ) then
CharSelectCharacterName:SetText(""); CharSelectCharacterName:SetText("");
else else
CharSelectCharacterName:SetText(GetCharacterInfo(arg1)); CharSelectCharacterName:SetText(GetCharacterInfo(arg1));
this.selectedIndex = arg1; if ( table.getn(this.translationTable) == 0 ) then
this.pendingSelectedCharID = arg1;
else
this.selectedIndex = GetIndexFromCharID(arg1);
end
end end
UpdateCharacterSelection(); UpdateCharacterSelection();
elseif ( event == "SELECT_LAST_CHARACTER" ) then elseif ( event == "SELECT_LAST_CHARACTER" ) then
@@ -200,9 +235,7 @@ function CharacterSelect_OnEvent()
GlueDialog_Show("SUGGEST_REALM"); GlueDialog_Show("SUGGEST_REALM");
elseif ( event == "FORCE_RENAME_CHARACTER" ) then elseif ( event == "FORCE_RENAME_CHARACTER" ) then
CharacterRenameDialog:Show(); CharacterRenameDialog:Show();
CharacterRenameBackground:SetHeight(16 + CharacterRenameText1:GetHeight() + CharacterRenameText2:GetHeight() + 23 + CharacterRenameEditBox:GetHeight() + 8 + CharacterRenameButton1:GetHeight() + 16); CharacterRenameText1:SetText(getglobal(arg1));
CharacterRenameText1:SetText(_G[arg1]);
end end
end end
@@ -212,44 +245,56 @@ function CharacterSelect_UpdateModel()
end end
function UpdateCharacterSelection() function UpdateCharacterSelection()
-- Drag-aware: during a drag, dim every slot except the one currently
-- holding the dragged character, and override the highlight to track
-- the drag rather than the engine-side "selected" character.
local draggedIndex = CharacterSelect.draggedIndex;
local highlightIndex = draggedIndex or CharacterSelect.selectedIndex;
for i=1, MAX_CHARACTERS_DISPLAYED, 1 do for i=1, MAX_CHARACTERS_DISPLAYED, 1 do
_G["CharSelectCharacterButton"..i]:UnlockHighlight(); local btn = getglobal("CharSelectCharacterButton"..i);
btn:UnlockHighlight();
if ( draggedIndex and i ~= draggedIndex ) then
btn:SetAlpha(0.6);
else
btn:SetAlpha(1);
end
end end
local index = this.selectedIndex; if ( highlightIndex and (highlightIndex > 0) and (highlightIndex <= MAX_CHARACTERS_DISPLAYED) ) then
if ( (index > 0) and (index <= MAX_CHARACTERS_DISPLAYED) )then getglobal("CharSelectCharacterButton"..highlightIndex):LockHighlight();
_G["CharSelectCharacterButton"..index]:LockHighlight();
end end
end end
function UpdateCharacterList() function UpdateCharacterList()
local numChars = GetNumCharacters(); local numChars = GetNumCharacters();
local index = 1; local index = 1;
local coords;
for i=1, numChars, 1 do for i=1, numChars, 1 do
local name, race, class, level, zone, fileString, gender, ghost = GetCharacterInfo(i); local name, race, class, level, zone, fileString, gender, ghost = GetCharacterInfo(GetCharIDFromIndex(i));
if ( gender == 0 ) then
local button = _G["CharSelectCharacterButton"..index]; gender = "MALE";
else
gender = "FEMALE";
end
local button = getglobal("CharSelectCharacterButton"..index);
if ( not name ) then if ( not name ) then
button:SetText("ERROR - Tell Jeremy"); button:SetText("ERROR - Tell Jeremy");
else else
if ( not zone ) then if ( not zone ) then
zone = ""; zone = "";
end end
local classToken = TW_CLASS_TOKEN and TW_CLASS_TOKEN[class];
local classColor if ( classToken and CLASS_COLORS[classToken] ) then
local classToken = TW_CLASS_TOKEN and TW_CLASS_TOKEN[class] class = CLASS_COLORS[classToken] .. class .. "|r";
if classToken and CLASS_COLORS[classToken] then
classColor = CLASS_COLORS[classToken]
class = classColor .. class .. "|r"
end end
_G["CharSelectCharacterButton"..index.."ButtonTextName"]:SetText(name); getglobal("CharSelectCharacterButton"..index.."ButtonTextName"):SetText(name);
if ( ghost ) then if( ghost ) then
_G["CharSelectCharacterButton"..index.."ButtonTextInfo"]:SetText(format(CHARACTER_SELECT_INFO_GHOST, level, class)); getglobal("CharSelectCharacterButton"..index.."ButtonTextInfo"):SetText(format(TEXT(CHARACTER_SELECT_INFO_GHOST), level, class));
else else
_G["CharSelectCharacterButton"..index.."ButtonTextInfo"]:SetText(format(CHARACTER_SELECT_INFO, level, class)); getglobal("CharSelectCharacterButton"..index.."ButtonTextInfo"):SetText(format(TEXT(CHARACTER_SELECT_INFO), level, class));
end end
_G["CharSelectCharacterButton"..index.."ButtonTextLocation"]:SetText(zone); getglobal("CharSelectCharacterButton"..index.."ButtonTextLocation"):SetText(zone);
end end
button:Show(); button:Show();
@@ -268,18 +313,18 @@ function UpdateCharacterList()
end end
CharacterSelect.createIndex = 0; CharacterSelect.createIndex = 0;
CharSelectCreateCharacterButton:Hide(); CharSelectCreateCharacterButton:Hide();
local connected = IsConnectedToServer(); local connected = IsConnectedToServer();
for i=index, MAX_CHARACTERS_DISPLAYED, 1 do for i=index, MAX_CHARACTERS_DISPLAYED, 1 do
local button = _G["CharSelectCharacterButton"..index]; local button = getglobal("CharSelectCharacterButton"..index);
if ( (CharacterSelect.createIndex == 0) and (numChars < MAX_CHARACTERS_PER_REALM) ) then if ( (CharacterSelect.createIndex == 0) and (numChars < MAX_CHARACTERS_PER_REALM) ) then
CharacterSelect.createIndex = index; CharacterSelect.createIndex = index;
if ( connected ) then if ( connected ) then
--If can create characters position and show the create button --If can create characters position and show the create button
CharSelectCreateCharacterButton:SetID(index); CharSelectCreateCharacterButton:SetID(index);
--CharSelectCreateCharacterButton:SetPoint("TOP", button, "TOP", 0, -5); --CharSelectCreateCharacterButton:SetPoint("TOP", button, "TOP", 0, -5);
CharSelectCreateCharacterButton:Show(); CharSelectCreateCharacterButton:Show();
end end
end end
button:Hide(); button:Hide();
@@ -307,6 +352,12 @@ function CharacterSelect_OnChar()
end end
function CharacterSelectButton_OnClick() function CharacterSelectButton_OnClick()
-- A drag just ended on top of this button — eat the click so the
-- drop doesn't also re-select the character at the new slot.
if ( CharacterSelect.suppressNextClick ) then
CharacterSelect.suppressNextClick = nil;
return;
end
local id = this:GetID(); local id = this:GetID();
if ( id ~= CharacterSelect.selectedIndex ) then if ( id ~= CharacterSelect.selectedIndex ) then
CharacterSelect_SelectCharacter(id); CharacterSelect_SelectCharacter(id);
@@ -314,6 +365,10 @@ function CharacterSelectButton_OnClick()
end end
function CharacterSelectButton_OnDoubleClick() function CharacterSelectButton_OnDoubleClick()
if ( CharacterSelect.suppressNextClick ) then
CharacterSelect.suppressNextClick = nil;
return;
end
local id = this:GetID(); local id = this:GetID();
if ( id ~= CharacterSelect.selectedIndex ) then if ( id ~= CharacterSelect.selectedIndex ) then
CharacterSelect_SelectCharacter(id); CharacterSelect_SelectCharacter(id);
@@ -322,10 +377,10 @@ function CharacterSelectButton_OnDoubleClick()
end end
function CharacterSelect_TabResize() function CharacterSelect_TabResize()
local buttonMiddle = _G[this:GetName().."Middle"]; local buttonMiddle = getglobal(this:GetName().."Middle");
local buttonMiddleDisabled = _G[this:GetName().."MiddleDisabled"]; local buttonMiddleDisabled = getglobal(this:GetName().."MiddleDisabled");
local width = this:GetTextWidth() - 8; local width = this:GetTextWidth() - 8;
local leftWidth = _G[this:GetName().."Left"]:GetWidth(); local leftWidth = getglobal(this:GetName().."Left"):GetWidth();
buttonMiddle:SetWidth(width); buttonMiddle:SetWidth(width);
buttonMiddleDisabled:SetWidth(width); buttonMiddleDisabled:SetWidth(width);
this:SetWidth(width + (2 * leftWidth)); this:SetWidth(width + (2 * leftWidth));
@@ -340,26 +395,27 @@ function CharacterSelect_SelectCharacter(id, noCreate)
SetGlueScreen("charcreate"); SetGlueScreen("charcreate");
end end
else else
local name, race, class, level, zone, fileString = GetCharacterInfo(id); local charID = GetCharIDFromIndex(id);
local name, race, class, level, zone, fileString = GetCharacterInfo(charID);
if ( fileString ~= CharacterSelect.currentModel ) then if ( fileString ~= CharacterSelect.currentModel ) then
CharacterSelect.currentModel = fileString; CharacterSelect.currentModel = fileString;
SetBackgroundModel(CharacterSelect, fileString); SetBackgroundModel(CharacterSelect, fileString);
end end
SelectCharacter(id); SelectCharacter(charID);
end end
end end
function CharacterDeleteDialog_OnShow() function CharacterDeleteDialog_OnShow()
local name, race, class, level = GetCharacterInfo(CharacterSelect.selectedIndex); local name, race, class, level = GetCharacterInfo(GetCharIDFromIndex(CharacterSelect.selectedIndex));
CharacterDeleteText1:SetText(format(CONFIRM_CHAR_DELETE, name, level, class)); CharacterDeleteText1:SetText(format(TEXT(CONFIRM_CHAR_DELETE), name, level, class));
CharacterDeleteBackground:SetHeight(16 + CharacterDeleteText1:GetHeight() + CharacterDeleteText2:GetHeight() + 23 + CharacterDeleteEditBox:GetHeight() + 8 + CharacterDeleteButton1:GetHeight() + 16); CharacterDeleteBackground:SetHeight(16 + CharacterDeleteText1:GetHeight() + CharacterDeleteText2:GetHeight() + 23 + CharacterDeleteEditBox:GetHeight() + 8 + CharacterDeleteButton1:GetHeight() + 16);
CharacterDeleteButton1:Disable(); CharacterDeleteButton1:Disable();
end end
function CharacterSelect_EnterWorld() function CharacterSelect_EnterWorld()
PlaySound("gsCharacterSelectionEnterWorld"); PlaySound("gsCharacterSelectionEnterWorld");
EnterWorld(); Autologin_EnterWorld();
end end
function CharacterSelect_Exit() function CharacterSelect_Exit()
@@ -374,7 +430,7 @@ end
function CharacterSelect_TechSupport() function CharacterSelect_TechSupport()
PlaySound("gsCharacterSelectionAcctOptions"); PlaySound("gsCharacterSelectionAcctOptions");
LaunchURL(TECH_SUPPORT_URL); LaunchURL(TEXT(TECH_SUPPORT_URL));
end end
function CharacterSelect_Delete() function CharacterSelect_Delete()
@@ -409,6 +465,16 @@ function CharacterSelectFrame_OnUpdate()
CHARACTER_SELECT_ROTATION_START_X = GetCursorPosition(); CHARACTER_SELECT_ROTATION_START_X = GetCursorPosition();
SetCharacterSelectFacing(GetCharacterSelectFacing() + diff); SetCharacterSelectFacing(GetCharacterSelectFacing() + diff);
end end
-- Press-and-hold detector: a character button held past AUTO_DRAG_TIME
-- promotes into drag mode. Released sooner, OnMouseUp clears the
-- button and the click flows normally.
if ( CharacterSelect.pressDownButton ) then
CharacterSelect.pressDownTime = CharacterSelect.pressDownTime + arg1;
if ( CharacterSelect.pressDownTime >= AUTO_DRAG_TIME ) then
CharacterSelectButton_OnDragStart(CharacterSelect.pressDownButton);
end
end
end end
function CharacterSelectRotateRight_OnUpdate() function CharacterSelectRotateRight_OnUpdate()
@@ -425,5 +491,225 @@ end
function CharacterSelect_ManageAccount() function CharacterSelect_ManageAccount()
PlaySound("gsCharacterSelectionAcctOptions"); PlaySound("gsCharacterSelectionAcctOptions");
LaunchURL(AUTH_NO_TIME_URL); LaunchURL(TEXT(AUTH_NO_TIME_URL));
end
-- ---------------------------------------------------------------------------
-- Character reordering
-- ---------------------------------------------------------------------------
--
-- `CharacterSelect.translationTable[displayIndex] = charID` maps the slot
-- a button occupies on screen to the server-assigned character ID expected
-- by `GetCharacterInfo` / `SelectCharacter` / `DeleteCharacter` / etc.
-- Default identity table (1->1, 2->2, ...) gets reshuffled by MoveCharacter
-- and restored on every CHARACTER_LIST_UPDATE.
function GetCharIDFromIndex(index)
return CharacterSelect.translationTable[index] or index;
end
function GetIndexFromCharID(charID)
-- Fast path while the table is still identity.
if ( not CharacterSelect.orderChanged ) then
return charID;
end
for index = 1, table.getn(CharacterSelect.translationTable) do
if ( CharacterSelect.translationTable[index] == charID ) then
return index;
end
end
return 0;
end
function CharacterSelect_RebuildTranslationTable()
CharacterSelect.translationTable = {};
CharacterSelect.orderChanged = nil;
local numChars = GetNumCharacters();
for i = 1, numChars do
table.insert(CharacterSelect.translationTable, i);
end
end
function MoveCharacter(originIndex, targetIndex, fromDrag)
CharacterSelect.orderChanged = 1;
local n = table.getn(CharacterSelect.translationTable);
if ( n < 2 ) then return; end
if ( targetIndex < 1 ) then
targetIndex = n;
elseif ( targetIndex > n ) then
targetIndex = 1;
end
if ( originIndex == CharacterSelect.selectedIndex ) then
CharacterSelect.selectedIndex = targetIndex;
elseif ( targetIndex == CharacterSelect.selectedIndex ) then
CharacterSelect.selectedIndex = originIndex;
end
local t = CharacterSelect.translationTable;
t[originIndex], t[targetIndex] = t[targetIndex], t[originIndex];
if ( fromDrag ) then
CharacterSelect.draggedIndex = targetIndex;
end
UpdateCharacterSelection();
UpdateCharacterList();
CharacterOrder_Save();
end
-- ---------------------------------------------------------------------------
-- Drag handlers
-- ---------------------------------------------------------------------------
--
-- 1.12 glue widgets don't expose `RegisterForDrag`, so we synthesize drag
-- mode by watching how long a character button is held: any press past
-- AUTO_DRAG_TIME enters drag mode. The dragged button's OnUpdate then
-- watches the cursor's Y position and calls MoveCharacter when it crosses
-- into another slot. Mouse-up tears everything down.
function CharacterSelectButton_OnMouseDown()
CharacterSelect.pressDownButton = this;
CharacterSelect.pressDownTime = 0;
end
function CharacterSelectButton_OnMouseUp()
if ( CharacterSelect.draggedIndex ) then
CharacterSelectButton_OnDragStop(this);
-- The drag concluded on a button; the engine still dispatches
-- OnClick (and OnDoubleClick) afterwards. Suppress one.
CharacterSelect.suppressNextClick = 1;
end
CharacterSelect.pressDownButton = nil;
end
function CharacterSelectButton_OnDragStart(button)
if ( GetNumCharacters() < 2 ) then return; end
CharacterSelect.pressDownButton = nil;
CharacterSelect.draggedIndex = button:GetID();
-- Cache slot geometry once per drag from the live buttons. Stride
-- accounts for the 13px visual overlap defined in CharacterSelect.xml.
CharacterSelect.dragListTop = CharSelectCharacterButton1:GetTop();
CharacterSelect.dragRowStride = CharSelectCharacterButton1:GetTop() - CharSelectCharacterButton2:GetTop();
if ( not CharacterSelect.dragRowStride or CharacterSelect.dragRowStride <= 0 ) then
-- Fallback if button2 wasn't ready (single character, etc.) —
-- shouldn't reach here because of the >=2 guard above, but be safe.
CharacterSelect.dragRowStride = 57;
end
button:SetScript("OnUpdate", CharacterSelectButton_OnDragUpdate);
UpdateCharacterSelection();
end
function CharacterSelectButton_OnDragUpdate()
if ( not CharacterSelect.draggedIndex ) then
CharacterSelectButton_OnDragStop(this);
return;
end
local _, cursorY = GetCursorPosition();
local top = CharacterSelect.dragListTop;
local stride = CharacterSelect.dragRowStride;
if ( cursorY <= top ) then
local hoverIndex = math.floor((top - cursorY) / stride) + 1;
local hover = getglobal("CharSelectCharacterButton"..hoverIndex);
if ( hover and hover:IsShown() and hoverIndex ~= CharacterSelect.draggedIndex ) then
if ( hoverIndex > CharacterSelect.draggedIndex ) then
MoveCharacter(CharacterSelect.draggedIndex, CharacterSelect.draggedIndex + 1, 1);
else
MoveCharacter(CharacterSelect.draggedIndex, CharacterSelect.draggedIndex - 1, 1);
end
end
end
end
function CharacterSelectButton_OnDragStop(button)
CharacterSelect.pressDownButton = nil;
CharacterSelect.draggedIndex = nil;
if ( button ) then
button:SetScript("OnUpdate", nil);
end
-- draggedIndex is now nil, so UpdateCharacterSelection restores
-- full alpha on every slot and highlights only the selected one.
UpdateCharacterSelection();
end
-- ---------------------------------------------------------------------------
-- Delete / Rename wrappers (selectedIndex is now a display index, so
-- engine calls expecting a charID need translation)
-- ---------------------------------------------------------------------------
function CharacterSelect_DeleteCharacter()
DeleteCharacter(GetCharIDFromIndex(CharacterSelect.selectedIndex));
CharacterDeleteDialog:Hide();
end
function CharacterSelect_RenameCharacter()
if ( RenameCharacter(GetCharIDFromIndex(CharacterSelect.selectedIndex), CharacterRenameEditBox:GetText()) ) then
CharacterRenameDialog:Hide();
end
end
-- ---------------------------------------------------------------------------
-- Persistence — relies on ClassicAPI's glue bindings
-- (GetSavedCharacterOrder / SetSavedCharacterOrder). Both calls are
-- guarded so the drag feature is functional in-session even before the
-- DLL binding ships; persistence kicks in automatically once it does.
-- ---------------------------------------------------------------------------
function CharacterOrder_Load()
if ( type(GetSavedCharacterOrder) ~= "function" ) then return ""; end
local realm = GetServerName() or "";
if ( realm == "" ) then return ""; end
return GetSavedCharacterOrder(realm) or "";
end
function CharacterOrder_Apply()
local saved = CharacterOrder_Load();
if ( not saved or saved == "" ) then return; end
local numChars = GetNumCharacters();
if ( numChars < 2 ) then return; end
-- Build name -> charID lookup against the server's natural order.
-- The translation table is identity at this point (set by
-- RebuildTranslationTable), so charID == display index here.
local nameToID = {};
for charID = 1, numChars do
local name = GetCharacterInfo(charID);
if ( name ) then nameToID[name] = charID; end
end
local newOrder = {};
local claimed = {};
for name in string.gfind(saved, "([^|]+)") do
local charID = nameToID[name];
if ( charID and not claimed[charID] ) then
table.insert(newOrder, charID);
claimed[charID] = 1;
end
end
-- Append any characters that exist on the server but weren't in the
-- saved order (newly created since last save).
for charID = 1, numChars do
if ( not claimed[charID] ) then
table.insert(newOrder, charID);
end
end
-- Only adopt the rebuilt order if it actually differs from identity.
local differs = false;
for i = 1, table.getn(newOrder) do
if ( newOrder[i] ~= i ) then differs = true; break; end
end
if ( differs ) then
CharacterSelect.translationTable = newOrder;
CharacterSelect.orderChanged = 1;
end
end
function CharacterOrder_Save()
if ( type(SetSavedCharacterOrder) ~= "function" ) then return; end
local realm = GetServerName() or "";
if ( realm == "" ) then return; end
local numChars = GetNumCharacters();
local names = {};
for i = 1, numChars do
local name = GetCharacterInfo(GetCharIDFromIndex(i));
if ( name ) then table.insert(names, name); end
end
SetSavedCharacterOrder(realm, table.concat(names, "|"));
end end
+60 -24
View File
@@ -56,6 +56,12 @@
<OnDoubleClick> <OnDoubleClick>
CharacterSelectButton_OnDoubleClick(); CharacterSelectButton_OnDoubleClick();
</OnDoubleClick> </OnDoubleClick>
<OnMouseDown>
CharacterSelectButton_OnMouseDown();
</OnMouseDown>
<OnMouseUp>
CharacterSelectButton_OnMouseUp();
</OnMouseUp>
</Scripts> </Scripts>
<HighlightTexture file="Interface\Glues\CharacterSelect\Glue-CharacterSelect-Highlight" alphaMode="ADD"> <HighlightTexture file="Interface\Glues\CharacterSelect\Glue-CharacterSelect-Highlight" alphaMode="ADD">
<Size> <Size>
@@ -119,16 +125,52 @@
CharacterSelect_EnterWorld(); CharacterSelect_EnterWorld();
</OnClick> </OnClick>
</Scripts> </Scripts>
<NormalText>
<Anchors>
<Anchor point="CENTER">
<Offset>
<AbsDimension x="-1" y="3"/>
</Offset>
</Anchor>
</Anchors>
</NormalText>
</Button> </Button>
<CheckButton name="AutologinSaveCharacterButton">
<Size>
<AbsDimension x="20" y="20"/>
</Size>
<Anchors>
<Anchor point="LEFT" relativeTo="CharSelectEnterWorldButton" relativePoint="RIGHT"/>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<FontString inherits="GlueFontNormalSmall" text="Auto-login this character">
<Anchors>
<Anchor point="LEFT">
<Offset>
<AbsDimension x="24" y="0"/>
</Offset>
</Anchor>
</Anchors>
<FontHeight>
<AbsValue val="10"/>
</FontHeight>
<Shadow>
<Offset>
<AbsDimension x="1" y="-1"/>
</Offset>
<Color r="0" g="0" b="0"/>
</Shadow>
<Color r="1.0" g="0.78" b="0"/>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnClick>
if ( this:GetChecked() ) then
PlaySound("igMainMenuOptionCheckBoxOff");
else
PlaySound("igMainMenuOptionCheckBoxOn");
end
</OnClick>
</Scripts>
<NormalTexture file="Interface\Buttons\UI-CheckBox-Up"/>
<PushedTexture file="Interface\Buttons\UI-CheckBox-Down"/>
<HighlightTexture file="Interface\Buttons\UI-CheckBox-Highlight" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\UI-CheckBox-Check"/>
<DisabledCheckedTexture file="Interface\Buttons\UI-CheckBox-Check-Disabled"/>
</CheckButton>
<Button name="CharacterSelectRotateLeft"> <Button name="CharacterSelectRotateLeft">
<Size> <Size>
<AbsDimension x="50" y="50"/> <AbsDimension x="50" y="50"/>
@@ -601,7 +643,7 @@
<Anchors> <Anchors>
<Anchor point="LEFT"> <Anchor point="LEFT">
<Offset> <Offset>
<AbsDimension x="12" y="4"/> <AbsDimension x="12" y="10"/>
</Offset> </Offset>
</Anchor> </Anchor>
</Anchors> </Anchors>
@@ -609,7 +651,7 @@
</Layer> </Layer>
</Layers> </Layers>
<Frames> <Frames>
<Button name="CharacterDeleteButton1" inherits="GlueDialogButtonTemplate" id="1" text="CONFIRM"> <Button name="CharacterDeleteButton1" inherits="GlueDialogButtonTemplate" id="1" text="OKAY">
<Anchors> <Anchors>
<Anchor point="BOTTOMRIGHT" relativeTo="CharacterDeleteBackground" relativePoint="BOTTOM"> <Anchor point="BOTTOMRIGHT" relativeTo="CharacterDeleteBackground" relativePoint="BOTTOM">
<Offset> <Offset>
@@ -619,8 +661,7 @@
</Anchors> </Anchors>
<Scripts> <Scripts>
<OnClick> <OnClick>
DeleteCharacter(CharacterSelect.selectedIndex); CharacterSelect_DeleteCharacter();
CharacterDeleteDialog:Hide();
PlaySound("gsTitleOptionOK"); PlaySound("gsTitleOptionOK");
</OnClick> </OnClick>
</Scripts> </Scripts>
@@ -690,11 +731,10 @@
else else
CharacterDeleteButton1:Disable(); CharacterDeleteButton1:Disable();
end end
</OnTextChanged> </OnTextChanged>
<OnEnterPressed> <OnEnterPressed>
if ( CharacterDeleteButton1:IsEnabled() == 1 ) then if ( CharacterDeleteButton1:IsEnabled() == 1 ) then
DeleteCharacter(CharacterSelect.selectedIndex); CharacterSelect_DeleteCharacter();
CharacterDeleteDialog:Hide();
end end
</OnEnterPressed> </OnEnterPressed>
<OnEscapePressed> <OnEscapePressed>
@@ -771,7 +811,7 @@
<Anchors> <Anchors>
<Anchor point="LEFT"> <Anchor point="LEFT">
<Offset> <Offset>
<AbsDimension x="12" y="4"/> <AbsDimension x="12" y="10"/>
</Offset> </Offset>
</Anchor> </Anchor>
</Anchors> </Anchors>
@@ -779,7 +819,7 @@
</Layer> </Layer>
</Layers> </Layers>
<Frames> <Frames>
<Button name="CharacterRenameButton1" inherits="GlueDialogButtonTemplate" id="1" text="CONFIRM"> <Button name="CharacterRenameButton1" inherits="GlueDialogButtonTemplate" id="1" text="OKAY">
<Anchors> <Anchors>
<Anchor point="BOTTOMRIGHT" relativeTo="CharacterRenameBackground" relativePoint="BOTTOM"> <Anchor point="BOTTOMRIGHT" relativeTo="CharacterRenameBackground" relativePoint="BOTTOM">
<Offset> <Offset>
@@ -789,9 +829,7 @@
</Anchors> </Anchors>
<Scripts> <Scripts>
<OnClick> <OnClick>
if ( RenameCharacter(CharacterSelect.selectedIndex, CharacterRenameEditBox:GetText()) ) then CharacterSelect_RenameCharacter();
CharacterRenameDialog:Hide();
end
</OnClick> </OnClick>
</Scripts> </Scripts>
</Button> </Button>
@@ -854,9 +892,7 @@
</Layers> </Layers>
<Scripts> <Scripts>
<OnEnterPressed> <OnEnterPressed>
if ( RenameCharacter(CharacterSelect.selectedIndex, CharacterRenameEditBox:GetText()) ) then CharacterSelect_RenameCharacter();
CharacterRenameDialog:Hide();
end
</OnEnterPressed> </OnEnterPressed>
<OnEscapePressed> <OnEscapePressed>
CharacterRenameDialog:Hide(); CharacterRenameDialog:Hide();
+65
View File
@@ -0,0 +1,65 @@
GLUE_CONSOLE_MAX_LINES = 200;
function GlueConsole_OnLoad()
GlueConsoleOutput:SetFading(false);
GlueConsoleOutput:SetMaxLines(GLUE_CONSOLE_MAX_LINES);
GlueConsoleOutput:SetJustifyH("LEFT");
-- ChatFrameBackground is a white pixel; tint to dark + faint border.
GlueConsoleInput:SetBackdropColor(0, 0, 0, 0.7);
GlueConsoleInput:SetBackdropBorderColor(0.4, 0.4, 0.4, 1);
-- Route print() output into the console so RunScript("print(...)") is visible.
local original_print = print;
print = function(...)
local parts = {};
for i = 1, arg.n do
parts[i] = tostring(arg[i]);
end
GlueConsole_Print(table.concat(parts, " "));
if ( original_print ) then
original_print(unpack(arg));
end
end
end
function GlueConsole_Print(msg)
if ( msg == nil ) then msg = "nil"; end
GlueConsoleOutput:AddMessage(tostring(msg));
end
function GlueConsole_Toggle()
if ( GlueConsoleFrame:IsVisible() ) then
GlueConsoleInput:ClearFocus();
GlueConsoleFrame:Hide();
else
GlueConsoleFrame:Show();
GlueConsoleFrame:Raise();
GlueConsoleInput:SetFocus();
end
end
-- Returns true if a backtick was found in the EditBox text. Strips it and toggles the console.
-- Used as an OnTextChanged interceptor so backtick still toggles the console while an EditBox has focus
-- (the parent frame's OnKeyDown doesn't fire when an EditBox has consumed the keystroke).
function GlueConsole_StripBacktickAndToggle(editBox)
local text = editBox:GetText();
if ( text == nil ) then return false; end
if ( string.find(text, "`", 1, true) == nil ) then return false; end
editBox:SetText((string.gsub(text, "`", "")));
GlueConsole_Toggle();
return true;
end
function GlueConsole_Submit()
local text = GlueConsoleInput:GetText();
GlueConsoleInput:SetText("");
if ( text == nil or text == "" ) then return; end
GlueConsole_Print("> " .. text);
if ( RunScript ) then
RunScript(text);
else
GlueConsole_Print("|cffff4444RunScript is not available in this context|r");
end
end
+119
View File
@@ -0,0 +1,119 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.blizzard.com/wow/ui/
..\FrameXML\UI.xsd">
<Script file="GlueConsole.lua"/>
<Frame name="GlueConsoleFrame" parent="GlueParent" frameStrata="FULLSCREEN_DIALOG" toplevel="true" hidden="true" movable="true">
<Size>
<AbsDimension x="600" y="320"/>
</Size>
<Anchors>
<Anchor point="TOP">
<Offset>
<AbsDimension x="0" y="-20"/>
</Offset>
</Anchor>
</Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets>
<AbsInset left="11" right="12" top="12" bottom="11"/>
</BackgroundInsets>
<TileSize>
<AbsValue val="32"/>
</TileSize>
<EdgeSize>
<AbsValue val="32"/>
</EdgeSize>
</Backdrop>
<Layers>
<Layer level="OVERLAY">
<FontString name="GlueConsoleTitle" inherits="GlueFontNormalSmall" text="Lua Console (` to close, Enter to run)">
<Anchors>
<Anchor point="TOP">
<Offset>
<AbsDimension x="0" y="-10"/>
</Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Frame name="GlueConsoleOutputBg">
<Size>
<AbsDimension x="568" y="240"/>
</Size>
<Anchors>
<Anchor point="TOPLEFT">
<Offset>
<AbsDimension x="16" y="-28"/>
</Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture>
<Color r="0" g="0" b="0" a="0.5"/>
</Texture>
</Layer>
</Layers>
<Frames>
<ScrollingMessageFrame name="GlueConsoleOutput">
<Size>
<AbsDimension x="556" y="232"/>
</Size>
<Anchors>
<Anchor point="TOPLEFT">
<Offset>
<AbsDimension x="6" y="-4"/>
</Offset>
</Anchor>
</Anchors>
<FontString font="Fonts\FRIZQT__.TTF" justifyH="LEFT">
<FontHeight>
<AbsValue val="12"/>
</FontHeight>
<Color r="1" g="1" b="1"/>
</FontString>
</ScrollingMessageFrame>
</Frames>
</Frame>
<EditBox name="GlueConsoleInput" letters="2000" autoFocus="false">
<Size>
<AbsDimension x="568" y="20"/>
</Size>
<Anchors>
<Anchor point="BOTTOMLEFT">
<Offset>
<AbsDimension x="16" y="16"/>
</Offset>
</Anchor>
</Anchors>
<Backdrop bgFile="Interface\ChatFrame\ChatFrameBackground" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
<BackgroundInsets>
<AbsInset left="4" right="4" top="4" bottom="4"/>
</BackgroundInsets>
<TileSize>
<AbsValue val="16"/>
</TileSize>
<EdgeSize>
<AbsValue val="16"/>
</EdgeSize>
</Backdrop>
<FontString font="Fonts\FRIZQT__.TTF">
<FontHeight>
<AbsValue val="12"/>
</FontHeight>
<Color r="1" g="1" b="1"/>
</FontString>
<Scripts>
<OnEnterPressed>GlueConsole_Submit();</OnEnterPressed>
<OnEscapePressed>GlueConsole_Toggle();</OnEscapePressed>
</Scripts>
</EditBox>
</Frames>
<Scripts>
<OnLoad>GlueConsole_OnLoad();</OnLoad>
<OnMouseDown>this:StartMoving();</OnMouseDown>
<OnMouseUp>this:StopMovingOrSizing();</OnMouseUp>
</Scripts>
</Frame>
</Ui>
+65
View File
@@ -0,0 +1,65 @@
# Octo GlueXML
Custom login and character-select UI for [Octo](https://octowow.st). Overlays
a small subset of the stock glue files with three features; everything else in
`Interface/GlueXML/` is loaded unchanged from the base MPQs.
## Features
- **Saved-account login.** The login screen shows a scrollable list of
previously-used accounts with their last-used timestamp. Clicking a row fills
the account name and hides the password field; a login without a typed
password logs in using the credential stored in the Windows Credential
Manager. A "Change password" button reveals the field on demand when you
need to update a saved credential.
- **Character reordering.** Hold on a character in the select screen for half
a second to enter drag mode, then move it up or down. Order is saved
per-realm and restored on the next login.
## ClassicAPI dependency
The saved-login and character-order features rely on Lua bindings exposed by
the ClassicAPI DLL, which is loaded alongside the client. Both features guard
their entry points with `type(fn) ~= "function"` checks so the UI still
functions when the DLL isn't present — you just lose persistence.
### Saved login — Windows Credential Manager
Passwords never live in Lua or in `WTF/`. They go straight from the password
field into the OS credential vault, and login submits are dispatched from C so
the plaintext never crosses back.
| Binding | Purpose |
|---|---|
| `SaveAccount(name, password)` | Writes a credential-vault entry. Called when the user types a password (either a first-time save or a "Change password" update). |
| `LoginWithSavedAccount(name)` | C-side dispatch of the login using the vault password for `name`. |
| `GetSavedAccounts()` | Returns `{ {name=…, lastUsed=epochSeconds}, … }` — `lastUsed` is the Windows `LastWritten` timestamp on the vault entry, auto-refreshed by `LoginWithSavedAccount`. |
| `DeleteAccount(name)` | Removes a vault entry (backs the "Remove account" button). |
| `GetSavedAccountName()` / `SetSavedAccountName(name)` | Persists just the *last-used* account name (via `SavedVariables`) so the same row is pre-selected on next launch. No password, no character map — those were dropped alongside the plaintext store. |
### Character sort order — per-realm WTF persistence
Persistence is keyed by character *name*, not by server-assigned charID. Slots
renumber every time a character is added or deleted, so name is the only
stable identifier.
| Binding | Purpose |
|---|---|
| `GetSavedCharacterOrder(realm)` | Reads a `\|`-separated name string for the current realm. |
| `SetSavedCharacterOrder(realm, names)` | Writes it back. |
On every `CHARACTER_LIST_UPDATE` the code loads the saved string, resolves
each name to the current charID, drops names the server no longer knows about
(deletions), appends any characters not in the saved list (creations), and
re-saves. That means the on-disk order self-cleans without a hook in the
delete path.
## Releases
Push a `v*` tag to trigger the
[mpq-packager](https://octowow.st/git/paste/mpq-packager) Gitea Action
(`.gitea/workflows/release.yml`), which packages this repo into a
`patch-Z.mpq` via StormLib according to `mpq.yaml`. `Z` sorts after Blizzard's
stock MPQs, so its files win on conflict. Only `.lua`, `.xml`, and `.toc`
files are placed in the MPQ — this README, workflow files, and build config
are excluded.
+20
View File
@@ -0,0 +1,20 @@
# Packs this repo into Interface\GlueXML\ inside a patch-Z.mpq.
# Z sorts after Blizzard's stock MPQs, so its files win on conflict.
# Built by paste/mpq-packager via .gitea/workflows/release.yml on a v* tag.
name: GlueXML
patch-letter: Z
substitute:
enabled: true
extensions: [".lua", ".xml", ".toc"]
contents:
- src: .
dest: Interface/GlueXML
ignore:
- ".git/**"
- ".gitea/**"
- "mpq.yaml"
- "*.md"
- "dist/**"