371 lines
16 KiB
C++
371 lines
16 KiB
C++
#include "gamelauncher.h"
|
||
|
||
#include "javalocator.h"
|
||
|
||
#include <QDir>
|
||
#include <QFile>
|
||
#include <QFileInfo>
|
||
#include <QHash>
|
||
#include <QProcess>
|
||
#include <QtCore/private/qzipreader_p.h>
|
||
|
||
namespace {
|
||
|
||
#if defined(Q_OS_WIN)
|
||
constexpr QChar kClasspathSeparator = u';';
|
||
#else
|
||
constexpr QChar kClasspathSeparator = u':';
|
||
#endif
|
||
|
||
QString libraryPath(const QString &gameDir, const MinecraftLibrary &library)
|
||
{
|
||
return gameDir + QStringLiteral("/libraries/") + library.path;
|
||
}
|
||
|
||
QString nativeToken(const QString &path)
|
||
{
|
||
return QDir::toNativeSeparators(path);
|
||
}
|
||
|
||
// Подставляет ${...}. Аргументы, в которых после подстановки остались
|
||
// неизвестные плейсхолдеры, вызывающий выбрасывает.
|
||
QString substitute(QString argument, const QHash<QString, QString> &variables)
|
||
{
|
||
for (auto it = variables.cbegin(); it != variables.cend(); ++it)
|
||
argument.replace(QStringLiteral("${") + it.key() + u'}', it.value());
|
||
return argument;
|
||
}
|
||
|
||
bool isNativeLibraryFile(const QString &fileName)
|
||
{
|
||
static const QStringList suffixes = {QStringLiteral(".dll"),
|
||
QStringLiteral(".so"),
|
||
QStringLiteral(".dylib"),
|
||
QStringLiteral(".jnilib")};
|
||
for (const QString &suffix : suffixes)
|
||
if (fileName.endsWith(suffix, Qt::CaseInsensitive))
|
||
return true;
|
||
return fileName.contains(QStringLiteral(".so.")); // libfoo.so.1
|
||
}
|
||
|
||
} // namespace
|
||
|
||
GameLauncher::GameLauncher(QObject *parent)
|
||
: QObject(parent)
|
||
{
|
||
}
|
||
|
||
bool GameLauncher::isRunning() const
|
||
{
|
||
return m_process && m_process->state() != QProcess::NotRunning;
|
||
}
|
||
|
||
QStringList GameLauncher::missingFiles(const LaunchOptions &options,
|
||
const MinecraftVersion &version,
|
||
int limit)
|
||
{
|
||
QStringList missing;
|
||
const auto add = [&missing, limit](const QString &path) {
|
||
if (missing.size() < limit)
|
||
missing << QDir::toNativeSeparators(path);
|
||
};
|
||
|
||
if (!QDir(options.gameDir).exists())
|
||
return {QDir::toNativeSeparators(options.gameDir)};
|
||
|
||
if (!QFile::exists(version.clientJarPath))
|
||
add(version.clientJarPath);
|
||
|
||
for (const MinecraftLibrary &library : version.libraries) {
|
||
const QString path = libraryPath(options.gameDir, library);
|
||
if (!QFile::exists(path))
|
||
add(path);
|
||
}
|
||
|
||
if (!version.assetIndexId.isEmpty()) {
|
||
const QString index = options.gameDir + QStringLiteral("/assets/indexes/")
|
||
+ version.assetIndexId + QStringLiteral(".json");
|
||
if (!QFile::exists(index))
|
||
add(index);
|
||
else if (!QDir(options.gameDir + QStringLiteral("/assets/objects")).exists())
|
||
add(options.gameDir + QStringLiteral("/assets/objects"));
|
||
}
|
||
|
||
return missing;
|
||
}
|
||
|
||
namespace {
|
||
|
||
// -DignoreList у Forge 1.17+ — список префиксов имён файлов, которые
|
||
// BootstrapLauncher не берёт в слой модулей. Сравнение идёт по началу имени.
|
||
bool ignoreListCovers(const QString &ignoreListArgument, const QString &fileName)
|
||
{
|
||
const QString value = ignoreListArgument.mid(QStringLiteral("-DignoreList=").size());
|
||
const QStringList prefixes = value.split(u',', Qt::SkipEmptyParts);
|
||
for (const QString &prefix : prefixes) {
|
||
if (!prefix.isEmpty() && fileName.startsWith(prefix))
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
QStringList GameLauncher::buildArguments(const LaunchOptions &options,
|
||
const MinecraftVersion &version,
|
||
const QString &nativesDir)
|
||
{
|
||
QStringList classpath;
|
||
for (const MinecraftLibrary &library : version.libraries) {
|
||
if (library.native)
|
||
continue; // натив-библиотеки только распаковываются
|
||
classpath << nativeToken(libraryPath(options.gameDir, library));
|
||
}
|
||
classpath << nativeToken(version.clientJarPath);
|
||
|
||
const QString assetsRoot = options.gameDir + QStringLiteral("/assets");
|
||
const bool legacyAssets = version.assetsKind == QLatin1String("legacy")
|
||
|| version.assetsKind == QLatin1String("pre-1.6");
|
||
|
||
QHash<QString, QString> variables;
|
||
variables[QStringLiteral("auth_player_name")] = options.playerName;
|
||
variables[QStringLiteral("auth_uuid")] = options.uuid;
|
||
variables[QStringLiteral("auth_access_token")] = options.accessToken;
|
||
variables[QStringLiteral("auth_session")] =
|
||
QStringLiteral("token:%1:%2").arg(options.accessToken, options.uuid);
|
||
variables[QStringLiteral("auth_xuid")] = QStringLiteral("0");
|
||
variables[QStringLiteral("clientid")] =
|
||
options.clientToken.isEmpty() ? QStringLiteral("0") : options.clientToken;
|
||
variables[QStringLiteral("user_type")] = options.userType;
|
||
variables[QStringLiteral("user_properties")] = QStringLiteral("{}");
|
||
variables[QStringLiteral("version_name")] = version.id;
|
||
variables[QStringLiteral("version_type")] = version.type;
|
||
variables[QStringLiteral("game_directory")] = nativeToken(options.gameDir);
|
||
variables[QStringLiteral("assets_root")] = nativeToken(assetsRoot);
|
||
variables[QStringLiteral("assets_index_name")] = version.assetIndexId;
|
||
variables[QStringLiteral("game_assets")] =
|
||
nativeToken(legacyAssets ? assetsRoot + QStringLiteral("/virtual/legacy") : assetsRoot);
|
||
variables[QStringLiteral("natives_directory")] = nativeToken(nativesDir);
|
||
variables[QStringLiteral("library_directory")] =
|
||
nativeToken(options.gameDir + QStringLiteral("/libraries"));
|
||
variables[QStringLiteral("classpath")] = classpath.join(kClasspathSeparator);
|
||
variables[QStringLiteral("classpath_separator")] = QString(kClasspathSeparator);
|
||
variables[QStringLiteral("primary_jar")] = nativeToken(version.clientJarPath);
|
||
variables[QStringLiteral("launcher_name")] = options.launcherName;
|
||
variables[QStringLiteral("launcher_version")] = options.launcherVersion;
|
||
variables[QStringLiteral("resolution_width")] = QString::number(options.windowWidth);
|
||
variables[QStringLiteral("resolution_height")] = QString::number(options.windowHeight);
|
||
|
||
QStringList arguments;
|
||
|
||
// Свои jvm-аргументы идут первыми — как в строке запуска из примера.
|
||
if (!options.authlibInjectorPath.isEmpty()) {
|
||
arguments << QStringLiteral("-javaagent:%1=%2")
|
||
.arg(nativeToken(options.authlibInjectorPath), options.authlibInjectorApi)
|
||
<< QStringLiteral("-Dauthlibinjector.side=client");
|
||
}
|
||
if (options.minMemoryMb > 0)
|
||
arguments << QStringLiteral("-Xms%1M").arg(options.minMemoryMb);
|
||
if (options.maxMemoryMb > 0)
|
||
arguments << QStringLiteral("-Xmx%1M").arg(options.maxMemoryMb);
|
||
arguments += options.extraJvmArgs;
|
||
|
||
// Forge 1.17+ собирает слой модулей из classpath, пропуская всё, чьё имя
|
||
// начинается с записи в -DignoreList. Ванильный клиентский jar записан там
|
||
// как ${version_name}.jar — с расчётом на лаунчер Mojang, который кладёт в
|
||
// classpath jar самого профиля. Своего jar у профиля Forge нет, мы кладём
|
||
// родительский, и без его имени в списке он становится вторым модулем с
|
||
// классами Minecraft: «Modules minecraft and _1._20._1 export package ...».
|
||
const QString clientJarName = QFileInfo(version.clientJarPath).fileName();
|
||
|
||
for (const QString &argument : version.jvmArgs) {
|
||
QString resolved = substitute(argument, variables);
|
||
if (resolved.contains(QStringLiteral("${")))
|
||
continue;
|
||
if (!clientJarName.isEmpty() && resolved.startsWith(QLatin1String("-DignoreList="))
|
||
&& !ignoreListCovers(resolved, clientJarName))
|
||
resolved += u',' + clientJarName;
|
||
arguments << resolved;
|
||
}
|
||
|
||
if (!version.loggingArgument.isEmpty() && QFile::exists(version.loggingConfigPath)) {
|
||
QHash<QString, QString> loggingVariable;
|
||
loggingVariable[QStringLiteral("path")] = nativeToken(version.loggingConfigPath);
|
||
arguments << substitute(version.loggingArgument, loggingVariable);
|
||
}
|
||
|
||
arguments << version.mainClass;
|
||
|
||
for (const QString &argument : version.gameArgs) {
|
||
const QString resolved = substitute(argument, variables);
|
||
if (!resolved.contains(QStringLiteral("${")))
|
||
arguments << resolved;
|
||
}
|
||
|
||
// Разрешение окна: у старых версий фич-флага нет, аргументы всё равно понимаются.
|
||
if (options.windowWidth > 0 && options.windowHeight > 0
|
||
&& !version.hasCustomResolutionArgs && !arguments.contains(QStringLiteral("--width"))) {
|
||
arguments << QStringLiteral("--width") << QString::number(options.windowWidth)
|
||
<< QStringLiteral("--height") << QString::number(options.windowHeight);
|
||
}
|
||
if (options.fullscreen && !arguments.contains(QStringLiteral("--fullscreen")))
|
||
arguments << QStringLiteral("--fullscreen");
|
||
|
||
if (!options.serverAddress.isEmpty()) {
|
||
if (version.supportsQuickPlay) {
|
||
arguments << QStringLiteral("--quickPlayMultiplayer") << options.serverAddress;
|
||
} else {
|
||
const int colon = options.serverAddress.lastIndexOf(u':');
|
||
const QString host = colon > 0 ? options.serverAddress.left(colon) : options.serverAddress;
|
||
const QString port = colon > 0 ? options.serverAddress.mid(colon + 1)
|
||
: QStringLiteral("25565");
|
||
arguments << QStringLiteral("--server") << host << QStringLiteral("--port") << port;
|
||
}
|
||
}
|
||
|
||
return arguments;
|
||
}
|
||
|
||
bool GameLauncher::extractNatives(const LaunchOptions &options,
|
||
const MinecraftVersion &version,
|
||
const QString &nativesDir,
|
||
QString *error)
|
||
{
|
||
if (!QDir().mkpath(nativesDir)) {
|
||
if (error)
|
||
*error = tr("Не удалось создать папку %1").arg(QDir::toNativeSeparators(nativesDir));
|
||
return false;
|
||
}
|
||
|
||
for (const MinecraftLibrary &library : version.libraries) {
|
||
if (!library.native)
|
||
continue;
|
||
|
||
const QString jar = libraryPath(options.gameDir, library);
|
||
QZipReader reader(jar);
|
||
if (!reader.isReadable()) {
|
||
if (error)
|
||
*error = tr("Не удалось прочитать натив-библиотеку %1")
|
||
.arg(QDir::toNativeSeparators(jar));
|
||
return false;
|
||
}
|
||
|
||
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
||
for (const QZipReader::FileInfo &entry : entries) {
|
||
if (!entry.isFile)
|
||
continue;
|
||
if (entry.filePath.startsWith(QStringLiteral("META-INF/")))
|
||
continue;
|
||
|
||
bool excluded = false;
|
||
for (const QString &prefix : library.extractExclude) {
|
||
if (entry.filePath.startsWith(prefix)) {
|
||
excluded = true;
|
||
break;
|
||
}
|
||
}
|
||
if (excluded)
|
||
continue;
|
||
|
||
// java.library.path ищет библиотеки только в корне папки, поэтому
|
||
// складываем их плоско, без структуры каталогов внутри jar.
|
||
const QString fileName = QFileInfo(entry.filePath).fileName();
|
||
if (!isNativeLibraryFile(fileName))
|
||
continue;
|
||
|
||
const QString target = nativesDir + u'/' + fileName;
|
||
const QByteArray data = reader.fileData(entry.filePath);
|
||
if (QFileInfo(target).size() == data.size() && QFile::exists(target))
|
||
continue; // уже распаковано этим же лаунчером
|
||
|
||
QFile file(target);
|
||
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
|
||
if (error)
|
||
*error = tr("Не удалось распаковать %1 в %2")
|
||
.arg(fileName, QDir::toNativeSeparators(nativesDir));
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
bool GameLauncher::launch(const LaunchOptions &options,
|
||
const MinecraftVersion &version,
|
||
QString *error)
|
||
{
|
||
if (isRunning()) {
|
||
if (error)
|
||
*error = tr("Игра уже запущена");
|
||
return false;
|
||
}
|
||
|
||
emit progress(tr("Проверка файлов игры…"));
|
||
const QStringList missing = missingFiles(options, version);
|
||
if (!missing.isEmpty()) {
|
||
if (error)
|
||
*error = tr("В .minecraft не хватает файлов (%1):\n%2")
|
||
.arg(missing.size())
|
||
.arg(missing.join(u'\n'));
|
||
return false;
|
||
}
|
||
|
||
emit progress(tr("Распаковка нативных библиотек…"));
|
||
const QString nativesDir = options.gameDir + QStringLiteral("/versions/") + version.id
|
||
+ QStringLiteral("/natives");
|
||
if (!extractNatives(options, version, nativesDir, error))
|
||
return false;
|
||
|
||
emit progress(tr("Поиск Java %1…").arg(version.javaMajor));
|
||
const QString java = JavaLocator::select(options.gameDir, version.javaMajor, options.javaPath, error);
|
||
if (java.isEmpty())
|
||
return false;
|
||
|
||
const QStringList arguments = buildArguments(options, version, nativesDir);
|
||
const QString executable = JavaLocator::windowlessVariant(java);
|
||
|
||
m_process = new QProcess(this);
|
||
m_process->setProgram(executable);
|
||
m_process->setArguments(arguments);
|
||
m_process->setWorkingDirectory(options.gameDir);
|
||
m_process->setProcessChannelMode(QProcess::MergedChannels);
|
||
|
||
connect(m_process, &QProcess::readyReadStandardOutput, this, [this]() {
|
||
const QString chunk = QString::fromLocal8Bit(m_process->readAllStandardOutput());
|
||
const QStringList lines = chunk.split(u'\n', Qt::SkipEmptyParts);
|
||
for (const QString &line : lines)
|
||
emit output(line.trimmed());
|
||
});
|
||
connect(m_process, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) {
|
||
emit gameFinished(exitCode, status == QProcess::CrashExit);
|
||
m_process->deleteLater();
|
||
m_process = nullptr;
|
||
});
|
||
// Токен в лог не попадает — маскируем его в отображаемой строке запуска.
|
||
QStringList shown = arguments;
|
||
const int tokenIndex = shown.indexOf(QStringLiteral("--accessToken"));
|
||
if (tokenIndex >= 0 && tokenIndex + 1 < shown.size())
|
||
shown[tokenIndex + 1] = QStringLiteral("<скрыт>");
|
||
|
||
m_process->start();
|
||
if (!m_process->waitForStarted(15000)) {
|
||
if (error)
|
||
*error = tr("Не удалось запустить %1: %2")
|
||
.arg(QDir::toNativeSeparators(executable), m_process->errorString());
|
||
m_process->deleteLater();
|
||
m_process = nullptr;
|
||
return false;
|
||
}
|
||
|
||
emit gameStarted(QDir::toNativeSeparators(executable) + QStringLiteral(" ")
|
||
+ shown.join(QStringLiteral(" ")));
|
||
return true;
|
||
}
|
||
|
||
void GameLauncher::terminate()
|
||
{
|
||
if (isRunning())
|
||
m_process->kill();
|
||
}
|