From de1b2190f38d0b6149d01480524b1d61412e0213 Mon Sep 17 00:00:00 2001 From: Mehmet Samet Duman Date: Fri, 11 Sep 2026 19:32:02 +0300 Subject: [PATCH] A skin management system has been added Signed-off-by: Mehmet Samet Duman --- buildconfig/BuildConfig.cpp.in | 1 + buildconfig/BuildConfig.h | 5 + launcher/CMakeLists.txt | 26 +- launcher/main.cpp | 1 + launcher/minecraft/auth/AccountData.cpp | 4 +- launcher/minecraft/auth/AccountData.h | 26 +- launcher/minecraft/auth/Parsers.cpp | 193 ++++- launcher/minecraft/auth/Parsers.h | 14 + .../minecraft/skins/ProfileSkinImport.cpp | 167 ++++ launcher/minecraft/skins/ProfileSkinImport.h | 108 +++ launcher/minecraft/skins/SkinEntry.cpp | 392 ++++++++++ launcher/minecraft/skins/SkinEntry.h | 143 ++++ launcher/minecraft/skins/SkinLibrary.cpp | 572 ++++++++++++++ launcher/minecraft/skins/SkinLibrary.h | 137 ++++ launcher/plugin/CoreSupersededPlugins.h | 2 + launcher/plugin/PluginManager.cpp | 16 +- launcher/plugin/plugins/CMakeLists.txt | 3 +- .../plugin/plugins/SkinManager/CMakeLists.txt | 110 --- .../plugins/SkinManager/SkinManagerDialog.cpp | 457 ----------- .../plugins/SkinManager/SkinManagerDialog.h | 75 -- .../plugins/SkinManager/SkinManagerDialog.ui | 185 ----- .../plugins/SkinManager/SkinManagerPlugin.cpp | 337 -------- .../plugin/plugins/SkinManager/SkinModel.cpp | 240 ------ .../plugin/plugins/SkinManager/SkinModel.h | 88 --- .../plugins/SkinManager/SkinViewerWidget.cpp | 489 ------------ .../plugins/SkinManager/SkinViewerWidget.h | 117 --- .../plugins/SkinManager/skinmanager.qrc | 26 - launcher/ui/MainWindow.cpp | 14 + launcher/ui/MainWindow.h | 2 + launcher/ui/MainWindow.ui | 14 + launcher/ui/dialogs/SkinUploadDialog.cpp | 161 ---- launcher/ui/dialogs/SkinUploadDialog.h | 49 -- launcher/ui/dialogs/SkinUploadDialog.ui | 97 --- .../ui/dialogs/skins/SkinManageDialog.cpp | 735 ++++++++++++++++++ launcher/ui/dialogs/skins/SkinManageDialog.h | 129 +++ launcher/ui/dialogs/skins/SkinManageDialog.ui | 203 +++++ launcher/ui/dialogs/skins/render/CubeMesh.cpp | 285 +++++++ launcher/ui/dialogs/skins/render/CubeMesh.h | 82 ++ .../ui/dialogs/skins/render/PlayerScene.cpp | 346 +++++++++ .../ui/dialogs/skins/render/PlayerScene.h | 80 ++ .../skins/render/SkinPreviewSurface.cpp | 424 ++++++++++ .../dialogs/skins/render/SkinPreviewSurface.h | 126 +++ launcher/ui/pages/global/AccountListPage.cpp | 14 +- 43 files changed, 4244 insertions(+), 2451 deletions(-) create mode 100644 launcher/minecraft/skins/ProfileSkinImport.cpp create mode 100644 launcher/minecraft/skins/ProfileSkinImport.h create mode 100644 launcher/minecraft/skins/SkinEntry.cpp create mode 100644 launcher/minecraft/skins/SkinEntry.h create mode 100644 launcher/minecraft/skins/SkinLibrary.cpp create mode 100644 launcher/minecraft/skins/SkinLibrary.h delete mode 100644 launcher/plugin/plugins/SkinManager/CMakeLists.txt delete mode 100644 launcher/plugin/plugins/SkinManager/SkinManagerDialog.cpp delete mode 100644 launcher/plugin/plugins/SkinManager/SkinManagerDialog.h delete mode 100644 launcher/plugin/plugins/SkinManager/SkinManagerDialog.ui delete mode 100644 launcher/plugin/plugins/SkinManager/SkinManagerPlugin.cpp delete mode 100644 launcher/plugin/plugins/SkinManager/SkinModel.cpp delete mode 100644 launcher/plugin/plugins/SkinManager/SkinModel.h delete mode 100644 launcher/plugin/plugins/SkinManager/SkinViewerWidget.cpp delete mode 100644 launcher/plugin/plugins/SkinManager/SkinViewerWidget.h delete mode 100644 launcher/plugin/plugins/SkinManager/skinmanager.qrc delete mode 100644 launcher/ui/dialogs/SkinUploadDialog.cpp delete mode 100644 launcher/ui/dialogs/SkinUploadDialog.h delete mode 100644 launcher/ui/dialogs/SkinUploadDialog.ui create mode 100644 launcher/ui/dialogs/skins/SkinManageDialog.cpp create mode 100644 launcher/ui/dialogs/skins/SkinManageDialog.h create mode 100644 launcher/ui/dialogs/skins/SkinManageDialog.ui create mode 100644 launcher/ui/dialogs/skins/render/CubeMesh.cpp create mode 100644 launcher/ui/dialogs/skins/render/CubeMesh.h create mode 100644 launcher/ui/dialogs/skins/render/PlayerScene.cpp create mode 100644 launcher/ui/dialogs/skins/render/PlayerScene.h create mode 100644 launcher/ui/dialogs/skins/render/SkinPreviewSurface.cpp create mode 100644 launcher/ui/dialogs/skins/render/SkinPreviewSurface.h diff --git a/buildconfig/BuildConfig.cpp.in b/buildconfig/BuildConfig.cpp.in index 4172f7c77..66d191010 100644 --- a/buildconfig/BuildConfig.cpp.in +++ b/buildconfig/BuildConfig.cpp.in @@ -33,6 +33,7 @@ Config::Config() MESHMC_CONFIGFILE = "@MeshMC_ConfigFile@"; MESHMC_GIT = "@MeshMC_Git@"; MESHMC_APPID = "@MeshMC_AppID@"; + MESHMC_ENVNAME = "@MeshMC_EnvName@"; USER_AGENT = "@MeshMC_UserAgent@"; USER_AGENT_UNCACHED = USER_AGENT + " (Uncached)"; diff --git a/buildconfig/BuildConfig.h b/buildconfig/BuildConfig.h index 09c8fd285..4a0945c5c 100644 --- a/buildconfig/BuildConfig.h +++ b/buildconfig/BuildConfig.h @@ -37,6 +37,11 @@ class Config QString MESHMC_GIT; QString MESHMC_APPID; + /** Prefix for the launcher's own environment variables, e.g. "MESHMC" + * for MESHMC_DISABLE_GLVULKAN. Configurable rather than hardcoded so a + * rebranded build's variables carry its own name. */ + QString MESHMC_ENVNAME; + /// The major version number. int VERSION_MAJOR; /// The minor version number. diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index f5d7c68fc..4894de93a 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -431,6 +431,14 @@ set(MINECRAFT_SOURCES minecraft/services/SkinDelete.cpp minecraft/services/SkinDelete.h + # Local skin library + minecraft/skins/SkinEntry.cpp + minecraft/skins/SkinEntry.h + minecraft/skins/SkinLibrary.cpp + minecraft/skins/SkinLibrary.h + minecraft/skins/ProfileSkinImport.cpp + minecraft/skins/ProfileSkinImport.h + mojang/PackageManifest.h mojang/PackageManifest.cpp ) @@ -776,6 +784,7 @@ SET(MESHMC_SOURCES resources/documents/documents.qrc resources/breeze_dark/breeze_dark.qrc resources/breeze_light/breeze_light.qrc + resources/shaders/shaders.qrc ${CMAKE_BINARY_DIR}/${MeshMC_AppBinaryName}.qrc # Icons @@ -993,8 +1002,14 @@ SET(MESHMC_SOURCES ui/dialogs/UpdateAvailableDialog.h ui/dialogs/VersionSelectDialog.cpp ui/dialogs/VersionSelectDialog.h - ui/dialogs/SkinUploadDialog.cpp - ui/dialogs/SkinUploadDialog.h + ui/dialogs/skins/SkinManageDialog.cpp + ui/dialogs/skins/SkinManageDialog.h + ui/dialogs/skins/render/CubeMesh.cpp + ui/dialogs/skins/render/CubeMesh.h + ui/dialogs/skins/render/PlayerScene.cpp + ui/dialogs/skins/render/PlayerScene.h + ui/dialogs/skins/render/SkinPreviewSurface.cpp + ui/dialogs/skins/render/SkinPreviewSurface.h ui/dialogs/DownloadContentDialog.cpp ui/dialogs/DownloadContentDialog.h ui/dialogs/DownloadSummaryDialog.cpp @@ -1114,7 +1129,7 @@ set(MESHMC_UI_FILES ui/dialogs/PluginsDialog.ui ui/dialogs/NewComponentDialog.ui ui/dialogs/ProfileSelectDialog.ui - ui/dialogs/SkinUploadDialog.ui + ui/dialogs/skins/SkinManageDialog.ui ui/dialogs/CreateShortcutDialog.ui ui/dialogs/ExportInstanceDialog.ui ui/dialogs/ExportPackDialog.ui @@ -1176,6 +1191,7 @@ set(MESHMC_QRC_FILES resources/documents/documents.qrc resources/breeze_dark/breeze_dark.qrc resources/breeze_light/breeze_light.qrc + resources/shaders/shaders.qrc ${CMAKE_BINARY_DIR}/${MeshMC_AppBinaryName}.qrc ) @@ -1248,6 +1264,10 @@ target_link_libraries(MeshMC_logic LocalPeer::LocalPeer ) +if(QT_VERSION_MAJOR EQUAL 6) + target_link_libraries(MeshMC_logic Qt6::OpenGL) +endif() + # Plugin system needs dlopen/dlsym on Unix if(UNIX AND NOT APPLE) target_link_libraries(MeshMC_logic ${CMAKE_DL_LIBS}) diff --git a/launcher/main.cpp b/launcher/main.cpp index 4fb5473fb..f47395404 100644 --- a/launcher/main.cpp +++ b/launcher/main.cpp @@ -150,6 +150,7 @@ int main(int argc, char* argv[]) Q_INIT_RESOURCE(backgrounds); Q_INIT_RESOURCE(documents); Q_INIT_RESOURCE(meshmc); + Q_INIT_RESOURCE(shaders); Q_INIT_RESOURCE(pe_dark); Q_INIT_RESOURCE(pe_light); diff --git a/launcher/minecraft/auth/AccountData.cpp b/launcher/minecraft/auth/AccountData.cpp index 4369a8297..66e7a3b6a 100644 --- a/launcher/minecraft/auth/AccountData.cpp +++ b/launcher/minecraft/auth/AccountData.cpp @@ -224,7 +224,7 @@ namespace qWarning() << "cape data is something unexpected"; return MinecraftProfile(); } - out.capes[cape.id] = cape; + out.capes.append(cape); } } // current cape @@ -232,7 +232,7 @@ namespace auto capeV = tokenObject.value("cape"); if (capeV.isString()) { auto currentCape = capeV.toString(); - if (out.capes.contains(currentCape)) { + if (out.capeById(currentCape)) { out.currentCape = currentCape; } } diff --git a/launcher/minecraft/auth/AccountData.h b/launcher/minecraft/auth/AccountData.h index 5c73cae97..60877131c 100644 --- a/launcher/minecraft/auth/AccountData.h +++ b/launcher/minecraft/auth/AccountData.h @@ -20,6 +20,7 @@ #pragma once #include #include +#include #include #include #include @@ -51,7 +52,30 @@ struct MinecraftProfile { QString name; Skin skin; QString currentCape; - QMap capes; + + /* Capes the account owns, in the order the profile service returned + * them. + * + * A list rather than a map keyed by id: the order is meaningful. It is + * the order the cape picker shows them in, and it is the order Mojang + * lists them in -- which a map would silently replace with alphabetical + * order by id, i.e. by opaque UUID, i.e. arbitrary. The stored JSON has + * always been an array, so nothing about the on-disk format changes; + * round-tripping through a list actually preserves it where the map did + * not. */ + QList capes; + + /* Look a cape up by id, or nullptr if the account does not own it. */ + const Cape* capeById(const QString& id) const + { + for (const Cape& cape : capes) { + if (cape.id == id) { + return &cape; + } + } + return nullptr; + } + Katabasis::Validity validity = Katabasis::Validity::None; }; diff --git a/launcher/minecraft/auth/Parsers.cpp b/launcher/minecraft/auth/Parsers.cpp index c9f4fd10a..6dbe98ea8 100644 --- a/launcher/minecraft/auth/Parsers.cpp +++ b/launcher/minecraft/auth/Parsers.cpp @@ -241,7 +241,9 @@ namespace Parsers continue; } - output.capes[capeOut.id] = capeOut; + /* Appended, not keyed: the service's order is the order the cape + * picker shows. */ + output.capes.append(capeOut); } output.currentCape = currentCape; output.validity = Katabasis::Validity::Certain; @@ -364,4 +366,193 @@ namespace Parsers return true; } + namespace + { + /* Skins of the MHF_Steve and MHF_Alex accounts. + * + * The session server omits the SKIN texture entirely for players who + * never set one, so there is no URL to read. These two are the + * canonical default skins, and pointing at them is the only way to + * show such a player as the game shows them. */ + const char* const kDefaultSkinUrlClassic = + "https://textures.minecraft.net/texture/" + "1a4af718455d4aab528e7a61f86fa25e6a369d1768dcb13f7df319a713eb810b"; + const char* const kDefaultSkinUrlSlim = + "https://textures.minecraft.net/texture/" + "83cee5ca6afcdb171285aa00e8049c297b2dbeba0efb8ff970a5677a1b644032"; + + /* Which of the two default skins a player without a custom one gets. + * + * The game decides this from the Java hashCode of the profile UUID: + * even means the classic model, odd means slim. Reproducing it means + * folding the 128-bit id down the same way Java's UUID.hashCode() + * does -- xor the two halves, then xor the halves of that. */ + bool defaultsToClassicModel(QString uuid) + { + uuid.remove('-'); + if (uuid.size() != 32) { + /* Not a UUID we can read; classic is the safer guess, since + * it is what the game falls back to as well. */ + return true; + } + + bool okHigh = false; + bool okLow = false; + const quint64 high = uuid.left(16).toULongLong(&okHigh, 16); + const quint64 low = uuid.right(16).toULongLong(&okLow, 16); + if (!okHigh || !okLow) { + return true; + } + + const quint64 folded = high ^ low; + const quint32 hashCode = static_cast(folded >> 32) ^ + static_cast(folded); + return hashCode % 2 == 0; + } + + /* The session server still hands out plain-http texture URLs. Qt will + * follow them, but there is no reason to fetch a skin in the clear + * when the same host serves it over TLS. */ + QString preferHttps(QString textureUrl) + { + return textureUrl.replace( + QLatin1String("http://textures.minecraft.net"), + QLatin1String("https://textures.minecraft.net")); + } + + /* Pull the base64 "textures" property out of a session profile. */ + QByteArray findTexturePayload(const QJsonArray& properties) + { + for (const QJsonValue& property : properties) { + const QJsonObject entry = property.toObject(); + if (entry.value("name").toString() != + QLatin1String("textures")) { + continue; + } + const QJsonValue value = entry.value("value"); + if (!value.isString()) { + continue; + } + const QByteArray decoded = QByteArray::fromBase64( + value.toString().toUtf8(), + QByteArray::AbortOnBase64DecodingErrors); + if (!decoded.isEmpty()) { + return decoded; + } + } + return QByteArray(); + } + } // namespace + + bool parseMojangSessionProfile(QByteArray& data, MinecraftProfile& output) + { + /* This is the *public* profile endpoint + * (sessionserver.mojang.com/session/minecraft/profile/), not the + * authenticated one parseMinecraftProfile() handles. It describes + * somebody else's account, so it carries no entitlements and no cape + * ids -- only texture URLs, and those are buried in a base64 blob. */ + qCDebug(minecraftauthLog) << "Parsing Mojang session profile..."; + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from sessionserver as " + "JSON:" + << jsonError.errorString(); + return false; + } + + QJsonObject obj = doc.object(); + if (!getString(obj.value("id"), output.id)) { + qWarning() << "Session profile id is not a string"; + return false; + } + if (!getString(obj.value("name"), output.name)) { + qWarning() << "Session profile name is not a string"; + return false; + } + + const QByteArray texturePayload = + findTexturePayload(obj.value("properties").toArray()); + if (texturePayload.isEmpty()) { + qWarning() << "Session profile carries no texture payload"; + return false; + } + + doc = QJsonDocument::fromJson(texturePayload, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse the session texture payload as " + "JSON:" + << jsonError.errorString(); + return false; + } + + const QJsonValue textures = doc.object().value("textures"); + if (!textures.isObject()) { + qWarning() << "Session texture payload has no textures object"; + return false; + } + + Skin skinOut; + /* Start from the default this player would be shown with, so that a + * profile with no SKIN entry still yields something displayable. */ + const bool classic = defaultsToClassicModel(output.id); + skinOut.variant = classic ? QStringLiteral("CLASSIC") + : QStringLiteral("SLIM"); + skinOut.url = classic ? QLatin1String(kDefaultSkinUrlClassic) + : QLatin1String(kDefaultSkinUrlSlim); + /* The endpoint does not expose texture ids at all. Nothing downstream + * of here needs one, so it is left empty rather than invented. */ + + Cape capeOut; + bool hasCape = false; + + const QJsonObject textureObj = textures.toObject(); + for (auto it = textureObj.constBegin(); it != textureObj.constEnd(); + ++it) { + if (!it->isObject()) { + continue; + } + const QJsonObject texture = it->toObject(); + + if (it.key() == QLatin1String("SKIN")) { + if (!getString(texture.value("url"), skinOut.url)) { + qWarning() << "Session profile skin url is not a string"; + return false; + } + skinOut.url = preferHttps(skinOut.url); + + /* Present only for slim skins; absent means classic, which + * is already what variant holds unless the UUID said + * otherwise -- so read it, but do not require it. */ + const QJsonValue metadata = texture.value("metadata"); + if (metadata.isObject()) { + getString(metadata.toObject().value("model"), + skinOut.variant); + } + } else if (it.key() == QLatin1String("CAPE")) { + if (!getString(texture.value("url"), capeOut.url)) { + qWarning() << "Session profile cape url is not a string"; + return false; + } + capeOut.url = preferHttps(capeOut.url); + /* No id is published for it either. A stable placeholder is + * enough: this profile is only ever read to copy a look, and + * nothing tries to equip somebody else's cape. */ + capeOut.id = QStringLiteral("cape"); + capeOut.alias = QStringLiteral("cape"); + hasCape = true; + } + } + + output.skin = skinOut; + if (hasCape) { + output.capes.clear(); + output.capes.append(capeOut); + output.currentCape = capeOut.id; + } + output.validity = Katabasis::Validity::Certain; + return true; + } + } // namespace Parsers diff --git a/launcher/minecraft/auth/Parsers.h b/launcher/minecraft/auth/Parsers.h index 0ceed1068..f998651c4 100644 --- a/launcher/minecraft/auth/Parsers.h +++ b/launcher/minecraft/auth/Parsers.h @@ -34,6 +34,20 @@ namespace Parsers bool parseMojangResponse(QByteArray& data, Katabasis::Token& output); bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output); + + /* Parse a *public* profile from + * sessionserver.mojang.com/session/minecraft/profile/. + * + * Unlike parseMinecraftProfile(), which reads the signed-in account's own + * profile, this describes an arbitrary player: only id, name and texture + * URLs come back, and the textures arrive as a base64 blob. Used to copy + * another player's skin into the local library. + * + * Fills output.skin (with the default Steve/Alex texture when the player + * never set one) and, if the player has a cape, a single cape entry under + * a placeholder id -- the endpoint publishes no cape ids. */ + bool parseMojangSessionProfile(QByteArray& data, MinecraftProfile& output); + bool parseMinecraftEntitlements(QByteArray& data, MinecraftEntitlement& output); bool parseRolloutResponse(QByteArray& data, bool& result); diff --git a/launcher/minecraft/skins/ProfileSkinImport.cpp b/launcher/minecraft/skins/ProfileSkinImport.cpp new file mode 100644 index 000000000..755bedc6b --- /dev/null +++ b/launcher/minecraft/skins/ProfileSkinImport.cpp @@ -0,0 +1,167 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "ProfileSkinImport.h" + +#include +#include +#include +#include +#include + +#include "Application.h" +#include "minecraft/auth/AccountData.h" +#include "minecraft/auth/Parsers.h" +#include "net/Download.h" +#include "net/NetJob.h" + +namespace +{ + const char* const kNameLookupEndpoint = + "https://api.minecraftservices.com/minecraft/profile/lookup/name/"; + const char* const kSessionProfileEndpoint = + "https://sessionserver.mojang.com/session/minecraft/profile/"; +} // namespace + +ProfileSkinImport::ProfileSkinImport(QObject* parent, QString username, + QString targetPath) + : Task(parent), m_username(std::move(username)), + m_targetPath(std::move(targetPath)) +{ +} + +bool ProfileSkinImport::abort() +{ + if (m_job) { + m_job->abort(); + } + emitAborted(); + return true; +} + +void ProfileSkinImport::executeTask() +{ + lookUpUuid(); +} + +void ProfileSkinImport::lookUpUuid() +{ + setStatus(tr("Looking up %1").arg(m_username)); + + m_response.clear(); + m_job = new NetJob(tr("Look up user"), APPLICATION->network()); + /* Percent-encoded: Mojang names are restricted to word characters today, + * but this string comes straight out of a text field. */ + const QString endpoint = + QString::fromLatin1(kNameLookupEndpoint) + + QString::fromUtf8(QUrl::toPercentEncoding(m_username)); + m_job->addNetAction( + Net::Download::makeByteArray(QUrl(endpoint), &m_response)); + + connect(m_job.get(), &NetJob::failed, this, [this](QString reason) { + qCritical() << "Could not look up the UUID for" << m_username << ":" + << reason; + emitFailed(tr("failed to get user UUID")); + }); + connect(m_job.get(), &NetJob::succeeded, this, [this] { + QJsonParseError parseError{}; + const QJsonDocument doc = + QJsonDocument::fromJson(m_response, &parseError); + if (parseError.error != QJsonParseError::NoError) { + qWarning() << "The name lookup response is not valid JSON at" + << parseError.offset << ":" + << parseError.errorString(); + emitFailed(tr("failed to parse get user UUID response")); + return; + } + + m_uuid = doc.object().value(QStringLiteral("id")).toString(); + if (m_uuid.isEmpty()) { + /* A name that nobody owns answers with a body that has no id + * rather than with an HTTP error, so this is the branch an + * ordinary typo lands in. */ + emitFailed(tr("user id is empty")); + return; + } + fetchProfile(); + }); + + m_job->start(); +} + +void ProfileSkinImport::fetchProfile() +{ + setStatus(tr("Fetching the profile of %1").arg(m_username)); + + m_response.clear(); + m_job = new NetJob(tr("Download user profile"), APPLICATION->network()); + const QString endpoint = + QString::fromLatin1(kSessionProfileEndpoint) + m_uuid; + m_job->addNetAction( + Net::Download::makeByteArray(QUrl(endpoint), &m_response)); + + connect(m_job.get(), &NetJob::failed, this, [this](QString reason) { + qCritical() << "Could not fetch the profile of" << m_username << ":" + << reason; + emitFailed(tr("failed to get user profile")); + }); + connect(m_job.get(), &NetJob::succeeded, this, [this] { + MinecraftProfile profile; + if (!Parsers::parseMojangSessionProfile(m_response, profile)) { + emitFailed(tr("failed to parse get user profile response")); + return; + } + + m_textureUrl = profile.skin.url; + m_arms = profile.skin.variant.toUpper() == QLatin1String("SLIM") + ? SkinEntry::Arms::Slim + : SkinEntry::Arms::Classic; + m_capeId = profile.currentCape; + + if (m_textureUrl.isEmpty()) { + /* The parser substitutes a default skin URL when the player has + * none, so an empty one here means the payload was malformed in + * a way the parser accepted. */ + emitFailed(tr("failed to parse get user profile response")); + return; + } + downloadTexture(); + }); + + m_job->start(); +} + +void ProfileSkinImport::downloadTexture() +{ + setStatus(tr("Downloading the skin of %1").arg(m_username)); + + m_job = new NetJob(tr("Download user skin"), APPLICATION->network()); + m_job->addNetAction( + Net::Download::makeFile(QUrl(m_textureUrl), m_targetPath)); + + connect(m_job.get(), &NetJob::failed, this, [this](QString reason) { + qCritical() << "Could not download the skin of" << m_username << ":" + << reason; + emitFailed(tr("failed to download skin")); + }); + connect(m_job.get(), &NetJob::succeeded, this, + [this] { emitSucceeded(); }); + + m_job->start(); +} diff --git a/launcher/minecraft/skins/ProfileSkinImport.h b/launcher/minecraft/skins/ProfileSkinImport.h new file mode 100644 index 000000000..d4620205d --- /dev/null +++ b/launcher/minecraft/skins/ProfileSkinImport.h @@ -0,0 +1,108 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include "QObjectPtr.h" +#include "minecraft/skins/SkinEntry.h" +#include "tasks/Task.h" + +class NetJob; + +/* Download another player's skin by username, into a file. + * + * Three requests that each depend on the one before it: + * + * 1. name -> uuid api.minecraftservices.com/.../lookup/name/ + * 2. uuid -> profile sessionserver.mojang.com/.../profile/ + * 3. url -> png whatever the profile's SKIN texture points at + * + * The URL for each step is only known once the previous one has answered, so + * this is one task that runs three jobs in sequence rather than a job with + * three actions queued up front. + * + * On failure the reason is deliberately short and non-technical ("failed to + * get user UUID"): the caller puts it inside a sentence naming the player, so + * it has to read as a clause rather than as a sentence of its own. + */ +class ProfileSkinImport : public Task +{ + Q_OBJECT + + public: + ProfileSkinImport(QObject* parent, QString username, QString targetPath); + ~ProfileSkinImport() override = default; + + bool canAbort() const override + { + return true; + } + + /* Where the PNG was written. Valid only after success. */ + QString targetPath() const + { + return m_targetPath; + } + + /* The profile's texture URL, so the imported skin can be recognised as + * the one that player wears. */ + QString textureUrl() const + { + return m_textureUrl; + } + + SkinEntry::Arms arms() const + { + return m_arms; + } + + /* The cape the player is wearing, under the placeholder id the session + * server forces on us. Empty when they have none. */ + QString capeId() const + { + return m_capeId; + } + + public slots: + bool abort() override; + + protected: + void executeTask() override; + + private: + void lookUpUuid(); + void fetchProfile(); + void downloadTexture(); + + QString m_username; + QString m_targetPath; + + QString m_uuid; + QString m_textureUrl; + QString m_capeId; + SkinEntry::Arms m_arms = SkinEntry::Arms::Classic; + + /* Reused for each step; replaced rather than accumulated so that abort() + * only ever has one thing to stop. */ + shared_qobject_ptr m_job; + QByteArray m_response; +}; diff --git a/launcher/minecraft/skins/SkinEntry.cpp b/launcher/minecraft/skins/SkinEntry.cpp new file mode 100644 index 000000000..99ef8863d --- /dev/null +++ b/launcher/minecraft/skins/SkinEntry.cpp @@ -0,0 +1,392 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "SkinEntry.h" + +#include +#include +#include +#include +#include +#include + +/* --------------------------------------------------------------------------- + * Every coordinate in this file is transcribed from the Minecraft skin + * format, not computed from a rule about it. + * + * An earlier version of this file expressed the layout as a formula -- "a box + * unwrap is a cross, so face N of a w*h*d box sits at offset X" -- and then + * generated the legacy limb copies and the thumbnail source rectangles from + * it. That is the wrong shape for this problem. The formula was an + * interpretation of the format, so any mistake in it was invisible: there was + * nothing to hold the output up against, and a single wrong term silently + * moved every limb at once. + * + * A format is data. It is written out as data, so that it can be read against + * the format and checked a line at a time. + * ------------------------------------------------------------------------ */ + +namespace +{ + /* Force a rectangle fully opaque, in place. + * + * Skins are authored with the base layer opaque, but plenty of editors + * leave stray alpha there. Left alone it renders as a hole straight + * through the model, so the base layer is made opaque unconditionally. + * + * NOTE: the whole rectangle is covered, its last row and column + * included. Qt's QRect::right()/bottom() are inclusive, so the obvious + * `x < rect.right()` loop quietly skips those. */ + void fillAlpha(QImage& image, const QRect& region, int alpha) + { + const QRect clipped = + region.intersected(QRect(0, 0, image.width(), image.height())); + for (int y = clipped.top(); y <= clipped.bottom(); ++y) { + QRgb* line = reinterpret_cast(image.scanLine(y)); + for (int x = clipped.left(); x <= clipped.right(); ++x) { + const QRgb pixel = line[x]; + line[x] = + qRgba(qRed(pixel), qGreen(pixel), qBlue(pixel), alpha); + } + } + } + + /* The three rectangles of a 64x64 skin that belong to the base layer. + * Everything else is either second layer -- which is *supposed* to have + * transparency -- or unused padding. */ + const QRect kBaseLayerRegions[] = { + QRect(0, 0, 32, 16), /* head */ + QRect(0, 16, 64, 16), /* torso, right arm, right leg */ + QRect(16, 48, 32, 16), /* left arm, left leg */ + }; + + /* The quadrant that a legacy 64x32 skin does not use, and that becomes + * the head and arm second layer once the texture is widened. */ + const QRect kLegacyUnusedQuadrant(32, 0, 32, 32); + + /* Legacy 64x32 -> 64x64 limb copies. + * + * Legacy skins carry only the right arm and the right leg; the game drew + * the left ones by mirroring at render time. Widening the texture has to + * bake that mirror in. + * + * Each row: take the (x, y, w, h) rectangle, mirror it horizontally, and + * draw it at (x + offsetX, y + offsetY). Twelve rows, because each limb + * is six faces and the two side faces swap places when mirrored. */ + struct LegacyLimbCopy { + int x; + int y; + int offsetX; + int offsetY; + int width; + int height; + }; + + const LegacyLimbCopy kLegacyLimbCopies[] = { + /* right leg -> left leg */ + {4, 16, 16, 32, 4, 4}, + {8, 16, 16, 32, 4, 4}, + {0, 20, 24, 32, 4, 12}, + {4, 20, 16, 32, 4, 12}, + {8, 20, 8, 32, 4, 12}, + {12, 20, 16, 32, 4, 12}, + /* right arm -> left arm */ + {44, 16, -8, 32, 4, 4}, + {48, 16, -8, 32, 4, 4}, + {40, 20, 0, 32, 4, 12}, + {44, 20, -8, 32, 4, 12}, + {48, 20, -16, 32, 4, 12}, + {52, 20, -8, 32, 4, 12}, + }; + + void makeBaseLayerOpaque(QImage& skin) + { + for (const QRect& region : kBaseLayerRegions) { + fillAlpha(skin, region, 255); + } + } + + /* Legacy skins predate the second layer, and the top-right quadrant of + * the texture was simply unused -- but it was *not* required to be + * transparent, and the classic tools left it full of opaque garbage. Once + * such a skin is widened that quadrant becomes the head and arm second + * layer, and the garbage renders as a solid shell around the player. + * + * There is no flag to tell the two cases apart, so use the same heuristic + * the game does: a texture authored with a second layer will have *some* + * transparency in it. If every pixel up there is opaque, the layer was + * never meant to exist and is cleared. */ + void clearUnusedLegacyOverlay(QImage& skin) + { + for (int y = kLegacyUnusedQuadrant.top(); + y <= kLegacyUnusedQuadrant.bottom(); ++y) { + const QRgb* line = reinterpret_cast(skin.scanLine(y)); + for (int x = kLegacyUnusedQuadrant.left(); + x <= kLegacyUnusedQuadrant.right(); ++x) { + if (qAlpha(line[x]) < 128) { + /* Deliberately authored second layer -- leave it. */ + return; + } + } + } + fillAlpha(skin, kLegacyUnusedQuadrant, 0); + } + + /* Widen a legacy 64x32 texture into the modern 64x64 layout. */ + QImage widenLegacyTexture(const QImage& legacy) + { + QImage widened(64, 64, legacy.format()); + widened.fill(Qt::transparent); + { + QPainter painter(&widened); + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.drawImage(0, 0, legacy); + } + + /* Read every copy out before writing any of them back. The source and + * destination rectangles do not overlap, but taking a copy() of an + * image that has a live QPainter on it is asking for trouble anyway: + * the painter holds its own reference to the buffer. */ + struct PendingCopy { + QPoint target; + QImage pixels; + }; + QVarLengthArray pending; + + for (const LegacyLimbCopy& copy : kLegacyLimbCopies) { + const QImage face = + widened.copy(copy.x, copy.y, copy.width, copy.height) + .mirrored(true, false); + pending.append( + {QPoint(copy.x + copy.offsetX, copy.y + copy.offsetY), face}); + } + + { + QPainter painter(&widened); + painter.setCompositionMode(QPainter::CompositionMode_Source); + for (const PendingCopy& copy : pending) { + painter.drawImage(copy.target, copy.pixels); + } + } + + clearUnusedLegacyOverlay(widened); + return widened; + } + + /* Read a skin PNG and put it into the one shape the rest of the code + * expects: 64x64, ARGB32, base layer opaque. + * + * Anything that is not a skin is handed back untouched, so that + * SkinEntry::isUsable() can reject it on its original dimensions rather + * than on something this function invented. */ + QImage loadNormalisedTexture(const QString& path) + { + QImage skin(path); + + const int width = skin.width(); + const int height = skin.height(); + if (width != 64 || (height != 32 && height != 64)) { + return skin; + } + + /* Everything below walks scanlines as QRgb, and QPainter refuses to + * paint onto a paletted image at all. Normalising the format up front + * is what makes both safe -- including for premultiplied input, where + * treating the bytes as plain ARGB would corrupt the colours. */ + if (skin.format() != QImage::Format_ARGB32) { + skin = skin.convertToFormat(QImage::Format_ARGB32); + } + + if (height == 32) { + skin = widenLegacyTexture(skin); + } + makeBaseLayerOpaque(skin); + return skin; + } + + /* Flat 36x36 sprite: the model seen from the front on the left half and + * from the back on the right half, each drawn base layer first and second + * layer over it. + * + * The draw order is part of the result -- arms come after the body so they + * sit on top of the torso, which is what makes a slim arm read as slim at + * this size -- so it is written out as a sequence of draws rather than + * looped over a table that could reorder it. */ + QImage renderThumbnail(const QImage& texture, bool slim) + { + QImage sprite(36, 36, QImage::Format_ARGB32); + sprite.fill(Qt::transparent); + if (texture.isNull()) { + return sprite; + } + + /* A slim arm is 3 pixels wide instead of 4, which both narrows the + * source rectangle and shifts the outer arm a pixel inwards so it + * still touches the shoulder. */ + const int armWidth = slim ? 3 : 4; + const int armX = slim ? 1 : 0; + + QPainter paint(&sprite); + + /* front */ + /* head */ + paint.drawImage(4, 2, texture.copy(8, 8, 8, 8)); + paint.drawImage(4, 2, texture.copy(40, 8, 8, 8)); + /* torso */ + paint.drawImage(4, 10, texture.copy(20, 20, 8, 12)); + paint.drawImage(4, 10, texture.copy(20, 36, 8, 12)); + /* right leg */ + paint.drawImage(4, 22, texture.copy(4, 20, 4, 12)); + paint.drawImage(4, 22, texture.copy(4, 36, 4, 12)); + /* left leg */ + paint.drawImage(8, 22, texture.copy(20, 52, 4, 12)); + paint.drawImage(8, 22, texture.copy(4, 52, 4, 12)); + /* right arm */ + paint.drawImage(armX, 10, texture.copy(44, 20, armWidth, 12)); + paint.drawImage(armX, 10, texture.copy(44, 36, armWidth, 12)); + /* left arm */ + paint.drawImage(12, 10, texture.copy(36, 52, armWidth, 12)); + paint.drawImage(12, 10, texture.copy(52, 52, armWidth, 12)); + + /* back */ + /* head */ + paint.drawImage(24, 2, texture.copy(24, 8, 8, 8)); + paint.drawImage(24, 2, texture.copy(56, 8, 8, 8)); + /* torso */ + paint.drawImage(24, 10, texture.copy(32, 20, 8, 12)); + paint.drawImage(24, 10, texture.copy(32, 36, 8, 12)); + /* right leg */ + paint.drawImage(24, 22, texture.copy(12, 20, 4, 12)); + paint.drawImage(24, 22, texture.copy(12, 36, 4, 12)); + /* left leg */ + paint.drawImage(28, 22, texture.copy(28, 52, 4, 12)); + paint.drawImage(28, 22, texture.copy(12, 52, 4, 12)); + /* right arm */ + paint.drawImage(armX + 20, 10, + texture.copy(48 + armWidth, 20, armWidth, 12)); + paint.drawImage(armX + 20, 10, + texture.copy(48 + armWidth, 36, armWidth, 12)); + /* left arm */ + paint.drawImage(32, 10, texture.copy(40 + armWidth, 52, armWidth, 12)); + paint.drawImage(32, 10, texture.copy(56 + armWidth, 52, armWidth, 12)); + + return sprite; + } +} // namespace + +SkinEntry::SkinEntry(const QString& pngPath) + : m_path(pngPath), m_texture(loadNormalisedTexture(pngPath)), + m_arms(Arms::Classic) +{ + /* Nothing on disk says which arm width a bare PNG was drawn for, so it + * starts out Classic and the thumbnail is generated to match. Importing + * from a profile overrides both afterwards. */ + m_thumbnail = renderThumbnail(m_texture, false); +} + +SkinEntry::SkinEntry(const QDir& libraryDir, const QJsonObject& record) + : m_capeId(record.value(QStringLiteral("capeId")).toString()), + m_arms(Arms::Classic), + m_textureUrl(record.value(QStringLiteral("url")).toString()) +{ + if (record.value(QStringLiteral("model")).toString() == + QLatin1String("SLIM")) { + m_arms = Arms::Slim; + } + + /* The index stores the display name, not a path: the library directory is + * relocatable and the ".png" is an implementation detail of how entries + * are stored. */ + const QString name = record.value(QStringLiteral("name")).toString(); + m_path = libraryDir.absoluteFilePath(name + QStringLiteral(".png")); + + m_texture = loadNormalisedTexture(m_path); + m_thumbnail = renderThumbnail(m_texture, m_arms == Arms::Slim); +} + +QString SkinEntry::name() const +{ + return QFileInfo(m_path).completeBaseName(); +} + +QString SkinEntry::armsToken() const +{ + switch (m_arms) { + case Arms::Classic: + return QStringLiteral("CLASSIC"); + case Arms::Slim: + return QStringLiteral("SLIM"); + } + return QString(); +} + +bool SkinEntry::isUsable() const +{ + if (m_texture.isNull()) { + return false; + } + if (m_texture.width() != 64) { + return false; + } + /* 64 is what a normalised texture always ends up as; 32 is accepted so + * that a legacy file which somehow skipped widening is still treated as a + * skin rather than silently vanishing from the library. */ + const int height = m_texture.height(); + return height == 64 || height == 32; +} + +bool SkinEntry::renameTo(const QString& newName) +{ + const QFileInfo current(m_path); + const QString target = + QDir(current.absolutePath()) + .absoluteFilePath(newName + QStringLiteral(".png")); + + if (QFileInfo::exists(target)) { + /* Refusing beats overwriting: the other file is somebody's skin. */ + return false; + } + if (!QFile::rename(current.absoluteFilePath(), target)) { + return false; + } + m_path = target; + return true; +} + +void SkinEntry::setArms(Arms arms) +{ + m_arms = arms; + m_thumbnail = renderThumbnail(m_texture, m_arms == Arms::Slim); +} + +void SkinEntry::reload() +{ + m_texture = loadNormalisedTexture(m_path); + m_thumbnail = renderThumbnail(m_texture, m_arms == Arms::Slim); +} + +QJsonObject SkinEntry::toRecord() const +{ + QJsonObject record; + record[QStringLiteral("name")] = name(); + record[QStringLiteral("capeId")] = m_capeId; + record[QStringLiteral("url")] = m_textureUrl; + record[QStringLiteral("model")] = armsToken(); + return record; +} diff --git a/launcher/minecraft/skins/SkinEntry.h b/launcher/minecraft/skins/SkinEntry.h new file mode 100644 index 000000000..31a9a7bff --- /dev/null +++ b/launcher/minecraft/skins/SkinEntry.h @@ -0,0 +1,143 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +/* One skin in the local skin library. + * + * A skin is a PNG file on disk plus three pieces of metadata that the file + * itself cannot carry: which arm width the player model should use, which + * cape was worn with it, and the profile texture URL it was last uploaded + * as. The URL is what lets the library recognise "this saved skin is the + * one the account is currently wearing" after a restart, since the account + * only ever tells us a URL. + * + * The file name (without extension) is the identity of the entry: it is the + * display name, the key the library and the dialog pass around, and what + * renaming changes. Nothing else is unique -- two files can easily hold the + * same pixels. + * + * Deliberately a plain value type, not a QObject: the library keeps these in + * a QList and hands out raw pointers into it, and copies are made freely + * while rebuilding that list. + */ +class SkinEntry +{ + public: + /* Arm width of the player model this texture was drawn for. + * + * Named after what it actually controls rather than after Steve and + * Alex, because the mapping between the two is a UI label, not a fact + * about the texture. The wire format spells these "CLASSIC" and "SLIM"; + * see armsToken(). */ + enum class Arms { Classic, Slim }; + + SkinEntry() = default; + + /* Adopt a PNG that is already sitting in the library directory. */ + explicit SkinEntry(const QString& pngPath); + + /* Restore an entry from the library index. `record` supplies the + * metadata, `libraryDir` is where the PNG named in it lives. */ + SkinEntry(const QDir& libraryDir, const QJsonObject& record); + + /* Display name and library key: the file name minus ".png". */ + QString name() const; + + QString path() const + { + return m_path; + } + + /* The normalised 64x64 texture. Empty if the file could not be read or + * is not a skin; see isUsable(). */ + QImage texture() const + { + return m_texture; + } + + /* Flat 36x36 front-and-back sprite, for the list view and for the + * fallback preview when there is no OpenGL. */ + QImage thumbnail() const + { + return m_thumbnail; + } + + QString capeId() const + { + return m_capeId; + } + + Arms arms() const + { + return m_arms; + } + + /* The spelling the profile API uses: "CLASSIC" or "SLIM". */ + QString armsToken() const; + + QString textureUrl() const + { + return m_textureUrl; + } + + /* Whether this is a skin at all: 64 pixels wide and either 32 (legacy) + * or 64 pixels tall. Everything else -- HD skins, arbitrary images, a + * PNG that failed to decode -- is rejected. */ + bool isUsable() const; + + /* Rename the file on disk, and with it this entry's identity. + * + * Fails without touching anything if the target name is already taken, + * so a rename can never silently swallow another skin. */ + bool renameTo(const QString& newName); + + void setCapeId(const QString& capeId) + { + m_capeId = capeId; + } + + /* Also regenerates the thumbnail: arm width changes which columns of + * the texture the arms are drawn from. */ + void setArms(Arms arms); + + void setTextureUrl(const QString& url) + { + m_textureUrl = url; + } + + /* Re-read the PNG from disk. Used when the directory watcher reports + * that the file changed underneath us. */ + void reload(); + + QJsonObject toRecord() const; + + private: + QString m_path; + QImage m_texture; + QImage m_thumbnail; + QString m_capeId; + Arms m_arms = Arms::Classic; + QString m_textureUrl; +}; diff --git a/launcher/minecraft/skins/SkinLibrary.cpp b/launcher/minecraft/skins/SkinLibrary.cpp new file mode 100644 index 000000000..27d74be6d --- /dev/null +++ b/launcher/minecraft/skins/SkinLibrary.cpp @@ -0,0 +1,572 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "SkinLibrary.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "FileSystem.h" +#include "Json.h" + +namespace +{ + /* The index lives inside the library directory, so it travels with it. */ + const char* const kIndexFileName = "index.json"; + const char* const kIndexSkinsKey = "skins"; + + /* Find a file name in `directory` that is not taken yet, by appending a + * counter before the extension. + * + * Bounded on purpose: if 256 variations are all taken, something is wrong + * with the directory rather than with this name, and looping forever + * would only hide it. */ + QString availableName(const QDir& directory, const QString& fileName) + { + if (!directory.exists(fileName)) { + return directory.absoluteFilePath(fileName); + } + + const QFileInfo info(fileName); + const QString base = info.completeBaseName(); + const QString extension = info.suffix(); + + for (int attempt = 1; attempt <= 256; ++attempt) { + const QString candidate = + QStringLiteral("%1%2.%3").arg(base).arg(attempt).arg(extension); + if (!directory.exists(candidate)) { + return directory.absoluteFilePath(candidate); + } + } + return QString(); + } + + SkinEntry::Arms armsFromVariant(const QString& variant) + { + return variant.toUpper() == QLatin1String("SLIM") + ? SkinEntry::Arms::Slim + : SkinEntry::Arms::Classic; + } +} // namespace + +SkinLibrary::SkinLibrary(QObject* parent, const QString& path, + MinecraftAccountPtr account) + : QAbstractListModel(parent), m_account(account) +{ + m_dir.setPath(path); + FS::ensureFolderPathExists(m_dir.absolutePath()); + + /* Files and directories both, so that a stray subdirectory does not make + * the listing look empty; non-PNG entries are filtered out per entry + * during the rescan. Locale-aware name sorting is what makes the list + * order match what the user sees in their file manager. */ + m_dir.setFilter(QDir::Readable | QDir::NoDotAndDotDot | QDir::Files | + QDir::Dirs); + m_dir.setSorting(QDir::Name | QDir::IgnoreCase | QDir::LocaleAware); + + m_watcher = new QFileSystemWatcher(this); + connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, + &SkinLibrary::onDirectoryChanged); + connect(m_watcher, &QFileSystemWatcher::fileChanged, this, + &SkinLibrary::onFileChanged); + + /* Arms the watcher and does the first rescan. Watching from the start is + * what makes "Open Folder", drop a PNG in, and see it appear work without + * reopening the dialog -- and the import buttons rely on it too, since + * they only copy a file into place and let the watcher notice. */ + startWatching(); +} + +SkinLibrary::~SkinLibrary() +{ + save(); +} + +void SkinLibrary::startWatching() +{ + if (m_isWatching) { + return; + } + rescan(); + m_isWatching = m_watcher->addPath(m_dir.absolutePath()); + if (m_isWatching) { + qDebug() << "Watching skin library at" << m_dir.absolutePath(); + } else { + qWarning() << "Could not watch skin library at" + << m_dir.absolutePath() + << "- skins added outside the launcher will only appear " + "after reopening the dialog"; + } +} + +void SkinLibrary::stopWatching() +{ + save(); + if (!m_isWatching) { + return; + } + m_isWatching = !m_watcher->removePath(m_dir.absolutePath()); +} + +bool SkinLibrary::adoptAccountSkin(QList& entries) const +{ + if (!m_account || !m_account->accountData()) { + return false; + } + + const Skin& worn = m_account->accountData()->minecraftProfile.skin; + + /* Both halves are required: the URL is the identity we match on, and + * without the bytes there is nothing to write to disk. An account that + * has a URL but no cached data will be adopted on a later refresh. */ + if (worn.url.isEmpty() || worn.data.isEmpty()) { + return false; + } + + for (SkinEntry& existing : entries) { + if (existing.textureUrl() != worn.url) { + continue; + } + /* Already in the library. The file is right, but the cape and arm + * width may have been changed elsewhere (in game, or on another + * machine), so those come from the profile every time. */ + existing.setCapeId( + m_account->accountData()->minecraftProfile.currentCape); + existing.setArms(armsFromVariant(worn.variant)); + return false; + } + + /* Not in the library yet -- save it under the profile name, which is + * what a user would recognise. If that name is taken by an unrelated + * skin, fall back to the file name from the texture URL. */ + QString fileName = m_account->profileName() + QStringLiteral(".png"); + if (m_dir.exists(fileName)) { + fileName = QUrl(worn.url).fileName() + QStringLiteral(".png"); + } + const QString path = m_dir.absoluteFilePath(fileName); + + QImage texture; + if (!texture.loadFromData(worn.data, "PNG")) { + qWarning() << "The skin the account is wearing could not be decoded, " + "so it was not added to the library"; + return false; + } + if (!texture.save(path, "PNG")) { + qWarning() << "Could not write the account's skin to" << path; + return false; + } + + SkinEntry adopted(path); + adopted.setArms(armsFromVariant(worn.variant)); + adopted.setCapeId(m_account->accountData()->minecraftProfile.currentCape); + adopted.setTextureUrl(worn.url); + entries.append(adopted); + return true; +} + +void SkinLibrary::rescan() +{ + QList found; + m_dir.refresh(); + + /* Pass 1: the index. It carries the metadata, so it goes first and + * everything found later is treated as new. */ + const QFileInfo indexInfo( + m_dir.absoluteFilePath(QLatin1String(kIndexFileName))); + if (indexInfo.exists()) { + try { + const QJsonDocument doc = Json::requireDocument( + indexInfo.absoluteFilePath(), QStringLiteral("skin index")); + const QJsonArray records = + doc.object().value(QLatin1String(kIndexSkinsKey)).toArray(); + for (const QJsonValue& record : records) { + SkinEntry entry(m_dir, record.toObject()); + /* An unusable entry means the file behind it is gone or is + * no longer a skin. Dropping it here is what lets the + * directory stay the source of truth. */ + if (entry.isUsable()) { + found.append(entry); + } + } + } catch (const Exception& e) { + qCritical() << "Could not read the skin index:" << e.cause(); + } + } + + bool needsSave = adoptAccountSkin(found); + + /* Pass 2: PNGs in the directory that the index did not account for -- + * files the user dropped in with a file manager. */ + const QFileInfoList contents = m_dir.entryInfoList(); + for (const QFileInfo& candidate : contents) { + if (!candidate.isFile() || + candidate.suffix().compare(QLatin1String("png"), + Qt::CaseInsensitive) != 0) { + continue; + } + + SkinEntry entry(candidate.absoluteFilePath()); + if (!entry.isUsable()) { + continue; + } + + const QString name = entry.name(); + const bool known = + std::any_of(found.cbegin(), found.cend(), + [&name](const SkinEntry& e) { return e.name() == name; }); + if (!known) { + found.append(entry); + needsSave = true; + } + } + + std::sort(found.begin(), found.end(), + [](const SkinEntry& a, const SkinEntry& b) { + return a.path().localeAwareCompare(b.path()) < 0; + }); + + beginResetModel(); + m_entries.swap(found); + endResetModel(); + + if (needsSave) { + save(); + } +} + +void SkinLibrary::onDirectoryChanged(const QString& path) +{ + QDir changed(path); + if (!changed.exists() && + !FS::ensureFolderPathExists(changed.absolutePath())) { + qWarning() << "Skin library directory" << changed.absolutePath() + << "does not exist and could not be created"; + return; + } + + if (m_dir.absolutePath() != changed.absolutePath()) { + /* The library was pointed somewhere else (the setting changed). + * Move the watch along with it before rescanning. */ + m_dir.setPath(path); + m_dir.refresh(); + if (m_isWatching) { + stopWatching(); + } + startWatching(); + return; + } + rescan(); +} + +void SkinLibrary::onFileChanged(const QString& path) +{ + const QFileInfo changed(path); + if (!changed.exists()) { + /* Deletions arrive as a directory change too, which rescans. */ + return; + } + + for (int row = 0; row < m_entries.size(); ++row) { + if (m_entries[row].path() != changed.absoluteFilePath()) { + continue; + } + m_entries[row].reload(); + const QModelIndex changedIndex = index(row); + emit dataChanged(changedIndex, changedIndex); + return; + } +} + +int SkinLibrary::indexOfName(const QString& name) const +{ + for (int row = 0; row < m_entries.size(); ++row) { + if (m_entries[row].name() == name) { + return row; + } + } + return -1; +} + +int SkinLibrary::indexOfAccountSkin() const +{ + if (!m_account || !m_account->accountData()) { + return -1; + } + const QString wornUrl = + m_account->accountData()->minecraftProfile.skin.url; + if (wornUrl.isEmpty()) { + return -1; + } + for (int row = 0; row < m_entries.size(); ++row) { + if (m_entries[row].textureUrl() == wornUrl) { + return row; + } + } + return -1; +} + +const SkinEntry* SkinLibrary::entry(const QString& name) const +{ + const int row = indexOfName(name); + return row == -1 ? nullptr : &m_entries[row]; +} + +SkinEntry* SkinLibrary::entry(const QString& name) +{ + const int row = indexOfName(name); + return row == -1 ? nullptr : &m_entries[row]; +} + +void SkinLibrary::save() +{ + QJsonArray records; + for (const SkinEntry& entry : m_entries) { + records.append(entry.toRecord()); + } + + QJsonObject index; + index[QLatin1String(kIndexSkinsKey)] = records; + + try { + Json::write(index, + m_dir.absoluteFilePath(QLatin1String(kIndexFileName))); + } catch (const Exception& e) { + qCritical() << "Could not write the skin index:" << e.cause(); + } +} + +void SkinLibrary::mergeEntry(const SkinEntry& incoming) +{ + for (int row = 0; row < m_entries.size(); ++row) { + if (m_entries[row].path() != incoming.path()) { + continue; + } + /* Same file: keep the row, take the metadata. */ + m_entries[row].setCapeId(incoming.capeId()); + m_entries[row].setArms(incoming.arms()); + m_entries[row].setTextureUrl(incoming.textureUrl()); + const QModelIndex changedIndex = index(row); + emit dataChanged(changedIndex, changedIndex); + save(); + return; + } + + const int row = m_entries.size(); + beginInsertRows(QModelIndex(), row, row); + m_entries.append(incoming); + endInsertRows(); + save(); +} + +QString SkinLibrary::importFile(const QString& sourcePath, + const QString& preferredName) +{ + if (sourcePath.isEmpty()) { + return tr("Path is empty."); + } + + const QFileInfo source(sourcePath); + if (!source.exists()) { + return tr("File doesn't exist."); + } + if (!source.isFile()) { + return tr("Not a file."); + } + if (!source.isReadable()) { + return tr("File is not readable."); + } + + /* A ".png" suffix is taken at face value; anything else has to prove it + * is a skin by decoding to the right dimensions. Decoding is the + * expensive check, so it only runs when the name gives no hint. */ + if (source.suffix().compare(QLatin1String("png"), Qt::CaseInsensitive) != + 0 && + !SkinEntry(source.absoluteFilePath()).isUsable()) { + return tr("Skin images must be 64x64 or 64x32 pixel PNG files."); + } + + const QString target = availableName( + m_dir, preferredName.isEmpty() ? source.fileName() : preferredName); + if (target.isEmpty()) { + return tr("Unable to find a free file name in the skins folder."); + } + + if (!QFile::copy(source.absoluteFilePath(), target)) { + return tr("Unable to copy file"); + } + return QString(); +} + +void SkinLibrary::importFiles(const QStringList& sourcePaths) +{ + for (const QString& path : sourcePaths) { + const QString problem = importFile(path); + if (!problem.isEmpty()) { + qWarning() << "Skipped" << path << "while importing skins:" + << problem; + } + } +} + +bool SkinLibrary::remove(const QString& name, bool toTrash) +{ + const int row = indexOfName(name); + if (row == -1) { + return false; + } + + const QString path = m_entries[row].path(); + const bool gone = toTrash ? FS::trash(path) : QFile::remove(path); + if (!gone) { + return false; + } + + beginRemoveRows(QModelIndex(), row, row); + /* removeAt() rather than remove(): QList only grew an index-taking + * remove() in Qt 6, and this builds against Qt 5.15 as well. */ + m_entries.removeAt(row); + endRemoveRows(); + save(); + return true; +} + +QVariant SkinLibrary::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) { + return QVariant(); + } + const int row = index.row(); + if (row < 0 || row >= m_entries.size()) { + return QVariant(); + } + + const SkinEntry& entry = m_entries[row]; + switch (role) { + case Qt::DecorationRole: { + /* The flat sprite is what the list is meant to show; the raw + * texture is only a fallback for the case where the sprite could + * not be produced, so the row still shows *something*. */ + const QImage thumbnail = entry.thumbnail(); + return thumbnail.isNull() ? entry.texture() : thumbnail; + } + case Qt::DisplayRole: + case Qt::EditRole: + case Qt::UserRole: + return entry.name(); + default: + return QVariant(); + } +} + +bool SkinLibrary::setData(const QModelIndex& index, const QVariant& value, + int role) +{ + if (!index.isValid() || role != Qt::EditRole) { + return false; + } + const int row = index.row(); + if (row < 0 || row >= m_entries.size()) { + return false; + } + + SkinEntry& entry = m_entries[row]; + const QString newName = value.toString(); + if (entry.name() == newName) { + /* Committing an unchanged name is not a failure. */ + return true; + } + if (!entry.renameTo(newName)) { + return false; + } + + emit dataChanged(index, index); + save(); + return true; +} + +int SkinLibrary::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_entries.size(); +} + +Qt::ItemFlags SkinLibrary::flags(const QModelIndex& index) const +{ + /* Drops are accepted on the empty area as well as on rows, so the flag + * is unconditional. */ + Qt::ItemFlags result = + Qt::ItemIsDropEnabled | QAbstractListModel::flags(index); + if (index.isValid()) { + result |= Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; + } + return result; +} + +QStringList SkinLibrary::mimeTypes() const +{ + return {QStringLiteral("text/uri-list")}; +} + +Qt::DropActions SkinLibrary::supportedDropActions() const +{ + /* Copy only: a dropped skin is imported into the library, the original + * is left where the user had it. */ + return Qt::CopyAction; +} + +bool SkinLibrary::dropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, const QModelIndex& parent) +{ + Q_UNUSED(row) + Q_UNUSED(column) + Q_UNUSED(parent) + + if (action == Qt::IgnoreAction) { + return true; + } + if (!data || !(action & supportedDropActions())) { + return false; + } + if (!data->hasUrls()) { + return false; + } + + QStringList files; + for (const QUrl& url : data->urls()) { + /* Remote URLs are the "Import URL" button's job; a drop has to be + * something already on this machine. */ + if (url.isLocalFile()) { + files.append(url.toLocalFile()); + } + } + if (files.isEmpty()) { + return false; + } + + importFiles(files); + return true; +} diff --git a/launcher/minecraft/skins/SkinLibrary.h b/launcher/minecraft/skins/SkinLibrary.h new file mode 100644 index 000000000..840e02177 --- /dev/null +++ b/launcher/minecraft/skins/SkinLibrary.h @@ -0,0 +1,137 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include "minecraft/auth/MinecraftAccount.h" +#include "minecraft/skins/SkinEntry.h" + +class QFileSystemWatcher; + +/* The local skin library: every skin the user has saved, as a list model. + * + * Backed by a directory of PNG files plus an "index.json" holding the + * metadata that a PNG cannot carry (arm width, cape, profile URL). The + * directory is the source of truth for *which* skins exist -- files dropped + * in by hand show up on the next rescan, and files deleted by hand disappear + * -- while the index only decorates the files it recognises. That way the + * library can never end up insisting on skins that are not there. + * + * The account is needed for more than display: whatever skin the account is + * currently wearing is adopted into the library on rescan, so a fresh install + * opens with the user's own skin already in the list and already selected. + * + * Entries are keyed by name (the file name without ".png"), which is also + * what the model reports for Qt::UserRole. Callers hold names, not pointers + * or row numbers, because a rescan can reorder or replace the whole list. + */ +class SkinLibrary : public QAbstractListModel +{ + Q_OBJECT + + public: + /* `path` is the library directory; it is created if missing. */ + SkinLibrary(QObject* parent, const QString& path, + MinecraftAccountPtr account); + ~SkinLibrary() override; + + /* Row of the entry with this name, or -1. */ + int indexOfName(const QString& name) const; + + /* Row of the entry whose texture URL matches what the account is + * currently wearing, or -1 if the account's skin is not in the library. + * This is what the dialog preselects on open. */ + int indexOfAccountSkin() const; + + const SkinEntry* entry(const QString& name) const; + SkinEntry* entry(const QString& name); + + QString directory() const + { + return m_dir.absolutePath(); + } + + /* Write index.json. Called on every mutation and on destruction. */ + void save(); + + /* Fold an entry built elsewhere (an import, say) into the library: + * updates the metadata of the matching file if there is one, otherwise + * appends it as a new row. */ + void mergeEntry(const SkinEntry& incoming); + + /* Copy a PNG into the library. + * + * Returns an empty string on success, or a human-readable reason why + * not -- the caller shows it verbatim. `preferredName` overrides the + * source file name; either way a numeric suffix is appended if the name + * is taken, so importing never overwrites. */ + QString importFile(const QString& sourcePath, + const QString& preferredName = QString()); + + /* Bulk form of importFile(), used by drag-and-drop. Failures are logged + * rather than reported: a drop of twelve files should not produce twelve + * modal dialogs. */ + void importFiles(const QStringList& sourcePaths); + + /* Delete a skin's file, and with it the entry. `toTrash` asks for the + * system trash; the caller is expected to retry with false if that is + * not available on this platform. */ + bool remove(const QString& name, bool toTrash); + + void startWatching(); + void stopWatching(); + + /* QAbstractListModel */ + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& index, const QVariant& value, + int role) override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + QStringList mimeTypes() const override; + Qt::DropActions supportedDropActions() const override; + bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, + int column, const QModelIndex& parent) override; + + protected slots: + void onDirectoryChanged(const QString& path); + void onFileChanged(const QString& path); + + /* Rebuild the whole list from the directory and the index. */ + void rescan(); + + private: + SkinLibrary(const SkinLibrary&) = delete; + SkinLibrary& operator=(const SkinLibrary&) = delete; + + /* Pull the account's currently worn skin into `entries`, saving it to + * disk if it is not there yet. Returns true if the index needs writing. */ + bool adoptAccountSkin(QList& entries) const; + + QFileSystemWatcher* m_watcher = nullptr; + bool m_isWatching = false; + QList m_entries; + QDir m_dir; + MinecraftAccountPtr m_account; +}; diff --git a/launcher/plugin/CoreSupersededPlugins.h b/launcher/plugin/CoreSupersededPlugins.h index 43466de1e..3b9115e50 100644 --- a/launcher/plugin/CoreSupersededPlugins.h +++ b/launcher/plugin/CoreSupersededPlugins.h @@ -71,6 +71,8 @@ findCoreSupersededPlugin(const QString& name) QLatin1String("The pack portal is now part of MeshMC itself")}, {QLatin1String("PackUpdater"), QLatin1String("The pack updater is now part of MeshMC itself")}, + {QLatin1String("SkinManager"), + QLatin1String("The skin manager is now part of MeshMC itself")}, }; if (name.isEmpty()) diff --git a/launcher/plugin/PluginManager.cpp b/launcher/plugin/PluginManager.cpp index 4469a3c79..2534d2d53 100644 --- a/launcher/plugin/PluginManager.cpp +++ b/launcher/plugin/PluginManager.cpp @@ -3915,9 +3915,12 @@ int PluginManager::api_account_cape_count(void* mh, const char* account_id) namespace { - /* Index → cape pair on the active account, using deterministic - * insertion order (QMap iterates sorted by key, which matches what - * SkinManagerDialog used to do when it walked the QMap directly). */ + /* Index → cape on the active account. + * + * The index is into the profile service's own ordering, which is what + * MinecraftProfile::capes preserves. It used to be a QMap keyed by cape + * id, so this walked it and got alphabetical-by-UUID order instead -- + * stable, but not the order anything else showed the capes in. */ const Cape* capeAt(MinecraftAccountPtr a, int index) { if (!a || !a->accountData() || index < 0) @@ -3925,12 +3928,7 @@ namespace const auto& capes = a->accountData()->minecraftProfile.capes; if (index >= capes.size()) return nullptr; - int i = 0; - for (auto it = capes.cbegin(); it != capes.cend(); ++it, ++i) { - if (i == index) - return &it.value(); - } - return nullptr; + return &capes.at(index); } } // namespace diff --git a/launcher/plugin/plugins/CMakeLists.txt b/launcher/plugin/plugins/CMakeLists.txt index 9c6153d6e..e7d023d0b 100644 --- a/launcher/plugin/plugins/CMakeLists.txt +++ b/launcher/plugin/plugins/CMakeLists.txt @@ -38,6 +38,7 @@ endif() # instead of doubling up on the core feature. # - Filelink: moved into core # - PackPortal: moved into core +# - SkinManager: moved into core # - BackupSystem: moved into core (launcher/backup + BackupPage) # - NewsViewer: moved into core (launcher/ui/dialogs/NewsViewerDialog, # multi-feed support folded into news/NewsChecker) @@ -45,7 +46,6 @@ set(MESHMC_IN_TREE_PLUGIN_TARGETS SystemTray DesktopNotifier DiscordRPC - SkinManager GitVersioning OfflineWiki ) @@ -53,7 +53,6 @@ set(MESHMC_IN_TREE_PLUGIN_TARGETS add_subdirectory(SystemTray) add_subdirectory(DesktopNotifier) add_subdirectory(DiscordRPC) -add_subdirectory(SkinManager) add_subdirectory(GitVersioning) add_subdirectory(OfflineWiki) diff --git a/launcher/plugin/plugins/SkinManager/CMakeLists.txt b/launcher/plugin/plugins/SkinManager/CMakeLists.txt deleted file mode 100644 index 5862aaa6c..000000000 --- a/launcher/plugin/plugins/SkinManager/CMakeLists.txt +++ /dev/null @@ -1,110 +0,0 @@ -# MeshMC standalone plugin build bootstrap. -# When this plugin is built from MeshMC's main tree, the parent project -# already provides Qt, MeshMC::SDK, output directories, and signing helpers. -# When this directory is configured as its own repository, provide those -# pieces here so the plugin can be built with only an installed MeshMC SDK. -if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) - cmake_minimum_required(VERSION 3.21) - project(SkinManager LANGUAGES CXX) - - set(CMAKE_AUTOMOC ON) - set(CMAKE_CXX_STANDARD 17) - set(CMAKE_CXX_STANDARD_REQUIRED ON) - - set(MeshMC_QT_VERSION_MAJOR "6" CACHE STRING "Major Qt version to build against (5 or 6)") - set(QT_VERSION_MAJOR "${MeshMC_QT_VERSION_MAJOR}") - - set(_skinmanager_qt_components Core Widgets Gui Network OpenGL) - if(QT_VERSION_MAJOR EQUAL 6) - list(APPEND _skinmanager_qt_components OpenGLWidgets) - endif() - - find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS ${_skinmanager_qt_components}) - find_package(MeshMC_SDK REQUIRED) - - set(MESHMC_PLUGIN_STAGING_DIR "${CMAKE_BINARY_DIR}/mmcmodules" CACHE PATH - "Directory where built .mmco plugins are placed") - set(MMCO_MODULES_DEST_DIR "lib/mmcmodules" CACHE PATH - "Install directory for .mmco plugins") - set(MMCO_PLUGIN_DATA_DEST_DIR "share/meshmc/mmcmodules" CACHE PATH - "Install directory for plugin data files") -endif() - -# SkinManager — MeshMC plugin (.mmco) -# -# 3D skin viewer + skin upload / delete / cape change UI. -# -# Adds a global-settings page (registered via MMCO_HOOK_UI_GLOBAL_SETTINGS_PAGES) -# that shows a real-time 3D model of the active MSA account's skin, lets -# the user upload / delete the skin, and switch between owned capes. -# -# Shaders come from MeshMC's own resource bundle (:/shaders/...), so -# this plugin does not ship any GLSL of its own. - -set(SKINMANAGER_SOURCES - SkinManagerPlugin.cpp - SkinManagerDialog.h - SkinManagerDialog.cpp - SkinViewerWidget.h - SkinViewerWidget.cpp - SkinModel.h - SkinModel.cpp -) - -set(SKINMANAGER_UIS - SkinManagerDialog.ui -) - -set(SKINMANAGER_RESOURCES - skinmanager.qrc -) - -if(QT_VERSION_MAJOR EQUAL 6) - qt6_wrap_ui(SKINMANAGER_UI_HEADERS ${SKINMANAGER_UIS}) - qt6_add_resources(SKINMANAGER_RES_SOURCES ${SKINMANAGER_RESOURCES}) -else() - qt5_wrap_ui(SKINMANAGER_UI_HEADERS ${SKINMANAGER_UIS}) - qt5_add_resources(SKINMANAGER_RES_SOURCES ${SKINMANAGER_RESOURCES}) -endif() - -add_library(SkinManager MODULE - ${SKINMANAGER_SOURCES} - ${SKINMANAGER_UI_HEADERS} - ${SKINMANAGER_RES_SOURCES} -) - -target_link_libraries(SkinManager PRIVATE - MeshMC::SDK # public SDK includes + Qt deps -) - -if(QT_VERSION_MAJOR EQUAL 6) - target_link_libraries(SkinManager PRIVATE - Qt6::OpenGL # QOpenGLShaderProgram / QOpenGLBuffer - Qt6::OpenGLWidgets # QOpenGLWidget - ) -endif() - -target_include_directories(SkinManager PRIVATE - ${CMAKE_CURRENT_BINARY_DIR} # generated ui_*.h -) - -set_target_properties(SkinManager PROPERTIES - PREFIX "" - SUFFIX ".mmco" - LIBRARY_OUTPUT_DIRECTORY "${MESHMC_PLUGIN_STAGING_DIR}" -) - -foreach(CFG ${CMAKE_CONFIGURATION_TYPES}) - string(TOUPPER "${CFG}" CFG_UPPER) - set_target_properties(SkinManager PROPERTIES - LIBRARY_OUTPUT_DIRECTORY_${CFG_UPPER} "${MESHMC_PLUGIN_STAGING_DIR}" - ) -endforeach() - -install(TARGETS SkinManager - LIBRARY DESTINATION "${MMCO_MODULES_DEST_DIR}" -) - -if(MeshMC_PLUGIN_SIGN_ALL) - mmco_sign_plugin(SkinManager) -endif() diff --git a/launcher/plugin/plugins/SkinManager/SkinManagerDialog.cpp b/launcher/plugin/plugins/SkinManager/SkinManagerDialog.cpp deleted file mode 100644 index c3987d826..000000000 --- a/launcher/plugin/plugins/SkinManager/SkinManagerDialog.cpp +++ /dev/null @@ -1,457 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Project Tick - * SPDX-License-Identifier: Apache-2.0 - * - * SkinManagerDialog implementation. See the header comment for the - * high-level shape of the dialog. - */ - -#include "SkinManagerDialog.h" -#include "SkinViewerWidget.h" -#include "ui_SkinManagerDialog.h" - -#include -#include -#include -#include -#include -#include - -using SkinManagerNS::ModelVariant; -using SkinManagerNS::SkinViewerWidget; - -namespace -{ - - /* Same heuristic as the global page used earlier — kept self-contained - * so the two files don't share state. */ - ModelVariant detectVariant(const QImage& image) - { - if (image.isNull()) - return ModelVariant::Classic; - QImage img = - image.scaled(64, 64, Qt::IgnoreAspectRatio, Qt::FastTransformation); - img = img.convertToFormat(QImage::Format_ARGB32); - const QRgb px = img.pixel(54, 20); - return (qAlpha(px) > 0) ? ModelVariant::Classic : ModelVariant::Slim; - } - - QImage normaliseSkin(const QImage& src) - { - if (src.isNull()) - return src; - if (src.width() == 64 && src.height() == 64) - return src.convertToFormat(QImage::Format_RGBA8888); - - /* Legacy 64×32 skin → mirror right limbs onto the bottom half. */ - QImage upgraded(64, 64, QImage::Format_RGBA8888); - upgraded.fill(Qt::transparent); - const QImage base = - src.scaled(64, 32, Qt::IgnoreAspectRatio, Qt::FastTransformation) - .convertToFormat(QImage::Format_RGBA8888); - for (int y = 0; y < 32; ++y) { - memcpy(upgraded.scanLine(y), base.constScanLine(y), - base.bytesPerLine()); - } - for (int y = 0; y < 16; ++y) { - for (int x = 0; x < 16; ++x) { - upgraded.setPixelColor(32 + x, 48 + y, - base.pixelColor(40 + (15 - x), 16 + y)); - upgraded.setPixelColor(16 + x, 48 + y, - base.pixelColor(0 + (15 - x), 16 + y)); - } - } - return upgraded; - } - -} // namespace - -/* Helper: pull the player-facing profile name from the C ABI. The - * accountId is the same string S7's account_get_profile_id returns. */ -static QString profileNameFromCtx(MMCOContext* ctx, const QString& accountId) -{ - if (!ctx || accountId.isEmpty()) - return {}; - const int total = ctx->account_count(ctx->module_handle); - for (int i = 0; i < total; ++i) { - const char* idC = ctx->account_get_id_by_index(ctx->module_handle, i); - if (!idC) - continue; - if (QString::fromUtf8(idC) != accountId) - continue; - const char* n = ctx->account_get_profile_name(ctx->module_handle, i); - return n ? QString::fromUtf8(n) : QString(); - } - return {}; -} - -SkinManagerDialog::SkinManagerDialog(const QString& accountId, MMCOContext* ctx, - QWidget* parent) - : QDialog(parent), ui(new Ui::SkinManagerDialog), m_accountId(accountId), - m_ctx(ctx) -{ - ui->setupUi(this); - const QString profileName = profileNameFromCtx(m_ctx, m_accountId); - setWindowTitle( - tr("Skin Upload — %1") - .arg(profileName.isEmpty() ? tr("(no account)") : profileName)); - - /* Replace the placeholder viewerHost with a real OpenGL viewer. */ - m_viewer = new SkinViewerWidget(ui->viewerHost); - ui->viewerHostLayout->addWidget(m_viewer); - - QObject::connect(m_viewer, &SkinViewerWidget::skinFileDropped, this, - &SkinManagerDialog::onSkinFileDropped); - QObject::connect(ui->btnBrowse, &QPushButton::clicked, this, - &SkinManagerDialog::onBrowseClicked); - QObject::connect(ui->btnReset, &QPushButton::clicked, this, - &SkinManagerDialog::onResetClicked); - QObject::connect(ui->rdoClassic, &QRadioButton::toggled, this, - &SkinManagerDialog::onVariantToggled); - QObject::connect(ui->rdoSlim, &QRadioButton::toggled, this, - &SkinManagerDialog::onVariantToggled); - QObject::connect(ui->chkOverlay, &QCheckBox::toggled, this, - &SkinManagerDialog::onOverlayToggled); - QObject::connect(ui->chkAutoRotate, &QCheckBox::toggled, this, - &SkinManagerDialog::onAutoRotateToggled); - QObject::connect(ui->capeCombo, - QOverload::of(qOverload(&QComboBox::currentIndexChanged)), this, - &SkinManagerDialog::onCapeChanged); - QObject::connect(ui->buttonBox, &QDialogButtonBox::accepted, this, - &SkinManagerDialog::onAccept); - QObject::connect(ui->buttonBox, &QDialogButtonBox::rejected, this, - &QDialog::reject); - - loadAccountState(); -} - -SkinManagerDialog::~SkinManagerDialog() -{ - delete ui; -} - -/* ── load: render whatever the account currently has ─────────────── */ - -void SkinManagerDialog::loadAccountState() -{ - if (!m_ctx || m_accountId.isEmpty()) - return; - - const QByteArray idUtf8 = m_accountId.toUtf8(); - ui->accountLabel->setText( - tr("Account: %1").arg(profileNameFromCtx(m_ctx, m_accountId))); - - /* Skin blob via the C ABI. */ - QImage skinImg; - const void* skinPtr = nullptr; - const int64_t skinLen = m_ctx->account_get_skin_blob( - m_ctx->module_handle, idUtf8.constData(), &skinPtr); - if (skinLen > 0 && skinPtr) { - skinImg.loadFromData( - QByteArray::fromRawData(static_cast(skinPtr), - static_cast(skinLen)), - "PNG"); - skinImg = normaliseSkin(skinImg); - } - - const char* variantC = m_ctx->account_get_skin_variant(m_ctx->module_handle, - idUtf8.constData()); - ModelVariant variant = ModelVariant::Classic; - if (variantC && QString::fromUtf8(variantC).compare( - QStringLiteral("SLIM"), Qt::CaseInsensitive) == 0) { - variant = ModelVariant::Slim; - } else if (!skinImg.isNull()) { - variant = detectVariant(skinImg); - } - if (variant == ModelVariant::Slim) - ui->rdoSlim->setChecked(true); - else - ui->rdoClassic->setChecked(true); - m_viewer->setSkin(skinImg, variant); - - /* Capes — walk the C ABI cape list. */ - const char* currentCapeC = m_ctx->account_get_current_cape_id( - m_ctx->module_handle, idUtf8.constData()); - const QString currentCape = - currentCapeC ? QString::fromUtf8(currentCapeC) : QString(); - - ui->capeCombo->blockSignals(true); - ui->capeCombo->clear(); - ui->capeCombo->addItem(tr("No Cape"), QString()); - int activeRow = 0; - const int capeTotal = - m_ctx->account_cape_count(m_ctx->module_handle, idUtf8.constData()); - for (int i = 0; i < capeTotal; ++i) { - const char* capeIdC = m_ctx->account_cape_get_id(m_ctx->module_handle, - idUtf8.constData(), i); - const char* capeAliasC = m_ctx->account_cape_get_alias( - m_ctx->module_handle, idUtf8.constData(), i); - const void* capePtr = nullptr; - const int64_t capeLen = m_ctx->account_cape_get_blob( - m_ctx->module_handle, idUtf8.constData(), i, &capePtr); - - const QString capeId = capeIdC ? QString::fromUtf8(capeIdC) : QString(); - const QString alias = - capeAliasC ? QString::fromUtf8(capeAliasC) : QString(); - - QPixmap preview; - if (capeLen > 0 && capePtr) { - QPixmap pix; - if (pix.loadFromData( - QByteArray::fromRawData(static_cast(capePtr), - static_cast(capeLen)), - "PNG")) { - preview = pix.copy(1, 1, 10, 16); - } - } - const QString label = alias.isEmpty() ? capeId : alias; - const int row = i + 1; /* +1 for the "No Cape" entry */ - if (preview.isNull()) - ui->capeCombo->addItem(label, capeId); - else - ui->capeCombo->addItem(QIcon(preview), label, capeId); - if (capeId == currentCape) - activeRow = row; - } - ui->capeCombo->setCurrentIndex(activeRow); - ui->capeCombo->blockSignals(false); - - /* Push cape preview into the 3D viewer. */ - onCapeChanged(activeRow); -} - -/* ── browse / drop handlers ──────────────────────────────────────── */ - -void SkinManagerDialog::onBrowseClicked() -{ - const QString picturesDir = - QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); - const QString chosen = - QFileDialog::getOpenFileName(this, tr("Select Skin Texture"), - picturesDir, tr("Minecraft skin (*.png)")); - if (chosen.isEmpty()) - return; - loadSkinFile(chosen); -} - -void SkinManagerDialog::onSkinFileDropped(const QString& path) -{ - loadSkinFile(path); -} - -void SkinManagerDialog::loadSkinFile(const QString& path) -{ - QImage img(path); - if (img.isNull() || img.width() != 64 || - (img.height() != 64 && img.height() != 32)) { - setStatus(tr("Skin PNGs must be 64×64 (modern) or 64×32 (legacy). " - "The chosen file is %1×%2.") - .arg(img.width()) - .arg(img.height()), - /*error=*/true); - return; - } - - m_chosenSkinPath = path; - m_chosenSkinImage = normaliseSkin(img); - ui->skinPathText->setText(path); - - /* Auto-detect variant from the new file unless the user has - * manually clicked one of the radios since opening the dialog — - * detect-once heuristic. */ - const ModelVariant detected = detectVariant(m_chosenSkinImage); - if (detected == ModelVariant::Slim) - ui->rdoSlim->setChecked(true); - else - ui->rdoClassic->setChecked(true); - - m_viewer->setSkin(m_chosenSkinImage, detected); - setStatus(QString()); -} - -void SkinManagerDialog::onResetClicked() -{ - if (!m_ctx || m_accountId.isEmpty()) - return; - - const QString profileName = profileNameFromCtx(m_ctx, m_accountId); - const int rc = QMessageBox::question( - this, tr("Reset skin"), - tr("Remove the active custom skin for %1?\n\n" - "Mojang will revert the account to the " - "default Steve or Alex skin.") - .arg(profileName), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (rc != QMessageBox::Yes) - return; - - const QByteArray idUtf8 = m_accountId.toUtf8(); - if (m_ctx->account_skin_reset(m_ctx->module_handle, idUtf8.constData()) != - 0) { - QMessageBox::warning(this, tr("Skin reset failed"), - tr("Mojang did not process the request.")); - return; - } - - /* Drop the cached skin so the next refresh re-fetches the default. */ - m_ctx->account_set_skin_blob(m_ctx->module_handle, idUtf8.constData(), - nullptr, 0); - m_viewer->clearSkin(); - setStatus(tr("Skin reset to the Mojang default.")); -} - -/* ── viewer toggles ───────────────────────────────────────────────── */ - -void SkinManagerDialog::onVariantToggled() -{ - const ModelVariant v = - ui->rdoSlim->isChecked() ? ModelVariant::Slim : ModelVariant::Classic; - QImage current; - if (!m_chosenSkinImage.isNull()) { - current = m_chosenSkinImage; - } else if (m_ctx && !m_accountId.isEmpty()) { - const void* ptr = nullptr; - const int64_t len = m_ctx->account_get_skin_blob( - m_ctx->module_handle, m_accountId.toUtf8().constData(), &ptr); - if (len > 0 && ptr) { - current.loadFromData( - QByteArray::fromRawData(static_cast(ptr), - static_cast(len)), - "PNG"); - current = normaliseSkin(current); - } - } - m_viewer->setSkin(current, v); -} - -void SkinManagerDialog::onOverlayToggled(bool on) -{ - m_viewer->setShowOverlay(on); -} - -void SkinManagerDialog::onAutoRotateToggled(bool on) -{ - m_viewer->setAutoRotate(on); -} - -void SkinManagerDialog::onCapeChanged(int row) -{ - if (!m_ctx || m_accountId.isEmpty()) - return; - const QString capeId = ui->capeCombo->itemData(row).toString(); - if (capeId.isEmpty()) { - m_viewer->setCape(QImage()); - return; - } - /* Find the cape blob whose id matches the user's pick. */ - const QByteArray idUtf8 = m_accountId.toUtf8(); - const int total = - m_ctx->account_cape_count(m_ctx->module_handle, idUtf8.constData()); - for (int i = 0; i < total; ++i) { - const char* cid = m_ctx->account_cape_get_id(m_ctx->module_handle, - idUtf8.constData(), i); - if (!cid || QString::fromUtf8(cid) != capeId) - continue; - const void* ptr = nullptr; - const int64_t len = m_ctx->account_cape_get_blob( - m_ctx->module_handle, idUtf8.constData(), i, &ptr); - QImage capeImg; - if (len > 0 && ptr) - capeImg.loadFromData( - QByteArray::fromRawData(static_cast(ptr), - static_cast(len)), - "PNG"); - m_viewer->setCape(capeImg); - return; - } - m_viewer->setCape(QImage()); -} - -/* ── commit ───────────────────────────────────────────────────────── */ - -void SkinManagerDialog::onAccept() -{ - if (!m_ctx || m_accountId.isEmpty()) { - reject(); - return; - } - - const QByteArray idUtf8 = m_accountId.toUtf8(); - - /* Read the user's chosen skin bytes once up front so we don't - * touch the file twice on the happy path. */ - QByteArray skinBytes; - if (!m_chosenSkinPath.isEmpty()) { - QFile f(m_chosenSkinPath); - if (!f.open(QIODevice::ReadOnly)) { - setStatus(tr("Could not read %1: %2") - .arg(m_chosenSkinPath, f.errorString()), - /*error=*/true); - return; - } - skinBytes = f.readAll(); - } - - /* Mirror the legacy SequentialTask: optional skin upload, then - * optional cape change, both routed through the S26 helpers - * which pump their own modal progress dialogs. */ - const QString chosenCape = ui->capeCombo->currentData().toString(); - const char* currentCapeC = m_ctx->account_get_current_cape_id( - m_ctx->module_handle, idUtf8.constData()); - const QString currentCape = - currentCapeC ? QString::fromUtf8(currentCapeC) : QString(); - const bool capeChanged = chosenCape != currentCape; - - if (skinBytes.isEmpty() && !capeChanged) { - accept(); - return; - } - - if (!skinBytes.isEmpty()) { - const char* variant = ui->rdoSlim->isChecked() ? "ALEX" : "STEVE"; - if (m_ctx->account_skin_upload(m_ctx->module_handle, idUtf8.constData(), - skinBytes.constData(), skinBytes.size(), - variant) != 0) { - QMessageBox::warning(this, tr("Skin Upload"), - tr("Failed to apply skin changes.")); - return; - } - } - if (capeChanged) { - const QByteArray capeUtf8 = chosenCape.toUtf8(); - if (m_ctx->account_cape_set(m_ctx->module_handle, idUtf8.constData(), - capeUtf8.constData()) != 0) { - QMessageBox::warning(this, tr("Skin Upload"), - tr("Failed to apply cape change.")); - return; - } - } - - /* Update in-memory cache via the C ABI setters so the launcher's - * account list re-renders immediately. */ - if (!skinBytes.isEmpty()) { - m_ctx->account_set_skin_blob(m_ctx->module_handle, idUtf8.constData(), - skinBytes.constData(), skinBytes.size()); - m_ctx->account_set_skin_variant( - m_ctx->module_handle, idUtf8.constData(), - ui->rdoSlim->isChecked() ? "SLIM" : "CLASSIC"); - } - if (capeChanged) { - const QByteArray capeUtf8 = chosenCape.toUtf8(); - m_ctx->account_set_current_cape( - m_ctx->module_handle, idUtf8.constData(), capeUtf8.constData()); - } - - QMessageBox::information(this, tr("Skin Upload"), - tr("Successfully applied skin changes.")); - accept(); -} - -/* ── status line ──────────────────────────────────────────────────── */ - -void SkinManagerDialog::setStatus(const QString& text, bool error) -{ - ui->statusLabel->setText(text); - ui->statusLabel->setStyleSheet( - error ? QStringLiteral("color: #d33; font-size: 11px;") - : QStringLiteral("color: palette(mid); font-size: 11px;")); -} diff --git a/launcher/plugin/plugins/SkinManager/SkinManagerDialog.h b/launcher/plugin/plugins/SkinManager/SkinManagerDialog.h deleted file mode 100644 index 0ffbb817f..000000000 --- a/launcher/plugin/plugins/SkinManager/SkinManagerDialog.h +++ /dev/null @@ -1,75 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Project Tick - * SPDX-License-Identifier: Apache-2.0 - * - * SkinManagerDialog — the "Upload Skin" replacement. - * - * Drop-in replacement for the launcher's built-in SkinUploadDialog, - * injected by SkinManagerPlugin when the user opens - * Settings → Accounts and clicks the toolbar's "Upload Skin" - * action. The plugin intercepts the action's `triggered` signal and - * shows this dialog instead of the stock one. - * - * Behaviour parity with the launcher's SkinUploadDialog: - * • Optional skin PNG path (drag-drop or Browse) - * • Classic / Slim model selector - * • Cape combo (owned capes only) - * • OK uploads the skin and / or changes the cape - * • Cancel does nothing - * - * Extras on top of vanilla: - * • Real-time 3D preview of the chosen skin + cape - * • Drop-zone PNG support - * • Auto-rotate + overlay toggles - * • "Reset to default" button (SkinDelete task) - */ - -#pragma once - -#include "plugin/sdk/mmco_cxx_sdk.hpp" -#include "SkinModel.h" - -namespace Ui -{ - class SkinManagerDialog; -} - -namespace SkinManagerNS -{ - class SkinViewerWidget; -} - -class SkinManagerDialog : public QDialog -{ - Q_OBJECT - - public: - SkinManagerDialog(const QString& accountId, MMCOContext* ctx, - QWidget* parent = nullptr); - ~SkinManagerDialog() override; - - private slots: - void onBrowseClicked(); - void onResetClicked(); - void onVariantToggled(); - void onOverlayToggled(bool on); - void onAutoRotateToggled(bool on); - void onCapeChanged(int row); - void onSkinFileDropped(const QString& path); - - void onAccept(); - - private: - void loadAccountState(); - void loadSkinFile(const QString& path); - void setStatus(const QString& text, bool error = false); - - Ui::SkinManagerDialog* ui; - SkinManagerNS::SkinViewerWidget* m_viewer = nullptr; - QString m_accountId; - MMCOContext* m_ctx = nullptr; - - /* Tracks the user's intent. m_chosenSkinPath empty == "keep - * current skin", otherwise upload from that path on OK. */ - QString m_chosenSkinPath; - QImage m_chosenSkinImage; -}; diff --git a/launcher/plugin/plugins/SkinManager/SkinManagerDialog.ui b/launcher/plugin/plugins/SkinManager/SkinManagerDialog.ui deleted file mode 100644 index f80badc2d..000000000 --- a/launcher/plugin/plugins/SkinManager/SkinManagerDialog.ui +++ /dev/null @@ -1,185 +0,0 @@ - - - SkinManagerDialog - - - - 0 - 0 - 820 - 540 - - - - Skin Upload - - - - - Account: — - - font-weight: bold; padding: 4px 0; - - - - - - Qt::Horizontal - false - - - 260360 - - - 0 - - - - - 320360 - - - - - - Skin file - - - - - - - Keep current skin, or pick a new PNG… - - true - - - - - Browse… - - - - - - - - Tip: drag-and-drop a 64×64 PNG onto the preview. - - - color: palette(mid); font-size: 11px; - - true - - - - - - - - - Model - - - - Classic (4-pixel arms, Steve) - true - - - - - Slim (3-pixel arms, Alex) - - - - - - - - - Preview - - - - Show second-layer overlay - true - - - - - Auto-rotate - - - - - - - - - Cape - - - - Active cape: - - - - - - - - - - - - - true - - color: palette(mid); font-size: 11px; - - - - - - Qt::Vertical - - 2040 - - - - - - - - - - - - Reset to default skin… - - Remove the active custom skin and revert to Steve / Alex. - - - - - - Qt::Horizontal - - 4020 - - - - - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - - - - diff --git a/launcher/plugin/plugins/SkinManager/SkinManagerPlugin.cpp b/launcher/plugin/plugins/SkinManager/SkinManagerPlugin.cpp deleted file mode 100644 index a9f93443b..000000000 --- a/launcher/plugin/plugins/SkinManager/SkinManagerPlugin.cpp +++ /dev/null @@ -1,337 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Project Tick - * SPDX-License-Identifier: Apache-2.0 - * - * SkinManager — MMCO plugin entry point. - * - * Replaces the launcher's built-in "Upload Skin" dialog with a richer - * one that includes a real-time 3D skin preview. - * - * Injection strategy - * ────────────────── - * The launcher exposes its "Upload Skin" action through AccountListPage - * (Settings → Accounts), specifically as a QAction with objectName - * "actionUploadSkin" inside an AccountListPage QMainWindow widget. - * Whenever the global settings dialog opens, the page is reconstructed - * and a fresh QAction is created, so we can't capture the action once - * and remember it forever — we have to re-hook on every open. - * - * Pattern used (NVIDIAPrime / LinuxPerf precedent): - * 1. Connect to Application::globalSettingsAboutToOpen. - * 2. On the next event-loop turn (QTimer::singleShot(0)), walk the - * widget tree for an AccountListPage and its actionUploadSkin - * child action. - * 3. action->disconnect(); action->connect(... → openOurDialog). - * 4. openOurDialog reads the selected account from the page's - * QListView selection, builds a SkinManagerDialog, and exec()s it. - * - * All Qt signal connections belong to g_guard, a QObject created in - * mmco_init and dropped (not deleted) in mmco_unload — see the same - * comment-block in NVIDIAPrime for why deleting it during shutdown - * is unsafe. - */ - -#include "plugin/sdk/mmco_cxx_sdk.hpp" -#include "SkinManagerDialog.h" - -#include -#include -#include -#include - -MMCO_DEFINE_MODULE("SkinManager", "1.0.0", "Project Tick", - "3D skin preview integrated into the Upload Skin dialog. " - "Replaces the launcher's stock skin-upload window with " - "a richer, viewer-equipped version.", - "Apache-2.0"); - -static MMCOContext* g_ctx = nullptr; -static QObject* g_guard = nullptr; - -/* Find the AccountListPage widget — set by Qt Designer's objectName - * attribute on its top-level QMainWindow node. Returns nullptr if the - * settings dialog has not built it yet (we re-try on the next event - * loop turn in that case). */ -static QWidget* findAccountListPage() -{ - for (auto* w : qApp->allWidgets()) { - if (w->objectName() == QStringLiteral("AccountListPage")) - return w; - } - return nullptr; -} - -/* Open the SkinManagerDialog against whichever account is currently - * selected in the AccountListPage's list view. Falls back to a - * user-visible message box on each of the three reasonable failure - * modes (no selection, offline-only selection, broken model row). - * - * Implementation note: the launcher's AccountListPage.ui declares the - * accounts list with objectName "listView" but the actual class is - * VersionListView, which derives from QTreeView (not QListView). We - * therefore search for the common base — QAbstractItemView — so the - * findChild<>() succeeds regardless of which subclass the .ui actually - * picks. */ -static void openSkinManagerForSelection(QWidget* accountListPage) -{ - if (!accountListPage || !g_ctx) - return; - - auto* listView = accountListPage->findChild( - QStringLiteral("listView")); - if (!listView || !listView->selectionModel()) { - MMCO_WARN(g_ctx, "SkinManager: AccountListPage has no listView; cannot " - "open the skin manager."); - return; - } - const auto indexes = listView->selectionModel()->selectedIndexes(); - if (indexes.isEmpty()) { - QMessageBox::information( - accountListPage, QObject::tr("No account selected"), - QObject::tr("Select a Microsoft account from the list and " - "click Upload Skin again.")); - return; - } - - /* AccountListPage's listView is bound straight to the host's - * AccountList QAbstractListModel. The selected row index maps - * 1:1 onto the index that S25's account_get_id_by_index expects. */ - const QModelIndex idx = indexes.first(); - const int row = idx.row(); - const char* accountIdC = - g_ctx->account_get_id_by_index(g_ctx->module_handle, row); - if (!accountIdC) { - MMCO_WARN(g_ctx, "SkinManager: selection index has no resolvable " - "account id — ignoring click."); - return; - } - const QString accountId = QString::fromUtf8(accountIdC); - - if (!g_ctx->account_is_msa_by_id(g_ctx->module_handle, - accountId.toUtf8().constData())) { - QMessageBox::information( - accountListPage, QObject::tr("Offline account"), - QObject::tr("Offline accounts have no Mojang profile to " - "upload a skin to. Pick a Microsoft account " - "instead.")); - return; - } - - SkinManagerDialog dlg(accountId, g_ctx, accountListPage); - dlg.exec(); -} - -/* Walk the AccountListPage for its actionUploadSkin QAction and - * replace its triggered() handler with ours. Re-runs each time the - * settings dialog opens because Qt re-builds the page every time. */ -static void rewireUploadSkinAction() -{ - QWidget* page = findAccountListPage(); - if (!page) - return; - - auto* action = - page->findChild(QStringLiteral("actionUploadSkin")); - if (!action) { - MMCO_WARN(g_ctx, - "SkinManager: actionUploadSkin not found on " - "AccountListPage; skin-manager dialog won't replace the " - "built-in one this session."); - return; - } - - /* Sever every existing connection — including the .ui's auto-wired - * on_actionUploadSkin_triggered slot — and add our own. The page - * widget itself is captured (not the action) because Qt deletes - * the action with the page; capturing `page` lets us re-resolve - * the listView every click defensively. */ - QPointer pageGuard = page; - action->disconnect(); - QObject::connect(action, &QAction::triggered, g_guard, [pageGuard]() { - if (pageGuard) - openSkinManagerForSelection(pageGuard); - }); - - /* - * Force the action permanently enabled. - * - * Why this matters: AccountListPage::updateButtonStates() disables - * actionUploadSkin whenever: - * • no row is selected, or - * • the selected account is still refreshing (isActive() == true) - * - * Both restrictions exist because the *built-in* SkinUploadDialog - * requires a pre-selected MSA account to write into. Our replacement - * dialog handles those edge cases itself with friendly message - * boxes, so the action should always be clickable. - * - * We can't simply call setEnabled(true) once: updateButtonStates() - * runs again on every selectionChanged() and accountActivityChanged() - * signal and flips the action back to disabled. We also can't hook - * QAction::changed and bounce setEnabled(true) inside that slot — - * even with a `!isEnabled()` guard, Qt's property-change cascade - * during AccountListPage construction sometimes lets the disabled - * state win. - * - * The bulletproof workaround is a low-frequency polling timer - * tied to the page's lifetime via g_guard. The poll only runs - * while the settings dialog is alive (we check the page pointer - * each tick) and stops itself once the page is destroyed. */ - /* Force-enable the action AND its WideBar button. - * - * AccountListPage uses a WideBar (a custom QToolBar subclass — - * launcher/ui/widgets/WideBar.cpp). Internally WideBar replaces - * every action with an ActionButton — a private QToolButton - * subclass that mirrors action->isEnabled() into its own - * setEnabled() inside an actionChanged() slot bound to - * QAction::changed. - * - * Consequence: setting action->setEnabled(true) is *not enough*. - * AccountListPage::updateButtonStates() calls action->setEnabled( - * false) every time the selection or refresh state changes, and - * the ActionButton dutifully greys itself out before we get a - * chance to flip the action back. - * - * We fight back on TWO levels: - * (1) keep the action enabled - * (2) keep every QToolButton inside the page's toolBar enabled - * (specifically the one bound to actionUploadSkin — the - * defaultAction() of an ActionButton is the underlying - * QAction). - * - * A 30 ms polling timer drives both, so we beat the page's own - * updateButtonStates() to the next paint cycle. The timer is - * parented to g_guard (long-lived) but tied to the page's - * lifetime via QPointer — once the page is gone, the - * timer self-destructs. */ - auto* targetAction = action; - QObject::connect(action, &QAction::changed, g_guard, [targetAction]() { - if (targetAction && !targetAction->isEnabled()) - targetAction->setEnabled(true); - }); - action->setEnabled(true); - - auto* keepEnabled = new QTimer(g_guard); - keepEnabled->setInterval(30); - QPointer actionRef = action; - QObject::connect( - keepEnabled, &QTimer::timeout, g_guard, - [keepEnabled, actionRef, pageGuard]() { - if (!pageGuard || !actionRef) { - keepEnabled->deleteLater(); - return; - } - /* Re-enable the QAction itself. */ - if (!actionRef->isEnabled()) - actionRef->setEnabled(true); - - /* - * Walk every QToolButton inside the page and force-enable - * the one bound to our action. WideBar::ActionButton does - * NOT call setDefaultAction(), so defaultAction() is null — - * we cannot match the button to the action by that field. - * Instead, the WideBar widget hierarchy is: - * - * WideBar (QToolBar, objectName "toolBar") - * └─ ActionButton (QToolButton; one per WideBar entry) - * ↑ has a private QAction* m_action linking it - * back to actionUploadSkin via a constructor - * argument, but that member is not visible to - * us. - * - * The pragmatic workaround is to compare button text to - * the action text: WideBar::ActionButton::actionChanged() - * mirrors the action's text into setText() on every change, - * so the button labelled with our action's text *is* the - * one bound to that action. */ - if (!actionRef->isEnabled()) - return; - const QString wanted = actionRef->text(); - if (wanted.isEmpty()) - return; - const auto buttons = pageGuard->findChildren(); - for (auto* btn : buttons) { - if (btn && btn->text() == wanted && !btn->isEnabled()) - btn->setEnabled(true); - } - }); - keepEnabled->start(); - - /* First tick immediately so the user never sees a greyed button - * even on the very first paint. */ - QTimer::singleShot(0, g_guard, [actionRef, pageGuard]() { - if (!pageGuard || !actionRef) - return; - actionRef->setEnabled(true); - const QString wanted = actionRef->text(); - const auto buttons = pageGuard->findChildren(); - for (auto* btn : buttons) { - if (btn && btn->text() == wanted && !btn->isEnabled()) - btn->setEnabled(true); - } - }); - - MMCO_DBG(g_ctx, "SkinManager: actionUploadSkin rewired to plugin dialog."); -} - -/* Application::globalSettingsAboutToOpen fires *before* the dialog is - * built, so the AccountListPage widget may not exist yet when we get - * here. Re-try a few times on the event loop to cover both the very - * first open (slow construction) and immediate re-opens. */ -static void scheduleRewire(int retries = 5) -{ - QTimer::singleShot(0, qApp, [retries]() { - if (findAccountListPage()) { - rewireUploadSkinAction(); - return; - } - if (retries <= 0) - return; - QTimer::singleShot(50, qApp, - [retries]() { scheduleRewire(retries - 1); }); - }); -} - -extern "C" { - -MMCO_EXPORT int mmco_init(MMCOContext* ctx) -{ - g_ctx = ctx; - MMCO_LOG(ctx, "SkinManager initializing..."); - - g_guard = new QObject(); - - /* Re-hook every time the settings dialog opens — the page is - * destroyed and rebuilt each open, so the action handle is fresh. - * The MMCO_HOOK_GLOBAL_SETTINGS_ABOUT_TO_OPEN hook is the C ABI - * replacement for the legacy Application::globalSettingsAboutToOpen - * signal connection. */ - ctx->hook_register( - ctx->module_handle, MMCO_HOOK_GLOBAL_SETTINGS_ABOUT_TO_OPEN, - [](void*, uint32_t, void*, void*) -> int { - scheduleRewire(); - return 0; - }, - nullptr); - - /* The plugin may load *after* the settings dialog is already - * visible (rare, but possible if the user opens settings before - * mmco_init runs). Cover that by trying once on init. */ - scheduleRewire(); - - MMCO_LOG(ctx, "SkinManager initialized."); - return 0; -} - -MMCO_EXPORT void mmco_unload() -{ - if (g_ctx) - MMCO_LOG(g_ctx, "SkinManager unloading."); - - /* g_guard intentionally not deleted — same rationale as NVIDIAPrime: - * tearing down a QObject mid-Application-shutdown can trip Qt's - * signal-slot bookkeeping. The OS reclaims memory at process exit. */ - g_guard = nullptr; - g_ctx = nullptr; -} - -} /* extern "C" */ diff --git a/launcher/plugin/plugins/SkinManager/SkinModel.cpp b/launcher/plugin/plugins/SkinManager/SkinModel.cpp deleted file mode 100644 index e40a5a02e..000000000 --- a/launcher/plugin/plugins/SkinManager/SkinModel.cpp +++ /dev/null @@ -1,240 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Project Tick - * SPDX-License-Identifier: Apache-2.0 - */ - -#include "SkinModel.h" - -#include -#include -#include - -using namespace SkinManagerNS; - -namespace -{ - - constexpr float TEX = 1.0f / 64.0f; - - void face(QVector& out, float ax, float ay, float az, float bx, - float by, float bz, float cx, float cy, float cz, float dx, - float dy, float dz, float u0, float v0, float u1, float v1) - { - const float V0 = 1.0f - v0; - const float V1 = 1.0f - v1; - out.push_back({ax, ay, az, u0, V1}); - out.push_back({bx, by, bz, u1, V1}); - out.push_back({cx, cy, cz, u1, V0}); - - out.push_back({ax, ay, az, u0, V1}); - out.push_back({cx, cy, cz, u1, V0}); - out.push_back({dx, dy, dz, u0, V0}); - } - - DrawRange addCuboid(QVector& out, float ox, float oy, float oz, - float gx, float gy, float gz, float tx, float ty, - float tz, int u, int v) - { - const int begin = static_cast(out.size()); - - const float hx = gx * 0.5f; - const float hy = gy * 0.5f; - const float hz = gz * 0.5f; - - const float x0 = ox - hx, x1 = ox + hx; - const float y0 = oy - hy, y1 = oy + hy; - const float z0 = oz - hz, z1 = oz + hz; - - const float U = float(u); - const float V = float(v); - const float SX = tx; - const float SY = ty; - const float SZ = tz; - - face(out, x0, y1, z0, x1, y1, z0, x1, y1, z1, x0, y1, z1, - (U + SZ) * TEX, V * TEX, (U + SZ + SX) * TEX, (V + SZ) * TEX); - - face(out, x0, y0, z1, x1, y0, z1, x1, y0, z0, x0, y0, z0, - (U + SZ + SX) * TEX, V * TEX, (U + SZ + 2.0f * SX) * TEX, - (V + SZ) * TEX); - - face(out, x1, y0, z1, x1, y0, z0, x1, y1, z0, x1, y1, z1, U * TEX, - (V + SZ) * TEX, (U + SZ) * TEX, (V + SZ + SY) * TEX); - - face(out, x0, y0, z1, x1, y0, z1, x1, y1, z1, x0, y1, z1, - (U + SZ) * TEX, (V + SZ) * TEX, (U + SZ + SX) * TEX, - (V + SZ + SY) * TEX); - - face(out, x0, y0, z0, x0, y0, z1, x0, y1, z1, x0, y1, z0, - (U + SZ + SX) * TEX, (V + SZ) * TEX, (U + 2.0f * SZ + SX) * TEX, - (V + SZ + SY) * TEX); - - face(out, x1, y0, z0, x0, y0, z0, x0, y1, z0, x1, y1, z0, - (U + 2.0f * SZ + SX) * TEX, (V + SZ) * TEX, - (U + 2.0f * SZ + 2.0f * SX) * TEX, (V + SZ + SY) * TEX); - - return DrawRange{begin, static_cast(out.size()) - begin}; - } - -} // namespace - -Mesh::~Mesh() -{ - if (m_vao.isCreated()) - m_vao.destroy(); - if (m_vbo.isCreated()) - m_vbo.destroy(); -} - -void Mesh::appendBox(QVector&, BodyPart, float, float, float, float, - float, float, int, int, float) -{ -} - -void Mesh::appendCape(QVector&) {} - -void Mesh::build(ModelVariant variant) -{ - m_variant = variant; - - QVector verts; - verts.reserve(36 * int(BodyPart::Count)); - - const bool slim = (variant == ModelVariant::Slim); - const float armW = slim ? 3.0f : 4.0f; - - struct PartSpec { - BodyPart base, over; - int u_base, v_base, u_over, v_over; - float ox, oy, oz; - float gx_base, gy_base, gz_base; - float gx_over, gy_over, gz_over; - float tx, ty, tz; - }; - - const PartSpec spec[] = { - {BodyPart::Head, BodyPart::HeadOverlay, 0, 0, 32, 0, 0.0f, 4.0f, 0.0f, - 8.0f, 8.0f, 8.0f, 9.0f, 9.0f, 9.0f, 8.0f, 8.0f, 8.0f}, - - {BodyPart::Body, BodyPart::BodyOverlay, 16, 16, 16, 32, 0.0f, -6.0f, - 0.0f, 8.0f, 12.0f, 4.0f, 8.5f, 12.5f, 4.5f, 8.0f, 12.0f, 4.0f}, - - {BodyPart::RightArm, BodyPart::RightArmOverlay, 40, 16, 40, 32, - slim ? -5.5f : -6.0f, -6.0f, 0.0f, armW, 12.0f, 4.0f, - slim ? 3.5f : 4.5f, 12.5f, 4.5f, armW, 12.0f, 4.0f}, - - {BodyPart::LeftArm, BodyPart::LeftArmOverlay, 32, 48, 48, 48, - slim ? 5.5f : 6.0f, -6.0f, 0.0f, armW, 12.0f, 4.0f, slim ? 3.5f : 4.5f, - 12.5f, 4.5f, armW, 12.0f, 4.0f}, - - {BodyPart::RightLeg, BodyPart::RightLegOverlay, 0, 16, 0, 32, -1.9f, - -18.0f, -0.1f, 4.0f, 12.0f, 4.0f, 4.5f, 12.5f, 4.5f, 4.0f, 12.0f, - 4.0f}, - - {BodyPart::LeftLeg, BodyPart::LeftLegOverlay, 16, 48, 0, 48, 1.9f, - -18.0f, -0.1f, 4.0f, 12.0f, 4.0f, 4.5f, 12.5f, 4.5f, 4.0f, 12.0f, - 4.0f}, - }; - - for (const auto& p : spec) { - m_ranges[int(p.base)] = - addCuboid(verts, p.ox, p.oy, p.oz, p.gx_base, p.gy_base, p.gz_base, - p.tx, p.ty, p.tz, p.u_base, p.v_base); - m_ranges[int(p.over)] = - addCuboid(verts, p.ox, p.oy, p.oz, p.gx_over, p.gy_over, p.gz_over, - p.tx, p.ty, p.tz, p.u_over, p.v_over); - } - - { - const float CW = 10.0f, CH = 16.0f, CD = 1.0f; - const float cape_ox = 0.0f; - const float cape_oy = -8.0f; - const float cape_oz = 2.5f; - - const int capeBegin = static_cast(verts.size()); - - const float hx = CW * 0.5f, hy = CH * 0.5f, hz = CD * 0.5f; - - auto px = [](float p) { return p * TEX; }; - - const float angleX = qDegreesToRadians(10.8f); - const float cx = std::cos(angleX), sx = std::sin(angleX); - auto xform = [&](float lx, float ly, float lz, float& wx, float& wy, - float& wz) { - const float yp = ly * cx - lz * sx; - const float zp = ly * sx + lz * cx; - wx = -lx + cape_ox; - wy = yp + cape_oy; - wz = -zp + cape_oz; - }; - - auto cubeFace = [&](float ax, float ay, float az, float bx, float by, - float bz, float cx_, float cy_, float cz_, float dx, - float dy, float dz, float u0, float v0, float u1, - float v1) { - float Ax, Ay, Az, Bx, By, Bz, Cx, Cy, Cz, Dx, Dy, Dz; - xform(ax, ay, az, Ax, Ay, Az); - xform(bx, by, bz, Bx, By, Bz); - xform(cx_, cy_, cz_, Cx, Cy, Cz); - xform(dx, dy, dz, Dx, Dy, Dz); - face(verts, Ax, Ay, Az, Bx, By, Bz, Cx, Cy, Cz, Dx, Dy, Dz, u0, v0, - u1, v1); - }; - - cubeFace(-hx, hy, -hz, hx, hy, -hz, hx, hy, hz, -hx, hy, hz, px(1), - px(0), px(1 + CW), px(0 + CD)); - cubeFace(-hx, -hy, hz, hx, -hy, hz, hx, -hy, -hz, -hx, -hy, -hz, px(11), - px(0), px(11 + CW), px(0 + CD)); - cubeFace(hx, -hy, hz, hx, -hy, -hz, hx, hy, -hz, hx, hy, hz, px(0), - px(1), px(0 + CD), px(1 + CH)); - cubeFace(-hx, -hy, hz, hx, -hy, hz, hx, hy, hz, -hx, hy, hz, px(1), - px(1), px(1 + CW), px(1 + CH)); - cubeFace(-hx, -hy, -hz, -hx, -hy, hz, -hx, hy, hz, -hx, hy, -hz, px(11), - px(1), px(11 + CD), px(1 + CH)); - cubeFace(hx, -hy, -hz, -hx, -hy, -hz, -hx, hy, -hz, hx, hy, -hz, px(12), - px(1), px(12 + CW), px(1 + CH)); - - m_ranges[int(BodyPart::Cape)] = - DrawRange{capeBegin, static_cast(verts.size()) - capeBegin}; - } - - if (!m_vao.isCreated()) - m_vao.create(); - if (!m_vbo.isCreated()) - m_vbo.create(); - - m_vao.bind(); - m_vbo.bind(); - m_vbo.setUsagePattern(QOpenGLBuffer::StaticDraw); - m_vbo.allocate(verts.constData(), int(verts.size() * sizeof(Vertex))); - - auto* f = QOpenGLContext::currentContext()->functions(); - f->glEnableVertexAttribArray(0); - f->glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), - reinterpret_cast(0)); - f->glEnableVertexAttribArray(1); - f->glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), - reinterpret_cast(sizeof(float) * 3)); - - m_vbo.release(); - m_vao.release(); - m_built = true; -} - -void Mesh::rebuild(ModelVariant variant) -{ - if (m_built && variant == m_variant) - return; - build(variant); -} - -void Mesh::bind() -{ - if (m_built) - m_vao.bind(); -} - -void Mesh::release() -{ - if (m_built) - m_vao.release(); -} diff --git a/launcher/plugin/plugins/SkinManager/SkinModel.h b/launcher/plugin/plugins/SkinManager/SkinModel.h deleted file mode 100644 index f9665667e..000000000 --- a/launcher/plugin/plugins/SkinManager/SkinModel.h +++ /dev/null @@ -1,88 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Project Tick - * SPDX-License-Identifier: Apache-2.0 - */ - -#pragma once - -#include -#include -#include -#include - -class QOpenGLShaderProgram; - -namespace SkinManagerNS -{ - - enum class BodyPart : int { - Head = 0, - Body, - LeftArm, - RightArm, - LeftLeg, - RightLeg, - - HeadOverlay, - BodyOverlay, - LeftArmOverlay, - RightArmOverlay, - LeftLegOverlay, - RightLegOverlay, - - Cape, - - Count - }; - - enum class ModelVariant { Classic, Slim }; - - struct Vertex { - float x, y, z; - float u, v; - }; - - struct DrawRange { - int first; - int count; - }; - - class Mesh - { - public: - Mesh() = default; - ~Mesh(); - - Mesh(const Mesh&) = delete; - Mesh& operator=(const Mesh&) = delete; - - void build(ModelVariant variant); - - void rebuild(ModelVariant variant); - - void bind(); - void release(); - - const DrawRange& range(BodyPart part) const - { - return m_ranges[int(part)]; - } - - ModelVariant variant() const - { - return m_variant; - } - - private: - void appendBox(QVector& verts, BodyPart tag, float ox, float oy, - float oz, float sx, float sy, float sz, int u0, int v0, - float scale = 1.0f); - void appendCape(QVector& verts); - - QOpenGLBuffer m_vbo{QOpenGLBuffer::VertexBuffer}; - QOpenGLVertexArrayObject m_vao; - std::array m_ranges{}; - ModelVariant m_variant = ModelVariant::Classic; - bool m_built = false; - }; - -} // namespace SkinManagerNS diff --git a/launcher/plugin/plugins/SkinManager/SkinViewerWidget.cpp b/launcher/plugin/plugins/SkinManager/SkinViewerWidget.cpp deleted file mode 100644 index 3ce13f040..000000000 --- a/launcher/plugin/plugins/SkinManager/SkinViewerWidget.cpp +++ /dev/null @@ -1,489 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Project Tick - * SPDX-License-Identifier: Apache-2.0 - * - * Real-time 3D skin preview. Uses MeshMC's bundled shaders so the - * plugin ships zero GLSL of its own. - */ - -#include "SkinViewerWidget.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace SkinManagerNS; - -namespace -{ - - /* - * Depth-test setup for the skin viewer. - * - * IMPORTANT — about the vertex shader and Reverse Z: - * vshader_skin_model.glsl contains a "Z-NDC re-inversion" step that - * flips a Reverse-Z output back into the standard [0, W] range - * (gl_Position.z = w_c - near_z). That post-step only produces a - * correct depth value when paintGL() feeds it a Reverse-Z - * perspective matrix — see the matrix construction in paintGL() - * below. With that matrix in place, depth values land in the - * standard [0, 1] range so we drive the pipeline with the - * *standard* settings (GL_LESS + clearDepth(1)). - * - * Backface culling is intentionally NOT enabled — Prism Launcher's - * SkinOpenGLWindow::paintGL() doesn't enable it either, and for good - * reason. SkinModel emits each face with its own hand-written winding - * order; the TOP and BOTTOM quads happen to come out clockwise from - * outside the cuboid (their (b-a) × (c-a) normal points back into - * the box), so glCullFace(GL_BACK) silently drops them. That made - * the top of the head and the soles of the feet invisible during - * testing. Disabling culling outright is the cheapest fix and also - * removes the per-draw enable/disable dance the cape used to need. - * - * Alpha blending stays on so the fragment shader's alpha-discard - * (already in fshader.glsl) composites cleanly against the body. - */ - void configureDepth(QOpenGLFunctions* f) - { - f->glEnable(GL_DEPTH_TEST); - f->glDepthFunc(GL_LESS); - f->glClearDepthf(1.0f); - f->glDisable(GL_CULL_FACE); - f->glEnable(GL_BLEND); - f->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - } - -} // namespace - -SkinViewerWidget::SkinViewerWidget(QWidget* parent) : QOpenGLWidget(parent) -{ - setMinimumSize(220, 320); - setAcceptDrops(true); - setFocusPolicy(Qt::StrongFocus); - - /* ~60 fps repaint when auto-rotate is on, idle otherwise. */ - m_animTimer.setInterval(16); - QObject::connect(&m_animTimer, &QTimer::timeout, this, [this]() { - if (m_autoRotate) { - m_yaw += 0.6f; - if (m_yaw > 360.0f) - m_yaw -= 360.0f; - update(); - } - }); -} - -SkinViewerWidget::~SkinViewerWidget() -{ - /* Tear-down requires a current context so the GL resources free - * properly. */ - makeCurrent(); - m_skinTex.reset(); - m_capeTex.reset(); - doneCurrent(); -} - -void SkinViewerWidget::setSkin(const QImage& image, ModelVariant variant) -{ - m_variant = variant; - if (image.isNull()) { - clearSkin(); - return; - } - - /* Mirror vertically before upload — matches Prism Launcher's - * Scene::setSkin() in ui/dialogs/skins/draw/Scene.cpp. The mirror - * + the UV layout used by SkinModel together give a Mojang-aligned - * sample: the top row of the texture lines up with V=0 in shader - * space after Qt's normal QImage → GL Y-flip. */ - m_pendingSkin = image.convertToFormat(QImage::Format_RGBA8888).mirrored(); - m_pendingSkinUpload = true; - - if (isVisible()) - update(); -} - -void SkinViewerWidget::setCape(const QImage& image) -{ - if (image.isNull()) { - m_hasCape = false; - m_pendingCape = QImage(); - m_pendingCapeUpload = true; - if (isVisible()) - update(); - return; - } - m_hasCape = true; - /* Cape PNGs are 64×32. Pad them into a 64×64 image so the same - * single texture-bind path works for skin and cape, then mirror - * vertically — exactly the dance the skin pipeline does. - * - * Layout walk-through (the bit that took three tries to land): - * - * 1. Start: raw 64×32 cape, Mojang convention — cape row 0 is - * the *top* of the cape graphic. - * - * 2. Pad into the TOP half of a 64×64 image, leaving the - * bottom half transparent: - * - * rows 0..31 : cape pixels (Mojang row k -> padded row k) - * rows 32..63 : transparent - * - * 3. QImage::mirrored() flips vertically: - * - * rows 0..31 : transparent - * rows 32..63 : cape pixels (Mojang row k -> mirrored row 63-k) - * - * 4. GL_TEXTURE_2D samples V = row / 64. So Mojang's v=0 (top - * of the cape) lives at mirrored row 63 -> V ≈ 1.0, and - * Mojang's v=31 (bottom) lives at mirrored row 32 -> V = 0.5. - * - * 5. SkinModel's cape face() calls use Mojang pixel v ∈ [0,17] - * and apply V_sample = 1 - v/64 -> V ∈ [47/64, 1.0] — exactly - * the band where the mirrored cape data now lives. - * - * Earlier attempts: - * - pad into BOTTOM half + mirror -> cape data lands in - * rows 0..31 (V ∈ [0, 0.5]); UV band [47/64, 1] misses it -> blank. - * - pad into BOTTOM half + no mirror -> cape data in rows 32..63 - * (V ∈ [0.5, 1]); first 5 rows of cape end up *above* the UV - * band, and the bottom of the cape ends up clamped at V=1 -> the - * cape did render but was vertically flipped and shifted by - * ~3 pixels, which read as "missing". - */ - QImage padded(64, 64, QImage::Format_RGBA8888); - padded.fill(Qt::transparent); - { - const QImage src = - image.scaled(64, 32, Qt::KeepAspectRatio, Qt::FastTransformation) - .convertToFormat(QImage::Format_RGBA8888); - /* Pad into the TOP half (rows 0..31). The subsequent - * mirrored() call moves the cape data into the bottom half, - * exactly where the cape UV band [47/64, 1] expects it. */ - for (int y = 0; y < src.height(); ++y) { - memcpy(padded.scanLine(y), src.constScanLine(y), - src.bytesPerLine()); - } - } - m_pendingCape = padded.mirrored(); - m_pendingCapeUpload = true; - if (isVisible()) - update(); -} - -void SkinViewerWidget::clearSkin() -{ - m_pendingSkin = QImage(); - m_pendingSkinUpload = true; - update(); -} - -void SkinViewerWidget::setShowOverlay(bool on) -{ - if (m_showOverlay == on) - return; - m_showOverlay = on; - update(); -} - -void SkinViewerWidget::setAutoRotate(bool on) -{ - if (m_autoRotate == on) - return; - m_autoRotate = on; - if (on) - m_animTimer.start(); - else - m_animTimer.stop(); - update(); -} - -/* ───────────────────────────── GL setup ──────────────────────────── */ - -void SkinViewerWidget::initializeGL() -{ - initializeOpenGLFunctions(); - - /* Build the program from the shader sources bundled in - * skinmanager.qrc under the "/skinmanager/shaders/" prefix so - * the plugin stays freestanding (no dependency on the launcher's - * own shader resource bundle being mounted at runtime). */ - if (!m_program.addShaderFromSourceFile( - QOpenGLShader::Vertex, - ":/skinmanager/shaders/vshader_skin_model.glsl")) { - qWarning() << "[SkinViewer] vertex shader compile failed:" - << m_program.log(); - } - if (!m_program.addShaderFromSourceFile( - QOpenGLShader::Fragment, ":/skinmanager/shaders/fshader.glsl")) { - qWarning() << "[SkinViewer] fragment shader compile failed:" - << m_program.log(); - } - m_program.bindAttributeLocation("a_position", 0); - m_program.bindAttributeLocation("a_texcoord", 1); - if (!m_program.link()) { - qWarning() << "[SkinViewer] shader program link failed:" - << m_program.log(); - } - - m_mesh.build(m_variant); - - configureDepth(this); - glClearColor(0.18f, 0.20f, 0.24f, 1.0f); -} - -void SkinViewerWidget::resizeGL(int w, int h) -{ - glViewport(0, 0, w, h); -} - -void SkinViewerWidget::paintGL() -{ - /* Drain any pending texture uploads inside a valid context. */ - if (m_pendingSkinUpload) { - uploadSkin(m_pendingSkin); - m_pendingSkin = QImage(); - m_pendingSkinUpload = false; - } - if (m_pendingCapeUpload) { - uploadCape(m_pendingCape); - m_pendingCape = QImage(); - m_pendingCapeUpload = false; - } - - /* Re-bake mesh if the variant changed since last paint. */ - m_mesh.rebuild(m_variant); - - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); - - if (!m_program.isLinked()) - return; - - m_program.bind(); - - /* ── Camera ─────────────────────────────────────────────────── * - * - * The bundled vertex shader (vshader_skin_model.glsl) ends with - * - * gl_Position.z = w_c - near_z; - * - * which un-flips a *Reverse-Z* depth value back into the standard - * [0, W] NDC range. That post-step is only correct when the input - * projection matrix is itself a Reverse-Z perspective. Feeding it - * a vanilla QMatrix4x4::perspective() inverts the depth ordering - * — far fragments win the depth test — and produces every artefact - * reported during testing: silhouettes z-fighting, the body - * bleeding through the head, overlays looking transparent. - * - * The matrix below is the exact Reverse-Z perspective used by - * Prism Launcher's SkinOpenGLWindow::resizeGL(): - * - * P(0,0) = cot(fov/2)/aspect - * P(1,1) = cot(fov/2) - * P(2,2) = 0 - * P(3,2) = -1 - * P(2,3) = zNear (infinite far plane) - * P(3,3) = 0 - * - * Combined with the shader's z-flip and a standard - * glDepthFunc(GL_LESS) + glClearDepthf(1.0) pipeline this gives - * us a depth-stable render that matches Prism's reference look. - */ - QMatrix4x4 proj; - { - const float aspect = float(width()) / float(qMax(1, height())); - const float fov = 45.0f; - const float zNear = 15.0f; - const float radians = qDegreesToRadians(fov * 0.5f); - const float sine = std::sin(radians); - if (sine != 0.0f) { - const float cotan = std::cos(radians) / sine; - proj.fill(0.0f); - proj(0, 0) = cotan / aspect; - proj(1, 1) = cotan; - proj(2, 2) = 0.0f; - proj(3, 2) = -1.0f; - proj(2, 3) = zNear; - proj(3, 3) = 0.0f; - } - } - - QMatrix4x4 view; - view.translate(0.0f, 0.0f, -m_zoom); - - /* The biped has its origin at the neck (head sits at y=+4, feet at - * y=-24). Shift the whole model up so the body centre lines up - * with the camera's centre of view — otherwise the head clips off - * the top of the viewport at standard zoom. */ - QMatrix4x4 model; - model.rotate(m_pitch, 1.0f, 0.0f, 0.0f); - model.rotate(m_yaw, 0.0f, 1.0f, 0.0f); - model.translate(0.0f, 8.0f, 0.0f); - - const QMatrix4x4 mvp = proj * view; - m_program.setUniformValue("mvp_matrix", mvp); - m_program.setUniformValue("model_matrix", model); - m_program.setUniformValue("texture", 0); - - /* ── Body: bind skin texture, draw base limbs ───────────────── */ - if (m_skinTex && m_skinTex->isCreated()) { - m_skinTex->bind(0); - m_mesh.bind(); - const BodyPart baseParts[] = { - BodyPart::Head, BodyPart::Body, BodyPart::LeftArm, - BodyPart::RightArm, BodyPart::LeftLeg, BodyPart::RightLeg, - }; - for (auto p : baseParts) { - const auto r = m_mesh.range(p); - if (r.count > 0) - glDrawArrays(GL_TRIANGLES, r.first, r.count); - } - - if (m_showOverlay) { - const BodyPart overlayParts[] = { - BodyPart::HeadOverlay, BodyPart::BodyOverlay, - BodyPart::LeftArmOverlay, BodyPart::RightArmOverlay, - BodyPart::LeftLegOverlay, BodyPart::RightLegOverlay, - }; - for (auto p : overlayParts) { - const auto r = m_mesh.range(p); - if (r.count > 0) - glDrawArrays(GL_TRIANGLES, r.first, r.count); - } - } - m_mesh.release(); - m_skinTex->release(0); - } - - /* ── Cape: separate texture, single draw call ───────────────── * - * - * Cull-face is globally disabled in configureDepth(), so the - * cape (which is pre-rotated 180° around Y at mesh-build time - * and would otherwise present its back faces to the camera) - * just draws on its own. */ - if (m_hasCape && m_capeTex && m_capeTex->isCreated()) { - m_capeTex->bind(0); - m_mesh.bind(); - const auto r = m_mesh.range(BodyPart::Cape); - if (r.count > 0) - glDrawArrays(GL_TRIANGLES, r.first, r.count); - m_mesh.release(); - m_capeTex->release(0); - } - - m_program.release(); -} - -void SkinViewerWidget::uploadSkin(const QImage& image) -{ - m_skinTex.reset(); - if (image.isNull()) - return; - m_skinTex = std::make_unique(image); - m_skinTex->setMinificationFilter(QOpenGLTexture::Nearest); - m_skinTex->setMagnificationFilter(QOpenGLTexture::Nearest); - m_skinTex->setWrapMode(QOpenGLTexture::ClampToEdge); -} - -void SkinViewerWidget::uploadCape(const QImage& image) -{ - m_capeTex.reset(); - if (image.isNull()) - return; - m_capeTex = std::make_unique(image); - m_capeTex->setMinificationFilter(QOpenGLTexture::Nearest); - m_capeTex->setMagnificationFilter(QOpenGLTexture::Nearest); - m_capeTex->setWrapMode(QOpenGLTexture::ClampToEdge); -} - -/* ──────────────────────────── Camera ─────────────────────────────── */ - -void SkinViewerWidget::resetCamera() -{ - m_yaw = 25.0f; - m_pitch = -5.0f; - /* 60 units puts the whole biped (roughly 32 units tall) comfortably - * inside a 38° fov frustum with some margin around the silhouette. */ - m_zoom = 60.0f; - update(); -} - -void SkinViewerWidget::mousePressEvent(QMouseEvent* e) -{ - if (e->button() == Qt::LeftButton) { - m_dragging = true; - m_dragLast = e->pos(); - } else if (e->button() == Qt::RightButton) { - resetCamera(); - } -} - -void SkinViewerWidget::mouseMoveEvent(QMouseEvent* e) -{ - if (!m_dragging) - return; - const QPoint delta = e->pos() - m_dragLast; - m_dragLast = e->pos(); - m_yaw += delta.x() * 0.5f; - m_pitch += delta.y() * 0.5f; - /* clamp pitch to keep the camera the right way up */ - if (m_pitch > 89.0f) - m_pitch = 89.0f; - if (m_pitch < -89.0f) - m_pitch = -89.0f; - update(); -} - -void SkinViewerWidget::mouseReleaseEvent(QMouseEvent* e) -{ - if (e->button() == Qt::LeftButton) - m_dragging = false; -} - -void SkinViewerWidget::wheelEvent(QWheelEvent* e) -{ - const float steps = e->angleDelta().y() / 120.0f; - m_zoom -= steps * 2.0f; - /* Keep the camera outside the Reverse-Z near plane (zNear = 15 - * in paintGL) so the biped never clips into the near clip. */ - if (m_zoom < 16.0f) - m_zoom = 16.0f; - if (m_zoom > 120.0f) - m_zoom = 120.0f; - update(); -} - -void SkinViewerWidget::dragEnterEvent(QDragEnterEvent* e) -{ - if (!e->mimeData()->hasUrls()) - return; - const auto urls = e->mimeData()->urls(); - for (const auto& u : urls) { - if (u.isLocalFile() && - QFileInfo(u.toLocalFile()).suffix().toLower() == "png") { - e->acceptProposedAction(); - return; - } - } -} - -void SkinViewerWidget::dropEvent(QDropEvent* e) -{ - if (!e->mimeData()->hasUrls()) - return; - for (const auto& u : e->mimeData()->urls()) { - if (!u.isLocalFile()) - continue; - const QString path = u.toLocalFile(); - if (QFileInfo(path).suffix().toLower() == "png") { - emit skinFileDropped(path); - e->acceptProposedAction(); - return; - } - } -} diff --git a/launcher/plugin/plugins/SkinManager/SkinViewerWidget.h b/launcher/plugin/plugins/SkinManager/SkinViewerWidget.h deleted file mode 100644 index 61d82faaf..000000000 --- a/launcher/plugin/plugins/SkinManager/SkinViewerWidget.h +++ /dev/null @@ -1,117 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Project Tick - * SPDX-License-Identifier: Apache-2.0 - * - * SkinViewerWidget — a self-contained QOpenGLWidget that renders the - * Minecraft biped + cape using MeshMC's own bundled shaders - * (:/shaders/vshader_skin_model.glsl + :/shaders/fshader.glsl). - * - * Public surface: - * - * setSkin(const QImage& skinPng, ModelVariant variant); - * setCape(const QImage& capePng); // pass a null QImage to clear - * clearSkin(); - * setShowOverlay(bool); // hat / jacket / sleeves layer - * setAutoRotate(bool); // slow yaw animation - * - * Mouse interaction: - * - * Left-drag → yaw + pitch - * Right-click → reset camera - * Wheel → zoom - * - * The widget does its own QImage → GL_TEXTURE_2D upload; the caller - * hands it a regular QImage and the widget converts it to RGBA8888 - * on its own. */ - -#pragma once - -#include "SkinModel.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace SkinManagerNS -{ - - class SkinViewerWidget : public QOpenGLWidget, protected QOpenGLFunctions - { - Q_OBJECT - - public: - explicit SkinViewerWidget(QWidget* parent = nullptr); - ~SkinViewerWidget() override; - - void setSkin(const QImage& image, ModelVariant variant); - void setCape(const QImage& image); - void clearSkin(); - - bool showOverlay() const - { - return m_showOverlay; - } - void setShowOverlay(bool on); - - bool autoRotate() const - { - return m_autoRotate; - } - void setAutoRotate(bool on); - - ModelVariant variant() const - { - return m_variant; - } - - signals: - /* Emitted when the user drops a PNG file onto the widget. The - * surrounding page binds this to "open this skin file". */ - void skinFileDropped(const QString& filePath); - - protected: - void initializeGL() override; - void resizeGL(int w, int h) override; - void paintGL() override; - - void mousePressEvent(QMouseEvent* e) override; - void mouseMoveEvent(QMouseEvent* e) override; - void mouseReleaseEvent(QMouseEvent* e) override; - void wheelEvent(QWheelEvent* e) override; - void dragEnterEvent(QDragEnterEvent* e) override; - void dropEvent(QDropEvent* e) override; - - private: - void uploadSkin(const QImage& image); - void uploadCape(const QImage& image); - void resetCamera(); - - Mesh m_mesh; - QOpenGLShaderProgram m_program; - std::unique_ptr m_skinTex; - std::unique_ptr m_capeTex; - - QImage m_pendingSkin; - QImage m_pendingCape; - bool m_pendingSkinUpload = false; - bool m_pendingCapeUpload = false; - - ModelVariant m_variant = ModelVariant::Classic; - bool m_showOverlay = true; - bool m_autoRotate = false; - bool m_hasCape = false; - - float m_yaw = 25.0f; /* degrees */ - float m_pitch = -5.0f; /* degrees */ - float m_zoom = 60.0f; /* camera distance, model units */ - QPoint m_dragLast; - bool m_dragging = false; - - QTimer m_animTimer; - }; - -} // namespace SkinManagerNS diff --git a/launcher/plugin/plugins/SkinManager/skinmanager.qrc b/launcher/plugin/plugins/SkinManager/skinmanager.qrc deleted file mode 100644 index 7b06a8dc9..000000000 --- a/launcher/plugin/plugins/SkinManager/skinmanager.qrc +++ /dev/null @@ -1,26 +0,0 @@ - - - - ../../../resources/shaders/vshader_skin_model.glsl - ../../../resources/shaders/fshader.glsl - - diff --git a/launcher/ui/MainWindow.cpp b/launcher/ui/MainWindow.cpp index f5110b206..48118dc5e 100644 --- a/launcher/ui/MainWindow.cpp +++ b/launcher/ui/MainWindow.cpp @@ -103,6 +103,7 @@ #include "ui/dialogs/ExportInstanceDialog.h" #include "ui/dialogs/ExportPackDialog.h" #include "ui/dialogs/CreateShortcutDialog.h" +#include "ui/dialogs/skins/SkinManageDialog.h" #include "KonamiCode.h" @@ -1074,6 +1075,10 @@ void MainWindow::repopulateAccountsMenu() &MainWindow::changeActiveAccount); ui->accountMenu->addSeparator(); + ui->actionManageSkins->setEnabled(defaultAccount && + defaultAccount->isMSA() && + !defaultAccount->isActive()); + ui->accountMenu->addAction(ui->actionManageSkins); ui->accountMenu->addAction(ui->actionManageAccounts); } @@ -1607,6 +1612,15 @@ void MainWindow::on_actionScreenshots_triggered() APPLICATION->showInstanceWindow(m_selectedInstance, "screenshots"); } +void MainWindow::on_actionManageSkins_triggered() +{ + auto account = APPLICATION->accounts()->defaultAccount(); + if (account && account->isMSA() && !account->isActive()) { + SkinManageDialog dialog(this, account); + dialog.exec(); + } +} + void MainWindow::on_actionManageAccounts_triggered() { APPLICATION->ShowGlobalSettings(this, "accounts"); diff --git a/launcher/ui/MainWindow.h b/launcher/ui/MainWindow.h index f5da0781d..7747e9d61 100644 --- a/launcher/ui/MainWindow.h +++ b/launcher/ui/MainWindow.h @@ -147,6 +147,8 @@ class MainWindow : public QMainWindow void on_actionViewSkinsFolder_triggered(); + void on_actionManageSkins_triggered(); + void checkForUpdates(); void on_actionSettings_triggered(); diff --git a/launcher/ui/MainWindow.ui b/launcher/ui/MainWindow.ui index b36853cc7..5967ba6fe 100644 --- a/launcher/ui/MainWindow.ui +++ b/launcher/ui/MainWindow.ui @@ -497,6 +497,20 @@ QAction::MenuRole::NoRole + + + settings + + + Manage Skins + + + Manage the skins of the currently selected account. + + + QAction::MenuRole::NoRole + + true diff --git a/launcher/ui/dialogs/SkinUploadDialog.cpp b/launcher/ui/dialogs/SkinUploadDialog.cpp deleted file mode 100644 index 1567bd28c..000000000 --- a/launcher/ui/dialogs/SkinUploadDialog.cpp +++ /dev/null @@ -1,161 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Project Tick - * SPDX-FileContributor: Project Tick - * SPDX-License-Identifier: Apache-2.0 - * - * Copyright (C) 2026 Project Tick - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include -#include -#include -#include - -#include - -#include -#include -#include - -#include "SkinUploadDialog.h" -#include "ui_SkinUploadDialog.h" -#include "ProgressDialog.h" -#include "CustomMessageBox.h" - -void SkinUploadDialog::on_buttonBox_rejected() -{ - close(); -} - -void SkinUploadDialog::on_buttonBox_accepted() -{ - QString fileName; - QString input = ui->skinPathTextBox->text(); - QRegularExpression urlPrefixMatcher("^([a-z]+)://.+$"); - bool isLocalFile = false; - // it has an URL prefix -> it is an URL - if (urlPrefixMatcher.match(input).hasMatch()) { - QUrl fileURL = input; - if (fileURL.isValid()) { - // local? - if (fileURL.isLocalFile()) { - isLocalFile = true; - fileName = fileURL.toLocalFile(); - } else { - CustomMessageBox::selectable( - this, tr("Skin Upload"), - tr("Using remote URLs for setting skins is not implemented " - "yet."), - QMessageBox::Warning) - ->exec(); - close(); - return; - } - } else { - CustomMessageBox::selectable( - this, tr("Skin Upload"), - tr("You cannot use an invalid URL for uploading skins."), - QMessageBox::Warning) - ->exec(); - close(); - return; - } - } else { - // just assume it's a path then - isLocalFile = true; - fileName = ui->skinPathTextBox->text(); - } - if (isLocalFile && !QFile::exists(fileName)) { - CustomMessageBox::selectable(this, tr("Skin Upload"), - tr("Skin file does not exist!"), - QMessageBox::Warning) - ->exec(); - close(); - return; - } - SkinUpload::Model model = SkinUpload::STEVE; - if (ui->steveBtn->isChecked()) { - model = SkinUpload::STEVE; - } else if (ui->alexBtn->isChecked()) { - model = SkinUpload::ALEX; - } - ProgressDialog prog(this); - SequentialTask skinUpload; - skinUpload.addTask(shared_qobject_ptr(new SkinUpload( - this, m_acct->accessToken(), FS::read(fileName), model))); - auto selectedCape = ui->capeCombo->currentData().toString(); - if (selectedCape != m_acct->accountData()->minecraftProfile.currentCape) { - skinUpload.addTask(shared_qobject_ptr( - new CapeChange(this, m_acct->accessToken(), selectedCape))); - } - if (prog.execWithTask(&skinUpload) != QDialog::Accepted) { - CustomMessageBox::selectable(this, tr("Skin Upload"), - tr("Failed to upload skin!"), - QMessageBox::Warning) - ->exec(); - close(); - return; - } - CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Success"), - QMessageBox::Information) - ->exec(); - close(); -} - -void SkinUploadDialog::on_skinBrowseBtn_clicked() -{ - QString raw_path = QFileDialog::getOpenFileName( - this, tr("Select Skin Texture"), QString(), "*.png"); - if (raw_path.isEmpty() || !QFileInfo::exists(raw_path)) { - return; - } - QString cooked_path = FS::NormalizePath(raw_path); - ui->skinPathTextBox->setText(cooked_path); -} - -SkinUploadDialog::SkinUploadDialog(MinecraftAccountPtr acct, QWidget* parent) - : QDialog(parent), m_acct(acct), ui(new Ui::SkinUploadDialog) -{ - ui->setupUi(this); - - // FIXME: add a model for this, download/refresh the capes on demand - auto& data = *acct->accountData(); - int index = 0; - ui->capeCombo->addItem(tr("No Cape"), QVariant()); - auto currentCape = data.minecraftProfile.currentCape; - if (currentCape.isEmpty()) { - ui->capeCombo->setCurrentIndex(index); - } - - for (auto& cape : data.minecraftProfile.capes) { - index++; - if (cape.data.size()) { - QPixmap capeImage; - if (capeImage.loadFromData(cape.data, "PNG")) { - QPixmap preview = QPixmap(10, 16); - QPainter painter(&preview); - painter.drawPixmap(0, 0, capeImage.copy(1, 1, 10, 16)); - ui->capeCombo->addItem(capeImage, cape.alias, cape.id); - if (currentCape == cape.id) { - ui->capeCombo->setCurrentIndex(index); - } - continue; - } - } - ui->capeCombo->addItem(cape.alias, cape.id); - if (currentCape == cape.id) { - ui->capeCombo->setCurrentIndex(index); - } - } -} diff --git a/launcher/ui/dialogs/SkinUploadDialog.h b/launcher/ui/dialogs/SkinUploadDialog.h deleted file mode 100644 index 6bd820f9d..000000000 --- a/launcher/ui/dialogs/SkinUploadDialog.h +++ /dev/null @@ -1,49 +0,0 @@ -/* SPDX-FileCopyrightText: 2026 Project Tick - * SPDX-FileContributor: Project Tick - * SPDX-License-Identifier: Apache-2.0 - * - * Copyright (C) 2026 Project Tick - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once - -#include -#include - -namespace Ui -{ - class SkinUploadDialog; -} - -class SkinUploadDialog : public QDialog -{ - Q_OBJECT - public: - explicit SkinUploadDialog(MinecraftAccountPtr acct, QWidget* parent = 0); - virtual ~SkinUploadDialog() {}; - - public slots: - void on_buttonBox_accepted(); - - void on_buttonBox_rejected(); - - void on_skinBrowseBtn_clicked(); - - protected: - MinecraftAccountPtr m_acct; - - private: - Ui::SkinUploadDialog* ui; -}; diff --git a/launcher/ui/dialogs/SkinUploadDialog.ui b/launcher/ui/dialogs/SkinUploadDialog.ui deleted file mode 100644 index f4b0ed0aa..000000000 --- a/launcher/ui/dialogs/SkinUploadDialog.ui +++ /dev/null @@ -1,97 +0,0 @@ - - - SkinUploadDialog - - - - 0 - 0 - 394 - 360 - - - - Skin Upload - - - - - - Skin File - - - - - - - - - - 0 - 0 - - - - - 28 - 16777215 - - - - ... - - - - - - - - - - Player Model - - - - - - Steve Model - - - true - - - - - - - Alex Model - - - - - - - - - - Cape - - - - - - - - - - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - - diff --git a/launcher/ui/dialogs/skins/SkinManageDialog.cpp b/launcher/ui/dialogs/skins/SkinManageDialog.cpp new file mode 100644 index 000000000..3ae7b0f5f --- /dev/null +++ b/launcher/ui/dialogs/skins/SkinManageDialog.cpp @@ -0,0 +1,735 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "SkinManageDialog.h" +#include "ui_SkinManageDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Application.h" +#include "DesktopServices.h" +#include "FileSystem.h" +#include "QObjectPtr.h" +#include "settings/SettingsObject.h" + +/* AccountTask, not just MinecraftAccount: refresh() returns a + * shared_qobject_ptr, and converting that to a Task::Ptr needs + * AccountTask to be a complete type here. MinecraftAccount.h only + * forward-declares it, which is enough to *name* the return type but not to + * prove the derivation to std::shared_ptr. */ +#include "minecraft/auth/AccountTask.h" +#include "minecraft/services/CapeChange.h" +#include "minecraft/services/SkinDelete.h" +#include "minecraft/services/SkinUpload.h" +#include "minecraft/skins/ProfileSkinImport.h" + +#include "net/Download.h" +#include "net/NetJob.h" +#include "tasks/SequentialTask.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui/instanceview/InstanceDelegate.h" + +namespace +{ + /* Cape textures are cached next to the skins, in their own subdirectory + * so they never turn up in the skin list. */ + const char* const kCapeCacheDirName = "capes"; + + /* Thumbnail geometry. The cape strip is 10x16 at (1, 1) of the cape + * texture; an elytra wing is 12x20 at (34, 2). Both are format facts, not + * choices. */ + const QRect kCapeFrontRegion(1, 1, 10, 16); + const QRect kElytraWingRegion(34, 2, 12, 20); + const QSize kCapeThumbnailSize(80, 128); + const QSize kElytraThumbnailSize(84, 128); + + /* Render the little cape picture shown in the combo and under it. + * + * The cape form is stretched to fill its box -- a 10x16 strip is far + * narrower than the space available and letterboxing it reads worse than + * the distortion does. The elytra form keeps its aspect ratio, because + * two wings side by side already fill the width. */ + QPixmap renderCapeThumbnail(const QImage& cape, bool asElytra) + { + if (cape.isNull()) { + return QPixmap(); + } + + if (!asElytra) { + return QPixmap::fromImage( + cape.copy(kCapeFrontRegion) + .scaled(kCapeThumbnailSize, Qt::IgnoreAspectRatio, + Qt::FastTransformation)); + } + + const QImage wing = cape.copy(kElytraWingRegion); + const QImage mirrored = wing.mirrored(true, false); + + /* One pixel of gap between the wings, and headroom above so the pair + * sits where it would on the model rather than flush to the top. */ + QImage pair(wing.width() * 2 + 1, wing.height() + 14, + QImage::Format_ARGB32); + pair.fill(Qt::transparent); + { + QPainter painter(&pair); + painter.drawImage(0, 7, wing); + painter.drawImage(wing.width() + 1, 7, mirrored); + } + return QPixmap::fromImage(pair.scaled(kElytraThumbnailSize, + Qt::KeepAspectRatio, + Qt::FastTransformation)); + } +} // namespace + +SkinManageDialog::SkinManageDialog(QWidget* parent, + MinecraftAccountPtr account) + : QDialog(parent), m_account(account), m_ui(new Ui::SkinManageDialog), + m_library(this, APPLICATION->settings()->get("SkinsDir").toString(), + account) +{ + m_ui->setupUi(this); + + /* Window-modal rather than application-modal: uploading a skin should + * not stop an instance from being launched in another window. */ + setWindowModality(Qt::WindowModal); + + if (skinrender::SkinPreviewSurface::isAvailable()) { + /* The chequerboard is derived from the dialog's own base colour, so + * the preview belongs to whatever theme is active. */ + m_preview = new skinrender::SkinPreviewSurface( + this, palette().color(QPalette::Normal, QPalette::Base)); + } else { + /* No GL: fall back to the flat front-and-back sprite. Same + * information, no rotation. */ + m_flatPreview = new QLabel(this); + m_flatPreview->setSizePolicy(QSizePolicy::Expanding, + QSizePolicy::Expanding); + m_flatPreview->setAlignment(Qt::AlignCenter); + } + + setUpSkinList(); + loadCapes(); + + /* Open on the skin the account is actually wearing. An invalid index + * (nothing matched) simply leaves the list unselected. */ + m_ui->skinList->setCurrentIndex( + m_library.index(m_library.indexOfAccountSkin())); + + m_ui->buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + m_ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("OK")); + + /* Added last so the layout's stretch factors apply to a preview that is + * already sized, and so nothing above can call into a half-built + * widget. */ + if (m_preview) { + m_previewContainer = QWidget::createWindowContainer(m_preview, this); + m_ui->previewLayout->insertWidget(0, m_previewContainer); + } else { + m_ui->previewLayout->insertWidget(0, m_flatPreview); + } +} + +SkinManageDialog::~SkinManageDialog() +{ + /* The container owns the preview window and destroys it properly (a GL + * context has to be current for that). Doing it here, rather than letting + * QWidget teardown get to it, means the platform window is still alive + * when the preview releases its textures and shaders. */ + if (m_previewContainer) { + delete m_previewContainer; + m_previewContainer = nullptr; + } + m_preview = nullptr; + + delete m_ui; +} + +void SkinManageDialog::setUpSkinList() +{ + QListView* view = m_ui->skinList; + + /* A grid of thumbnails that reflows with the dialog, one selection at a + * time. Uniform item sizes let the view skip measuring every row, which + * matters once a library has a few hundred skins in it. */ + view->setViewMode(QListView::IconMode); + view->setFlow(QListView::LeftToRight); + view->setIconSize(QSize(48, 48)); + view->setMovement(QListView::Static); + view->setResizeMode(QListView::Adjust); + view->setSelectionMode(QAbstractItemView::SingleSelection); + view->setSpacing(5); + view->setWordWrap(false); + view->setWrapping(true); + view->setUniformItemSizes(true); + view->setTextElideMode(Qt::ElideRight); + view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + view->setItemDelegate(new ListViewDelegate(this)); + + /* Drops only, and always a copy: a PNG dragged in from a file manager is + * imported into the library and left where it was. */ + view->setAcceptDrops(true); + view->setDropIndicatorShown(true); + view->viewport()->setAcceptDrops(true); + view->setDragDropMode(QAbstractItemView::DropOnly); + view->setDefaultDropAction(Qt::CopyAction); + + /* Del and F2 are handled here rather than through the actions' shortcuts: + * the actions only ever live in the context menu, and a shortcut on a + * menu that is not open would fire from anywhere in the dialog -- + * including while the user is typing a URL. */ + view->installEventFilter(this); + + view->setModel(&m_library); + + connect(view, &QAbstractItemView::doubleClicked, this, + &SkinManageDialog::onSkinActivated); + connect(view->selectionModel(), &QItemSelectionModel::selectionChanged, + this, &SkinManageDialog::onSelectionChanged); + connect(view, &QListView::customContextMenuRequested, this, + &SkinManageDialog::showContextMenu); + connect(m_ui->elytraCheck, &QCheckBox::toggled, this, + &SkinManageDialog::onElytraToggled); +} + +void SkinManageDialog::loadCapes() +{ + if (!m_account || !m_account->accountData()) { + return; + } + const MinecraftProfile& profile = + m_account->accountData()->minecraftProfile; + + /* Row 0, and the only entry that is not a cape. Its data is a null + * QVariant, which is what CapeChange reads as "take the cape off". */ + m_ui->capeCombo->addItem(tr("No Cape"), QVariant()); + if (profile.currentCape.isEmpty()) { + m_ui->capeCombo->setCurrentIndex(0); + } + + const QString capeCacheDir = + FS::PathCombine(m_library.directory(), QLatin1String(kCapeCacheDirName)); + FS::ensureFolderPathExists(capeCacheDir); + + NetJob::Ptr job(new NetJob(tr("Download capes"), APPLICATION->network())); + bool needsDownload = false; + + for (const Cape& cape : profile.capes) { + const QString path = + FS::PathCombine(capeCacheDir, cape.id + QStringLiteral(".png")); + + /* The account sometimes carries the cape bytes inline. Then there is + * nothing to fetch -- but still write them out, so the next run does + * not depend on the account being refreshed first. */ + if (!cape.data.isEmpty()) { + QImage inline_; + if (inline_.loadFromData(cape.data, "PNG")) { + m_capes.insert(cape.id, inline_); + inline_.save(path, "PNG"); + continue; + } + } + if (QFileInfo::exists(path)) { + continue; + } + if (!cape.url.isEmpty()) { + needsDownload = true; + job->addNetAction(Net::Download::makeFile(QUrl(cape.url), path)); + } + } + + if (needsDownload) { + ProgressDialog progress(this); + progress.execWithTask(job.get()); + } + + int row = 0; + for (const Cape& cape : profile.capes) { + ++row; + + if (!m_capes.contains(cape.id)) { + const QString path = FS::PathCombine( + capeCacheDir, cape.id + QStringLiteral(".png")); + QImage cached; + if (QFileInfo::exists(path) && cached.load(path)) { + m_capes.insert(cape.id, cached); + } + } + + /* A cape whose texture could not be had is still listed -- it can be + * equipped, it just has no picture. Dropping it would make the cape + * unreachable because of a failed download. */ + const QPixmap thumbnail = renderCapeThumbnail( + m_capes.value(cape.id), m_ui->elytraCheck->isChecked()); + if (thumbnail.isNull()) { + m_ui->capeCombo->addItem(cape.alias, cape.id); + } else { + m_ui->capeCombo->addItem(QIcon(thumbnail), cape.alias, cape.id); + } + + m_capeRows.insert(cape.id, row); + } +} + +QString SkinManageDialog::currentCapeId() const +{ + return m_ui->capeCombo->currentData().toString(); +} + +const SkinEntry* SkinManageDialog::previewSkin() const +{ + const SkinEntry* entry = m_library.entry(m_selectedName); + return (entry && entry->isUsable()) ? entry : nullptr; +} + +QImage SkinManageDialog::previewCape(const QString& capeId) const +{ + return m_capes.value(capeId); +} + +SkinEntry* SkinManageDialog::selectedSkin() +{ + SkinEntry* entry = m_library.entry(m_selectedName); + return (entry && entry->isUsable()) ? entry : nullptr; +} + +void SkinManageDialog::refreshPreview() +{ + SkinEntry* skin = selectedSkin(); + if (!skin) { + return; + } + + if (m_preview) { + m_preview->showSkin(skin); + } else if (m_flatPreview) { + m_flatPreview->setPixmap( + QPixmap::fromImage(skin->thumbnail()) + .scaled(m_flatPreview->size(), Qt::KeepAspectRatio, + Qt::FastTransformation)); + } +} + +void SkinManageDialog::refreshCapePreview() +{ + const QImage cape = m_capes.value(currentCapeId()); + if (cape.isNull()) { + m_ui->capePreview->clear(); + return; + } + + /* A third of the dialog: big enough to judge the cape by, small enough + * that it never pushes the controls around. */ + const QSize target = size() * (1.0 / 3.0); + m_ui->capePreview->setPixmap( + renderCapeThumbnail(cape, m_ui->elytraCheck->isChecked()) + .scaled(target, Qt::KeepAspectRatio, Qt::FastTransformation)); +} + +void SkinManageDialog::onSelectionChanged(const QItemSelection& selected, + const QItemSelection& deselected) +{ + Q_UNUSED(deselected) + + if (selected.isEmpty()) { + return; + } + const QModelIndexList indexes = selected.first().indexes(); + if (indexes.isEmpty()) { + return; + } + const QString name = indexes.first().data(Qt::UserRole).toString(); + if (name.isEmpty()) { + return; + } + + m_selectedName = name; + SkinEntry* skin = selectedSkin(); + if (!skin) { + return; + } + + refreshPreview(); + + /* The controls follow the selection, not the other way round: each entry + * remembers its own cape and arm width. value() gives 0 -- "No Cape" -- + * for an entry whose cape the account no longer owns. */ + m_ui->capeCombo->setCurrentIndex(m_capeRows.value(skin->capeId())); + m_ui->classicRadio->setChecked(skin->arms() == SkinEntry::Arms::Classic); + m_ui->slimRadio->setChecked(skin->arms() == SkinEntry::Arms::Slim); +} + +void SkinManageDialog::onSkinActivated(const QModelIndex& index) +{ + /* Double-click means "wear this one" -- take the entry under the cursor + * rather than the selection, which has not necessarily caught up. */ + m_selectedName = index.data(Qt::UserRole).toString(); + accept(); +} + +void SkinManageDialog::onElytraToggled(bool checked) +{ + if (m_preview) { + m_preview->setElytraVisible(checked); + } + /* Same code path as picking a cape: it re-renders the thumbnail and + * pushes the texture at the preview, both of which depend on this flag. */ + on_capeCombo_currentIndexChanged(m_ui->capeCombo->currentIndex()); +} + +void SkinManageDialog::on_capeCombo_currentIndexChanged(int index) +{ + Q_UNUSED(index) + + refreshCapePreview(); + + const QString capeId = currentCapeId(); + if (m_preview) { + m_preview->showCape(m_capes.value(capeId)); + } + + /* Picking a cape edits the selected entry, so the choice survives closing + * the dialog even if nothing is uploaded. */ + if (SkinEntry* skin = selectedSkin()) { + skin->setCapeId(capeId); + refreshPreview(); + } +} + +void SkinManageDialog::on_classicRadio_toggled(bool checked) +{ + /* Only this radio is connected: the two share a group, so slim toggling + * emits this as well, with checked == false. */ + if (SkinEntry* skin = selectedSkin()) { + skin->setArms(checked ? SkinEntry::Arms::Classic + : SkinEntry::Arms::Slim); + refreshPreview(); + } +} + +void SkinManageDialog::on_openFolderButton_clicked() +{ + DesktopServices::openDirectory(m_library.directory(), true); +} + +void SkinManageDialog::on_importFileButton_clicked() +{ + const QString filter = + QMimeDatabase().mimeTypeForName(QStringLiteral("image/png")) + .filterString(); + const QString chosen = QFileDialog::getOpenFileName( + this, tr("Select Skin Texture"), QString(), filter); + if (chosen.isNull()) { + return; + } + + /* The library copies the file in; the directory watcher is what puts it + * in the list. */ + const QString problem = m_library.importFile(chosen); + if (!problem.isEmpty()) { + CustomMessageBox::selectable(this, + tr("Selected file is not a valid skin"), + problem, QMessageBox::Critical) + ->show(); + } +} + +void SkinManageDialog::on_importUrlButton_clicked() +{ + const QUrl url(m_ui->sourceEdit->text()); + if (!url.isValid()) { + CustomMessageBox::selectable(this, tr("Invalid url"), tr("Invalid url"), + QMessageBox::Critical) + ->show(); + return; + } + + const QString path = + FS::PathCombine(m_library.directory(), url.fileName()); + + NetJob::Ptr job(new NetJob(tr("Download skin"), APPLICATION->network())); + job->addNetAction(Net::Download::makeFile(url, path)); + ProgressDialog progress(this); + progress.execWithTask(job.get()); + + /* Judge it by what actually landed on disk rather than by the job's exit + * code: a server can answer 200 with an HTML error page. */ + if (!SkinEntry(path).isUsable()) { + CustomMessageBox::selectable( + this, tr("URL is not a valid skin"), + QFileInfo::exists(path) + ? tr("Skin images must be 64x64 or 64x32 pixel PNG files.") + : tr("Unable to download the skin: '%1'.") + .arg(m_ui->sourceEdit->text()), + QMessageBox::Critical) + ->show(); + QFile::remove(path); + return; + } + + m_ui->sourceEdit->clear(); + /* A URL ending in a bare name gives a file the library would not pick up, + * since it only tracks ".png". */ + if (QFileInfo(path).suffix().isEmpty()) { + QFile::rename(path, path + QStringLiteral(".png")); + } +} + +void SkinManageDialog::on_importUserButton_clicked() +{ + const QString username = m_ui->sourceEdit->text(); + if (username.isEmpty()) { + return; + } + + const QString path = FS::PathCombine( + m_library.directory(), username + QStringLiteral(".png")); + + shared_qobject_ptr import( + new ProfileSkinImport(nullptr, username, path)); + + ProgressDialog progress(this); + progress.execWithTask(import.get()); + + SkinEntry imported(path); + if (!imported.isUsable()) { + /* Read the reason off the task rather than catching failed() into a + * local: shared_qobject_ptr destroys through deleteLater(), so the + * task outlives this scope and a connection capturing a local by + * reference would be left pointing at dead stack. */ + QString failure = import->failReason(); + if (failure.isEmpty()) { + failure = tr("the skin is invalid"); + } + CustomMessageBox::selectable( + this, tr("Username not found"), + tr("Unable to find the skin for '%1'\n because: %2.") + .arg(username, failure), + QMessageBox::Critical) + ->show(); + QFile::remove(path); + return; + } + + m_ui->sourceEdit->clear(); + + /* The profile knows things the PNG does not, so they are carried over + * before the entry is folded into the library. */ + imported.setArms(import->arms()); + imported.setTextureUrl(import->textureUrl()); + if (m_capes.contains(import->capeId())) { + /* Only if this account owns the same cape -- the session server + * publishes no usable cape ids, so this almost never matches, and + * equipping a cape we do not own would fail at upload time. */ + imported.setCapeId(import->capeId()); + } + m_library.mergeEntry(imported); +} + +void SkinManageDialog::accept() +{ + SkinEntry* skin = m_library.entry(m_selectedName); + if (!skin) { + /* Nothing selected -- OK cannot mean anything, so treat it as + * cancelling rather than uploading whatever happens to be first. */ + reject(); + return; + } + + const QString path = skin->path(); + QFile file(path); + if (!QFile::exists(path) || !file.open(QIODevice::ReadOnly)) { + CustomMessageBox::selectable(this, tr("Skin Upload"), + tr("Skin file does not exist!"), + QMessageBox::Warning) + ->exec(); + reject(); + return; + } + const QByteArray texture = file.readAll(); + file.close(); + + /* Sequential, not concurrent: the cape change and the profile refresh + * only make sense once the upload went through, and a refresh that + * overlaps the upload can report the old skin back. */ + shared_qobject_ptr job( + new SequentialTask(nullptr, tr("Change skin"))); + + job->addTask(Task::Ptr(new SkinUpload( + nullptr, m_account->accessToken(), texture, + skin->arms() == SkinEntry::Arms::Slim ? SkinUpload::ALEX + : SkinUpload::STEVE))); + + const QString capeId = skin->capeId(); + if (capeId != m_account->accountData()->minecraftProfile.currentCape) { + job->addTask(Task::Ptr( + new CapeChange(nullptr, m_account->accessToken(), capeId))); + } + + /* Refresh last: it is what tells us the URL the skin ended up at, which + * is how this entry is recognised as the worn one from now on. */ + job->addTask(m_account->refresh()); + + ProgressDialog progress(this); + if (progress.execWithTask(job.get()) != QDialog::Accepted) { + CustomMessageBox::selectable(this, tr("Skin Upload"), + tr("Failed to upload skin!"), + QMessageBox::Warning) + ->exec(); + reject(); + return; + } + + skin->setTextureUrl( + m_account->accountData()->minecraftProfile.skin.url); + m_library.save(); + + QDialog::accept(); +} + +void SkinManageDialog::on_resetSkinButton_clicked() +{ + shared_qobject_ptr job( + new SequentialTask(nullptr, tr("Reset skin"))); + job->addTask( + Task::Ptr(new SkinDelete(nullptr, m_account->accessToken()))); + job->addTask(m_account->refresh()); + + ProgressDialog progress(this); + if (progress.execWithTask(job.get()) != QDialog::Accepted) { + CustomMessageBox::selectable(this, tr("Skin Delete"), + tr("Failed to delete current skin!"), + QMessageBox::Warning) + ->exec(); + reject(); + return; + } + QDialog::accept(); +} + +void SkinManageDialog::showContextMenu(const QPoint& position) +{ + QMenu menu(tr("Context menu"), this); + menu.addAction(m_ui->actionRenameSkin); + menu.addAction(m_ui->actionDeleteSkin); + menu.exec(m_ui->skinList->mapToGlobal(position)); +} + +bool SkinManageDialog::eventFilter(QObject* watched, QEvent* event) +{ + if (watched == m_ui->skinList && event->type() == QEvent::KeyPress) { + switch (static_cast(event)->key()) { + case Qt::Key_Delete: + on_actionDeleteSkin_triggered(); + return true; + case Qt::Key_F2: + on_actionRenameSkin_triggered(); + return true; + default: + break; + } + } + return QDialog::eventFilter(watched, event); +} + +void SkinManageDialog::on_actionRenameSkin_triggered() +{ + if (m_selectedName.isEmpty()) { + return; + } + /* Renaming is editing the item in place; the model turns that into a file + * rename. */ + m_ui->skinList->edit(m_ui->skinList->currentIndex()); +} + +void SkinManageDialog::on_actionDeleteSkin_triggered() +{ + if (m_selectedName.isEmpty()) { + return; + } + + /* Deleting the skin the account is wearing would leave the library unable + * to say what is currently worn, and the file is the only copy. */ + if (m_library.indexOfName(m_selectedName) == + m_library.indexOfAccountSkin()) { + CustomMessageBox::selectable(this, tr("Delete error"), + tr("Can not delete skin that is in use."), + QMessageBox::Warning) + ->exec(); + return; + } + + const SkinEntry* skin = m_library.entry(m_selectedName); + if (!skin) { + return; + } + /* Copied out before anything can invalidate the entry. */ + const QString name = skin->name(); + + const int answer = + CustomMessageBox::selectable(this, tr("Confirm Deletion"), + tr("You are about to delete \"%1\".\n" + "Are you sure?") + .arg(name), + QMessageBox::Warning, + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) + ->exec(); + if (answer != QMessageBox::Yes) { + return; + } + + /* Trash first so it stays recoverable; fall back to an outright delete + * where there is no trash to put it in. */ + if (!m_library.remove(m_selectedName, true)) { + m_library.remove(m_selectedName, false); + } +} + +void SkinManageDialog::resizeEvent(QResizeEvent* event) +{ + QDialog::resizeEvent(event); + + /* Both previews are sized in terms of the dialog, so both have to be + * redrawn when it changes. */ + refreshCapePreview(); + if (m_flatPreview) { + refreshPreview(); + } +} diff --git a/launcher/ui/dialogs/skins/SkinManageDialog.h b/launcher/ui/dialogs/skins/SkinManageDialog.h new file mode 100644 index 000000000..e6866ec47 --- /dev/null +++ b/launcher/ui/dialogs/skins/SkinManageDialog.h @@ -0,0 +1,129 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "minecraft/auth/MinecraftAccount.h" +#include "minecraft/skins/SkinEntry.h" +#include "minecraft/skins/SkinLibrary.h" +#include "ui/dialogs/skins/render/SkinPreviewSurface.h" + +class QLabel; + +namespace Ui +{ + class SkinManageDialog; +} + +/* Pick, edit and upload a skin. + * + * The dialog is the skin library's editor as well as its uploader: the local + * list on the right is the library, and the controls on the left edit the + * *selected* entry in place -- switching arm width or cape writes straight + * into the entry, and the index is saved with it. Accepting uploads whatever + * the selection currently says. + * + * Also the SkinPreviewSource for the 3D preview, which asks back for the + * selection when its GL context comes up. + */ +class SkinManageDialog : public QDialog, public skinrender::SkinPreviewSource +{ + Q_OBJECT + + public: + SkinManageDialog(QWidget* parent, MinecraftAccountPtr account); + ~SkinManageDialog() override; + + /* skinrender::SkinPreviewSource */ + const SkinEntry* previewSkin() const override; + QImage previewCape(const QString& capeId) const override; + + public slots: + /* Uploads the selected skin (and its cape, if it changed) before closing. + * Rejects instead of closing when there is nothing to upload or the + * upload failed. */ + void accept() override; + + protected: + bool eventFilter(QObject* watched, QEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + + private slots: + /* Connected by name from the .ui through connectSlotsByName(). */ + void on_openFolderButton_clicked(); + void on_resetSkinButton_clicked(); + void on_importFileButton_clicked(); + void on_importUrlButton_clicked(); + void on_importUserButton_clicked(); + void on_capeCombo_currentIndexChanged(int index); + void on_classicRadio_toggled(bool checked); + void on_actionRenameSkin_triggered(); + void on_actionDeleteSkin_triggered(); + + void onSkinActivated(const QModelIndex& index); + void onSelectionChanged(const QItemSelection& selected, + const QItemSelection& deselected); + void onElytraToggled(bool checked); + void showContextMenu(const QPoint& position); + + private: + void setUpSkinList(); + + /* Fill the cape combo, downloading any cape textures that are not + * cached yet. Blocking, with a progress dialog, because the combo cannot + * be built without them. */ + void loadCapes(); + + /* Push the selection into whichever preview is in use. */ + void refreshPreview(); + + /* Redraw the cape thumbnail under the combo. */ + void refreshCapePreview(); + + /* The selected entry, or nullptr when nothing usable is selected. */ + SkinEntry* selectedSkin(); + + QString currentCapeId() const; + + MinecraftAccountPtr m_account; + Ui::SkinManageDialog* m_ui; + SkinLibrary m_library; + + /* Exactly one of these two is used, decided once at construction by + * whether a GL context can be had at all. */ + skinrender::SkinPreviewSurface* m_preview = nullptr; + QPointer m_previewContainer; + QLabel* m_flatPreview = nullptr; + + /* Cape textures by cape id, and the combo row each id sits on. Row 0 is + * always "No Cape", so value() returning 0 for an unknown id is exactly + * the right fallback. */ + QHash m_capes; + QHash m_capeRows; + + QString m_selectedName; +}; diff --git a/launcher/ui/dialogs/skins/SkinManageDialog.ui b/launcher/ui/dialogs/skins/SkinManageDialog.ui new file mode 100644 index 000000000..d1406d033 --- /dev/null +++ b/launcher/ui/dialogs/skins/SkinManageDialog.ui @@ -0,0 +1,203 @@ + + + SkinManageDialog + + + + 0 + 0 + 968 + 757 + + + + Skin Upload + + + + + + + + + + + + + + 0 + 0 + + + + Model + + + + + + Classic + + + true + + + + + + + Slim + + + + + + + + + + Cape + + + + + + Preview Elytra + + + + + + + + + + + + + false + + + Qt::AlignCenter + + + + + + + + + + + + Qt::CustomContextMenu + + + false + + + 0 + + + + + + + + + + + Open Folder + + + + + + + Reset Skin + + + + + + + + + + + + + + Import URL + + + + + + + Import user + + + + + + + Import File + + + + + + + + 0 + 0 + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + &Delete Skin + + + Deletes selected skin + + + Del + + + + + &Rename Skin + + + Rename selected skin + + + F2 + + + + + + + buttonBox + rejected() + SkinManageDialog + reject() + + + buttonBox + accepted() + SkinManageDialog + accept() + + + diff --git a/launcher/ui/dialogs/skins/render/CubeMesh.cpp b/launcher/ui/dialogs/skins/render/CubeMesh.cpp new file mode 100644 index 000000000..5468aef0b --- /dev/null +++ b/launcher/ui/dialogs/skins/render/CubeMesh.cpp @@ -0,0 +1,285 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "CubeMesh.h" + +#include +#include +#include + +namespace skinrender +{ + namespace + { + struct Vertex { + QVector4D position; + QVector2D texCoord; + }; + + /* Unit cube, four corners per face, in face order + * front(+Z), right(+X), back(-Z), left(-X), bottom(-Y), top(+Y). */ + const QVector4D kCubeCorners[24] = { + /* face 0 */ + QVector4D(-0.5f, -0.5f, 0.5f, 1.0f), + QVector4D(0.5f, -0.5f, 0.5f, 1.0f), + QVector4D(-0.5f, 0.5f, 0.5f, 1.0f), + QVector4D(0.5f, 0.5f, 0.5f, 1.0f), + /* face 1 */ + QVector4D(0.5f, -0.5f, 0.5f, 1.0f), + QVector4D(0.5f, -0.5f, -0.5f, 1.0f), + QVector4D(0.5f, 0.5f, 0.5f, 1.0f), + QVector4D(0.5f, 0.5f, -0.5f, 1.0f), + /* face 2 */ + QVector4D(0.5f, -0.5f, -0.5f, 1.0f), + QVector4D(-0.5f, -0.5f, -0.5f, 1.0f), + QVector4D(0.5f, 0.5f, -0.5f, 1.0f), + QVector4D(-0.5f, 0.5f, -0.5f, 1.0f), + /* face 3 */ + QVector4D(-0.5f, -0.5f, -0.5f, 1.0f), + QVector4D(-0.5f, -0.5f, 0.5f, 1.0f), + QVector4D(-0.5f, 0.5f, -0.5f, 1.0f), + QVector4D(-0.5f, 0.5f, 0.5f, 1.0f), + /* face 4 */ + QVector4D(-0.5f, -0.5f, -0.5f, 1.0f), + QVector4D(0.5f, -0.5f, -0.5f, 1.0f), + QVector4D(-0.5f, -0.5f, 0.5f, 1.0f), + QVector4D(0.5f, -0.5f, 0.5f, 1.0f), + /* face 5 */ + QVector4D(-0.5f, 0.5f, 0.5f, 1.0f), + QVector4D(0.5f, 0.5f, 0.5f, 1.0f), + QVector4D(-0.5f, 0.5f, -0.5f, 1.0f), + QVector4D(0.5f, 0.5f, -0.5f, 1.0f), + }; + + /* One triangle strip across all six faces. The repeated indices are + * degenerate triangles that stitch each face's strip to the next. */ + const GLushort kCubeIndices[] = { + 0, 1, 2, 3, 3, /* face 0 */ + 4, 4, 5, 6, 7, 7, /* face 1 */ + 8, 8, 9, 10, 11, 11, /* face 2 */ + 12, 12, 13, 14, 15, 15, /* face 3 */ + 16, 16, 17, 18, 19, 19, /* face 4 */ + 20, 20, 21, 22, 23, /* face 5 */ + }; + + /* The four texture coordinates of one face's rectangle, in the order + * (x1,y2), (x2,y2), (x2,y1), (x1,y1). + * + * The V axis is flipped because the textures themselves are uploaded + * flipped -- see assignTexture() in PlayerScene. */ + void faceTexCoords(float x1, float y1, float x2, float y2, + float textureWidth, float textureHeight, + QVector2D out[4]) + { + out[0] = QVector2D(x1 / textureWidth, 1.0f - y2 / textureHeight); + out[1] = QVector2D(x2 / textureWidth, 1.0f - y2 / textureHeight); + out[2] = QVector2D(x2 / textureWidth, 1.0f - y1 / textureHeight); + out[3] = QVector2D(x1 / textureWidth, 1.0f - y1 / textureHeight); + } + + /* All 24 texture coordinates for a box unwrapped at (u, v) with width + * w, height h and depth d, in the same face order as kCubeCorners. + * + * Both the six rectangles and the per-face corner order are literal: + * each face picks its four coordinates out of faceTexCoords() in the + * arrangement that lines up with that face's vertices above. */ + void cubeTexCoords(float u, float v, float w, float h, float d, + float textureWidth, float textureHeight, + QVector2D out[24]) + { + QVector2D top[4]; + QVector2D bottom[4]; + QVector2D left[4]; + QVector2D front[4]; + QVector2D right[4]; + QVector2D back[4]; + + faceTexCoords(u + d, v, u + w + d, v + d, textureWidth, + textureHeight, top); + faceTexCoords(u + w + d, v, u + w * 2 + d, v + d, textureWidth, + textureHeight, bottom); + faceTexCoords(u, v + d, u + d, v + d + h, textureWidth, + textureHeight, left); + faceTexCoords(u + d, v + d, u + w + d, v + d + h, textureWidth, + textureHeight, front); + faceTexCoords(u + w + d, v + d, u + w + d * 2, v + h + d, + textureWidth, textureHeight, right); + faceTexCoords(u + w + d * 2, v + d, u + w * 2 + d * 2, v + h + d, + textureWidth, textureHeight, back); + + /* face 0, front(+Z) */ + out[0] = front[0]; + out[1] = front[1]; + out[2] = front[3]; + out[3] = front[2]; + /* face 1, right(+X) */ + out[4] = right[0]; + out[5] = right[1]; + out[6] = right[3]; + out[7] = right[2]; + /* face 2, back(-Z) */ + out[8] = back[0]; + out[9] = back[1]; + out[10] = back[3]; + out[11] = back[2]; + /* face 3, left(-X) */ + out[12] = left[0]; + out[13] = left[1]; + out[14] = left[3]; + out[15] = left[2]; + /* face 4, bottom(-Y) */ + out[16] = bottom[3]; + out[17] = bottom[2]; + out[18] = bottom[0]; + out[19] = bottom[1]; + /* face 5, top(+Y) */ + out[20] = top[0]; + out[21] = top[1]; + out[22] = top[3]; + out[23] = top[2]; + } + + /* Full-viewport quad in clip space, for the background. */ + const Vertex kPlaneVertices[4] = { + {QVector4D(-1.0f, -1.0f, -0.5f, 1.0f), QVector2D(0.0f, 0.0f)}, + {QVector4D(1.0f, -1.0f, -0.5f, 1.0f), QVector2D(1.0f, 0.0f)}, + {QVector4D(-1.0f, 1.0f, -0.5f, 1.0f), QVector2D(0.0f, 1.0f)}, + {QVector4D(1.0f, 1.0f, -0.5f, 1.0f), QVector2D(1.0f, 1.0f)}, + }; + const GLushort kPlaneIndices[] = {0, 1, 2, 3, 3}; + } // namespace + + CubeMesh::CubeMesh() + : QOpenGLFunctions(), m_indexBuffer(QOpenGLBuffer::IndexBuffer) + { + initializeOpenGLFunctions(); + m_vertexBuffer.create(); + m_indexBuffer.create(); + } + + CubeMesh::CubeMesh(QVector3D size, QVector3D position, QPoint uv, + QVector3D textureDim, QSize textureSize) + : CubeMesh() + { + uploadBox(size, position, uv, textureDim, textureSize); + } + + CubeMesh::~CubeMesh() + { + m_vertexBuffer.destroy(); + m_indexBuffer.destroy(); + } + + void CubeMesh::uploadBox(QVector3D size, QVector3D position, QPoint uv, + QVector3D textureDim, QSize textureSize) + { + QVector2D texCoords[24]; + cubeTexCoords(static_cast(uv.x()), static_cast(uv.y()), + textureDim.x(), textureDim.y(), textureDim.z(), + static_cast(textureSize.width()), + static_cast(textureSize.height()), texCoords); + + /* The box transform is baked into the vertex buffer: it never changes + * after construction, which leaves the model matrix free for the cape + * and elytra rotations. */ + Vertex vertices[24]; + for (int i = 0; i < 24; ++i) { + const QVector4D& corner = kCubeCorners[i]; + vertices[i].position = + QVector4D(corner.x() * size.x() + position.x(), + corner.y() * size.y() + position.y(), + corner.z() * size.z() + position.z(), 1.0f); + vertices[i].texCoord = texCoords[i]; + } + + m_vertexBuffer.bind(); + m_vertexBuffer.allocate(vertices, static_cast(sizeof(vertices))); + + m_indexBuffer.bind(); + m_indexBuffer.allocate(kCubeIndices, + static_cast(sizeof(kCubeIndices))); + + m_indexCount = + static_cast(sizeof(kCubeIndices) / sizeof(GLushort)); + } + + CubeMesh* CubeMesh::plane() + { + auto* mesh = new CubeMesh(); + + mesh->m_vertexBuffer.bind(); + mesh->m_vertexBuffer.allocate(kPlaneVertices, + static_cast(sizeof(kPlaneVertices))); + mesh->m_indexBuffer.bind(); + mesh->m_indexBuffer.allocate(kPlaneIndices, + static_cast(sizeof(kPlaneIndices))); + mesh->m_indexCount = + static_cast(sizeof(kPlaneIndices) / sizeof(GLushort)); + + return mesh; + } + + void CubeMesh::draw(QOpenGLShaderProgram* program) + { + if (!program || m_indexCount == 0) { + return; + } + + program->setUniformValue("model_matrix", m_modelMatrix); + + m_vertexBuffer.bind(); + m_indexBuffer.bind(); + + /* Offsets are spelled out rather than taken with offsetof(): Vertex + * holds QVector4D/QVector2D, which are not standard-layout types, and + * offsetof() on those is only conditionally supported -- it warns + * under -pedantic. The layout is fixed and trivial, so writing it out + * costs nothing. */ + const int positionOffset = 0; + const int texCoordOffset = static_cast(sizeof(QVector4D)); + + const int positionLocation = program->attributeLocation("a_position"); + if (positionLocation != -1) { + program->enableAttributeArray(positionLocation); + program->setAttributeBuffer(positionLocation, GL_FLOAT, + positionOffset, 4, sizeof(Vertex)); + } + + const int texCoordLocation = program->attributeLocation("a_texcoord"); + if (texCoordLocation != -1) { + program->enableAttributeArray(texCoordLocation); + program->setAttributeBuffer(texCoordLocation, GL_FLOAT, + texCoordOffset, 2, sizeof(Vertex)); + } + + /* Triangle strip, matching kCubeIndices and kPlaneIndices. */ + glDrawElements(GL_TRIANGLE_STRIP, m_indexCount, GL_UNSIGNED_SHORT, + nullptr); + } + + void CubeMesh::rotate(float degrees, const QVector3D& axis) + { + m_modelMatrix.rotate(degrees, axis); + } + + void CubeMesh::scale(const QVector3D& factor) + { + m_modelMatrix.scale(factor); + } +} // namespace skinrender diff --git a/launcher/ui/dialogs/skins/render/CubeMesh.h b/launcher/ui/dialogs/skins/render/CubeMesh.h new file mode 100644 index 000000000..c2046a3cc --- /dev/null +++ b/launcher/ui/dialogs/skins/render/CubeMesh.h @@ -0,0 +1,82 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace skinrender +{ + /* One textured cuboid of the player model, ready to draw. + * + * Size and position are baked into the vertex buffer at construction: + * every box in the model is static relative to the body, so there is + * nothing to recompute per frame. Only rotate()/scale(), which the cape + * and the elytra wings need, go through the model matrix uniform. + * + * The texture coordinates come from the Minecraft box unwrap: a cross of + * six faces laid out at `uv` in a texture of `textureSize`, for a box of + * `textureDim` (width, height, depth) *texture* pixels. That is separate + * from the geometric `size` because the overlay boxes are drawn slightly + * larger than the base ones while sampling the same-sized patch. + * + * Requires a current OpenGL context for its whole lifetime, construction + * and destruction included. + */ + class CubeMesh : protected QOpenGLFunctions + { + public: + /* Bare mesh with empty buffers; only useful via plane(). */ + CubeMesh(); + + CubeMesh(QVector3D size, QVector3D position, QPoint uv, + QVector3D textureDim, QSize textureSize = QSize(64, 64)); + virtual ~CubeMesh(); + + /* A full-viewport quad in clip space, for the background. + * + * Its vertices are already where they need to be on screen, which is + * why the background vertex shader passes a_position through + * untouched instead of applying a matrix. */ + static CubeMesh* plane(); + + void draw(QOpenGLShaderProgram* program); + + void rotate(float degrees, const QVector3D& axis); + void scale(const QVector3D& factor); + + private: + CubeMesh(const CubeMesh&) = delete; + CubeMesh& operator=(const CubeMesh&) = delete; + + void uploadBox(QVector3D size, QVector3D position, QPoint uv, + QVector3D textureDim, QSize textureSize); + + QOpenGLBuffer m_vertexBuffer; + QOpenGLBuffer m_indexBuffer; + QMatrix4x4 m_modelMatrix; + GLsizei m_indexCount = 0; + }; +} // namespace skinrender diff --git a/launcher/ui/dialogs/skins/render/PlayerScene.cpp b/launcher/ui/dialogs/skins/render/PlayerScene.cpp new file mode 100644 index 000000000..0f30a22f6 --- /dev/null +++ b/launcher/ui/dialogs/skins/render/PlayerScene.cpp @@ -0,0 +1,346 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "PlayerScene.h" + +#include +#include +#include +#include + +namespace skinrender +{ + namespace + { + /* Model space is in Minecraft pixels, with the origin between the + * head and the torso: the head sits at y = +4 and the legs end at + * y = -24. The camera looks at (0, -8, 0), which is chest height. + * + * The second layer boxes are drawn half a pixel larger all round so + * they sit just outside the base layer instead of fighting it for + * depth. The head is the exception at a full pixel, which is what + * makes hats read as hats. + * + * Arms and legs are nudged by fractions of a pixel (the 1.9 and the + * -0.1) rather than placed flush: at 4 pixels wide, two boxes sharing + * an exact edge produce a visible seam of z-fighting as the model + * turns. */ + + const QSize kSkinTextureSize(64, 64); + /* Capes and elytra share the 64x32 cape texture. */ + const QSize kCapeTextureSize(64, 32); + + QList buildBody() + { + return { + /* head */ + new CubeMesh(QVector3D(8, 8, 8), QVector3D(0, 4, 0), + QPoint(0, 0), QVector3D(8, 8, 8), + kSkinTextureSize), + /* torso */ + new CubeMesh(QVector3D(8, 12, 4), QVector3D(0, -6, 0), + QPoint(16, 16), QVector3D(8, 12, 4), + kSkinTextureSize), + /* right leg */ + new CubeMesh(QVector3D(4, 12, 4), + QVector3D(-1.9f, -18, -0.1f), QPoint(0, 16), + QVector3D(4, 12, 4), kSkinTextureSize), + /* left leg */ + new CubeMesh(QVector3D(4, 12, 4), QVector3D(1.9f, -18, -0.1f), + QPoint(16, 48), QVector3D(4, 12, 4), + kSkinTextureSize), + }; + } + + QList buildBodyOverlay() + { + return { + /* head */ + new CubeMesh(QVector3D(9, 9, 9), QVector3D(0, 4, 0), + QPoint(32, 0), QVector3D(8, 8, 8), + kSkinTextureSize), + /* torso */ + new CubeMesh(QVector3D(8.5f, 12.5f, 4.5f), + QVector3D(0, -6, 0), QPoint(16, 32), + QVector3D(8, 12, 4), kSkinTextureSize), + /* right leg */ + new CubeMesh(QVector3D(4.5f, 12.5f, 4.5f), + QVector3D(-1.9f, -18, -0.1f), QPoint(0, 32), + QVector3D(4, 12, 4), kSkinTextureSize), + /* left leg */ + new CubeMesh(QVector3D(4.5f, 12.5f, 4.5f), + QVector3D(1.9f, -18, -0.1f), QPoint(0, 48), + QVector3D(4, 12, 4), kSkinTextureSize), + }; + } + + /* The four arm sets are written out separately rather than generated + * from a width parameter. A slim arm is not "the classic arm minus a + * pixel": its size, its position and its texture patch all differ, + * and the overlay differs again. Parameterising it hid those numbers + * behind arithmetic that could only be checked by redoing the + * arithmetic. */ + + QList buildClassicArms() + { + return { + /* right arm */ + new CubeMesh(QVector3D(4, 12, 4), QVector3D(-6, -6, 0), + QPoint(40, 16), QVector3D(4, 12, 4), + kSkinTextureSize), + /* left arm */ + new CubeMesh(QVector3D(4, 12, 4), QVector3D(6, -6, 0), + QPoint(32, 48), QVector3D(4, 12, 4), + kSkinTextureSize), + }; + } + + QList buildClassicArmsOverlay() + { + return { + /* right arm */ + new CubeMesh(QVector3D(4.5f, 12.5f, 4.5f), + QVector3D(-6, -6, 0), QPoint(40, 32), + QVector3D(4, 12, 4), kSkinTextureSize), + /* left arm */ + new CubeMesh(QVector3D(4.5f, 12.5f, 4.5f), + QVector3D(6, -6, 0), QPoint(48, 48), + QVector3D(4, 12, 4), kSkinTextureSize), + }; + } + + QList buildSlimArms() + { + return { + /* right arm */ + new CubeMesh(QVector3D(3, 12, 4), QVector3D(-5.5f, -6, 0), + QPoint(40, 16), QVector3D(3, 12, 4), + kSkinTextureSize), + /* left arm */ + new CubeMesh(QVector3D(3, 12, 4), QVector3D(5.5f, -6, 0), + QPoint(32, 48), QVector3D(3, 12, 4), + kSkinTextureSize), + }; + } + + QList buildSlimArmsOverlay() + { + return { + /* right arm */ + new CubeMesh(QVector3D(3.5f, 12.5f, 4.5f), + QVector3D(-5.5f, -6, 0), QPoint(40, 32), + QVector3D(3, 12, 4), kSkinTextureSize), + /* left arm */ + new CubeMesh(QVector3D(3.5f, 12.5f, 4.5f), + QVector3D(5.5f, -6, 0), QPoint(48, 48), + QVector3D(3, 12, 4), kSkinTextureSize), + }; + } + + CubeMesh* buildCape() + { + auto* cape = new CubeMesh(QVector3D(10, 16, 1), + QVector3D(0, -8, 2.5f), QPoint(0, 0), + QVector3D(10, 16, 1), kCapeTextureSize); + /* Tilted away from the back, then turned to face backwards -- + * the box is built on the +Z side so that the outer face of the + * cape is the one the texture's front is mapped to. */ + cape->rotate(10.8f, QVector3D(1, 0, 0)); + cape->rotate(180, QVector3D(0, 1, 0)); + return cape; + } + + /* One elytra wing, swept back and out from the shoulders. */ + CubeMesh* buildWing(bool mirrored) + { + auto* wing = new CubeMesh(QVector3D(12, 22, 4), + QVector3D(0, -13, -2), QPoint(22, 0), + QVector3D(10, 20, 2), kCapeTextureSize); + if (mirrored) { + /* The texture only holds one wing; the other is this one + * flipped. Applied before the rotations so that the sweep + * comes out symmetric rather than mirrored along with it. */ + wing->scale(QVector3D(-1, 1, 1)); + } + wing->rotate(15, QVector3D(1, 0, 0)); + wing->rotate(15, QVector3D(0, 0, 1)); + wing->rotate(1, QVector3D(1, 0, 0)); + return wing; + } + + /* Create or refresh a texture from an image. + * + * A null image leaves *no* texture behind rather than an invalid one: + * binding a texture that was never created warns on every frame, and + * "there is no cape" is a perfectly normal state to be in. */ + void assignTexture(QOpenGLTexture*& texture, const QImage& image) + { + if (image.isNull()) { + if (texture) { + if (texture->isCreated()) { + texture->destroy(); + } + delete texture; + texture = nullptr; + } + return; + } + + /* Uploaded flipped, to pair with the flipped V axis in + * CubeMesh::uploadBox(). */ + const QImage flipped = image.mirrored(); + + if (!texture) { + texture = new QOpenGLTexture(flipped); + } else { + if (texture->isBound()) { + texture->release(); + } + texture->destroy(); + texture->create(); + texture->setSize(flipped.width(), flipped.height()); + texture->setData(flipped); + } + + /* Nearest, always: a skin is 64x64 and every pixel is meant to + * be a visible square. Linear filtering turns it to mush. */ + texture->setMinificationFilter(QOpenGLTexture::Nearest); + texture->setMagnificationFilter(QOpenGLTexture::Nearest); + } + + void destroyTexture(QOpenGLTexture*& texture) + { + if (!texture) { + return; + } + if (texture->isCreated()) { + texture->destroy(); + } + delete texture; + texture = nullptr; + } + + void drawAll(const QList& meshes, + QOpenGLShaderProgram* program) + { + for (CubeMesh* mesh : meshes) { + mesh->draw(program); + } + } + } // namespace + + PlayerScene::PlayerScene(const QImage& skin, bool slim, const QImage& cape) + : QOpenGLFunctions(), m_slim(slim), m_capeVisible(!cape.isNull()) + { + initializeOpenGLFunctions(); + + m_body = buildBody(); + m_bodyOverlay = buildBodyOverlay(); + + /* Both arm widths are built up front and the unused pair is simply + * not drawn: the user switches between Classic and Slim by clicking + * a radio button, and rebuilding vertex buffers for that would be + * needless work on the UI thread. */ + m_classicArms = buildClassicArms(); + m_classicArmsOverlay = buildClassicArmsOverlay(); + m_slimArms = buildSlimArms(); + m_slimArmsOverlay = buildSlimArmsOverlay(); + + m_cape = buildCape(); + m_elytra = {buildWing(false), buildWing(true)}; + + assignTexture(m_skinTexture, skin); + assignTexture(m_capeTexture, cape); + } + + PlayerScene::~PlayerScene() + { + for (const QList& group : + {m_body, m_bodyOverlay, m_classicArms, m_classicArmsOverlay, + m_slimArms, m_slimArmsOverlay, m_elytra}) { + for (CubeMesh* mesh : group) { + delete mesh; + } + } + delete m_cape; + + destroyTexture(m_skinTexture); + destroyTexture(m_capeTexture); + } + + void PlayerScene::draw(QOpenGLShaderProgram* program) + { + if (!program) { + return; + } + + if (m_skinTexture) { + m_skinTexture->bind(); + program->setUniformValue("texture", 0); + + /* Base layer first, then the second layer over it: the fragment + * shader discards fully transparent pixels rather than blending + * them, so the order is what decides which one wins where they + * overlap. */ + drawAll(m_body, program); + drawAll(m_slim ? m_slimArms : m_classicArms, program); + drawAll(m_bodyOverlay, program); + drawAll(m_slim ? m_slimArmsOverlay : m_classicArmsOverlay, + program); + + m_skinTexture->release(); + } + + if (m_capeVisible && m_capeTexture) { + m_capeTexture->bind(); + program->setUniformValue("texture", 0); + if (m_elytraVisible) { + drawAll(m_elytra, program); + } else { + m_cape->draw(program); + } + m_capeTexture->release(); + } + } + + void PlayerScene::setSkin(const QImage& skin) + { + assignTexture(m_skinTexture, skin); + } + + void PlayerScene::setCape(const QImage& cape) + { + assignTexture(m_capeTexture, cape); + } + + void PlayerScene::setSlim(bool slim) + { + m_slim = slim; + } + + void PlayerScene::setCapeVisible(bool visible) + { + m_capeVisible = visible; + } + + void PlayerScene::setElytraVisible(bool visible) + { + m_elytraVisible = visible; + } +} // namespace skinrender diff --git a/launcher/ui/dialogs/skins/render/PlayerScene.h b/launcher/ui/dialogs/skins/render/PlayerScene.h new file mode 100644 index 000000000..731947592 --- /dev/null +++ b/launcher/ui/dialogs/skins/render/PlayerScene.h @@ -0,0 +1,80 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include "ui/dialogs/skins/render/CubeMesh.h" + +class QOpenGLTexture; + +namespace skinrender +{ + /* The player model: body, second layer, arms, and either a cape or a pair + * of elytra wings. + * + * Both arm widths are built up front and the unused pair is simply not + * drawn, because switching between Classic and Slim happens while the + * user clicks radio buttons and rebuilding vertex buffers for that would + * be needless work on the UI thread. + * + * Requires a current OpenGL context for its whole lifetime. + */ + class PlayerScene : protected QOpenGLFunctions + { + public: + PlayerScene(const QImage& skin, bool slim, const QImage& cape); + virtual ~PlayerScene(); + + void draw(QOpenGLShaderProgram* program); + + void setSkin(const QImage& skin); + void setCape(const QImage& cape); + void setSlim(bool slim); + void setCapeVisible(bool visible); + + /* Draw the cape as elytra wings instead of as a cape. Same texture: + * the elytra sample a different region of it. */ + void setElytraVisible(bool visible); + + private: + PlayerScene(const PlayerScene&) = delete; + PlayerScene& operator=(const PlayerScene&) = delete; + + QList m_body; + QList m_bodyOverlay; + QList m_classicArms; + QList m_classicArmsOverlay; + QList m_slimArms; + QList m_slimArmsOverlay; + CubeMesh* m_cape = nullptr; + QList m_elytra; + + QOpenGLTexture* m_skinTexture = nullptr; + QOpenGLTexture* m_capeTexture = nullptr; + + bool m_slim = false; + bool m_capeVisible = false; + bool m_elytraVisible = false; + }; +} // namespace skinrender diff --git a/launcher/ui/dialogs/skins/render/SkinPreviewSurface.cpp b/launcher/ui/dialogs/skins/render/SkinPreviewSurface.cpp new file mode 100644 index 000000000..f19d5f5d8 --- /dev/null +++ b/launcher/ui/dialogs/skins/render/SkinPreviewSurface.cpp @@ -0,0 +1,424 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "SkinPreviewSurface.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "BuildConfig.h" +#include "minecraft/skins/SkinEntry.h" +#include "rainbow.h" +#include "ui/dialogs/skins/render/CubeMesh.h" +#include "ui/dialogs/skins/render/PlayerScene.h" + +namespace skinrender +{ + namespace + { + /* Shaders live in the core resource bundle; the model one applies the + * matrices, the background one passes clip-space vertices straight + * through, and both share the fragment shader. */ + const char* const kModelVertexShader = + ":/shaders/vshader_skin_model.glsl"; + const char* const kBackgroundVertexShader = + ":/shaders/vshader_skin_background.glsl"; + const char* const kFragmentShader = ":/shaders/fshader.glsl"; + + /* Camera. The model spans roughly y = -24 to y = +8, and the camera + * targets its chest, so these frame it head to toe at the default + * distance. */ + constexpr float kInitialDistance = 48.0f; + constexpr float kMinimumDistance = 16.0f; + constexpr float kOrbitDegreesPerPixel = 0.5f; + constexpr float kZoomPerWheelUnit = 0.01f; + const QVector3D kCameraTarget(0, -8, 0); + constexpr qreal kNearPlane = 15.0; + constexpr qreal kFieldOfView = 45.0; + + /* A colour that reads as different from `colour` without leaving the + * theme: nudge dark colours lighter and light colours darker. The + * asymmetry is deliberate -- the eye tolerates far less lightening of + * a dark surface than darkening of a light one. */ + QColor contrastingColour(const QColor& colour) + { + if (Rainbow::luma(colour) < 0.5) { + return Rainbow::lighten(colour, 0.05); + } + return Rainbow::darken(colour, 0.2); + } + + /* The chequerboard behind the model, in two shades of the dialog's + * own background colour. */ + QImage chequerboard(int width, int height, int tileSize, + const QColor& base) + { + QImage image(width, height, QImage::Format_RGB888); + + const bool darkBase = Rainbow::luma(base) < 0.5; + const qreal contrast = darkBase ? 0.05 : 0.45; + const auto shade = [darkBase, contrast](const QColor& colour) { + return darkBase ? Rainbow::lighten(colour, contrast, 1.0) + : Rainbow::darken(colour, contrast, 1.0); + }; + + const QColor light = shade(base); + const QColor dark = shade(contrastingColour(base)); + + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + const bool onLightTile = + ((x / tileSize) + (y / tileSize)) % 2 == 0; + image.setPixelColor(x, y, onLightTile ? light : dark); + } + } + return image; + } + } // namespace + + SkinPreviewSurface::SkinPreviewSurface(SkinPreviewSource* source, + QColor background) + : QOpenGLWindow(), QOpenGLFunctions(), m_distance(kInitialDistance), + m_background(background), m_source(source) + { + QSurfaceFormat format = QSurfaceFormat::defaultFormat(); + /* Without an explicit request the default format may come back with + * no depth buffer at all, and the model then draws in whatever order + * the boxes happen to be submitted. */ + format.setDepthBufferSize(24); + setFormat(format); + } + + SkinPreviewSurface::~SkinPreviewSurface() + { + /* Every one of these owns a GL object, so the context has to be + * current while they go away. They are also created in + * initializeGL(), not in the constructor, so a surface that was never + * shown has nothing to release -- hence the null checks. */ + makeCurrent(); + + delete m_scene; + m_scene = nullptr; + + delete m_backgroundQuad; + m_backgroundQuad = nullptr; + + if (m_backgroundTexture) { + if (m_backgroundTexture->isCreated()) { + m_backgroundTexture->destroy(); + } + delete m_backgroundTexture; + m_backgroundTexture = nullptr; + } + + for (QOpenGLShaderProgram** program : {&m_modelProgram, + &m_backgroundProgram}) { + if (!*program) { + continue; + } + if ((*program)->isLinked()) { + (*program)->release(); + } + (*program)->removeAllShaders(); + delete *program; + *program = nullptr; + } + + doneCurrent(); + } + + bool SkinPreviewSurface::isAvailable() + { + /* Escape hatch for machines where creating a context succeeds but + * then wedges the driver. Checked before touching GL at all, so it + * works even when probing itself is what crashes. */ + const QString disableVariable = + QStringLiteral("%1_DISABLE_GLVULKAN").arg(BuildConfig.MESHMC_ENVNAME); + if (!QProcessEnvironment::systemEnvironment() + .value(disableVariable) + .isEmpty()) { + qDebug() << "3D skin preview disabled by" << disableVariable; + return false; + } + + QOpenGLContext probe; + return probe.create(); + } + + void SkinPreviewSurface::loadShaders() + { + const auto build = [this](const char* vertexPath) { + auto* program = new QOpenGLShaderProgram(this); + if (!program->addCacheableShaderFromSourceFile( + QOpenGLShader::Vertex, QLatin1String(vertexPath))) { + qCritical() << "Skin preview vertex shader failed:" + << program->log(); + delete program; + return static_cast(nullptr); + } + if (!program->addCacheableShaderFromSourceFile( + QOpenGLShader::Fragment, QLatin1String(kFragmentShader))) { + qCritical() << "Skin preview fragment shader failed:" + << program->log(); + delete program; + return static_cast(nullptr); + } + if (!program->link()) { + qCritical() << "Skin preview shader link failed:" + << program->log(); + delete program; + return static_cast(nullptr); + } + return program; + }; + + m_modelProgram = build(kModelVertexShader); + m_backgroundProgram = build(kBackgroundVertexShader); + } + + void SkinPreviewSurface::initializeGL() + { + initializeOpenGLFunctions(); + glClearColor(0, 0, 1, 1); + + loadShaders(); + buildBackgroundTexture(32, 32, 1); + + /* The dialog has finished building by now, so asking it what to show + * gives the actual current selection rather than an empty model that + * would have to be replaced immediately. */ + QImage skinTexture; + QImage capeTexture; + bool slim = false; + if (m_source) { + if (const SkinEntry* skin = m_source->previewSkin()) { + skinTexture = skin->texture(); + slim = skin->arms() == SkinEntry::Arms::Slim; + capeTexture = m_source->previewCape(skin->capeId()); + } + } + + m_scene = new PlayerScene(skinTexture, slim, capeTexture); + m_backgroundQuad = CubeMesh::plane(); + } + + void SkinPreviewSurface::resizeGL(int w, int h) + { + const qreal aspect = qreal(w) / qreal(h ? h : 1); + + const qreal halfFovRadians = qDegreesToRadians(kFieldOfView / 2.0); + const qreal sine = std::sin(halfFovRadians); + if (sine == 0) { + return; + } + const qreal cotangent = std::cos(halfFovRadians) / sine; + + /* Reverse-Z infinite perspective projection: depth is mapped so that + * precision is concentrated near the camera, and there is no far + * plane to clip the model against when zoomed out. + * + * The vertex shader flips the result back into the conventional + * [0, w] range, because the GL 2.0 context this runs in cannot be + * told to clear depth to 0. */ + m_projection.setToIdentity(); + m_projection(0, 0) = cotangent / aspect; + m_projection(1, 1) = cotangent; + m_projection(2, 2) = 0.0; + m_projection(3, 2) = -1.0; + m_projection(2, 3) = kNearPlane; + m_projection(3, 3) = 0.0; + } + + void SkinPreviewSurface::paintGL() + { + /* Fractional display scaling gives a window whose device pixel size + * is not what GL defaulted the viewport to, and the model ends up + * drawn into a corner. Setting it explicitly costs nothing. */ + const qreal ratio = devicePixelRatio(); + if (ratio != 1.0) { + const QSize scaled = size() * ratio; + glViewport(0, 0, scaled.width(), scaled.height()); + } + + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + glEnable(GL_DEPTH_TEST); + glDepthFunc(GL_LESS); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + if (m_backgroundProgram && m_backgroundQuad && m_backgroundTexture) { + m_backgroundProgram->bind(); + drawBackground(); + m_backgroundProgram->release(); + } + + if (m_modelProgram && m_scene) { + const float yaw = qDegreesToRadians(m_yaw); + const float pitch = qDegreesToRadians(m_pitch); + + /* Orbit around the target: yaw sweeps horizontally, pitch lifts + * the camera while keeping it aimed at the chest. */ + const QVector3D eye(m_distance * qCos(pitch) * qCos(yaw), + m_distance * qSin(pitch) + kCameraTarget.y(), + m_distance * qCos(pitch) * qSin(yaw)); + + QMatrix4x4 view; + view.lookAt(eye, kCameraTarget, QVector3D(0, 1, 0)); + + m_modelProgram->bind(); + m_modelProgram->setUniformValue("mvp_matrix", + m_projection * view); + m_scene->draw(m_modelProgram); + m_modelProgram->release(); + } + + /* Wayland does not settle the fractional scale factor until after the + * first frame has been presented, so that frame is drawn at the wrong + * size no matter what. Asking for one more repaint is what makes the + * preview appear correctly instead of needing a nudge from the user. */ + if (m_firstFrame) { + m_firstFrame = false; + update(); + } + } + + void SkinPreviewSurface::buildBackgroundTexture(int width, int height, + int tileSize) + { + m_backgroundTexture = new QOpenGLTexture( + chequerboard(width, height, tileSize, m_background)); + m_backgroundTexture->setMinificationFilter(QOpenGLTexture::Nearest); + m_backgroundTexture->setMagnificationFilter(QOpenGLTexture::Nearest); + } + + void SkinPreviewSurface::drawBackground() + { + /* The quad is already in clip space and has to lose every depth + * argument with the model, so the test is off *and* writing is + * masked -- otherwise it would fill the depth buffer and hide the + * model behind it. */ + glDisable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); + + m_backgroundTexture->bind(); + m_backgroundProgram->setUniformValue("texture", 0); + m_backgroundQuad->draw(m_backgroundProgram); + m_backgroundTexture->release(); + + glDepthMask(GL_TRUE); + glEnable(GL_DEPTH_TEST); + } + + void SkinPreviewSurface::showSkin(const SkinEntry* skin) + { + if (!skin || !m_scene) { + return; + } + m_scene->setSlim(skin->arms() == SkinEntry::Arms::Slim); + m_scene->setSkin(skin->texture()); + update(); + } + + void SkinPreviewSurface::showCape(const QImage& cape) + { + if (!m_scene) { + return; + } + m_scene->setCapeVisible(!cape.isNull()); + m_scene->setCape(cape); + update(); + } + + void SkinPreviewSurface::setElytraVisible(bool visible) + { + if (!m_scene) { + return; + } + m_scene->setElytraVisible(visible); + update(); + } + + void SkinPreviewSurface::mousePressEvent(QMouseEvent* event) + { + /* pos() rather than position(): the latter only exists from Qt 6, and + * this builds against Qt 5.15 as well. Integer pixels are all the + * orbit needs anyway. */ + m_lastMousePosition = event->pos(); + m_dragging = true; + } + + void SkinPreviewSurface::mouseReleaseEvent(QMouseEvent* event) + { + Q_UNUSED(event) + m_dragging = false; + } + + void SkinPreviewSurface::mouseMoveEvent(QMouseEvent* event) + { + /* Some Wayland compositors deliver a move without ever delivering + * the release, which leaves the model stuck to the pointer. Trusting + * the button state on every move instead of only the release event is + * what unsticks it. */ + if (!(event->buttons() & Qt::LeftButton)) { + m_dragging = false; + return; + } + if (!m_dragging) { + return; + } + + const QPoint position = event->pos(); + const QPoint delta = position - m_lastMousePosition; + + m_yaw += delta.x() * kOrbitDegreesPerPixel; + m_pitch += delta.y() * kOrbitDegreesPerPixel; + + /* Keep yaw in one turn's worth so it cannot drift off into values + * where float precision starts to show. */ + if (m_yaw > 360.0f) { + m_yaw -= 360.0f; + } else if (m_yaw < 0.0f) { + m_yaw += 360.0f; + } + + m_lastMousePosition = position; + update(); + } + + void SkinPreviewSurface::wheelEvent(QWheelEvent* event) + { + m_distance -= event->angleDelta().y() * kZoomPerWheelUnit; + /* Only a near limit: closer than this and the camera ends up inside + * the model. Zooming out is harmless because the projection has no + * far plane. */ + m_distance = qMax(kMinimumDistance, m_distance); + update(); + } +} // namespace skinrender diff --git a/launcher/ui/dialogs/skins/render/SkinPreviewSurface.h b/launcher/ui/dialogs/skins/render/SkinPreviewSurface.h new file mode 100644 index 000000000..210765d47 --- /dev/null +++ b/launcher/ui/dialogs/skins/render/SkinPreviewSurface.h @@ -0,0 +1,126 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright (C) 2026 Project Tick + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QOpenGLShaderProgram; +class QOpenGLTexture; +class SkinEntry; + +namespace skinrender +{ + class CubeMesh; + class PlayerScene; + + /* What the preview needs from its owner in order to draw the first + * frame. + * + * Pulled rather than pushed because initializeGL() runs whenever the + * windowing system gets around to it, which is after the dialog has + * finished constructing -- so the preview has to be able to ask for the + * current state at that moment instead of being told at creation time. + */ + class SkinPreviewSource + { + public: + virtual ~SkinPreviewSource() = default; + + /* The skin to show, or nullptr if nothing is selected. */ + virtual const SkinEntry* previewSkin() const = 0; + + /* The cape texture for an id, or a null image for "no cape". */ + virtual QImage previewCape(const QString& capeId) const = 0; + }; + + /* Orbiting 3D preview of the player model. + * + * A QOpenGLWindow rather than a QOpenGLWidget: the widget path renders + * into an FBO that the rest of the dialog then composites, which on + * several drivers costs more than the model itself. The dialog wraps this + * in QWidget::createWindowContainer(). + * + * Drag to turn, wheel to zoom. Nothing here owns the skin or the cape -- + * both are handed in, and re-handed in whenever the dialog changes them. + */ + class SkinPreviewSurface : public QOpenGLWindow, protected QOpenGLFunctions + { + Q_OBJECT + + public: + /* `background` is the colour the chequerboard is derived from; the + * dialog passes its own base palette colour so the preview sits in + * the theme rather than on top of it. */ + SkinPreviewSurface(SkinPreviewSource* source, QColor background); + ~SkinPreviewSurface() override; + + /* Whether a preview can be created at all. False when there is no + * usable GL context, or when the user asked for it to be skipped + * through _DISABLE_GLVULKAN. */ + static bool isAvailable(); + + void showSkin(const SkinEntry* skin); + void showCape(const QImage& cape); + void setElytraVisible(bool visible); + + protected: + void mousePressEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void wheelEvent(QWheelEvent* event) override; + + void initializeGL() override; + void resizeGL(int w, int h) override; + void paintGL() override; + + private: + void loadShaders(); + void buildBackgroundTexture(int width, int height, int tileSize); + void drawBackground(); + + QOpenGLShaderProgram* m_modelProgram = nullptr; + QOpenGLShaderProgram* m_backgroundProgram = nullptr; + PlayerScene* m_scene = nullptr; + CubeMesh* m_backgroundQuad = nullptr; + QOpenGLTexture* m_backgroundTexture = nullptr; + + QMatrix4x4 m_projection; + + QPoint m_lastMousePosition; + bool m_dragging = false; + + /* Orbit camera. Starting yaw of 90 degrees puts the camera on +Z, + * which is the model's front. */ + float m_distance = 48.0f; + float m_yaw = 90.0f; + float m_pitch = 0.0f; + + bool m_firstFrame = true; + + QColor m_background; + SkinPreviewSource* m_source = nullptr; + }; +} // namespace skinrender diff --git a/launcher/ui/pages/global/AccountListPage.cpp b/launcher/ui/pages/global/AccountListPage.cpp index 093afda98..8510db16c 100644 --- a/launcher/ui/pages/global/AccountListPage.cpp +++ b/launcher/ui/pages/global/AccountListPage.cpp @@ -33,7 +33,7 @@ #include "ui/dialogs/ProgressDialog.h" #include "ui/dialogs/MSALoginDialog.h" #include "ui/dialogs/CustomMessageBox.h" -#include "ui/dialogs/SkinUploadDialog.h" +#include "ui/dialogs/skins/SkinManageDialog.h" #include "tasks/Task.h" #include "minecraft/auth/AccountTask.h" @@ -295,7 +295,17 @@ void AccountListPage::on_actionUploadSkin_triggered() QModelIndex selected = selection.first(); MinecraftAccountPtr account = selected.data(AccountList::PointerRole) .value(); - SkinUploadDialog dialog(account, this); + /* Offline accounts have no profile to upload to, and the dialog + * cannot do anything useful without one. */ + if (!account || !account->isMSA()) { + CustomMessageBox::selectable( + this, tr("Offline account"), + tr("Skins can only be managed for Microsoft accounts."), + QMessageBox::Warning) + ->exec(); + return; + } + SkinManageDialog dialog(this, account); dialog.exec(); } }