Files
BiLauncher/tests/unit/tst_gamelauncher.cpp
T

454 lines
21 KiB
C++
Raw Normal View History

2026-10-12 13:14:08 +03:00
#include "testenv.h"
#include "fixtures.h"
#include "gamelauncher.h"
#include "javalocator.h"
#include "localization.h"
#include "minecraftversion.h"
#include <QDir>
#include <QSignalSpy>
namespace {
#if defined(Q_OS_WIN)
const QChar kSep = u';';
const char *kNativeName = "lwjgl.dll";
#elif defined(Q_OS_MACOS)
const QChar kSep = u':';
const char *kNativeName = "liblwjgl.dylib";
#else
const QChar kSep = u':';
const char *kNativeName = "liblwjgl.so";
#endif
QString nat(const QString &path)
{
return QDir::toNativeSeparators(path);
}
MinecraftLibrary lib(const QString &name, const QString &path, bool native = false)
{
MinecraftLibrary library;
library.name = name;
library.path = path;
library.native = native;
return library;
}
} // namespace
class TestGameLauncher : public QObject
{
Q_OBJECT
QString m_game;
MinecraftVersion modernVersion() const
{
MinecraftVersion v;
v.id = QStringLiteral("1.20.1");
v.mainClass = QStringLiteral("net.minecraft.client.main.Main");
v.type = QStringLiteral("release");
v.assetIndexId = QStringLiteral("5");
v.assetsKind = QStringLiteral("5");
v.clientJarPath = m_game + QStringLiteral("/versions/1.20.1/1.20.1.jar");
v.javaMajor = 17;
v.jvmArgs = QStringList{"-Djava.library.path=${natives_directory}",
"-Dminecraft.launcher.brand=${launcher_name}",
"-Dminecraft.launcher.version=${launcher_version}",
"-Dunknown=${not_a_variable}",
"-cp", "${classpath}"};
v.gameArgs = QStringList{"--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}", "--clientId", "${clientid}",
"--xuid", "${auth_xuid}", "--userType", "${user_type}",
"--versionType", "${version_type}", "--mystery", "${unknown_thing}"};
v.libraries << lib("com.mojang:brigadier:1.1.8", "com/mojang/brigadier-1.1.8.jar")
<< lib("org.lwjgl:lwjgl:3.3.1", "org/lwjgl/lwjgl-3.3.1.jar")
<< lib("org.lwjgl:lwjgl:3.3.1:natives", "org/lwjgl/lwjgl-3.3.1-natives.jar", true);
return v;
}
LaunchOptions options() const
{
LaunchOptions o;
o.gameDir = m_game;
o.versionId = QStringLiteral("1.20.1");
o.playerName = QStringLiteral("Steve");
o.uuid = QStringLiteral("0123456789abcdef0123456789abcdef");
o.accessToken = QStringLiteral("secret-token");
o.userType = QStringLiteral("msa");
o.minMemoryMb = 1024;
o.maxMemoryMb = 6144;
return o;
}
static QString valueAfter(const QStringList &args, const QString &flag)
{
const qsizetype i = args.indexOf(flag);
return (i >= 0 && i + 1 < args.size()) ? args.at(i + 1) : QString();
}
void createGameFiles(const MinecraftVersion &v)
{
Fixtures::write(v.clientJarPath, "client");
Fixtures::write(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar", "lib");
Fixtures::write(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1.jar", "lib");
Fixtures::makeZip(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1-natives.jar",
{{QStringLiteral("META-INF/MANIFEST.MF"), "x"},
{QStringLiteral("linux/x64/org/lwjgl/") + kNativeName, "native-bytes"},
2026-10-13 08:51:07 +03:00
{QStringLiteral("skip/") + kNativeName + ".sha1", "hash"},
// Служебные файлы LWJGL рядом с .so: не должны приниматься
// за версионированную библиотеку (libfoo.so.1) ни на одной ОС.
{QStringLiteral("skip/liblwjgl.so.sha1"), "hash"},
{QStringLiteral("skip/liblwjgl.so.git"), "git"}});
2026-10-12 13:14:08 +03:00
Fixtures::write(m_game + "/assets/indexes/5.json", "{}");
QDir().mkpath(m_game + "/assets/objects");
}
private slots:
void init()
{
TestEnv::resetLauncherRoot();
m_game = TestEnv::freshDir(QStringLiteral("game"));
}
// --- buildArguments ---------------------------------------------------------
void substitutesVariables()
{
const MinecraftVersion v = modernVersion();
const QString natives = m_game + "/versions/1.20.1/natives";
const QStringList args = GameLauncher::buildArguments(options(), v, natives);
QCOMPARE(args.at(0), QStringLiteral("-Xms1024M"));
QCOMPARE(args.at(1), QStringLiteral("-Xmx6144M"));
QVERIFY(args.contains("-Djava.library.path=" + nat(natives)));
QVERIFY(args.contains("-Dminecraft.launcher.brand=BiLauncher"));
QVERIFY(args.contains(QStringLiteral("-Dminecraft.launcher.version=") + QStringLiteral(LAUNCHER_VERSION)));
// Неизвестный плейсхолдер — аргумент выбрасывается целиком.
QVERIFY(!args.join(' ').contains("not_a_variable"));
QVERIFY(!args.contains("${unknown_thing}"));
QVERIFY(args.contains("--mystery")); // сам флаг остаётся, как у официального лаунчера
// classpath: обычные библиотеки, без нативов, клиентский jar последним.
const QStringList cp = valueAfter(args, "-cp").split(kSep);
QCOMPARE(cp, QStringList({nat(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar"),
nat(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1.jar"),
nat(v.clientJarPath)}));
// mainClass — после jvm-аргументов, перед игровыми.
const qsizetype main = args.indexOf(v.mainClass);
QVERIFY(main > args.indexOf("-cp"));
QVERIFY(main < args.indexOf("--username"));
QCOMPARE(valueAfter(args, "--username"), QStringLiteral("Steve"));
QCOMPARE(valueAfter(args, "--version"), QStringLiteral("1.20.1"));
QCOMPARE(valueAfter(args, "--gameDir"), nat(m_game));
QCOMPARE(valueAfter(args, "--assetsDir"), nat(m_game + "/assets"));
QCOMPARE(valueAfter(args, "--assetIndex"), QStringLiteral("5"));
QCOMPARE(valueAfter(args, "--uuid"), QStringLiteral("0123456789abcdef0123456789abcdef"));
QCOMPARE(valueAfter(args, "--accessToken"), QStringLiteral("secret-token"));
QCOMPARE(valueAfter(args, "--clientId"), QStringLiteral("0")); // пустой → «0»
QCOMPARE(valueAfter(args, "--xuid"), QStringLiteral("0"));
QCOMPARE(valueAfter(args, "--userType"), QStringLiteral("msa"));
QCOMPARE(valueAfter(args, "--versionType"), QStringLiteral("release"));
QVERIFY(!args.contains("--width"));
QVERIFY(!args.contains("--fullscreen"));
QVERIFY(!args.contains("--server"));
}
void legacyAssetsAndSession()
{
MinecraftVersion v = modernVersion();
v.assetsKind = QStringLiteral("legacy");
v.gameArgs = QStringList{"--session", "${auth_session}", "--assetsDir", "${game_assets}"};
const QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n");
QCOMPARE(valueAfter(args, "--session"),
QStringLiteral("token:secret-token:0123456789abcdef0123456789abcdef"));
QCOMPARE(valueAfter(args, "--assetsDir"), nat(m_game + "/assets/virtual/legacy"));
v.assetsKind = QStringLiteral("pre-1.6");
QCOMPARE(valueAfter(GameLauncher::buildArguments(options(), v, m_game + "/n"), "--assetsDir"),
nat(m_game + "/assets/virtual/legacy"));
v.assetsKind = QStringLiteral("5");
QCOMPARE(valueAfter(GameLauncher::buildArguments(options(), v, m_game + "/n"), "--assetsDir"),
nat(m_game + "/assets"));
}
void memoryAndExtraJvmArgs()
{
LaunchOptions o = options();
o.minMemoryMb = 0;
o.maxMemoryMb = 0;
o.extraJvmArgs = QStringList{"-XX:+UseG1GC", "-Dfoo=bar"};
const QStringList args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n");
QVERIFY(!args.join(' ').contains("-Xms"));
QVERIFY(!args.join(' ').contains("-Xmx"));
QCOMPARE(args.at(0), QStringLiteral("-XX:+UseG1GC"));
QCOMPARE(args.at(1), QStringLiteral("-Dfoo=bar"));
}
void authlibInjectorGoesFirst()
{
LaunchOptions o = options();
o.authlibInjectorPath = m_game + "/authlib-injector.jar";
const QStringList args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n");
QCOMPARE(args.at(0), "-javaagent:" + nat(o.authlibInjectorPath) + "=ely.by");
QCOMPARE(args.at(1), QStringLiteral("-Dauthlibinjector.side=client"));
QCOMPARE(args.at(2), QStringLiteral("-Xms1024M"));
}
void windowSizeAndFullscreen()
{
LaunchOptions o = options();
o.windowWidth = 1280;
o.windowHeight = 720;
o.fullscreen = true;
MinecraftVersion v = modernVersion();
QStringList args = GameLauncher::buildArguments(o, v, m_game + "/n");
QCOMPARE(valueAfter(args, "--width"), QStringLiteral("1280"));
QCOMPARE(valueAfter(args, "--height"), QStringLiteral("720"));
QCOMPARE(args.count("--fullscreen"), 1);
// У версии свои аргументы разрешения — лаунчер свои не дублирует.
v.hasCustomResolutionArgs = true;
v.gameArgs << "--width" << "${resolution_width}" << "--height" << "${resolution_height}";
args = GameLauncher::buildArguments(o, v, m_game + "/n");
QCOMPARE(args.count("--width"), 1);
QCOMPARE(valueAfter(args, "--width"), QStringLiteral("1280"));
// Только ширина — не передаём ничего.
o.windowHeight = 0;
o.fullscreen = false;
args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n");
QVERIFY(!args.contains("--width"));
QVERIFY(!args.contains("--fullscreen"));
}
void serverAutoJoin_data()
{
QTest::addColumn<bool>("quickPlay");
QTest::addColumn<QString>("address");
QTest::addColumn<QStringList>("expectedTail");
QTest::newRow("quickplay") << true << "play.example.com:25570"
<< QStringList{"--quickPlayMultiplayer", "play.example.com:25570"};
QTest::newRow("legacy with port") << false << "play.example.com:25570"
<< QStringList{"--server", "play.example.com", "--port", "25570"};
QTest::newRow("legacy default port") << false << "play.example.com"
<< QStringList{"--server", "play.example.com", "--port", "25565"};
}
void serverAutoJoin()
{
QFETCH(bool, quickPlay);
QFETCH(QString, address);
QFETCH(QStringList, expectedTail);
LaunchOptions o = options();
o.serverAddress = address;
MinecraftVersion v = modernVersion();
v.supportsQuickPlay = quickPlay;
const QStringList args = GameLauncher::buildArguments(o, v, m_game + "/n");
QCOMPARE(args.mid(args.size() - expectedTail.size()), expectedTail);
}
void forgeIgnoreListGetsClientJar()
{
MinecraftVersion v = modernVersion();
v.jvmArgs.prepend("-DignoreList=bootstraplauncher,securejarhandler,${version_name}.jar");
QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n");
// ${version_name} = 1.20.1 совпадает с именем jar — дописывать нечего.
QCOMPARE(args.filter("-DignoreList=").constFirst(),
QStringLiteral("-DignoreList=bootstraplauncher,securejarhandler,1.20.1.jar"));
// Профиль Forge со своим id, а jar родительский — имя jar дописывается.
v.id = QStringLiteral("1.20.1-forge-47.4.0");
args = GameLauncher::buildArguments(options(), v, m_game + "/n");
QCOMPARE(args.filter("-DignoreList=").constFirst(),
QStringLiteral("-DignoreList=bootstraplauncher,securejarhandler,1.20.1-forge-47.4.0.jar,1.20.1.jar"));
}
void loggingArgumentOnlyWhenConfigExists()
{
MinecraftVersion v = modernVersion();
v.loggingArgument = QStringLiteral("-Dlog4j.configurationFile=${path}");
v.loggingConfigPath = m_game + "/assets/log_configs/client-1.12.xml";
QVERIFY(GameLauncher::buildArguments(options(), v, m_game + "/n").filter("log4j").isEmpty());
Fixtures::write(v.loggingConfigPath, "<xml/>");
const QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n");
QCOMPARE(args.filter("log4j"), QStringList{"-Dlog4j.configurationFile=" + nat(v.loggingConfigPath)});
QVERIFY(args.indexOf(args.filter("log4j").constFirst()) < args.indexOf(v.mainClass));
}
// --- missingFiles / extractNatives -------------------------------------------
void missingFiles()
{
const MinecraftVersion v = modernVersion();
LaunchOptions o = options();
o.gameDir = m_game + "/does-not-exist";
QCOMPARE(GameLauncher::missingFiles(o, v), QStringList{nat(o.gameDir)});
o = options();
QStringList missing = GameLauncher::missingFiles(o, v);
QVERIFY(missing.contains(nat(v.clientJarPath)));
QVERIFY(missing.contains(nat(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar")));
QVERIFY(missing.contains(nat(m_game + "/assets/indexes/5.json")));
QCOMPARE(GameLauncher::missingFiles(o, v, 2).size(), 2);
createGameFiles(v);
QVERIFY(GameLauncher::missingFiles(o, v).isEmpty());
QDir(m_game + "/assets/objects").removeRecursively();
QCOMPARE(GameLauncher::missingFiles(o, v), QStringList{nat(m_game + "/assets/objects")});
}
void extractNatives()
{
MinecraftVersion v = modernVersion();
createGameFiles(v);
const QString natives = m_game + "/versions/1.20.1/natives";
QString error;
QVERIFY2(GameLauncher::extractNatives(options(), v, natives, &error), qPrintable(error));
// Только сама библиотека: без META-INF и без .sha1, в плоскую папку.
QCOMPARE(Fixtures::listTree(natives), QStringList{QString::fromLatin1(kNativeName)});
QCOMPARE(Fixtures::readFile(natives + u'/' + kNativeName), QByteArray("native-bytes"));
// Повторный запуск поверх готовой папки — успех.
QVERIFY(GameLauncher::extractNatives(options(), v, natives, &error));
// extractExclude по префиксу пути внутри архива.
QDir(natives).removeRecursively();
v.libraries[2].extractExclude = QStringList{"linux/"};
QVERIFY(GameLauncher::extractNatives(options(), v, natives, &error));
QVERIFY(Fixtures::listTree(natives).isEmpty());
// Архива нет вовсе.
const QString jar = m_game + "/libraries/org/lwjgl/lwjgl-3.3.1-natives.jar";
QFile::remove(jar);
error.clear();
QVERIFY(!GameLauncher::extractNatives(options(), v, natives, &error));
QCOMPARE(error, Loc::text("launch.error.nativeReadFailed").arg(nat(jar)));
// Битый архив: QZipReader::isReadable() проверяет только, что файл открылся,
// поэтому испорченный jar «распаковывается» в ничто без ошибки, и игра
// падает уже на загрузке LWJGL. Фиксируем текущее поведение.
Fixtures::write(jar, "not a zip");
error.clear();
QEXPECT_FAIL("", "битый jar с нативами не распознаётся как ошибка (QZipReader::isReadable)", Continue);
QVERIFY(!GameLauncher::extractNatives(options(), v, natives, &error));
}
// --- launch (с подставной java) ----------------------------------------------
void launchRunsJavaAndReportsExit()
{
MinecraftVersion v = modernVersion();
createGameFiles(v);
const QString argsFile = m_game + "/java-args.txt";
const QString java = Fixtures::installFakeJava(m_game + "/jdk", "17.0.8",
{"args_file=" + argsFile, "stdout=[main] Setting user: Steve",
"stdout=Game crashed? no", "exit=0"});
LaunchOptions o = options();
o.javaPath = java;
GameLauncher launcher;
QSignalSpy started(&launcher, &GameLauncher::gameStarted);
QSignalSpy finished(&launcher, &GameLauncher::gameFinished);
QSignalSpy output(&launcher, &GameLauncher::output);
QSignalSpy progress(&launcher, &GameLauncher::progress);
QString error;
QVERIFY2(launcher.launch(o, v, &error), qPrintable(error));
QCOMPARE(started.count(), 1);
// Токен скрыт в показанной строке запуска.
const QString shown = started.constFirst().constFirst().toString();
QVERIFY(!shown.contains("secret-token"));
QVERIFY(shown.contains("--accessToken"));
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 15000);
QCOMPARE(finished.constFirst().at(0).toInt(), 0);
QCOMPARE(finished.constFirst().at(1).toBool(), false);
QVERIFY(!launcher.isRunning());
QVERIFY(progress.count() >= 3);
QStringList lines;
for (const QList<QVariant> &call : output)
lines << call.constFirst().toString();
QVERIFY(lines.contains("[main] Setting user: Steve"));
// Java получила те же аргументы, что строит buildArguments, и в рабочей папке игры.
const QStringList received = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split(u'\n', Qt::SkipEmptyParts);
QCOMPARE(received, GameLauncher::buildArguments(o, v, m_game + "/versions/1.20.1/natives"));
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/natives/" + kNativeName));
}
void launchReportsCrash()
{
MinecraftVersion v = modernVersion();
createGameFiles(v);
LaunchOptions o = options();
o.javaPath = Fixtures::installFakeJava(m_game + "/jdk-crash", "17.0.8", {"crash=1"});
GameLauncher launcher;
QSignalSpy finished(&launcher, &GameLauncher::gameFinished);
QString error;
QVERIFY2(launcher.launch(o, v, &error), qPrintable(error));
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 15000);
QCOMPARE(finished.constFirst().at(1).toBool(), true);
}
void launchRefusesWhileRunningAndTerminate()
{
MinecraftVersion v = modernVersion();
createGameFiles(v);
LaunchOptions o = options();
o.javaPath = Fixtures::installFakeJava(m_game + "/jdk-slow", "17.0.8", {"sleep_ms=30000"});
GameLauncher launcher;
QSignalSpy finished(&launcher, &GameLauncher::gameFinished);
QString error;
QVERIFY(launcher.launch(o, v, &error));
QVERIFY(launcher.isRunning());
QVERIFY(!launcher.launch(o, v, &error));
QCOMPARE(error, Loc::text("common.error.gameRunning"));
launcher.terminate();
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 15000);
QVERIFY(!launcher.isRunning());
}
void launchFailsOnMissingFilesAndOldJava()
{
MinecraftVersion v = modernVersion();
LaunchOptions o = options();
GameLauncher launcher;
QSignalSpy started(&launcher, &GameLauncher::gameStarted);
QString error;
QVERIFY(!launcher.launch(o, v, &error));
QVERIFY(error.startsWith(Loc::text("launch.error.filesMissing").section(u'%', 0, 0)));
createGameFiles(v);
// На машине может стоять своя Java 17+: тогда лаунчер законно выберет её.
for (const QString &candidate : JavaLocator::findAll(m_game)) {
if (JavaLocator::majorVersion(candidate) >= 17)
QSKIP("в системе есть Java 17+, проверка «слишком старой Java» невозможна");
}
o.javaPath = Fixtures::installFakeJava(m_game + "/jdk8", "1.8.0_392");
error.clear();
QVERIFY(!launcher.launch(o, v, &error));
QCOMPARE(error, Loc::text("java.error.tooOld").arg(17).arg(8).arg(nat(o.javaPath)));
QCOMPARE(started.count(), 0);
}
};
BILAUNCHER_TEST_MAIN(TestGameLauncher)
#include "tst_gamelauncher.moc"