244 lines
9.3 KiB
C++
244 lines
9.3 KiB
C++
#include "gamedirimport.h"
|
||||
|
|
|
|||
|
|
#include "minecraftversion.h"
|
|||
|
|
|
|||
|
|
#include <QDateTime>
|
|||
|
|
#include <QDir>
|
|||
|
|
#include <QFile>
|
|||
|
|
#include <QFileInfo>
|
|||
|
|
#include <QJsonArray>
|
|||
|
|
#include <QJsonDocument>
|
|||
|
|
#include <QList>
|
|||
|
|
#include <algorithm>
|
|||
|
|
#include <utility>
|
|||
|
|
|
|||
|
|
namespace {
|
|||
|
|
|
|||
|
|
// Признаки того, что в папке играли: это принадлежит сборке. Служебные файлы
|
|||
|
|
// чужих лаунчеров (launcher_log.txt, webcache2 и т. п.) сюда не входят, иначе
|
|||
|
|
// пустая папка официального лаунчера давала бы сборку без версии.
|
|||
|
|
const QStringList kContentMarkers = {QStringLiteral("mods"),
|
|||
|
|
QStringLiteral("saves"),
|
|||
|
|
QStringLiteral("resourcepacks"),
|
|||
|
|
QStringLiteral("shaderpacks"),
|
|||
|
|
QStringLiteral("config"),
|
|||
|
|
QStringLiteral("options.txt"),
|
|||
|
|
QStringLiteral("servers.dat")};
|
|||
|
|
|
|||
|
|
struct Candidate
|
|||
|
|
{
|
|||
|
|
QString id;
|
|||
|
|
QJsonObject json;
|
|||
|
|
bool inherits = false;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
QJsonObject readObject(const QString &path)
|
|||
|
|
{
|
|||
|
|
QFile file(path);
|
|||
|
|
if (!file.open(QIODevice::ReadOnly))
|
|||
|
|
return {};
|
|||
|
|
return QJsonDocument::fromJson(file.readAll()).object();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
QString versionJsonPath(const QString &gameDir, const QString &id)
|
|||
|
|
{
|
|||
|
|
return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool hasContent(const QString &gameDir)
|
|||
|
|
{
|
|||
|
|
for (const QString &name : kContentMarkers) {
|
|||
|
|
const QFileInfo info(gameDir + u'/' + name);
|
|||
|
|
if (info.isFile())
|
|||
|
|
return true;
|
|||
|
|
if (info.isDir() && !QDir(info.filePath()).isEmpty())
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool hasMods(const QString &gameDir)
|
|||
|
|
{
|
|||
|
|
return !QDir(gameDir + QStringLiteral("/mods"))
|
|||
|
|
.entryList({QStringLiteral("*.jar")}, QDir::Files)
|
|||
|
|
.isEmpty();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
QDateTime parseLastUsed(const QString &text)
|
|||
|
|
{
|
|||
|
|
QDateTime when = QDateTime::fromString(text, Qt::ISODateWithMs);
|
|||
|
|
if (!when.isValid())
|
|||
|
|
when = QDateTime::fromString(text, Qt::ISODate);
|
|||
|
|
return when;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Версии из launcher_profiles.json, которые есть в versions/, свежие сверху.
|
|||
|
|
// Профили latest-release/latest-snapshot без lastVersionId пропускаются: без
|
|||
|
|
// манифеста не узнать, во что они разворачивались.
|
|||
|
|
QStringList profileVersions(const QString &gameDir, const QStringList &installed)
|
|||
|
|
{
|
|||
|
|
const QJsonObject profiles =
|
|||
|
|
readObject(gameDir + QStringLiteral("/launcher_profiles.json"))
|
|||
|
|
.value(QStringLiteral("profiles"))
|
|||
|
|
.toObject();
|
|||
|
|
|
|||
|
|
struct Used
|
|||
|
|
{
|
|||
|
|
QString id;
|
|||
|
|
QDateTime when;
|
|||
|
|
};
|
|||
|
|
QList<Used> used;
|
|||
|
|
for (auto it = profiles.constBegin(); it != profiles.constEnd(); ++it) {
|
|||
|
|
const QJsonObject profile = it.value().toObject();
|
|||
|
|
const QString id = profile.value(QStringLiteral("lastVersionId")).toString();
|
|||
|
|
if (id.isEmpty() || !installed.contains(id))
|
|||
|
|
continue;
|
|||
|
|
used.append({id, parseLastUsed(profile.value(QStringLiteral("lastUsed")).toString())});
|
|||
|
|
}
|
|||
|
|
// Профиль без даты считается самым старым.
|
|||
|
|
std::stable_sort(used.begin(), used.end(), [](const Used &a, const Used &b) {
|
|||
|
|
if (a.when.isValid() != b.when.isValid())
|
|||
|
|
return a.when.isValid();
|
|||
|
|
return a.when > b.when;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
QStringList ids;
|
|||
|
|
for (const Used &u : used) {
|
|||
|
|
if (!ids.contains(u.id))
|
|||
|
|
ids << u.id;
|
|||
|
|
}
|
|||
|
|
return ids;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Установленные версии по времени правки описания, свежие сверху.
|
|||
|
|
QStringList versionsByMtime(const QString &gameDir, QStringList installed)
|
|||
|
|
{
|
|||
|
|
std::stable_sort(installed.begin(), installed.end(), [&](const QString &a, const QString &b) {
|
|||
|
|
return QFileInfo(versionJsonPath(gameDir, a)).lastModified()
|
|||
|
|
> QFileInfo(versionJsonPath(gameDir, b)).lastModified();
|
|||
|
|
});
|
|||
|
|
return installed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
QList<Candidate> load(const QString &gameDir, const QStringList &ids)
|
|||
|
|
{
|
|||
|
|
QList<Candidate> out;
|
|||
|
|
for (const QString &id : ids) {
|
|||
|
|
const QJsonObject json = readObject(versionJsonPath(gameDir, id));
|
|||
|
|
// Битое описание не запустится — такую версию не предлагаем.
|
|||
|
|
if (json.isEmpty())
|
|||
|
|
continue;
|
|||
|
|
out.append({id, json, !json.value(QStringLiteral("inheritsFrom")).toString().isEmpty()});
|
|||
|
|
}
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// "1.20.1-47.2.0" → "47.2.0". Хвост "-1.7.10" у старых Forge остаётся: в
|
|||
|
|
// каталоге модлоадеров версии записаны так же.
|
|||
|
|
QString stripGamePrefix(const QString &version, const QString &game)
|
|||
|
|
{
|
|||
|
|
if (!game.isEmpty() && version.startsWith(game + u'-'))
|
|||
|
|
return version.mid(game.size() + 1);
|
|||
|
|
return version;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
namespace GameDirImport {
|
|||
|
|
|
|||
|
|
LoaderInfo loaderOf(const QJsonObject &versionJson)
|
|||
|
|
{
|
|||
|
|
const QString game = versionJson.value(QStringLiteral("inheritsFrom")).toString();
|
|||
|
|
|
|||
|
|
for (const QJsonValue &value : versionJson.value(QStringLiteral("libraries")).toArray()) {
|
|||
|
|
// group:artifact:version[:classifier]
|
|||
|
|
const QStringList parts = value.toObject().value(QStringLiteral("name")).toString().split(u':');
|
|||
|
|
if (parts.size() < 3)
|
|||
|
|
continue;
|
|||
|
|
const QString coord = parts[0] + u':' + parts[1];
|
|||
|
|
const QString version = parts[2];
|
|||
|
|
if (coord == QLatin1String("net.fabricmc:fabric-loader"))
|
|||
|
|
return {QStringLiteral("fabric"), version};
|
|||
|
|
if (coord == QLatin1String("org.quiltmc:quilt-loader"))
|
|||
|
|
return {QStringLiteral("quilt"), version};
|
|||
|
|
if (coord == QLatin1String("net.neoforged:neoforge"))
|
|||
|
|
return {QStringLiteral("neoforge"), version};
|
|||
|
|
// NeoForge для 1.20.1 публиковался под старым именем артефакта.
|
|||
|
|
if (coord == QLatin1String("net.neoforged:forge"))
|
|||
|
|
return {QStringLiteral("neoforge"), stripGamePrefix(version, game)};
|
|||
|
|
if (coord == QLatin1String("net.minecraftforge:forge"))
|
|||
|
|
return {QStringLiteral("forge"), stripGamePrefix(version, game)};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Библиотек модлоадера в описании может не быть (их вынесли в родителя или
|
|||
|
|
// описание урезали руками) — тогда смотрим на имя, которое дают установщики.
|
|||
|
|
const QString id = versionJson.value(QStringLiteral("id")).toString();
|
|||
|
|
for (const auto &[key, prefix] : {std::pair{QStringLiteral("fabric"), QStringLiteral("fabric-loader-")},
|
|||
|
|
std::pair{QStringLiteral("quilt"), QStringLiteral("quilt-loader-")}}) {
|
|||
|
|
const QString suffix = u'-' + game;
|
|||
|
|
if (!game.isEmpty() && id.startsWith(prefix) && id.endsWith(suffix))
|
|||
|
|
return {key, id.mid(prefix.size(), id.size() - prefix.size() - suffix.size())};
|
|||
|
|
}
|
|||
|
|
if (id.startsWith(QLatin1String("neoforge-")))
|
|||
|
|
return {QStringLiteral("neoforge"), id.mid(9)};
|
|||
|
|
const QString forgeInfix = game + QStringLiteral("-forge-");
|
|||
|
|
if (!game.isEmpty() && id.startsWith(forgeInfix))
|
|||
|
|
return {QStringLiteral("forge"), id.mid(forgeInfix.size())};
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
std::optional<Detected> detect(const QString &gameDir)
|
|||
|
|
{
|
|||
|
|
if (gameDir.isEmpty() || !QFileInfo(gameDir).isDir())
|
|||
|
|
return std::nullopt;
|
|||
|
|
|
|||
|
|
const QStringList installed = VersionLoader::installedVersions(gameDir);
|
|||
|
|
const bool content = hasContent(gameDir);
|
|||
|
|
if (installed.isEmpty() && !content)
|
|||
|
|
return std::nullopt;
|
|||
|
|
|
|||
|
|
const QList<Candidate> fromProfiles = load(gameDir, profileVersions(gameDir, installed));
|
|||
|
|
const QList<Candidate> fromVersions = load(gameDir, versionsByMtime(gameDir, installed));
|
|||
|
|
|
|||
|
|
// С модами в папке ванильная версия почти наверняка не та, с которой играли:
|
|||
|
|
// сначала ищем профиль модлоадера, и только потом берём что есть.
|
|||
|
|
const Candidate *chosen = nullptr;
|
|||
|
|
QString source;
|
|||
|
|
auto pick = [&](const QList<Candidate> &list, const QString &from, bool needInherits) {
|
|||
|
|
if (chosen)
|
|||
|
|
return;
|
|||
|
|
for (const Candidate &c : list) {
|
|||
|
|
if (!needInherits || c.inherits) {
|
|||
|
|
chosen = &c;
|
|||
|
|
source = from;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
if (hasMods(gameDir)) {
|
|||
|
|
pick(fromProfiles, QStringLiteral("launcher_profiles"), true);
|
|||
|
|
pick(fromVersions, QStringLiteral("versions"), true);
|
|||
|
|
}
|
|||
|
|
pick(fromProfiles, QStringLiteral("launcher_profiles"), false);
|
|||
|
|
pick(fromVersions, QStringLiteral("versions"), false);
|
|||
|
|
|
|||
|
|
Detected result;
|
|||
|
|
if (!chosen) {
|
|||
|
|
result.source = QStringLiteral("content");
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result.source = source;
|
|||
|
|
if (chosen->inherits) {
|
|||
|
|
result.minecraftVersion = chosen->json.value(QStringLiteral("inheritsFrom")).toString();
|
|||
|
|
result.resolvedVersionId = chosen->id;
|
|||
|
|
const LoaderInfo loader = loaderOf(chosen->json);
|
|||
|
|
result.loader = loader.key;
|
|||
|
|
result.loaderVersion = loader.version;
|
|||
|
|
} else {
|
|||
|
|
result.minecraftVersion = chosen->id;
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
}
|