diff --git a/include/config.h b/include/config.h index 26d2a353..3f441229 100644 --- a/include/config.h +++ b/include/config.h @@ -345,6 +345,7 @@ public: this->unusedTileCovered = 0x0000; this->unusedTileSplit = 0x0000; this->maxEventsPerGroup = 255; + this->forcedMajorVersion = 0; this->globalConstantsFilepaths.clear(); this->globalConstants.clear(); this->identifiers.clear(); @@ -419,6 +420,7 @@ public: QMargins playerViewDistance; QList warpBehaviors; int maxEventsPerGroup; + int forcedMajorVersion; QStringList globalConstantsFilepaths; QMap globalConstants; diff --git a/include/mainwindow.h b/include/mainwindow.h index c65cac79..5de2a50a 100644 --- a/include/mainwindow.h +++ b/include/mainwindow.h @@ -351,7 +351,9 @@ private: void refreshCollisionSelector(); void setLayoutOnlyMode(bool layoutOnly); - bool checkProjectSanity(); + bool isInvalidProject(Project *project); + bool checkProjectSanity(Project *project); + bool checkProjectVersion(Project *project); bool loadProjectData(); bool setProjectUI(); void clearProjectUI(); diff --git a/include/project.h b/include/project.h index 3c11009f..6f981a85 100644 --- a/include/project.h +++ b/include/project.h @@ -85,6 +85,7 @@ public: void clearHealLocations(); bool sanityCheck(); + int getSupportedMajorVersion(QString *errorOut = nullptr); bool load(); Map* loadMap(const QString &mapName); diff --git a/src/config.cpp b/src/config.cpp index 0e22840e..45144a2d 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -890,6 +890,8 @@ void ProjectConfig::parseConfigKeyValue(QString key, QString value) { this->warpBehaviors.append(getConfigUint32(key, s)); } else if (key == "max_events_per_group") { this->maxEventsPerGroup = getConfigInteger(key, value, 1, INT_MAX, 255); + } else if (key == "forced_major_version") { + this->forcedMajorVersion = getConfigInteger(key, value); } else { logWarn(QString("Invalid config key found in config file %1: '%2'").arg(this->getConfigFilepath()).arg(key)); } @@ -997,6 +999,7 @@ QMap ProjectConfig::getKeyValueMap() { warpBehaviorStrs.append("0x" + QString("%1").arg(value, 2, 16, QChar('0')).toUpper()); map.insert("warp_behaviors", warpBehaviorStrs.join(",")); map.insert("max_events_per_group", QString::number(this->maxEventsPerGroup)); + map.insert("forced_major_version", QString::number(this->forcedMajorVersion)); return map; } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index e422cfc8..322d6e83 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -684,7 +684,7 @@ bool MainWindow::openProject(QString dir, bool initial) { // Make sure project looks reasonable before attempting to load it porysplash->showMessage("Verifying project"); - if (!checkProjectSanity()) { + if (isInvalidProject(this->editor->project)) { delete this->editor->project; porysplash->stop(); return false; @@ -728,16 +728,20 @@ bool MainWindow::loadProjectData() { return success; } -bool MainWindow::checkProjectSanity() { - if (editor->project->sanityCheck()) +bool MainWindow::isInvalidProject(Project *project) { + return !(checkProjectSanity(project) && checkProjectVersion(project)); +} + +bool MainWindow::checkProjectSanity(Project *project) { + if (project->sanityCheck()) return true; - logWarn(QString("The directory '%1' failed the project sanity check.").arg(editor->project->root)); + logWarn(QString("The directory '%1' failed the project sanity check.").arg(project->root)); ErrorMessage msgBox(QStringLiteral("The selected directory appears to be invalid."), porysplash); msgBox.setInformativeText(QString("The directory '%1' is missing key files.\n\n" "Make sure you selected the correct project directory " - "(the one used to make your .gba file, e.g. 'pokeemerald').").arg(editor->project->root)); + "(the one used to make your .gba file, e.g. 'pokeemerald').").arg(project->root)); auto tryAnyway = msgBox.addButton("Try Anyway", QMessageBox::ActionRole); msgBox.exec(); if (msgBox.clickedButton() == tryAnyway) { @@ -748,6 +752,43 @@ bool MainWindow::checkProjectSanity() { return false; } +bool MainWindow::checkProjectVersion(Project *project) { + QString error; + int projectVersion = project->getSupportedMajorVersion(&error); + if (projectVersion < 0) { + // Failed to identify a supported major version. + // We can't draw any conclusions from this, so we don't consider the project to be invalid. + QString msg = QStringLiteral("Failed to check project version"); + logWarn(error.isEmpty() ? msg : QString("%1: '%2'").arg(msg).arg(error)); + } else { + QString msg = QStringLiteral("Successfully checked project version. "); + logInfo(msg + ((projectVersion != 0) ? QString("Supports at least Porymap v%1").arg(projectVersion) + : QStringLiteral("Too old for any Porymap version"))); + + if (projectVersion < porymapVersion.majorVersion() && projectConfig.forcedMajorVersion < porymapVersion.majorVersion()) { + // We were unable to find the necessary changes for Porymap's current major version. + // Unless they have explicitly suppressed this message, warn the user that this might mean their project is missing breaking changes. + // Note: Do not report 'projectVersion' to the user in this message. We've already logged it for troubleshooting. + // It is very plausible that the user may have reproduced the required changes in an + // unknown commit, rather than merging the required changes directly from the base repo. + // In this case the 'projectVersion' may actually be too old to use for their repo. + ErrorMessage msgBox(QStringLiteral("Your project may be incompatible!"), porysplash); + msgBox.setInformativeText(QString("Make sure '%1' has all the required changes for Porymap version %2." + "") // TODO: Once we have a wiki or manual page describing breaking changes, link that here. + .arg(project->getProjectTitle()) + .arg(porymapVersion.majorVersion())); + auto tryAnyway = msgBox.addButton("Try Anyway", QMessageBox::ActionRole); + msgBox.exec(); + if (msgBox.clickedButton() != tryAnyway){ + return false; + } + // User opted to try with this version anyway. Don't warn them about this version again. + projectConfig.forcedMajorVersion = porymapVersion.majorVersion(); + } + } + return true; +} + void MainWindow::showProjectOpenFailure() { if (!this->isVisible()){ // The main window is not visible during the initial project open; the splash screen is busy providing visual feedback. diff --git a/src/project.cpp b/src/project.cpp index bf43143e..f378cfcb 100644 --- a/src/project.cpp +++ b/src/project.cpp @@ -75,6 +75,117 @@ bool Project::sanityCheck() { return false; } +// Porymap projects have no standardized way for Porymap to determine whether they're compatible as of the latest breaking changes. +// We can use the project's git history (if it has one, and we're able to get it) to make a reasonable guess. +// We know the hashes of the commits in the base repos that contain breaking changes, so if we find one of these then the project +// should support at least up to that Porymap major version. If this fails for any reason it returns a version of -1. +int Project::getSupportedMajorVersion(QString *errorOut) { + // This has relatively tight timeout windows (500ms for each process, compared to the default 30,000ms). This version check + // is not important enough to significantly slow down project launch, we'd rather just timeout. + const int timeoutLimit = 500; + const int failureVersion = -1; + QString gitName = "git"; + QString gitPath = QStandardPaths::findExecutable(gitName); + if (gitPath.isEmpty()) { + if (errorOut) *errorOut = QString("Unable to locate %1.").arg(gitName); + return failureVersion; + } + + QProcess process; + process.setWorkingDirectory(this->root); + process.setProgram(gitPath); + process.setReadChannel(QProcess::StandardOutput); + process.setStandardInputFile(QProcess::nullDevice()); // We won't have any writing to do. + + // First we need to know which (if any) known history this project belongs to. + // We'll get the root commit, then compare it to the known root commits for the base project repos. + static const QStringList args_getRootCommit = { "rev-list", "--max-parents=0", "HEAD" }; + process.setArguments(args_getRootCommit); + process.start(); + if (!process.waitForFinished(timeoutLimit) || process.exitStatus() != QProcess::ExitStatus::NormalExit || process.exitCode() != 0) { + if (errorOut) { + *errorOut = QStringLiteral("Failed to identify commit history"); + if (process.error() != QProcess::UnknownError && !process.errorString().isEmpty()) { + errorOut->append(QString(": %1").arg(process.errorString())); + } else { + process.setReadChannel(QProcess::StandardError); + QString error = QString(process.readLine()).remove('\n'); + if (!error.isEmpty()) errorOut->append(QString(": %1").arg(error)); + } + } + return failureVersion; + } + const QString rootCommit = QString(process.readLine()).remove('\n'); + + // The keys in this map are the hashes of the root commits for each of the 3 base repos. + // The values are a list of pairs, where the first element is a major version number, and the + // second element is the hash of the earliest commit that supports that major version. + static const QMap>> historyMap = { + // pokeemerald + {"33b799c967fd63d04afe82eecc4892f3e45781b3", { + {6, "07c897ad48c36b178093bde8ca360823127d812b"}, // TODO: Update to merge commit for pokeemerald's porymap-6 branch + {5, "c76beed98990a57c84d3930190fd194abfedf7e8"}, + {4, "cb5b8da77b9ba6837fcc8c5163bedc5008b12c2c"}, + {3, "204c431993dad29661a9ff47326787cd0cf381e6"}, + {2, "cdae0c1444bed98e652c87dc3e3edcecacfef8be"}, + {1, ""} + }}, + // pokefirered + {"670fef77ac4d9116d5fdc28c0da40622919a062b", { + {6, "7722e7a92ca5fa69925dcef82f6c89c35ec48171"}, // TODO: Update to merge commit for pokefirered's porymap-6 branch + {5, "52591dcee42933d64f60c59276fc13c3bb89c47b"}, + {4, "200c82e01a94dbe535e6ed8768d8afad4444d4d2"}, + }}, + // pokeruby + {"1362b60f3467f0894d55e82f3294980b6373021d", { + {6, "bc5aeaa64ecad03aa4ab9e1000ba94916276c936"}, // TODO: Update to merge commit for pokeruby's porymap-6 branch + {5, "d99cb43736dd1d4ee4820f838cb259d773d8bf25"}, + {4, "f302fcc134bf354c3655e3423be68fd7a99cb396"}, + {3, "b4f4d2c0f03462dcdf3492aad27890294600eb2e"}, + {2, "0e8ccfc4fd3544001f4c25fafd401f7558bdefba"}, + {1, ""} + }}, + }; + if (!historyMap.contains(rootCommit)) { + // Either this repo does not share history with one of the base repos, or we got some unexpected result. + if (errorOut) *errorOut = QStringLiteral("Unrecognized commit history"); + return failureVersion; + } + + // We now know which base repo that the user's repo shares history with. + // Next we check to see if it contains the changes required to support particular major versions of Porymap. + // We'll start with the most recent major version and work backwards. + for (const auto &pair : historyMap.value(rootCommit)) { + int versionNum = pair.first; + QString commitHash = pair.second; + if (commitHash.isEmpty()) { + // An empty commit hash means 'consider any point in the history a supported version' + return versionNum; + } + process.setArguments({ "merge-base", "--is-ancestor", commitHash, "HEAD" }); + process.start(); + if (!process.waitForFinished(timeoutLimit) || process.exitStatus() != QProcess::ExitStatus::NormalExit) { + if (errorOut) { + *errorOut = QStringLiteral("Failed to search commit history"); + if (process.error() != QProcess::UnknownError && !process.errorString().isEmpty()) { + errorOut->append(QString(": %1").arg(process.errorString())); + } else { + process.setReadChannel(QProcess::StandardError); + QString error = QString(process.readLine()).remove('\n'); + if (!error.isEmpty()) errorOut->append(QString(": %1").arg(error)); + } + } + return failureVersion; + } + if (process.exitCode() == 0) { + // Identified a supported major version + return versionNum; + } + } + // We recognized the commit history, but it's too old for any version of Porymap to support. + return 0; +} + bool Project::load() { this->parser.setUpdatesSplashScreen(true); resetFileCache();