#include "testenv.h" #include "fixtures.h" #include "gamelauncher.h" #include "javalocator.h" #include "localization.h" #include "minecraftversion.h" #include #include 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"}, {QStringLiteral("skip/") + kNativeName + ".sha1", "hash"}, // Служебные файлы LWJGL рядом с .so: не должны приниматься // за версионированную библиотеку (libfoo.so.1) ни на одной ОС. {QStringLiteral("skip/liblwjgl.so.sha1"), "hash"}, {QStringLiteral("skip/liblwjgl.so.git"), "git"}}); 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("quickPlay"); QTest::addColumn("address"); QTest::addColumn("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, ""); 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 &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"