diff --git a/src/citra/citra.cpp b/src/citra/citra.cpp index 8ecc1b202..30f3a5493 100644 --- a/src/citra/citra.cpp +++ b/src/citra/citra.cpp @@ -39,6 +39,7 @@ #include "core/gdbstub/gdbstub.h" #include "core/hle/service/am/am.h" #include "core/loader/loader.h" +#include "core/movie.h" #include "core/settings.h" #include "network/network.h" @@ -268,8 +269,6 @@ int main(int argc, char** argv) { // Apply the command line arguments Settings::values.gdbstub_port = gdb_port; Settings::values.use_gdbstub = use_gdbstub; - Settings::values.movie_play = std::move(movie_play); - Settings::values.movie_record = std::move(movie_record); Settings::Apply(); // Register frontend applets @@ -327,9 +326,18 @@ int main(int argc, char** argv) { } } + if (!movie_play.empty()) { + Core::Movie::GetInstance().StartPlayback(movie_play); + } + if (!movie_record.empty()) { + Core::Movie::GetInstance().StartRecording(movie_record); + } + while (emu_window->IsOpen()) { system.RunLoop(); } + Core::Movie::GetInstance().Shutdown(); + return 0; } diff --git a/src/citra_qt/configuration/config.cpp b/src/citra_qt/configuration/config.cpp index e1cced42d..cb0d58780 100644 --- a/src/citra_qt/configuration/config.cpp +++ b/src/citra_qt/configuration/config.cpp @@ -230,6 +230,8 @@ void Config::ReadValues() { qt_config->beginGroup("Paths"); UISettings::values.roms_path = ReadSetting("romsPath").toString(); UISettings::values.symbols_path = ReadSetting("symbolsPath").toString(); + UISettings::values.movie_record_path = ReadSetting("movieRecordPath").toString(); + UISettings::values.movie_playback_path = ReadSetting("moviePlaybackPath").toString(); UISettings::values.game_dir_deprecated = ReadSetting("gameListRootDir", ".").toString(); UISettings::values.game_dir_deprecated_deepscan = ReadSetting("gameListDeepScan", false).toBool(); @@ -461,6 +463,8 @@ void Config::SaveValues() { qt_config->beginGroup("Paths"); WriteSetting("romsPath", UISettings::values.roms_path); WriteSetting("symbolsPath", UISettings::values.symbols_path); + WriteSetting("movieRecordPath", UISettings::values.movie_record_path); + WriteSetting("moviePlaybackPath", UISettings::values.movie_playback_path); qt_config->beginWriteArray("gamedirs"); for (int i = 0; i < UISettings::values.game_dirs.size(); ++i) { qt_config->setArrayIndex(i); diff --git a/src/citra_qt/game_list.cpp b/src/citra_qt/game_list.cpp index 0ae101dc6..6ec24e8eb 100644 --- a/src/citra_qt/game_list.cpp +++ b/src/citra_qt/game_list.cpp @@ -628,6 +628,24 @@ void GameList::RefreshGameDirectory() { } } +QString GameList::FindGameByProgramID(u64 program_id) { + return FindGameByProgramID(item_model->invisibleRootItem(), program_id); +} + +QString GameList::FindGameByProgramID(QStandardItem* current_item, u64 program_id) { + if (current_item->type() == static_cast(GameListItemType::Game) && + current_item->data(GameListItemPath::ProgramIdRole).toULongLong() == program_id) { + return current_item->data(GameListItemPath::FullPathRole).toString(); + } else if (current_item->hasChildren()) { + for (int child_id = 0; child_id < current_item->rowCount(); child_id++) { + QString path = FindGameByProgramID(current_item->child(child_id, 0), program_id); + if (!path.isEmpty()) + return path; + } + } + return ""; +} + void GameListWorker::AddFstEntriesToGameList(const std::string& dir_path, unsigned int recursion, GameListDir* parent_dir) { const auto callback = [this, recursion, parent_dir](u64* num_entries_out, diff --git a/src/citra_qt/game_list.h b/src/citra_qt/game_list.h index a0d383c3e..f8102e0b9 100644 --- a/src/citra_qt/game_list.h +++ b/src/citra_qt/game_list.h @@ -59,6 +59,8 @@ public: QStandardItemModel* GetModel() const; + QString FindGameByProgramID(u64 program_id); + static const QStringList supported_file_extensions; signals: @@ -91,6 +93,8 @@ private: void AddCustomDirPopup(QMenu& context_menu, QModelIndex selected); void AddPermDirPopup(QMenu& context_menu, QModelIndex selected); + QString FindGameByProgramID(QStandardItem* current_item, u64 program_id); + GameListSearchField* search_field; GMainWindow* main_window = nullptr; QVBoxLayout* layout = nullptr; diff --git a/src/citra_qt/main.cpp b/src/citra_qt/main.cpp index 2b6e96f95..b56c953b6 100644 --- a/src/citra_qt/main.cpp +++ b/src/citra_qt/main.cpp @@ -57,6 +57,7 @@ #include "core/gdbstub/gdbstub.h" #include "core/hle/service/fs/archive.h" #include "core/loader/loader.h" +#include "core/movie.h" #include "core/settings.h" #ifdef USE_DISCORD_PRESENCE @@ -527,6 +528,12 @@ void GMainWindow::ConnectMenuEvents() { connect(ui.action_Screen_Layout_Swap_Screens, &QAction::triggered, this, &GMainWindow::OnSwapScreens); + // Movie + connect(ui.action_Record_Movie, &QAction::triggered, this, &GMainWindow::OnRecordMovie); + connect(ui.action_Play_Movie, &QAction::triggered, this, &GMainWindow::OnPlayMovie); + connect(ui.action_Stop_Recording_Playback, &QAction::triggered, this, + &GMainWindow::OnStopRecordingPlayback); + // Help connect(ui.action_FAQ, &QAction::triggered, []() { QDesktopServices::openUrl(QUrl("https://citra-emu.org/wiki/faq/")); }); @@ -775,6 +782,7 @@ void GMainWindow::BootGame(const QString& filename) { void GMainWindow::ShutdownGame() { discord_rpc->Pause(); + OnStopRecordingPlayback(); emu_thread->RequestStop(); // Release emu threads from any breakpoints @@ -1066,6 +1074,13 @@ void GMainWindow::OnMenuRecentFile() { void GMainWindow::OnStartGame() { Camera::QtMultimediaCameraHandler::ResumeCameras(); + + if (movie_record_on_start) { + Core::Movie::GetInstance().StartRecording(movie_record_path.toStdString()); + movie_record_on_start = false; + movie_record_path.clear(); + } + emu_thread->SetRunning(true); qRegisterMetaType("Core::System::ResultStatus"); qRegisterMetaType("std::string"); @@ -1245,6 +1260,127 @@ void GMainWindow::OnCreateGraphicsSurfaceViewer() { graphicsSurfaceViewerWidget->show(); } +void GMainWindow::OnRecordMovie() { + const QString path = + QFileDialog::getSaveFileName(this, tr("Record Movie"), UISettings::values.movie_record_path, + tr("Citra TAS Movie (*.ctm)")); + if (path.isEmpty()) + return; + UISettings::values.movie_record_path = QFileInfo(path).path(); + if (emulation_running) { + Core::Movie::GetInstance().StartRecording(path.toStdString()); + } else { + movie_record_on_start = true; + movie_record_path = path; + QMessageBox::information(this, tr("Record Movie"), + tr("Recording will start once you boot a game.")); + } + ui.action_Record_Movie->setEnabled(false); + ui.action_Play_Movie->setEnabled(false); + ui.action_Stop_Recording_Playback->setEnabled(true); +} + +bool GMainWindow::ValidateMovie(const QString& path, u64 program_id) { + using namespace Core; + Movie::ValidationResult result = + Core::Movie::GetInstance().ValidateMovie(path.toStdString(), program_id); + const QString revision_dismatch_text = + tr("The movie file you are trying to load was created on a different revision of Citra." + "
Citra has had some changes during the time, and the playback may desync or not " + "work as expected." + "

Are you sure you still want to load the movie file?"); + const QString game_dismatch_text = + tr("The movie file you are trying to load was recorded with a different game." + "
The playback may not work as expected, and it may cause unexpected results." + "

Are you sure you still want to load the movie file?"); + const QString invalid_movie_text = + tr("The movie file you are trying to load is invalid." + "
Either the file is corrupted, or Citra has had made some major changes to the " + "Movie module." + "
Please choose a different movie file and try again."); + int answer; + switch (result) { + case Movie::ValidationResult::RevisionDismatch: + answer = QMessageBox::question(this, tr("Revision Dismatch"), revision_dismatch_text, + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer != QMessageBox::Yes) + return false; + break; + case Movie::ValidationResult::GameDismatch: + answer = QMessageBox::question(this, tr("Game Dismatch"), game_dismatch_text, + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer != QMessageBox::Yes) + return false; + break; + case Movie::ValidationResult::Invalid: + QMessageBox::critical(this, tr("Invalid Movie File"), invalid_movie_text); + return false; + default: + break; + } + return true; +} + +void GMainWindow::OnPlayMovie() { + const QString path = + QFileDialog::getOpenFileName(this, tr("Play Movie"), UISettings::values.movie_playback_path, + tr("Citra TAS Movie (*.ctm)")); + if (path.isEmpty()) + return; + UISettings::values.movie_playback_path = QFileInfo(path).path(); + + if (emulation_running) { + if (!ValidateMovie(path)) + return; + } else { + const QString invalid_movie_text = + tr("The movie file you are trying to load is invalid." + "
Either the file is corrupted, or Citra has had made some major changes to the " + "Movie module." + "
Please choose a different movie file and try again."); + u64 program_id = Core::Movie::GetInstance().GetMovieProgramID(path.toStdString()); + if (!program_id) { + QMessageBox::critical(this, tr("Invalid Movie File"), invalid_movie_text); + return; + } + QString game_path = game_list->FindGameByProgramID(program_id); + if (game_path.isEmpty()) { + QMessageBox::warning(this, tr("Game Not Found"), + tr("The movie you are trying to play is from a game that is not " + "in the game list. If you own the game, please add the game " + "folder to the game list and try to play the movie again.")); + return; + } + if (!ValidateMovie(path, program_id)) + return; + BootGame(game_path); + } + Core::Movie::GetInstance().StartPlayback(path.toStdString(), [this] { + QMetaObject::invokeMethod(this, "OnMoviePlaybackCompleted"); + }); + ui.action_Record_Movie->setEnabled(false); + ui.action_Play_Movie->setEnabled(false); + ui.action_Stop_Recording_Playback->setEnabled(true); +} + +void GMainWindow::OnStopRecordingPlayback() { + if (movie_record_on_start) { + QMessageBox::information(this, tr("Record Movie"), tr("Movie recording cancelled.")); + movie_record_on_start = false; + movie_record_path.clear(); + } else { + const bool was_recording = Core::Movie::GetInstance().IsRecordingInput(); + Core::Movie::GetInstance().Shutdown(); + if (was_recording) { + QMessageBox::information(this, tr("Movie Saved"), + tr("The movie is successfully saved.")); + } + } + ui.action_Record_Movie->setEnabled(true); + ui.action_Play_Movie->setEnabled(true); + ui.action_Stop_Recording_Playback->setEnabled(false); +} + void GMainWindow::UpdateStatusBar() { if (emu_thread == nullptr) { status_bar_update_timer.stop(); @@ -1480,6 +1616,13 @@ void GMainWindow::OnLanguageChanged(const QString& locale) { ui.action_Start->setText(tr("Continue")); } +void GMainWindow::OnMoviePlaybackCompleted() { + QMessageBox::information(this, tr("Playback Completed"), tr("Movie playback completed.")); + ui.action_Record_Movie->setEnabled(true); + ui.action_Play_Movie->setEnabled(true); + ui.action_Stop_Recording_Playback->setEnabled(false); +} + void GMainWindow::SetupUIStrings() { if (game_title.isEmpty()) { setWindowTitle(tr("Citra %1").arg(Common::g_build_fullname)); diff --git a/src/citra_qt/main.h b/src/citra_qt/main.h index f6f4eba31..1cb9aebe0 100644 --- a/src/citra_qt/main.h +++ b/src/citra_qt/main.h @@ -176,6 +176,9 @@ private slots: void HideFullscreen(); void ToggleWindowMode(); void OnCreateGraphicsSurfaceViewer(); + void OnRecordMovie(); + void OnPlayMovie(); + void OnStopRecordingPlayback(); void OnCoreError(Core::System::ResultStatus, std::string); /// Called whenever a user selects Help->About Citra void OnMenuAboutCitra(); @@ -185,6 +188,8 @@ private slots: void OnLanguageChanged(const QString& locale); private: + bool ValidateMovie(const QString& path, u64 program_id = 0); + Q_INVOKABLE void OnMoviePlaybackCompleted(); void UpdateStatusBar(); void LoadTranslation(); void SetupUIStrings(); @@ -215,6 +220,10 @@ private: // The path to the game currently running QString game_path; + // Movie + bool movie_record_on_start = false; + QString movie_record_path; + // Debugger panes ProfilerWidget* profilerWidget; MicroProfileDialog* microProfileDialog; diff --git a/src/citra_qt/main.ui b/src/citra_qt/main.ui index bee688600..ef0f1aae2 100644 --- a/src/citra_qt/main.ui +++ b/src/citra_qt/main.ui @@ -107,6 +107,14 @@ + + + Movie + + + + + true @@ -136,6 +144,7 @@ + @@ -243,6 +252,30 @@ Create Pica Surface Viewer + + + true + + + Record Movie + + + + + true + + + Play Movie + + + + + false + + + Stop Recording / Playback + + true diff --git a/src/citra_qt/ui_settings.h b/src/citra_qt/ui_settings.h index 6b76a52f1..1cd94c99d 100644 --- a/src/citra_qt/ui_settings.h +++ b/src/citra_qt/ui_settings.h @@ -63,6 +63,8 @@ struct Values { QString roms_path; QString symbols_path; + QString movie_record_path; + QString movie_playback_path; QString game_dir_deprecated; bool game_dir_deprecated_deepscan; QList game_dirs; diff --git a/src/core/core.cpp b/src/core/core.cpp index 8f84174e4..ffcb94fd8 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -179,7 +179,6 @@ System::ResultStatus System::Init(EmuWindow* emu_window, u32 system_mode) { Kernel::Init(system_mode); Service::Init(service_manager); GDBStub::Init(); - Movie::GetInstance().Init(); ResultStatus result = VideoCore::Init(emu_window); if (result != ResultStatus::Success) { @@ -218,7 +217,6 @@ void System::Shutdown() { perf_results.frametime * 1000.0); // Shutdown emulation session - Movie::GetInstance().Shutdown(); GDBStub::Shutdown(); VideoCore::Shutdown(); Service::Shutdown(); diff --git a/src/core/movie.cpp b/src/core/movie.cpp index 16b3da7ca..04b470782 100644 --- a/src/core/movie.cpp +++ b/src/core/movie.cpp @@ -118,10 +118,10 @@ struct CTMHeader { static_assert(sizeof(CTMHeader) == 256, "CTMHeader should be 256 bytes"); #pragma pack(pop) -bool Movie::IsPlayingInput() { +bool Movie::IsPlayingInput() const { return play_mode == PlayMode::Playing; } -bool Movie::IsRecordingInput() { +bool Movie::IsRecordingInput() const { return play_mode == PlayMode::Recording; } @@ -129,6 +129,7 @@ void Movie::CheckInputEnd() { if (current_byte + sizeof(ControllerState) > recorded_input.size()) { LOG_INFO(Movie, "Playback finished"); play_mode = PlayMode::None; + playback_completion_callback(); } } @@ -343,33 +344,35 @@ void Movie::Record(const Service::IR::ExtraHIDResponse& extra_hid_response) { Record(s); } -bool Movie::ValidateHeader(const CTMHeader& header) { +Movie::ValidationResult Movie::ValidateHeader(const CTMHeader& header, u64 program_id) const { if (header_magic_bytes != header.filetype) { LOG_ERROR(Movie, "Playback file does not have valid header"); - return false; + return ValidationResult::Invalid; } std::string revision = Common::ArrayToString(header.revision.data(), header.revision.size(), 21, false); revision = Common::ToLower(revision); + if (!program_id) + Core::System::GetInstance().GetAppLoader().ReadProgramId(program_id); + if (program_id != header.program_id) { + LOG_WARNING(Movie, "This movie was recorded using a ROM with a different program id"); + return ValidationResult::GameDismatch; + } + if (revision != Common::g_scm_rev) { LOG_WARNING(Movie, "This movie was created on a different version of Citra, playback may desync"); + return ValidationResult::RevisionDismatch; } - u64 program_id; - Core::System::GetInstance().GetAppLoader().ReadProgramId(program_id); - if (program_id != header.program_id) { - LOG_WARNING(Movie, "This movie was recorded using a ROM with a different program id"); - } - - return true; + return ValidationResult::OK; } void Movie::SaveMovie() { - LOG_INFO(Movie, "Saving movie"); - FileUtil::IOFile save_record(Settings::values.movie_record, "wb"); + LOG_INFO(Movie, "Saving recorded movie to '{}'", record_movie_file); + FileUtil::IOFile save_record(record_movie_file, "wb"); if (!save_record.IsGood()) { LOG_ERROR(Movie, "Unable to open file to save movie"); @@ -394,31 +397,63 @@ void Movie::SaveMovie() { } } -void Movie::Init() { - if (!Settings::values.movie_play.empty()) { - LOG_INFO(Movie, "Loading Movie for playback"); - FileUtil::IOFile save_record(Settings::values.movie_play, "rb"); - u64 size = save_record.GetSize(); +void Movie::StartPlayback(const std::string& movie_file, + std::function completion_callback) { + LOG_INFO(Movie, "Loading Movie for playback"); + FileUtil::IOFile save_record(movie_file, "rb"); + const u64 size = save_record.GetSize(); - if (save_record.IsGood() && size > sizeof(CTMHeader)) { - CTMHeader header; - save_record.ReadArray(&header, 1); - if (ValidateHeader(header)) { - play_mode = PlayMode::Playing; - recorded_input.resize(size - sizeof(CTMHeader)); - save_record.ReadArray(recorded_input.data(), recorded_input.size()); - current_byte = 0; - } - } else { - LOG_ERROR(Movie, "Failed to playback movie: Unable to open '{}'", - Settings::values.movie_play); + if (save_record.IsGood() && size > sizeof(CTMHeader)) { + CTMHeader header; + save_record.ReadArray(&header, 1); + if (ValidateHeader(header) != ValidationResult::Invalid) { + play_mode = PlayMode::Playing; + recorded_input.resize(size - sizeof(CTMHeader)); + save_record.ReadArray(recorded_input.data(), recorded_input.size()); + current_byte = 0; + playback_completion_callback = completion_callback; } + } else { + LOG_ERROR(Movie, "Failed to playback movie: Unable to open '{}'", movie_file); + } +} + +void Movie::StartRecording(const std::string& movie_file) { + LOG_INFO(Movie, "Enabling Movie recording"); + play_mode = PlayMode::Recording; + record_movie_file = movie_file; +} + +Movie::ValidationResult Movie::ValidateMovie(const std::string& movie_file, u64 program_id) const { + LOG_INFO(Movie, "Validating Movie file '{}'", movie_file); + FileUtil::IOFile save_record(movie_file, "rb"); + const u64 size = save_record.GetSize(); + + if (!save_record || size <= sizeof(CTMHeader)) { + return ValidationResult::Invalid; } - if (!Settings::values.movie_record.empty()) { - LOG_INFO(Movie, "Enabling Movie recording"); - play_mode = PlayMode::Recording; + CTMHeader header; + save_record.ReadArray(&header, 1); + return ValidateHeader(header, program_id); +} + +u64 Movie::GetMovieProgramID(const std::string& movie_file) const { + FileUtil::IOFile save_record(movie_file, "rb"); + const u64 size = save_record.GetSize(); + + if (!save_record || size <= sizeof(CTMHeader)) { + return 0; } + + CTMHeader header; + save_record.ReadArray(&header, 1); + + if (header_magic_bytes != header.filetype) { + return 0; + } + + return static_cast(header.program_id); } void Movie::Shutdown() { @@ -428,6 +463,7 @@ void Movie::Shutdown() { play_mode = PlayMode::None; recorded_input.resize(0); + record_movie_file.clear(); current_byte = 0; } diff --git a/src/core/movie.h b/src/core/movie.h index 1f1ed6158..6923db3d5 100644 --- a/src/core/movie.h +++ b/src/core/movie.h @@ -4,6 +4,7 @@ #pragma once +#include #include "common/common_types.h" namespace Service { @@ -26,6 +27,12 @@ enum class PlayMode; class Movie { public: + enum class ValidationResult { + OK, + RevisionDismatch, + GameDismatch, + Invalid, + }; /** * Gets the instance of the Movie singleton class. * @returns Reference to the instance of the Movie singleton class. @@ -34,7 +41,11 @@ public: return s_instance; } - void Init(); + void StartPlayback(const std::string& movie_file, + std::function completion_callback = {}); + void StartRecording(const std::string& movie_file); + ValidationResult ValidateMovie(const std::string& movie_file, u64 program_id = 0) const; + u64 GetMovieProgramID(const std::string& movie_file) const; void Shutdown(); @@ -74,14 +85,12 @@ public: * When playing: Replaces the given input states with the ones stored in the playback file */ void HandleExtraHidResponse(Service::IR::ExtraHIDResponse& extra_hid_response); + bool IsPlayingInput() const; + bool IsRecordingInput() const; private: static Movie s_instance; - bool IsPlayingInput(); - - bool IsRecordingInput(); - void CheckInputEnd(); template @@ -103,12 +112,14 @@ private: void Record(const Service::IR::PadState& pad_state, const s16& c_stick_x, const s16& c_stick_y); void Record(const Service::IR::ExtraHIDResponse& extra_hid_response); - bool ValidateHeader(const CTMHeader& header); + ValidationResult ValidateHeader(const CTMHeader& header, u64 program_id = 0) const; void SaveMovie(); PlayMode play_mode; + std::string record_movie_file; std::vector recorded_input; + std::function playback_completion_callback; size_t current_byte = 0; }; } // namespace Core \ No newline at end of file diff --git a/src/core/settings.h b/src/core/settings.h index 762246c33..90b5be6f8 100644 --- a/src/core/settings.h +++ b/src/core/settings.h @@ -156,10 +156,6 @@ struct Values { std::string log_filter; std::unordered_map lle_modules; - // Movie - std::string movie_play; - std::string movie_record; - // WebService bool enable_telemetry; std::string telemetry_endpoint_url;