diff --git a/src/duckstation-qt/achievementlogindialog.ui b/src/duckstation-qt/achievementlogindialog.ui index 555bf393c..08a3fab62 100644 --- a/src/duckstation-qt/achievementlogindialog.ui +++ b/src/duckstation-qt/achievementlogindialog.ui @@ -66,7 +66,7 @@ - Please enter user name and password for retroachievements.org below. Your password will not be saved in DuckStation, instead an access token will be generated and used instead. + Please enter user name and password for retroachievements.org below. Your password will not be saved in DuckStation, an access token will be generated and used instead. true diff --git a/src/duckstation-qt/achievementsettingswidget.cpp b/src/duckstation-qt/achievementsettingswidget.cpp index 35399a4c1..a9f0eeb3c 100644 --- a/src/duckstation-qt/achievementsettingswidget.cpp +++ b/src/duckstation-qt/achievementsettingswidget.cpp @@ -3,6 +3,7 @@ #include "common/string_util.h" #include "core/system.h" #include "frontend-common/cheevos.h" +#include "mainwindow.h" #include "qtutils.h" #include "settingsdialog.h" #include "settingwidgetbinder.h" @@ -15,11 +16,12 @@ AchievementSettingsWidget::AchievementSettingsWidget(QtHostInterface* host_inter { m_ui.setupUi(this); - SettingWidgetBinder::BindWidgetToBoolSetting(m_host_interface, m_ui.enable, "Cheevos", "Enabled", false); SettingWidgetBinder::BindWidgetToBoolSetting(m_host_interface, m_ui.richPresence, "Cheevos", "RichPresence", true); SettingWidgetBinder::BindWidgetToBoolSetting(m_host_interface, m_ui.testMode, "Cheevos", "TestMode", false); SettingWidgetBinder::BindWidgetToBoolSetting(m_host_interface, m_ui.useFirstDiscFromPlaylist, "Cheevos", "UseFirstDiscFromPlaylist", true); + m_ui.enable->setChecked(m_host_interface->GetBoolSettingValue("Cheevos", "Enabled", false)); + m_ui.challengeMode->setChecked(m_host_interface->GetBoolSettingValue("Cheevos", "ChallengeMode", false)); dialog->registerWidgetHelp(m_ui.enable, tr("Enable Achievements"), tr("Unchecked"), tr("When enabled and logged in, DuckStation will scan for achievements on startup.")); @@ -33,10 +35,14 @@ AchievementSettingsWidget::AchievementSettingsWidget(QtHostInterface* host_inter m_ui.useFirstDiscFromPlaylist, tr("Use First Disc From Playlist"), tr("Unchecked"), tr( "When enabled, the first disc in a playlist will be used for achievements, regardless of which disc is active.")); + dialog->registerWidgetHelp(m_ui.challengeMode, tr("Enable Hardcore Mode"), tr("Unchecked"), + tr("\"Challenge\" mode for achievements. Disables save state, cheats, and slowdown " + "functions, but you receive double the achievement points.")); - connect(m_ui.enable, &QCheckBox::stateChanged, this, &AchievementSettingsWidget::updateEnableState); + connect(m_ui.enable, &QCheckBox::toggled, this, &AchievementSettingsWidget::onEnableToggled); connect(m_ui.loginButton, &QPushButton::clicked, this, &AchievementSettingsWidget::onLoginLogoutPressed); connect(m_ui.viewProfile, &QPushButton::clicked, this, &AchievementSettingsWidget::onViewProfilePressed); + connect(m_ui.challengeMode, &QCheckBox::toggled, this, &AchievementSettingsWidget::onChallengeModeToggled); connect(host_interface, &QtHostInterface::achievementsLoaded, this, &AchievementSettingsWidget::onAchievementsLoaded); updateEnableState(); @@ -51,9 +57,11 @@ AchievementSettingsWidget::~AchievementSettingsWidget() = default; void AchievementSettingsWidget::updateEnableState() { const bool enabled = m_host_interface->GetBoolSettingValue("Cheevos", "Enabled", false); + const bool challenge_mode = m_host_interface->GetBoolSettingValue("Cheevos", "ChallengeMode", false); m_ui.testMode->setEnabled(enabled); m_ui.useFirstDiscFromPlaylist->setEnabled(enabled); m_ui.richPresence->setEnabled(enabled); + m_ui.challengeMode->setEnabled(enabled); } void AchievementSettingsWidget::updateLoginState() @@ -98,17 +106,73 @@ void AchievementSettingsWidget::onLoginLogoutPressed() void AchievementSettingsWidget::onViewProfilePressed() { - if (!Cheevos::IsLoggedIn()) + const std::string username(m_host_interface->GetStringSettingValue("Cheevos", "Username")); + if (username.empty()) return; - const QByteArray encoded_username(QUrl::toPercentEncoding(QString::fromStdString(Cheevos::GetUsername()))); + const QByteArray encoded_username(QUrl::toPercentEncoding(QString::fromStdString(username))); QtUtils::OpenURL( QtUtils::GetRootWidget(this), QUrl(QStringLiteral("https://retroachievements.org/user/%1").arg(QString::fromUtf8(encoded_username)))); } +void AchievementSettingsWidget::onEnableToggled(bool checked) +{ + const bool challenge_mode = m_host_interface->GetBoolSettingValue("Cheevos", "ChallengeMode", false); + const bool challenge_mode_active = checked && challenge_mode; + if (challenge_mode_active && !confirmChallengeModeEnable()) + { + QSignalBlocker sb(m_ui.challengeMode); + m_ui.challengeMode->setChecked(false); + return; + } + + m_host_interface->SetBoolSettingValue("Cheevos", "Enabled", checked); + m_host_interface->applySettings(false); + + if (challenge_mode) + m_host_interface->getMainWindow()->onAchievementsChallengeModeToggled(challenge_mode_active); + + updateEnableState(); +} + +void AchievementSettingsWidget::onChallengeModeToggled(bool checked) +{ + if (checked && !confirmChallengeModeEnable()) + { + QSignalBlocker sb(m_ui.challengeMode); + m_ui.challengeMode->setChecked(false); + return; + } + + m_host_interface->SetBoolSettingValue("Cheevos", "ChallengeMode", checked); + m_host_interface->applySettings(false); + m_host_interface->getMainWindow()->onAchievementsChallengeModeToggled(checked); +} + void AchievementSettingsWidget::onAchievementsLoaded(quint32 id, const QString& game_info_string, quint32 total, quint32 points) { m_ui.gameInfo->setText(game_info_string); } + +bool AchievementSettingsWidget::confirmChallengeModeEnable() +{ + if (!System::IsValid()) + return true; + + QString message = tr("Enabling hardcore mode will shut down your current game.\n\n"); + + if (m_host_interface->ShouldSaveResumeState()) + { + message += + tr("The current state will be saved, but you will be unable to load it until you disable hardcore mode.\n\n"); + } + + message += tr("Do you want to continue?"); + if (QMessageBox::question(QtUtils::GetRootWidget(this), tr("Enable Hardcore Mode"), message) != QMessageBox::Yes) + return false; + + m_host_interface->synchronousPowerOffSystem(); + return true; +} diff --git a/src/duckstation-qt/achievementsettingswidget.h b/src/duckstation-qt/achievementsettingswidget.h index a97f7708f..402464942 100644 --- a/src/duckstation-qt/achievementsettingswidget.h +++ b/src/duckstation-qt/achievementsettingswidget.h @@ -14,13 +14,17 @@ public: ~AchievementSettingsWidget(); private Q_SLOTS: - void updateEnableState(); - void updateLoginState(); + void onEnableToggled(bool checked); + void onChallengeModeToggled(bool checked); void onLoginLogoutPressed(); void onViewProfilePressed(); void onAchievementsLoaded(quint32 id, const QString& game_info_string, quint32 total, quint32 points); private: + bool confirmChallengeModeEnable(); + void updateEnableState(); + void updateLoginState(); + Ui::AchievementSettingsWidget m_ui; QtHostInterface* m_host_interface; diff --git a/src/duckstation-qt/achievementsettingswidget.ui b/src/duckstation-qt/achievementsettingswidget.ui index b03a828af..40d0ecf54 100644 --- a/src/duckstation-qt/achievementsettingswidget.ui +++ b/src/duckstation-qt/achievementsettingswidget.ui @@ -60,6 +60,13 @@ + + + + Enable Hardcore Mode + + + @@ -97,32 +104,6 @@ - - - - Account Settings - - - - - - false - - - Enable Hardcore Mode - - - - - - - Enabling hardcore mode will disable cheats, save sates, and debugging features. - - - - - - diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp index 0efdb316f..fedc7847d 100644 --- a/src/duckstation-qt/mainwindow.cpp +++ b/src/duckstation-qt/mainwindow.cpp @@ -354,7 +354,7 @@ void MainWindow::updateMouseMode(bool paused) void MainWindow::onEmulationStarting() { m_emulation_running = true; - updateEmulationActions(true, false); + updateEmulationActions(true, false, m_host_interface->IsCheevosChallengeModeActive()); // ensure it gets updated, since the boot can take a while QGuiApplication::processEvents(QEventLoop::ExcludeUserInputEvents); @@ -362,13 +362,13 @@ void MainWindow::onEmulationStarting() void MainWindow::onEmulationStarted() { - updateEmulationActions(false, true); + updateEmulationActions(false, true, m_host_interface->IsCheevosChallengeModeActive()); } void MainWindow::onEmulationStopped() { m_emulation_running = false; - updateEmulationActions(false, false); + updateEmulationActions(false, false, m_host_interface->IsCheevosChallengeModeActive()); switchToGameListView(); if (m_cheat_manager_dialog) @@ -610,7 +610,8 @@ void MainWindow::onGameListEntryDoubleClicked(const GameListEntry* entry) QString path = QString::fromStdString(entry->path); if (!m_emulation_running) { - if (!entry->code.empty() && m_host_interface->GetBoolSettingValue("Main", "SaveStateOnExit", true)) + if (!entry->code.empty() && m_host_interface->GetBoolSettingValue("Main", "SaveStateOnExit", true) && + !m_host_interface->IsCheevosChallengeModeActive()) { m_host_interface->resumeSystemFromState(path, true); } @@ -670,7 +671,7 @@ void MainWindow::onGameListContextMenuRequested(const QPoint& point, const GameL m_host_interface->bootSystem(std::move(boot_params)); }); - if (m_ui.menuDebug->menuAction()->isVisible()) + if (m_ui.menuDebug->menuAction()->isVisible() && !m_host_interface->IsCheevosChallengeModeActive()) { connect(menu.addAction(tr("Boot and Debug")), &QAction::triggered, [this, entry]() { m_open_debugger_on_start = true; @@ -841,27 +842,27 @@ void MainWindow::setupAdditionalUi() } } -void MainWindow::updateEmulationActions(bool starting, bool running) +void MainWindow::updateEmulationActions(bool starting, bool running, bool cheevos_challenge_mode) { m_ui.actionStartDisc->setDisabled(starting || running); m_ui.actionStartBios->setDisabled(starting || running); - m_ui.actionResumeLastState->setDisabled(starting || running); + m_ui.actionResumeLastState->setDisabled(starting || running || cheevos_challenge_mode); m_ui.actionPowerOff->setDisabled(starting || !running); m_ui.actionPowerOffWithoutSaving->setDisabled(starting || !running); m_ui.actionReset->setDisabled(starting || !running); m_ui.actionPause->setDisabled(starting || !running); m_ui.actionChangeDisc->setDisabled(starting || !running); - m_ui.actionCheats->setDisabled(starting || !running); + m_ui.actionCheats->setDisabled(starting || !running || cheevos_challenge_mode); m_ui.actionScreenshot->setDisabled(starting || !running); m_ui.actionViewSystemDisplay->setEnabled(starting || running); m_ui.menuChangeDisc->setDisabled(starting || !running); - m_ui.menuCheats->setDisabled(starting || !running); - m_ui.actionCheatManager->setDisabled(starting || !running); - m_ui.actionCPUDebugger->setDisabled(starting || !running); - m_ui.actionDumpRAM->setDisabled(starting || !running); - m_ui.actionDumpVRAM->setDisabled(starting || !running); - m_ui.actionDumpSPURAM->setDisabled(starting || !running); + m_ui.menuCheats->setDisabled(starting || !running || cheevos_challenge_mode); + m_ui.actionCheatManager->setDisabled(starting || !running || cheevos_challenge_mode); + m_ui.actionCPUDebugger->setDisabled(starting || !running || cheevos_challenge_mode); + m_ui.actionDumpRAM->setDisabled(starting || !running || cheevos_challenge_mode); + m_ui.actionDumpVRAM->setDisabled(starting || !running || cheevos_challenge_mode); + m_ui.actionDumpSPURAM->setDisabled(starting || !running || cheevos_challenge_mode); m_ui.actionSaveState->setDisabled(starting || !running); m_ui.menuSaveState->setDisabled(starting || !running); @@ -869,6 +870,9 @@ void MainWindow::updateEmulationActions(bool starting, bool running) m_ui.actionFullscreen->setDisabled(starting || !running); + m_ui.actionLoadState->setDisabled(cheevos_challenge_mode); + m_ui.menuLoadState->setDisabled(cheevos_challenge_mode); + if (running && m_status_speed_widget->isHidden()) { m_status_speed_widget->show(); @@ -945,7 +949,7 @@ void MainWindow::switchToEmulationView() void MainWindow::connectSignals() { - updateEmulationActions(false, false); + updateEmulationActions(false, false, m_host_interface->IsCheevosChallengeModeActive()); onEmulationPaused(false); connect(qApp, &QGuiApplication::applicationStateChanged, this, &MainWindow::onApplicationStateChanged); @@ -1458,6 +1462,28 @@ void MainWindow::openMemoryCardEditor(const QString& card_a_path, const QString& } } +void MainWindow::onAchievementsChallengeModeToggled(bool enabled) +{ + if (enabled) + { + if (m_cheat_manager_dialog) + { + m_cheat_manager_dialog->close(); + delete m_cheat_manager_dialog; + m_cheat_manager_dialog = nullptr; + } + + if (m_debugger_window) + { + m_debugger_window->close(); + delete m_debugger_window; + m_debugger_window = nullptr; + } + } + + updateEmulationActions(false, System::IsValid(), enabled); +} + void MainWindow::onToolsMemoryCardEditorTriggered() { openMemoryCardEditor(QString(), QString()); diff --git a/src/duckstation-qt/mainwindow.h b/src/duckstation-qt/mainwindow.h index 47a344996..b13f00577 100644 --- a/src/duckstation-qt/mainwindow.h +++ b/src/duckstation-qt/mainwindow.h @@ -40,6 +40,9 @@ public: /// Opens memory card editor with the specified paths. void openMemoryCardEditor(const QString& card_a_path, const QString& card_b_path); + /// Updates the state of the controls which should be disabled by achievements challenge mode. + void onAchievementsChallengeModeToggled(bool enabled); + public Q_SLOTS: /// Updates debug menu visibility (hides if disabled). void updateDebugMenuVisibility(); @@ -113,7 +116,7 @@ private: void setupAdditionalUi(); void connectSignals(); void addThemeToMenu(const QString& name, const QString& key); - void updateEmulationActions(bool starting, bool running); + void updateEmulationActions(bool starting, bool running, bool cheevos_challenge_mode); bool isShowingGameList() const; void switchToGameListView(); void switchToEmulationView(); diff --git a/src/duckstation-qt/qthostinterface.cpp b/src/duckstation-qt/qthostinterface.cpp index baec13e53..47a381aff 100644 --- a/src/duckstation-qt/qthostinterface.cpp +++ b/src/duckstation-qt/qthostinterface.cpp @@ -957,6 +957,7 @@ void QtHostInterface::populateGameListContextMenu(const GameListEntry* entry, QW { const std::vector available_states(GetAvailableSaveStates(entry->code.c_str())); const QString timestamp_format = QLocale::system().dateTimeFormat(QLocale::ShortFormat); + const bool challenge_mode = IsCheevosChallengeModeActive(); for (const SaveStateInfo& ssi : available_states) { if (ssi.global) @@ -971,7 +972,7 @@ void QtHostInterface::populateGameListContextMenu(const GameListEntry* entry, QW if (slot < 0) { resume_action->setText(tr("Resume (%1)").arg(timestamp_str)); - resume_action->setEnabled(true); + resume_action->setEnabled(!challenge_mode); action = resume_action; } else @@ -980,6 +981,7 @@ void QtHostInterface::populateGameListContextMenu(const GameListEntry* entry, QW action = load_state_menu->addAction(tr("Game Save %1 (%2)").arg(slot).arg(timestamp_str)); } + action->setDisabled(challenge_mode); connect(action, &QAction::triggered, [this, path]() { loadState(path); }); } } diff --git a/src/frontend-common/cheevos.cpp b/src/frontend-common/cheevos.cpp index 2817e3ba5..930fc28cc 100644 --- a/src/frontend-common/cheevos.cpp +++ b/src/frontend-common/cheevos.cpp @@ -49,12 +49,12 @@ static void SendPlaying(); static void GameChanged(); bool g_active = false; +bool g_challenge_mode = false; u32 g_game_id = 0; static bool s_logged_in = false; static bool s_test_mode = false; static bool s_use_first_disc_from_playlist = true; -static bool s_hardcode_mode = false; static bool s_rich_presence_enabled = false; static rc_runtime_t s_rcheevos_runtime; @@ -210,7 +210,7 @@ static std::string GetUserAgent() return StringUtil::StdStringFromFormat("DuckStation %s", g_scm_tag_str); } -bool Initialize(bool test_mode, bool use_first_disc_from_playlist, bool enable_rich_presence) +bool Initialize(bool test_mode, bool use_first_disc_from_playlist, bool enable_rich_presence, bool challenge_mode) { s_http_downloader = FrontendCommon::HTTPDownloader::Create(); if (!s_http_downloader) @@ -221,6 +221,7 @@ bool Initialize(bool test_mode, bool use_first_disc_from_playlist, bool enable_r s_http_downloader->SetUserAgent(GetUserAgent()); g_active = true; + g_challenge_mode = challenge_mode; s_test_mode = test_mode; s_use_first_disc_from_playlist = use_first_disc_from_playlist; s_rich_presence_enabled = enable_rich_presence; @@ -318,6 +319,9 @@ const std::string& GetRichPresenceString() static void LoginASyncCallback(s32 status_code, const FrontendCommon::HTTPDownloader::Request::Data& data) { + if (GetHostInterface()->IsFullscreenUIEnabled()) + ImGuiFullscreen::CloseBackgroundProgressDialog("cheevos_async_login"); + rapidjson::Document doc; if (!ParseResponseJSON("Login", status_code, data, doc)) return; @@ -341,7 +345,12 @@ static void LoginASyncCallback(s32 status_code, const FrontendCommon::HTTPDownlo GetHostInterface()->GetSettingsInterface()->Save(); } - GetHostInterface()->ReportFormattedMessage("Logged into RetroAchievements using username '%s'.", username.c_str()); + if (GetHostInterface()->IsFullscreenUIEnabled()) + { + GetHostInterface()->ReportFormattedMessage( + GetHostInterface()->TranslateString("Cheevos", "Logged into RetroAchievements using username '%s'."), + username.c_str()); + } if (g_active) { @@ -371,6 +380,13 @@ bool LoginAsync(const char* username, const char* password) if (s_logged_in || std::strlen(username) == 0 || std::strlen(password) == 0) return false; + if (GetHostInterface()->IsFullscreenUIEnabled()) + { + ImGuiFullscreen::OpenBackgroundProgressDialog( + "cheevos_async_login", GetHostInterface()->TranslateStdString("Cheevos", "Logging in to RetroAchivements..."), 0, + 1, 0); + } + SendLogin(username, password, s_http_downloader.get()); return true; } @@ -444,7 +460,7 @@ static void UpdateImageDownloadProgress() if (!GetHostInterface()->IsFullscreenUIEnabled()) return; - std::string message("Downloading achievement resources..."); + std::string message(g_host_interface->TranslateStdString("Cheevos", "Downloading achievement resources...")); if (!s_image_download_progress_active) { ImGuiFullscreen::OpenBackgroundProgressDialog(str_id, std::move(message), 0, @@ -497,8 +513,8 @@ static std::string GetBadgeImageFilename(const char* badge_name, bool locked, bo SmallString clean_name(badge_name); FileSystem::SanitizeFileName(clean_name); return GetHostInterface()->GetUserDirectoryRelativePath("cache" FS_OSPATH_SEPARATOR_STR - "achievement_badge" FS_OSPATH_SEPARATOR_STR "%s%s.png", - clean_name.GetCharArray(), locked ? "_lock" : ""); + "achievement_badge" FS_OSPATH_SEPARATOR_STR "%s%s.png", + clean_name.GetCharArray(), locked ? "_lock" : ""); } } @@ -520,19 +536,20 @@ static std::string ResolveBadgePath(const char* badge_name, bool locked) static void DisplayAchievementSummary() { - std::string title( - StringUtil::StdStringFromFormat("%s%s", s_game_title.c_str(), s_hardcode_mode ? " (Hardcore Mode)" : "")); - std::string summary; + std::string title = s_game_title; + if (g_challenge_mode) + title += GetHostInterface()->TranslateString("Cheevos", " (Hardcore Mode)"); + std::string summary; if (GetAchievementCount() > 0) { - summary = StringUtil::StdStringFromFormat("You have earned %u of %u achievements, and %u of %u points.", - GetUnlockedAchiementCount(), GetAchievementCount(), - GetCurrentPointsForGame(), GetMaximumPointsForGame()); + summary = StringUtil::StdStringFromFormat( + GetHostInterface()->TranslateString("Cheevos", "You have earned %u of %u achievements, and %u of %u points."), + GetUnlockedAchiementCount(), GetAchievementCount(), GetCurrentPointsForGame(), GetMaximumPointsForGame()); } else { - summary = "This game has no achievements."; + summary = GetHostInterface()->TranslateString("Cheevos", "This game has no achievements."); } ImGuiFullscreen::AddNotification(10.0f, std::move(title), std::move(summary), s_game_icon); @@ -588,7 +605,7 @@ static void GetUserUnlocks() { char url[256]; int res = rc_url_get_unlock_list(url, sizeof(url), s_username.c_str(), s_login_token.c_str(), g_game_id, - static_cast(s_hardcode_mode)); + static_cast(g_challenge_mode)); Assert(res == 0); s_http_downloader->CreateRequest(url, GetUserUnlocksCallback); @@ -642,19 +659,29 @@ static void GetPatchesCallback(s32 status_code, const FrontendCommon::HTTPDownlo const auto achievements(patch_data["Achievements"].GetArray()); for (const auto& achievement : achievements) { - if (!achievement.HasMember("ID") || !achievement["ID"].IsNumber() || !achievement.HasMember("MemAddr") || - !achievement["MemAddr"].IsString() || !achievement.HasMember("Title") || !achievement["Title"].IsString()) + if (!achievement.HasMember("ID") || !achievement["ID"].IsNumber() || !achievement.HasMember("Flags") || + !achievement["Flags"].IsNumber() || !achievement.HasMember("MemAddr") || !achievement["MemAddr"].IsString() || + !achievement.HasMember("Title") || !achievement["Title"].IsString()) { continue; } const u32 id = achievement["ID"].GetUint(); + const u32 category = achievement["Flags"].GetUint(); const char* memaddr = achievement["MemAddr"].GetString(); std::string title = achievement["Title"].GetString(); std::string description = GetOptionalString(achievement, "Description"); std::string badge_name = GetOptionalString(achievement, "BadgeName"); const u32 points = GetOptionalUInt(achievement, "Points"); + // Skip local and unofficial achievements for now. + if (static_cast(category) == AchievementCategory::Local || + static_cast(category) == AchievementCategory::Unofficial) + { + Log_WarningPrintf("Skipping unofficial achievement %u (%s)", id, title.c_str()); + continue; + } + if (GetAchievementByID(id)) { Log_ErrorPrintf("Achievement %u already exists", id); @@ -681,7 +708,8 @@ static void GetPatchesCallback(s32 status_code, const FrontendCommon::HTTPDownlo } // parse rich presence - if (s_rich_presence_enabled && patch_data.HasMember("RichPresencePatch") && patch_data["RichPresencePatch"].IsString()) + if (s_rich_presence_enabled && patch_data.HasMember("RichPresencePatch") && + patch_data["RichPresencePatch"].IsString()) { const char* patch = patch_data["RichPresencePatch"].GetString(); int res = rc_runtime_activate_richpresence(&s_rcheevos_runtime, patch, nullptr, 0); @@ -719,19 +747,11 @@ static void GetPatchesCallback(s32 status_code, const FrontendCommon::HTTPDownlo static void GetPatches(u32 game_id) { -#if 1 char url[256] = {}; int res = rc_url_get_patch(url, sizeof(url), s_username.c_str(), s_login_token.c_str(), game_id); Assert(res == 0); s_http_downloader->CreateRequest(url, GetPatchesCallback); -#else - std::optional> f = FileSystem::ReadBinaryFile("D:\\10434.txt"); - if (!f) - return; - - GetPatchesCallback(200, *f); -#endif } static std::string GetGameHash(CDImage* cdi) @@ -855,9 +875,9 @@ void GameChanged(const std::string& path, CDImage* image) if (s_game_hash.empty()) { - GetHostInterface()->AddOSDMessage( - GetHostInterface()->TranslateStdString("OSDMessage", "Failed to read executable from disc. Achievements disabled."), - 10.0f); + GetHostInterface()->AddOSDMessage(GetHostInterface()->TranslateStdString( + "OSDMessage", "Failed to read executable from disc. Achievements disabled."), + 10.0f); return; } @@ -1086,7 +1106,7 @@ void UnlockAchievement(u32 achievement_id, bool add_notification /* = true*/) char url[256]; rc_url_award_cheevo(url, sizeof(url), s_username.c_str(), s_login_token.c_str(), achievement_id, - static_cast(s_hardcode_mode), s_game_hash.c_str()); + static_cast(g_challenge_mode), s_game_hash.c_str()); s_http_downloader->CreateRequest(url, UnlockAchievementCallback); } diff --git a/src/frontend-common/cheevos.h b/src/frontend-common/cheevos.h index 9365ce4f4..d6c108f24 100644 --- a/src/frontend-common/cheevos.h +++ b/src/frontend-common/cheevos.h @@ -7,6 +7,13 @@ class CDImage; namespace Cheevos { +enum class AchievementCategory : u32 +{ + Local = 0, + Core = 3, + Unofficial = 5 +}; + struct Achievement { u32 id; @@ -21,6 +28,7 @@ struct Achievement }; extern bool g_active; +extern bool g_challenge_mode; extern u32 g_game_id; ALWAYS_INLINE bool IsActive() @@ -28,6 +36,16 @@ ALWAYS_INLINE bool IsActive() return g_active; } +ALWAYS_INLINE bool IsChallengeModeEnabled() +{ + return g_challenge_mode; +} + +ALWAYS_INLINE bool IsChallengeModeActive() +{ + return g_active && g_challenge_mode; +} + ALWAYS_INLINE bool HasActiveGame() { return g_game_id != 0; @@ -38,7 +56,7 @@ ALWAYS_INLINE u32 GetGameID() return g_game_id; } -bool Initialize(bool test_mode, bool use_first_disc_from_playlist, bool enable_rich_presence); +bool Initialize(bool test_mode, bool use_first_disc_from_playlist, bool enable_rich_presence, bool challenge_mode); void Reset(); void Shutdown(); void Update(); diff --git a/src/frontend-common/common_host_interface.cpp b/src/frontend-common/common_host_interface.cpp index 96f98c7a2..ef189406b 100644 --- a/src/frontend-common/common_host_interface.cpp +++ b/src/frontend-common/common_host_interface.cpp @@ -985,7 +985,10 @@ void CommonHostInterface::OnRunningGameChanged(const std::string& path, CDImage* { System::SetCheatList(nullptr); if (g_settings.auto_load_cheats) + { + DebugAssert(!IsCheevosChallengeModeActive()); LoadCheatListFromGameTitle(); + } } #ifdef WITH_DISCORD_PRESENCE @@ -1018,7 +1021,8 @@ void CommonHostInterface::DrawImGuiWindows() if (System::IsValid()) { - DrawDebugWindows(); + if (!IsCheevosChallengeModeActive()) + DrawDebugWindows(); DrawFPSWindow(); } @@ -1236,6 +1240,15 @@ void CommonHostInterface::DrawDebugWindows() g_dma.DrawDebugStateWindow(); } +bool CommonHostInterface::IsCheevosChallengeModeActive() const +{ +#ifdef WITH_CHEEVOS + return Cheevos::IsChallengeModeActive(); +#else + return false; +#endif +} + void CommonHostInterface::DoFrameStep() { if (System::IsShutdown()) @@ -1766,6 +1779,12 @@ void CommonHostInterface::RegisterHotkeys() RegisterAudioHotkeys(); } +static void DisplayHotkeyBlockedByChallengeModeMessage() +{ + g_host_interface->AddOSDMessage(g_host_interface->TranslateStdString( + "OSDMessage", "Hotkey unavailable because achievements hardcore mode is active.")); +} + void CommonHostInterface::RegisterGeneralHotkeys() { RegisterHotkey(StaticString(TRANSLATABLE("Hotkeys", "General")), StaticString("OpenQuickMenu"), @@ -1807,7 +1826,12 @@ void CommonHostInterface::RegisterGeneralHotkeys() RegisterHotkey(StaticString(TRANSLATABLE("Hotkeys", "General")), StaticString("ToggleCheats"), StaticString(TRANSLATABLE("Hotkeys", "Toggle Cheats")), [this](bool pressed) { if (pressed && System::IsValid()) - DoToggleCheats(); + { + if (!IsCheevosChallengeModeActive()) + DoToggleCheats(); + else + DisplayHotkeyBlockedByChallengeModeMessage(); + } }); RegisterHotkey(StaticString(TRANSLATABLE("Hotkeys", "General")), StaticString("PowerOff"), @@ -1838,7 +1862,7 @@ void CommonHostInterface::RegisterGeneralHotkeys() #else RegisterHotkey(StaticString(TRANSLATABLE("Hotkeys", "General")), StaticString("TogglePatchCodes"), StaticString(TRANSLATABLE("Hotkeys", "Toggle Patch Codes")), [this](bool pressed) { - if (pressed && System::IsValid()) + if (pressed && System::IsValid() && !IsCheevosChallengeModeActive()) DoToggleCheats(); }); #endif @@ -1858,7 +1882,12 @@ void CommonHostInterface::RegisterGeneralHotkeys() RegisterHotkey(StaticString(TRANSLATABLE("Hotkeys", "General")), StaticString("FrameStep"), StaticString(TRANSLATABLE("Hotkeys", "Frame Step")), [this](bool pressed) { if (pressed && System::IsValid()) - DoFrameStep(); + { + if (!IsCheevosChallengeModeActive()) + DoFrameStep(); + else + DisplayHotkeyBlockedByChallengeModeMessage(); + } }); #ifndef __ANDROID__ @@ -1866,10 +1895,17 @@ void CommonHostInterface::RegisterGeneralHotkeys() StaticString(TRANSLATABLE("Hotkeys", "Rewind")), [this](bool pressed) { if (System::IsValid()) { - AddOSDMessage(pressed ? TranslateStdString("OSDMessage", "Rewinding...") : - TranslateStdString("OSDMessage", "Stopped rewinding."), - 5.0f); - System::SetRewinding(pressed); + if (!IsCheevosChallengeModeActive()) + { + AddOSDMessage(pressed ? TranslateStdString("OSDMessage", "Rewinding...") : + TranslateStdString("OSDMessage", "Stopped rewinding."), + 5.0f); + System::SetRewinding(pressed); + } + else + { + DisplayHotkeyBlockedByChallengeModeMessage(); + } } }); #endif @@ -1966,7 +2002,12 @@ void CommonHostInterface::RegisterSaveStateHotkeys() RegisterHotkey(StaticString(TRANSLATABLE("Hotkeys", "Save States")), StaticString("LoadSelectedSaveState"), StaticString(TRANSLATABLE("Hotkeys", "Load From Selected Slot")), [this](bool pressed) { if (pressed) - m_save_state_selector_ui->LoadCurrentSlot(); + { + if (!IsCheevosChallengeModeActive()) + m_save_state_selector_ui->LoadCurrentSlot(); + else + DisplayHotkeyBlockedByChallengeModeMessage(); + } }); RegisterHotkey(StaticString(TRANSLATABLE("Hotkeys", "Save States")), StaticString("SaveSelectedSaveState"), StaticString(TRANSLATABLE("Hotkeys", "Save To Selected Slot")), [this](bool pressed) { @@ -1990,7 +2031,12 @@ void CommonHostInterface::RegisterSaveStateHotkeys() TinyString::FromFormat("LoadGameState%u", slot), TinyString::FromFormat("Load Game State %u", slot), [this, slot](bool pressed) { if (pressed) - LoadState(false, slot); + { + if (!IsCheevosChallengeModeActive()) + LoadState(false, slot); + else + DisplayHotkeyBlockedByChallengeModeMessage(); + } }); RegisterHotkey(StaticString(TRANSLATABLE("Hotkeys", "Save States")), TinyString::FromFormat("SaveGameState%u", slot), TinyString::FromFormat("Save Game State %u", slot), @@ -2006,7 +2052,12 @@ void CommonHostInterface::RegisterSaveStateHotkeys() TinyString::FromFormat("LoadGlobalState%u", slot), TinyString::FromFormat("Load Global State %u", slot), [this, slot](bool pressed) { if (pressed) - LoadState(true, slot); + { + if (!IsCheevosChallengeModeActive()) + LoadState(true, slot); + else + DisplayHotkeyBlockedByChallengeModeMessage(); + } }); RegisterHotkey(StaticString(TRANSLATABLE("Hotkeys", "Save States")), TinyString::FromFormat("SaveGlobalState%u", slot), @@ -2606,6 +2657,31 @@ void CommonHostInterface::SaveSettings(SettingsInterface& si) HostInterface::SaveSettings(si); } +void CommonHostInterface::FixIncompatibleSettings(bool display_osd_messages) +{ + // if challenge mode is enabled, disable things like rewind since they use save states + if (IsCheevosChallengeModeActive()) + { + g_settings.emulation_speed = std::max(g_settings.emulation_speed, 1.0f); + g_settings.fast_forward_speed = std::max(g_settings.fast_forward_speed, 1.0f); + g_settings.turbo_speed = std::max(g_settings.turbo_speed, 1.0f); + g_settings.rewind_enable = false; + g_settings.auto_load_cheats = false; + g_settings.debugging.enable_gdb_server = false; + g_settings.debugging.show_vram = false; + g_settings.debugging.show_gpu_state = false; + g_settings.debugging.show_cdrom_state = false; + g_settings.debugging.show_spu_state = false; + g_settings.debugging.show_timers_state = false; + g_settings.debugging.show_mdec_state = false; + g_settings.debugging.show_dma_state = false; + g_settings.debugging.dump_cpu_to_vram_copies = false; + g_settings.debugging.dump_vram_to_cpu_copies = false; + } + + HostInterface::FixIncompatibleSettings(display_osd_messages); +} + void CommonHostInterface::ApplySettings(bool display_osd_messages) { Settings old_settings(std::move(g_settings)); @@ -2967,6 +3043,9 @@ bool CommonHostInterface::LoadCheatList(const char* filename) bool CommonHostInterface::LoadCheatListFromGameTitle() { + if (IsCheevosChallengeModeActive()) + return false; + const std::string filename(GetCheatFileName()); if (filename.empty() || !FileSystem::FileExists(filename.c_str())) return false; @@ -2976,7 +3055,7 @@ bool CommonHostInterface::LoadCheatListFromGameTitle() bool CommonHostInterface::LoadCheatListFromDatabase() { - if (System::GetRunningCode().empty()) + if (System::GetRunningCode().empty() || IsCheevosChallengeModeActive()) return false; std::unique_ptr cl = std::make_unique(); @@ -3308,15 +3387,18 @@ void CommonHostInterface::UpdateCheevosActive() const bool cheevos_test_mode = GetBoolSettingValue("Cheevos", "TestMode", false); const bool cheevos_use_first_disc_from_playlist = GetBoolSettingValue("Cheevos", "UseFirstDiscFromPlaylist", true); const bool cheevos_rich_presence = GetBoolSettingValue("Cheevos", "RichPresence", true); + const bool cheevos_hardcore = GetBoolSettingValue("Cheevos", "ChallengeMode", false); if (cheevos_enabled != Cheevos::IsActive() || cheevos_test_mode != Cheevos::IsTestModeActive() || cheevos_use_first_disc_from_playlist != Cheevos::IsUsingFirstDiscFromPlaylist() || - cheevos_rich_presence != Cheevos::IsRichPresenceEnabled()) + cheevos_rich_presence != Cheevos::IsRichPresenceEnabled() || + cheevos_hardcore != Cheevos::IsChallengeModeEnabled()) { Cheevos::Shutdown(); if (cheevos_enabled) { - if (!Cheevos::Initialize(cheevos_test_mode, cheevos_use_first_disc_from_playlist, cheevos_rich_presence)) + if (!Cheevos::Initialize(cheevos_test_mode, cheevos_use_first_disc_from_playlist, cheevos_rich_presence, + cheevos_hardcore)) ReportError("Failed to initialize cheevos after settings change."); } } diff --git a/src/frontend-common/common_host_interface.h b/src/frontend-common/common_host_interface.h index 4647f6cc9..17abac4ef 100644 --- a/src/frontend-common/common_host_interface.h +++ b/src/frontend-common/common_host_interface.h @@ -308,6 +308,9 @@ public: void DrawOSDMessages(); void DrawDebugWindows(); + /// Returns true if features such as save states should be disabled. + bool IsCheevosChallengeModeActive() const; + protected: enum : u32 { @@ -391,6 +394,9 @@ protected: /// Saves current settings variables to ini. virtual void SaveSettings(SettingsInterface& si) override; + /// Checks and fixes up any incompatible settings. + virtual void FixIncompatibleSettings(bool display_osd_messages); + /// Checks for settings changes, std::move() the old settings away for comparing beforehand. virtual void CheckForSettingsChanges(const Settings& old_settings) override; diff --git a/src/frontend-common/fullscreen_ui.cpp b/src/frontend-common/fullscreen_ui.cpp index 980a9911d..00b5152ea 100644 --- a/src/frontend-common/fullscreen_ui.cpp +++ b/src/frontend-common/fullscreen_ui.cpp @@ -93,6 +93,15 @@ static void OpenAboutWindow(); static void SetDebugMenuEnabled(bool enabled); static void UpdateDebugMenuVisibility(); +static ALWAYS_INLINE bool IsCheevosHardcoreModeActive() +{ +#ifdef WITH_CHEEVOS + return Cheevos::IsChallengeModeActive(); +#else + return false; +#endif +} + static CommonHostInterface* s_host_interface; static MainWindowType s_current_main_window = MainWindowType::Landing; static std::bitset(FrontendCommon::ControllerNavigationButton::Count)> s_nav_input_values{}; @@ -285,14 +294,14 @@ void Shutdown() void Render() { if (s_debug_menu_enabled) - { DrawDebugMenu(); - if (System::IsValid()) - s_host_interface->DrawDebugWindows(); - } - else if (System::IsValid()) + + if (System::IsValid()) { DrawStatsOverlay(); + + if (!IsCheevosHardcoreModeActive()) + s_host_interface->DrawDebugWindows(); } ImGuiFullscreen::BeginLayout(); @@ -707,7 +716,7 @@ void DrawLandingWindow() BeginMenuButtons(7, 0.5f); if (MenuButton(" " ICON_FA_PLAY_CIRCLE " Resume", - "Starts the console from where it was before it was last closed.")) + "Starts the console from where it was before it was last closed.", !IsCheevosHardcoreModeActive())) { s_host_interface->RunLater([]() { s_host_interface->ResumeSystemFromMostRecentState(); }); ClearImGuiFocus(); @@ -725,7 +734,7 @@ void DrawLandingWindow() if (MenuButton(" " ICON_FA_TOOLBOX " Start BIOS", "Start the console without any disc inserted.")) s_host_interface->RunLater(DoStartBIOS); - if (MenuButton(" " ICON_FA_UNDO " Load State", "Loads a global save state.")) + if (MenuButton(" " ICON_FA_UNDO " Load State", "Loads a global save state.", !IsCheevosHardcoreModeActive())) { OpenSaveStateSelector(true); } @@ -1066,6 +1075,91 @@ static bool ToggleButtonForNonSetting(const char* title, const char* summary, co return true; } +static void DrawAchievementsLoginWindow() +{ + ImGui::SetNextWindowSize(LayoutScale(700.0f, 0.0f)); + ImGui::SetNextWindowPos(ImGui::GetIO().DisplaySize * 0.5f, ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, LayoutScale(10.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, LayoutScale(10.0f, 10.0f)); + ImGui::PushFont(g_large_font); + + bool is_open = true; + if (ImGui::BeginPopupModal("Achievements Login", &is_open, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize)) + { + + ImGui::TextWrapped("Please enter user name and password for retroachievements.org."); + ImGui::NewLine(); + ImGui::TextWrapped( + "Your password will not be saved in DuckStation, an access token will be generated and used instead."); + + ImGui::NewLine(); + + static char username[256] = {}; + static char password[256] = {}; + + ImGui::Text("User Name: "); + ImGui::SameLine(LayoutScale(200.0f)); + ImGui::InputText("##username", username, sizeof(username)); + + ImGui::Text("Password: "); + ImGui::SameLine(LayoutScale(200.0f)); + ImGui::InputText("##password", password, sizeof(password), ImGuiInputTextFlags_Password); + + ImGui::NewLine(); + + BeginMenuButtons(); + + const bool login_enabled = (std::strlen(username) > 0 && std::strlen(password) > 0); + + if (ActiveButton(ICON_FA_KEY " Login", false, login_enabled)) + { + Cheevos::LoginAsync(username, password); + std::memset(username, 0, sizeof(username)); + std::memset(password, 0, sizeof(password)); + ImGui::CloseCurrentPopup(); + } + + if (ActiveButton(ICON_FA_TIMES " Cancel", false)) + ImGui::CloseCurrentPopup(); + + EndMenuButtons(); + + ImGui::EndPopup(); + } + + ImGui::PopFont(); + ImGui::PopStyleVar(2); +} + +static bool ConfirmChallengeModeEnable() +{ + if (!System::IsValid()) + return true; + + const bool cheevos_enabled = s_host_interface->GetBoolSettingValue("Cheevos", "Enabled", false); + const bool cheevos_hardcore = s_host_interface->GetBoolSettingValue("Cheevos", "ChallengeMode", false); + if (!cheevos_enabled || !cheevos_hardcore) + return true; + + SmallString message; + message.AppendString("Enabling hardcore mode will shut down your current game.\n\n"); + + if (s_host_interface->ShouldSaveResumeState()) + { + message.AppendString( + "The current state will be saved, but you will be unable to load it until you disable hardcore mode.\n\n"); + } + + message.AppendString("Do you want to continue?"); + + if (!s_host_interface->ConfirmMessage(message)) + return false; + + s_host_interface->PowerOffSystem(s_host_interface->ShouldSaveResumeState()); + return true; +} + void DrawSettingsWindow() { BeginFullscreenColumns(); @@ -1954,23 +2048,45 @@ void DrawSettingsWindow() BeginMenuButtons(); MenuHeading("Settings"); + if (ToggleButtonForNonSetting(ICON_FA_TROPHY " Enable RetroAchievements", + "When enabled and logged in, DuckStation will scan for achievements on startup.", + "Cheevos", "Enabled", false)) + { + s_host_interface->RunLater([]() { + if (!ConfirmChallengeModeEnable()) + s_host_interface->GetSettingsInterface()->SetBoolValue("Cheevos", "Enabled", false); + else + SaveAndApplySettings(); + }); + } + settings_changed |= ToggleButtonForNonSetting( - "Enable RetroAchievements", "When enabled and logged in, DuckStation will scan for achievements on startup.", - "Cheevos", "Enabled", false); - settings_changed |= ToggleButtonForNonSetting( - "Rich Presence", + ICON_FA_USER_FRIENDS " Rich Presence", "When enabled, rich presence information will be collected and sent to the server where supported.", "Cheevos", "RichPresence", true); settings_changed |= - ToggleButtonForNonSetting("Test Mode", + ToggleButtonForNonSetting(ICON_FA_STETHOSCOPE " Test Mode", "When enabled, DuckStation will assume all achievements are locked and not " "send any unlock notifications to the server.", "Cheevos", "TestMode", false); - settings_changed |= ToggleButtonForNonSetting("Use First Disc From Playlist", + settings_changed |= ToggleButtonForNonSetting(ICON_FA_COMPACT_DISC " Use First Disc From Playlist", "When enabled, the first disc in a playlist will be used for " "achievements, regardless of which disc is active.", "Cheevos", "UseFirstDiscFromPlaylist", true); + if (ToggleButtonForNonSetting(ICON_FA_HARD_HAT " Hardcore Mode", + "\"Challenge\" mode for achievements. Disables save state, cheats, and slowdown " + "functions, but you receive double the achievement points.", + "Cheevos", "ChallengeMode", false)) + { + s_host_interface->RunLater([]() { + if (!ConfirmChallengeModeEnable()) + s_host_interface->GetSettingsInterface()->SetBoolValue("Cheevos", "ChallengeMode", false); + else + SaveAndApplySettings(); + }); + } + MenuHeading("Account"); if (Cheevos::IsLoggedIn()) { @@ -1991,13 +2107,20 @@ void DrawSettingsWindow() if (MenuButton(ICON_FA_KEY " Logout", "Logs out of RetroAchievements.")) Cheevos::Logout(); } - else + else if (Cheevos::IsActive()) { - ActiveButton(SmallString::FromFormat(ICON_FA_USER " Not Logged In", Cheevos::GetUsername().c_str()), false, - false, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + ActiveButton(ICON_FA_USER " Not Logged In", false, false, + ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); if (MenuButton(ICON_FA_KEY " Login", "Logs in to RetroAchievements.")) - Cheevos::LoginAsync("", ""); + ImGui::OpenPopup("Achievements Login"); + + DrawAchievementsLoginWindow(); + } + else + { + ActiveButton(ICON_FA_USER " Achievements are disabled.", false, false, + ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); } MenuHeading("Current Game"); @@ -2220,7 +2343,7 @@ void DrawQuickMenu(MainWindowType type) s_host_interface->RunLater([]() { s_host_interface->SaveScreenshot(); }); } - if (ActiveButton(ICON_FA_UNDO " Load State", false)) + if (ActiveButton(ICON_FA_UNDO " Load State", false, !IsCheevosHardcoreModeActive())) { s_current_main_window = MainWindowType::None; OpenSaveStateSelector(true); @@ -2232,7 +2355,7 @@ void DrawQuickMenu(MainWindowType type) OpenSaveStateSelector(false); } - if (ActiveButton(ICON_FA_FROWN_OPEN " Cheat List", false)) + if (ActiveButton(ICON_FA_FROWN_OPEN " Cheat List", false, !IsCheevosHardcoreModeActive())) { s_current_main_window = MainWindowType::None; DoCheatsMenu(); @@ -3248,9 +3371,7 @@ void DrawDebugSystemMenu() if (ImGui::MenuItem("Change Disc", nullptr, false, system_enabled)) { -#if 0 DoChangeDisc(); -#endif ClearImGuiFocus(); } @@ -3260,17 +3381,9 @@ void DrawDebugSystemMenu() ClearImGuiFocus(); } - if (ImGui::MenuItem("Frame Step", nullptr, false, system_enabled)) - { -#if 0 - s_host_interface->RunLater([]() { DoFrameStep(); }); -#endif - ClearImGuiFocus(); - } - ImGui::Separator(); - if (ImGui::BeginMenu("Load State")) + if (ImGui::BeginMenu("Load State", !IsCheevosHardcoreModeActive())) { for (u32 i = 1; i <= CommonHostInterface::GLOBAL_SAVE_STATE_SLOTS; i++) { @@ -3302,7 +3415,7 @@ void DrawDebugSystemMenu() ImGui::Separator(); - if (ImGui::BeginMenu("Cheats", system_enabled)) + if (ImGui::BeginMenu("Cheats", system_enabled && !IsCheevosHardcoreModeActive())) { const bool has_cheat_file = System::HasCheatList(); if (ImGui::BeginMenu("Enabled Cheats", has_cheat_file)) @@ -3812,9 +3925,8 @@ void DrawAchievementWindow() const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + g_large_font->FontSize)); text.Assign(Cheevos::GetGameTitle()); - const std::string& developer = Cheevos::GetGameDeveloper(); - if (!developer.empty()) - text.AppendFormattedString(" (%s)", developer.c_str()); + if (Cheevos::IsChallengeModeActive()) + text.AppendString(" (Hardcore Mode)"); top += g_large_font->FontSize + spacing;