#include "gamedirimport.h" #include "minecraftversion.h" #include #include #include #include #include #include #include #include #include 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; 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 load(const QString &gameDir, const QStringList &ids) { QList 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 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 fromProfiles = load(gameDir, profileVersions(gameDir, installed)); const QList fromVersions = load(gameDir, versionsByMtime(gameDir, installed)); // С модами в папке ванильная версия почти наверняка не та, с которой играли: // сначала ищем профиль модлоадера, и только потом берём что есть. const Candidate *chosen = nullptr; QString source; auto pick = [&](const QList &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; } }