551 lines
22 KiB
C++
551 lines
22 KiB
C++
#include "minecraftversion.h"
|
||
|
||
#include <QDir>
|
||
#include <QDirIterator>
|
||
#include <QFile>
|
||
#include <QFileInfo>
|
||
#include <QHash>
|
||
#include <QJsonDocument>
|
||
#include <QJsonObject>
|
||
#include <QObject>
|
||
#include <QRegularExpression>
|
||
#include <QSysInfo>
|
||
#include <algorithm>
|
||
#include <utility>
|
||
|
||
namespace {
|
||
|
||
// Больше десяти уровней inheritsFrom не бывает даже у Forge поверх Fabric-подобных сборок.
|
||
constexpr int kMaxInheritanceDepth = 10;
|
||
|
||
QString versionJsonPath(const QString &gameDir, const QString &id)
|
||
{
|
||
return gameDir + "/versions/" + id + "/" + id + ".json";
|
||
}
|
||
|
||
QString versionJarPath(const QString &gameDir, const QString &id)
|
||
{
|
||
return gameDir + "/versions/" + id + "/" + id + ".jar";
|
||
}
|
||
|
||
QString versionsRoot(const QString &gameDir)
|
||
{
|
||
return gameDir + QStringLiteral("/versions");
|
||
}
|
||
|
||
// Идентификатор версии — это имя папки, поэтому «чинить» его как sanitizeId()
|
||
// нельзя: подстановка символов увела бы нас не в ту версию. Только проверяем.
|
||
bool isSafeVersionId(const QString &id)
|
||
{
|
||
return !id.isEmpty() && !id.startsWith(u'.') && !id.contains(u'/') && !id.contains(u'\\');
|
||
}
|
||
|
||
// Верхний объект <id>.json без разворачивания цепочки: нужен только inheritsFrom.
|
||
QJsonObject readVersionObject(const QString &gameDir, const QString &id)
|
||
{
|
||
QFile file(versionJsonPath(gameDir, id));
|
||
if (!file.open(QIODevice::ReadOnly))
|
||
return {};
|
||
return QJsonDocument::fromJson(file.readAll()).object();
|
||
}
|
||
|
||
// org.lwjgl:lwjgl:3.3.1:natives-windows -> org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar
|
||
QString mavenToPath(const QString &name)
|
||
{
|
||
QString coords = name;
|
||
QString extension = QStringLiteral("jar");
|
||
|
||
const int at = coords.indexOf(u'@');
|
||
if (at >= 0) {
|
||
extension = coords.mid(at + 1);
|
||
coords.truncate(at);
|
||
}
|
||
|
||
const QStringList parts = coords.split(u':');
|
||
if (parts.size() < 3)
|
||
return {};
|
||
|
||
QString file = parts[1] + u'-' + parts[2];
|
||
if (parts.size() > 3)
|
||
file += u'-' + parts[3];
|
||
|
||
return QString(parts[0]).replace(u'.', u'/') + u'/' + parts[1] + u'/' + parts[2]
|
||
+ u'/' + file + u'.' + extension;
|
||
}
|
||
|
||
// Ключ для дедупликации: у наследника и родителя одна и та же библиотека часто
|
||
// разных версий, и побеждать должна версия наследника.
|
||
QString libraryKey(const QString &name)
|
||
{
|
||
const QStringList parts = name.split(u':');
|
||
if (parts.size() < 2)
|
||
return name;
|
||
QString key = parts[0] + u':' + parts[1];
|
||
if (parts.size() > 3)
|
||
key += u':' + parts[3];
|
||
return key;
|
||
}
|
||
|
||
QString mavenClassifier(const QString &name)
|
||
{
|
||
const QStringList parts = name.split(u':');
|
||
if (parts.size() < 4)
|
||
return {};
|
||
QString classifier = parts[3];
|
||
const int at = classifier.indexOf(u'@');
|
||
if (at >= 0)
|
||
classifier.truncate(at);
|
||
return classifier;
|
||
}
|
||
|
||
bool ruleMatches(const QJsonObject &rule, const QSet<QString> &features)
|
||
{
|
||
if (rule.contains(QStringLiteral("features"))) {
|
||
const QJsonObject wanted = rule.value(QStringLiteral("features")).toObject();
|
||
for (auto it = wanted.constBegin(); it != wanted.constEnd(); ++it) {
|
||
if (it.value().toBool() != features.contains(it.key()))
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const QJsonObject os = rule.value(QStringLiteral("os")).toObject();
|
||
if (!os.isEmpty()) {
|
||
if (os.contains(QStringLiteral("name"))
|
||
&& os.value(QStringLiteral("name")).toString() != VersionLoader::osName())
|
||
return false;
|
||
if (os.contains(QStringLiteral("arch"))
|
||
&& os.value(QStringLiteral("arch")).toString() != VersionLoader::osArch())
|
||
return false;
|
||
if (os.contains(QStringLiteral("version"))) {
|
||
const QRegularExpression re(os.value(QStringLiteral("version")).toString());
|
||
if (!re.isValid() || !re.match(QSysInfo::productVersion()).hasMatch())
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
void appendArguments(const QJsonArray &source, QStringList &target, const QSet<QString> &features)
|
||
{
|
||
for (const QJsonValue &entry : source) {
|
||
if (entry.isString()) {
|
||
target << entry.toString();
|
||
continue;
|
||
}
|
||
|
||
const QJsonObject object = entry.toObject();
|
||
if (!VersionLoader::rulesAllow(object.value(QStringLiteral("rules")).toArray(), features))
|
||
continue;
|
||
|
||
const QJsonValue value = object.value(QStringLiteral("value"));
|
||
if (value.isString())
|
||
target << value.toString();
|
||
else
|
||
for (const QJsonValue &item : value.toArray())
|
||
target << item.toString();
|
||
}
|
||
}
|
||
|
||
MinecraftLibrary libraryFromDownload(const QString &name,
|
||
const QJsonObject &download,
|
||
const QString &fallbackPath)
|
||
{
|
||
MinecraftLibrary library;
|
||
library.name = name;
|
||
library.path = download.value(QStringLiteral("path")).toString();
|
||
if (library.path.isEmpty())
|
||
library.path = fallbackPath;
|
||
library.url = download.value(QStringLiteral("url")).toString();
|
||
library.sha1 = download.value(QStringLiteral("sha1")).toString();
|
||
library.size = static_cast<qint64>(download.value(QStringLiteral("size")).toDouble());
|
||
return library;
|
||
}
|
||
|
||
void parseLibraries(const QJsonObject &root,
|
||
QList<MinecraftLibrary> &target,
|
||
const QSet<QString> &features)
|
||
{
|
||
for (const QJsonValue &entry : root.value(QStringLiteral("libraries")).toArray()) {
|
||
const QJsonObject object = entry.toObject();
|
||
if (!VersionLoader::rulesAllow(object.value(QStringLiteral("rules")).toArray(), features))
|
||
continue;
|
||
|
||
const QString name = object.value(QStringLiteral("name")).toString();
|
||
if (name.isEmpty())
|
||
continue;
|
||
|
||
const QString relativePath = mavenToPath(name);
|
||
const QJsonObject downloads = object.value(QStringLiteral("downloads")).toObject();
|
||
const QJsonObject extract = object.value(QStringLiteral("extract")).toObject();
|
||
|
||
QStringList exclude;
|
||
for (const QJsonValue &value : extract.value(QStringLiteral("exclude")).toArray())
|
||
exclude << value.toString();
|
||
|
||
// Основной артефакт. В новом формате (1.19+) натив-библиотеки — это
|
||
// отдельные записи с классификатором natives-*, у них тоже есть artifact.
|
||
if (downloads.contains(QStringLiteral("artifact")) || !downloads.contains(QStringLiteral("classifiers"))) {
|
||
MinecraftLibrary library = libraryFromDownload(
|
||
name, downloads.value(QStringLiteral("artifact")).toObject(), relativePath);
|
||
|
||
// У Fabric у части библиотек нет блока downloads вообще — только
|
||
// name и адрес Maven-репозитория, путь собираем из координат.
|
||
if (library.url.isEmpty()) {
|
||
const QString repository = object.value(QStringLiteral("url")).toString();
|
||
if (!repository.isEmpty())
|
||
library.url = repository.endsWith(u'/') ? repository + library.path
|
||
: repository + u'/' + library.path;
|
||
}
|
||
|
||
if (!library.path.isEmpty()) {
|
||
library.native = mavenClassifier(name).startsWith(QStringLiteral("natives-"));
|
||
library.extractExclude = exclude;
|
||
target << library;
|
||
}
|
||
}
|
||
|
||
// Старый формат (до 1.18): отдельная карта natives + downloads.classifiers.
|
||
const QJsonObject natives = object.value(QStringLiteral("natives")).toObject();
|
||
if (natives.isEmpty())
|
||
continue;
|
||
|
||
QString classifier = natives.value(VersionLoader::osName()).toString();
|
||
if (classifier.isEmpty())
|
||
continue;
|
||
classifier.replace(QStringLiteral("${arch}"),
|
||
VersionLoader::osArch() == QStringLiteral("x86") ? QStringLiteral("32")
|
||
: QStringLiteral("64"));
|
||
|
||
const QJsonObject classifiers = downloads.value(QStringLiteral("classifiers")).toObject();
|
||
MinecraftLibrary library = libraryFromDownload(
|
||
name + u':' + classifier,
|
||
classifiers.value(classifier).toObject(),
|
||
mavenToPath(name + u':' + classifier));
|
||
if (library.path.isEmpty())
|
||
continue;
|
||
|
||
library.native = true;
|
||
library.extractExclude = exclude;
|
||
target << library;
|
||
}
|
||
}
|
||
|
||
// Для 1.19+ в списке лежат natives-windows, natives-windows-x86 и natives-windows-arm64
|
||
// одновременно — правила фильтруют только по ОС. Оставляем по одной на артефакт.
|
||
void pickNativesForCurrentArch(QList<MinecraftLibrary> &libraries)
|
||
{
|
||
const QString exact = VersionLoader::nativeClassifier();
|
||
const QString osPart = VersionLoader::osName() == QStringLiteral("osx")
|
||
? QStringLiteral("macos")
|
||
: VersionLoader::osName();
|
||
const QString base = QStringLiteral("natives-") + osPart;
|
||
|
||
QHash<QString, QString> chosen; // group:artifact -> классификатор
|
||
for (const MinecraftLibrary &library : libraries) {
|
||
if (!library.native)
|
||
continue;
|
||
const QStringList parts = library.name.split(u':');
|
||
if (parts.size() < 4)
|
||
continue;
|
||
const QString artifact = parts[0] + u':' + parts[1];
|
||
const QString classifier = mavenClassifier(library.name);
|
||
const QString current = chosen.value(artifact);
|
||
if (current == exact)
|
||
continue;
|
||
if (classifier == exact || current.isEmpty() || classifier == base)
|
||
chosen.insert(artifact, classifier);
|
||
}
|
||
|
||
libraries.removeIf([&](const MinecraftLibrary &library) {
|
||
if (!library.native)
|
||
return false;
|
||
const QStringList parts = library.name.split(u':');
|
||
if (parts.size() < 4)
|
||
return false;
|
||
return chosen.value(parts[0] + u':' + parts[1]) != mavenClassifier(library.name);
|
||
});
|
||
}
|
||
|
||
} // namespace
|
||
|
||
QString VersionLoader::osName()
|
||
{
|
||
#if defined(Q_OS_WIN)
|
||
return QStringLiteral("windows");
|
||
#elif defined(Q_OS_MACOS)
|
||
return QStringLiteral("osx");
|
||
#else
|
||
return QStringLiteral("linux");
|
||
#endif
|
||
}
|
||
|
||
QString VersionLoader::osArch()
|
||
{
|
||
const QString arch = QSysInfo::currentCpuArchitecture();
|
||
if (arch == QLatin1String("i386") || arch == QLatin1String("i686"))
|
||
return QStringLiteral("x86");
|
||
return arch; // x86_64 / arm64 — совпадают с тем, что пишет Mojang
|
||
}
|
||
|
||
QString VersionLoader::nativeClassifier()
|
||
{
|
||
const QString arch = osArch();
|
||
const QString os = osName();
|
||
|
||
if (os == QLatin1String("windows")) {
|
||
if (arch == QLatin1String("arm64"))
|
||
return QStringLiteral("natives-windows-arm64");
|
||
if (arch == QLatin1String("x86"))
|
||
return QStringLiteral("natives-windows-x86");
|
||
return QStringLiteral("natives-windows");
|
||
}
|
||
if (os == QLatin1String("osx"))
|
||
return arch == QLatin1String("arm64") ? QStringLiteral("natives-macos-arm64")
|
||
: QStringLiteral("natives-macos");
|
||
return arch == QLatin1String("arm64") ? QStringLiteral("natives-linux-arm64")
|
||
: QStringLiteral("natives-linux");
|
||
}
|
||
|
||
bool VersionLoader::rulesAllow(const QJsonArray &rules, const QSet<QString> &features)
|
||
{
|
||
if (rules.isEmpty())
|
||
return true;
|
||
|
||
bool allowed = false;
|
||
for (const QJsonValue &entry : rules) {
|
||
const QJsonObject rule = entry.toObject();
|
||
if (ruleMatches(rule, features))
|
||
allowed = rule.value(QStringLiteral("action")).toString() != QLatin1String("disallow");
|
||
}
|
||
return allowed;
|
||
}
|
||
|
||
QStringList VersionLoader::installedVersions(const QString &gameDir)
|
||
{
|
||
QStringList result;
|
||
const QDir versions(gameDir + QStringLiteral("/versions"));
|
||
const QStringList entries = versions.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||
for (const QString &id : entries) {
|
||
if (QFile::exists(versionJsonPath(gameDir, id)))
|
||
result << id;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
QStringList VersionLoader::dependentsOf(const QString &gameDir, const QString &versionId)
|
||
{
|
||
if (versionId.isEmpty())
|
||
return {};
|
||
|
||
// Одного уровня хватает: профили модлоадеров наследуются от ванильных версий,
|
||
// а не друг от друга.
|
||
QStringList result;
|
||
const QStringList installed = installedVersions(gameDir);
|
||
for (const QString &id : installed) {
|
||
if (id == versionId)
|
||
continue;
|
||
const QJsonObject object = readVersionObject(gameDir, id);
|
||
if (object.value(QStringLiteral("inheritsFrom")).toString() == versionId)
|
||
result << id;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
qint64 VersionLoader::installedSize(const QString &gameDir, const QString &versionId)
|
||
{
|
||
if (!isSafeVersionId(versionId))
|
||
return 0;
|
||
|
||
qint64 total = 0;
|
||
QDirIterator it(versionsRoot(gameDir) + u'/' + versionId,
|
||
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
|
||
QDirIterator::Subdirectories);
|
||
while (it.hasNext()) {
|
||
it.next();
|
||
total += it.fileInfo().size();
|
||
}
|
||
return total;
|
||
}
|
||
|
||
bool VersionLoader::remove(const QString &gameDir, const QString &versionId, QString *error)
|
||
{
|
||
const QString root = versionsRoot(gameDir);
|
||
if (gameDir.isEmpty() || !isSafeVersionId(versionId)) {
|
||
if (error)
|
||
*error = QObject::tr("недопустимый идентификатор версии");
|
||
return false;
|
||
}
|
||
|
||
// Удаляем только внутри <gameDir>/versions: проверка id уже не пропустит «..»,
|
||
// но цена ошибки — чужая папка, а проверка стоит одной строки.
|
||
const QString dir = QDir::cleanPath(root + u'/' + versionId);
|
||
if (!dir.startsWith(root + u'/')) {
|
||
if (error)
|
||
*error = QObject::tr("недопустимый идентификатор версии");
|
||
return false;
|
||
}
|
||
if (!QDir(dir).exists())
|
||
return true;
|
||
if (!QDir(dir).removeRecursively()) {
|
||
if (error)
|
||
*error = QObject::tr("не удалось удалить %1").arg(QDir::toNativeSeparators(dir));
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
MinecraftVersion VersionLoader::load(const QString &gameDir,
|
||
const QString &versionId,
|
||
const QSet<QString> &features,
|
||
QString *error)
|
||
{
|
||
MinecraftVersion version;
|
||
|
||
// Цепочка от самой производной версии к базовой: Fabric -> 1.20.1.
|
||
QList<QJsonObject> chain;
|
||
QStringList chainIds;
|
||
QString currentId = versionId;
|
||
|
||
while (!currentId.isEmpty()) {
|
||
if (chainIds.contains(currentId)) {
|
||
if (error)
|
||
*error = QObject::tr("Циклическая зависимость inheritsFrom в версии «%1»").arg(currentId);
|
||
return version;
|
||
}
|
||
if (chain.size() >= kMaxInheritanceDepth) {
|
||
if (error)
|
||
*error = QObject::tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(versionId);
|
||
return version;
|
||
}
|
||
|
||
const QString path = versionJsonPath(gameDir, currentId);
|
||
QFile file(path);
|
||
if (!file.open(QIODevice::ReadOnly)) {
|
||
if (error)
|
||
*error = QObject::tr("Не найден файл версии: %1").arg(QDir::toNativeSeparators(path));
|
||
return version;
|
||
}
|
||
|
||
QJsonParseError parseError;
|
||
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
|
||
if (parseError.error != QJsonParseError::NoError || !document.isObject()) {
|
||
if (error)
|
||
*error = QObject::tr("Повреждён файл версии %1: %2")
|
||
.arg(QDir::toNativeSeparators(path), parseError.errorString());
|
||
return version;
|
||
}
|
||
|
||
const QJsonObject object = document.object();
|
||
chain << object;
|
||
chainIds << currentId;
|
||
currentId = object.value(QStringLiteral("inheritsFrom")).toString();
|
||
}
|
||
|
||
version.id = versionId;
|
||
|
||
// Скаляры: идём от базовой версии к производной, наследник перекрывает родителя.
|
||
for (int i = chain.size() - 1; i >= 0; --i) {
|
||
const QJsonObject &object = chain[i];
|
||
|
||
if (object.contains(QStringLiteral("mainClass")))
|
||
version.mainClass = object.value(QStringLiteral("mainClass")).toString();
|
||
if (object.contains(QStringLiteral("type")))
|
||
version.type = object.value(QStringLiteral("type")).toString();
|
||
if (object.contains(QStringLiteral("assets")))
|
||
version.assetsKind = object.value(QStringLiteral("assets")).toString();
|
||
if (object.contains(QStringLiteral("assetIndex")))
|
||
version.assetIndexId = object.value(QStringLiteral("assetIndex"))
|
||
.toObject()
|
||
.value(QStringLiteral("id"))
|
||
.toString();
|
||
if (object.contains(QStringLiteral("javaVersion")))
|
||
version.javaMajor = object.value(QStringLiteral("javaVersion"))
|
||
.toObject()
|
||
.value(QStringLiteral("majorVersion"))
|
||
.toInt(version.javaMajor);
|
||
|
||
const QJsonObject logging = object.value(QStringLiteral("logging"))
|
||
.toObject()
|
||
.value(QStringLiteral("client"))
|
||
.toObject();
|
||
if (!logging.isEmpty()) {
|
||
version.loggingArgument = logging.value(QStringLiteral("argument")).toString();
|
||
const QString fileId = logging.value(QStringLiteral("file"))
|
||
.toObject()
|
||
.value(QStringLiteral("id"))
|
||
.toString();
|
||
if (!fileId.isEmpty())
|
||
version.loggingConfigPath = gameDir + QStringLiteral("/assets/log_configs/") + fileId;
|
||
}
|
||
|
||
// minecraftArguments (до 1.13) заменяет список целиком, arguments.game — дополняет.
|
||
if (object.contains(QStringLiteral("minecraftArguments"))) {
|
||
version.gameArgs = object.value(QStringLiteral("minecraftArguments"))
|
||
.toString()
|
||
.split(QRegularExpression(QStringLiteral("\\s+")),
|
||
Qt::SkipEmptyParts);
|
||
}
|
||
|
||
const QJsonObject arguments = object.value(QStringLiteral("arguments")).toObject();
|
||
appendArguments(arguments.value(QStringLiteral("jvm")).toArray(), version.jvmArgs, features);
|
||
appendArguments(arguments.value(QStringLiteral("game")).toArray(), version.gameArgs, features);
|
||
|
||
const QByteArray raw = QJsonDocument(arguments.value(QStringLiteral("game")).toArray()).toJson();
|
||
if (raw.contains("quickPlayMultiplayer"))
|
||
version.supportsQuickPlay = true;
|
||
if (raw.contains("resolution_width"))
|
||
version.hasCustomResolutionArgs = true;
|
||
}
|
||
|
||
if (version.assetIndexId.isEmpty())
|
||
version.assetIndexId = version.assetsKind;
|
||
if (version.assetsKind.isEmpty())
|
||
version.assetsKind = version.assetIndexId;
|
||
if (version.type.isEmpty())
|
||
version.type = QStringLiteral("release");
|
||
|
||
// Библиотеки: наследник первым, дубли по group:artifact:classifier отбрасываем.
|
||
QList<MinecraftLibrary> collected;
|
||
for (const QJsonObject &object : chain)
|
||
parseLibraries(object, collected, features);
|
||
|
||
QSet<QString> seen;
|
||
for (const MinecraftLibrary &library : std::as_const(collected)) {
|
||
const QString key = libraryKey(library.name);
|
||
if (seen.contains(key))
|
||
continue;
|
||
seen.insert(key);
|
||
version.libraries << library;
|
||
}
|
||
pickNativesForCurrentArch(version.libraries);
|
||
|
||
// До 1.13 jvm-аргументов в json нет — собираем их сами.
|
||
const bool hasClasspathArg = std::any_of(
|
||
version.jvmArgs.cbegin(), version.jvmArgs.cend(), [](const QString &argument) {
|
||
return argument == QLatin1String("-cp") || argument == QLatin1String("-classpath");
|
||
});
|
||
if (!hasClasspathArg) {
|
||
version.jvmArgs << QStringLiteral("-Djava.library.path=${natives_directory}")
|
||
<< QStringLiteral("-Dminecraft.launcher.brand=${launcher_name}")
|
||
<< QStringLiteral("-Dminecraft.launcher.version=${launcher_version}")
|
||
<< QStringLiteral("-cp") << QStringLiteral("${classpath}");
|
||
}
|
||
|
||
// Клиентский jar: у Fabric-версии он свой, у Forge обычно только у родителя.
|
||
for (const QString &id : std::as_const(chainIds)) {
|
||
const QString jar = versionJarPath(gameDir, id);
|
||
if (QFile::exists(jar)) {
|
||
version.clientJarPath = jar;
|
||
break;
|
||
}
|
||
}
|
||
if (version.clientJarPath.isEmpty())
|
||
version.clientJarPath = versionJarPath(gameDir, chainIds.constLast());
|
||
|
||
if (version.mainClass.isEmpty() && error)
|
||
*error = QObject::tr("В файле версии «%1» не указан mainClass").arg(versionId);
|
||
|
||
return version;
|
||
}
|