diff --git a/src/citra/citra.cpp b/src/citra/citra.cpp index 024980a91..a9b0b3eca 100644 --- a/src/citra/citra.cpp +++ b/src/citra/citra.cpp @@ -66,6 +66,7 @@ static void PrintHelp(const char* argv0) { "-m, --multiplayer=nick:password@address:port" " Nickname, password, address and port for multiplayer\n" "-r, --movie-record=[file] Record a movie (game inputs) to the given file\n" + "-a, --movie-record-author=AUTHOR Sets the author of the movie to be recorded\n" "-p, --movie-play=[file] Playback the movie (game inputs) from the given file\n" "-d, --dump-video=[file] Dumps audio and video to the given video file\n" "-f, --fullscreen Start in fullscreen mode\n" @@ -192,6 +193,7 @@ int main(int argc, char** argv) { bool use_gdbstub = Settings::values.use_gdbstub; u32 gdb_port = static_cast(Settings::values.gdbstub_port); std::string movie_record; + std::string movie_record_author; std::string movie_play; std::string dump_video; @@ -217,11 +219,17 @@ int main(int argc, char** argv) { u16 port = Network::DefaultRoomPort; static struct option long_options[] = { - {"gdbport", required_argument, 0, 'g'}, {"install", required_argument, 0, 'i'}, - {"multiplayer", required_argument, 0, 'm'}, {"movie-record", required_argument, 0, 'r'}, - {"movie-play", required_argument, 0, 'p'}, {"dump-video", required_argument, 0, 'd'}, - {"fullscreen", no_argument, 0, 'f'}, {"help", no_argument, 0, 'h'}, - {"version", no_argument, 0, 'v'}, {0, 0, 0, 0}, + {"gdbport", required_argument, 0, 'g'}, + {"install", required_argument, 0, 'i'}, + {"multiplayer", required_argument, 0, 'm'}, + {"movie-record", required_argument, 0, 'r'}, + {"movie-record-author", required_argument, 0, 'a'}, + {"movie-play", required_argument, 0, 'p'}, + {"dump-video", required_argument, 0, 'd'}, + {"fullscreen", no_argument, 0, 'f'}, + {"help", no_argument, 0, 'h'}, + {"version", no_argument, 0, 'v'}, + {0, 0, 0, 0}, }; while (optind < argc) { @@ -285,6 +293,9 @@ int main(int argc, char** argv) { case 'r': movie_record = optarg; break; + case 'a': + movie_record_author = optarg; + break; case 'p': movie_play = optarg; break; @@ -401,10 +412,14 @@ int main(int argc, char** argv) { } if (!movie_play.empty()) { + auto metadata = Core::Movie::GetInstance().GetMovieMetadata(movie_play); + LOG_INFO(Movie, "Author: {}", metadata.author); + LOG_INFO(Movie, "Rerecord count: {}", metadata.rerecord_count); + LOG_INFO(Movie, "Input count: {}", metadata.input_count); Core::Movie::GetInstance().StartPlayback(movie_play); } if (!movie_record.empty()) { - Core::Movie::GetInstance().StartRecording(movie_record); + Core::Movie::GetInstance().StartRecording(movie_record, movie_record_author); } if (!dump_video.empty()) { Layout::FramebufferLayout layout{ diff --git a/src/citra_qt/CMakeLists.txt b/src/citra_qt/CMakeLists.txt index 025c817e6..7fb9b5fb7 100644 --- a/src/citra_qt/CMakeLists.txt +++ b/src/citra_qt/CMakeLists.txt @@ -128,6 +128,12 @@ add_executable(citra-qt main.cpp main.h main.ui + movie/movie_play_dialog.cpp + movie/movie_play_dialog.h + movie/movie_play_dialog.ui + movie/movie_record_dialog.cpp + movie/movie_record_dialog.h + movie/movie_record_dialog.ui multiplayer/chat_room.cpp multiplayer/chat_room.h multiplayer/chat_room.ui diff --git a/src/citra_qt/bootmanager.cpp b/src/citra_qt/bootmanager.cpp index 9c9e99232..8b4c145bf 100644 --- a/src/citra_qt/bootmanager.cpp +++ b/src/citra_qt/bootmanager.cpp @@ -18,6 +18,7 @@ #include "core/3ds.h" #include "core/core.h" #include "core/frontend/scope_acquire_context.h" +#include "core/perf_stats.h" #include "core/settings.h" #include "input_common/keyboard.h" #include "input_common/main.h" @@ -52,6 +53,13 @@ void EmuThread::run() { emit LoadProgress(VideoCore::LoadCallbackStage::Complete, 0, 0); + if (Core::System::GetInstance().frame_limiter.IsFrameAdvancing()) { + // Usually the loading screen is hidden after the first frame is drawn. In this case + // we hide it immediately as we need to wait for user input to start the emulation. + emit HideLoadingScreen(); + Core::System::GetInstance().frame_limiter.WaitOnce(); + } + // Holds whether the cpu was running during the last iteration, // so that the DebugModeLeft signal can be emitted before the // next execution step. diff --git a/src/citra_qt/bootmanager.h b/src/citra_qt/bootmanager.h index c0f1134b8..58c15e4b9 100644 --- a/src/citra_qt/bootmanager.h +++ b/src/citra_qt/bootmanager.h @@ -122,6 +122,8 @@ signals: void ErrorThrown(Core::System::ResultStatus, std::string); void LoadProgress(VideoCore::LoadCallbackStage stage, std::size_t value, std::size_t total); + + void HideLoadingScreen(); }; class OpenGLWindow : public QWindow { diff --git a/src/citra_qt/game_list.cpp b/src/citra_qt/game_list.cpp index f2c0860c7..db7cb7370 100644 --- a/src/citra_qt/game_list.cpp +++ b/src/citra_qt/game_list.cpp @@ -722,17 +722,17 @@ void GameList::RefreshGameDirectory() { } } -QString GameList::FindGameByProgramID(u64 program_id) { - return FindGameByProgramID(item_model->invisibleRootItem(), program_id); +QString GameList::FindGameByProgramID(u64 program_id, int role) { + return FindGameByProgramID(item_model->invisibleRootItem(), program_id, role); } -QString GameList::FindGameByProgramID(QStandardItem* current_item, u64 program_id) { +QString GameList::FindGameByProgramID(QStandardItem* current_item, u64 program_id, int role) { if (current_item->type() == static_cast(GameListItemType::Game) && current_item->data(GameListItemPath::ProgramIdRole).toULongLong() == program_id) { - return current_item->data(GameListItemPath::FullPathRole).toString(); + return current_item->data(role).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); + QString path = FindGameByProgramID(current_item->child(child_id, 0), program_id, role); if (!path.isEmpty()) return path; } diff --git a/src/citra_qt/game_list.h b/src/citra_qt/game_list.h index 8383f9aaf..e76c0edee 100644 --- a/src/citra_qt/game_list.h +++ b/src/citra_qt/game_list.h @@ -70,7 +70,7 @@ public: QStandardItemModel* GetModel() const; - QString FindGameByProgramID(u64 program_id); + QString FindGameByProgramID(u64 program_id, int role); void RefreshGameDirectory(); @@ -105,7 +105,7 @@ private: void AddCustomDirPopup(QMenu& context_menu, QModelIndex selected); void AddPermDirPopup(QMenu& context_menu, QModelIndex selected); - QString FindGameByProgramID(QStandardItem* current_item, u64 program_id); + QString FindGameByProgramID(QStandardItem* current_item, u64 program_id, int role); GameListSearchField* search_field; GMainWindow* main_window = nullptr; diff --git a/src/citra_qt/main.cpp b/src/citra_qt/main.cpp index f4ae1cb98..607cd71b2 100644 --- a/src/citra_qt/main.cpp +++ b/src/citra_qt/main.cpp @@ -51,6 +51,8 @@ #include "citra_qt/hotkeys.h" #include "citra_qt/loading_screen.h" #include "citra_qt/main.h" +#include "citra_qt/movie/movie_play_dialog.h" +#include "citra_qt/movie/movie_record_dialog.h" #include "citra_qt/multiplayer/state.h" #include "citra_qt/qt_image_interface.h" #include "citra_qt/uisettings.h" @@ -174,6 +176,10 @@ GMainWindow::GMainWindow() Network::Init(); + Core::Movie::GetInstance().SetPlaybackCompletionCallback([this] { + QMetaObject::invokeMethod(this, "OnMoviePlaybackCompleted", Qt::BlockingQueuedConnection); + }); + InitializeWidgets(); InitializeDebugWidgets(); InitializeRecentFileMenuActions(); @@ -755,8 +761,10 @@ void GMainWindow::ConnectMenuEvents() { // 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); + connect(ui->action_Close_Movie, &QAction::triggered, this, &GMainWindow::OnCloseMovie); + connect(ui->action_Save_Movie, &QAction::triggered, this, &GMainWindow::OnSaveMovie); + connect(ui->action_Movie_Read_Only_Mode, &QAction::toggled, this, + [this](bool checked) { Core::Movie::GetInstance().SetReadOnly(checked); }); connect(ui->action_Enable_Frame_Advancing, &QAction::triggered, this, [this] { if (emulation_running) { Core::System::GetInstance().frame_limiter.SetFrameAdvancing( @@ -1025,6 +1033,9 @@ void GMainWindow::BootGame(const QString& filename) { if (movie_record_on_start) { Core::Movie::GetInstance().PrepareForRecording(); } + if (movie_playback_on_start) { + Core::Movie::GetInstance().PrepareForPlayback(movie_playback_path.toStdString()); + } // Save configurations UpdateUISettings(); @@ -1034,6 +1045,42 @@ void GMainWindow::BootGame(const QString& filename) { if (!LoadROM(filename)) return; + // Set everything up + if (movie_record_on_start) { + Core::Movie::GetInstance().StartRecording(movie_record_path.toStdString(), + movie_record_author.toStdString()); + movie_record_on_start = false; + movie_record_path.clear(); + movie_record_author.clear(); + } + if (movie_playback_on_start) { + Core::Movie::GetInstance().StartPlayback(movie_playback_path.toStdString()); + movie_playback_on_start = false; + movie_playback_path.clear(); + } + + if (ui->action_Enable_Frame_Advancing->isChecked()) { + ui->action_Advance_Frame->setEnabled(true); + Core::System::GetInstance().frame_limiter.SetFrameAdvancing(true); + } else { + ui->action_Advance_Frame->setEnabled(false); + } + + if (video_dumping_on_start) { + Layout::FramebufferLayout layout{ + Layout::FrameLayoutFromResolutionScale(VideoCore::GetResolutionScaleFactor())}; + if (!Core::System::GetInstance().VideoDumper().StartDumping( + video_dumping_path.toStdString(), layout)) { + + QMessageBox::critical( + this, tr("Citra"), + tr("Could not start video dumping.
Refer to the log for details.")); + ui->action_Dump_Video->setChecked(false); + } + video_dumping_on_start = false; + video_dumping_path.clear(); + } + // Create and start the emulation thread emu_thread = std::make_unique(*render_window); emit EmulationStarting(emu_thread.get()); @@ -1055,6 +1102,8 @@ void GMainWindow::BootGame(const QString& filename) { connect(emu_thread.get(), &EmuThread::LoadProgress, loading_screen, &LoadingScreen::OnLoadProgress, Qt::QueuedConnection); + connect(emu_thread.get(), &EmuThread::HideLoadingScreen, loading_screen, + &LoadingScreen::OnLoadComplete); // Update the GUI registersWidget->OnDebugModeEntered(); @@ -1062,7 +1111,7 @@ void GMainWindow::BootGame(const QString& filename) { game_list->hide(); game_list_placeholder->hide(); } - status_bar_update_timer.start(2000); + status_bar_update_timer.start(1000); if (UISettings::values.hide_mouse) { mouse_hide_timer.start(); @@ -1081,20 +1130,6 @@ void GMainWindow::BootGame(const QString& filename) { ShowFullscreen(); } - if (video_dumping_on_start) { - Layout::FramebufferLayout layout{ - Layout::FrameLayoutFromResolutionScale(VideoCore::GetResolutionScaleFactor())}; - if (!Core::System::GetInstance().VideoDumper().StartDumping( - video_dumping_path.toStdString(), layout)) { - - QMessageBox::critical( - this, tr("Citra"), - tr("Could not start video dumping.
Refer to the log for details.")); - ui->action_Dump_Video->setChecked(false); - } - video_dumping_on_start = false; - video_dumping_path.clear(); - } OnStartGame(); } @@ -1118,7 +1153,6 @@ void GMainWindow::ShutdownGame() { AllowOSSleep(); discord_rpc->Pause(); - OnStopRecordingPlayback(); emu_thread->RequestStop(); // Release emu threads from any breakpoints @@ -1137,6 +1171,8 @@ void GMainWindow::ShutdownGame() { emu_thread->wait(); emu_thread = nullptr; + OnCloseMovie(); + discord_rpc->Update(); Camera::QtMultimediaCameraHandler::ReleaseHandlers(); @@ -1154,8 +1190,6 @@ void GMainWindow::ShutdownGame() { ui->action_Load_Amiibo->setEnabled(false); ui->action_Remove_Amiibo->setEnabled(false); ui->action_Report_Compatibility->setEnabled(false); - ui->action_Enable_Frame_Advancing->setEnabled(false); - ui->action_Enable_Frame_Advancing->setChecked(false); ui->action_Advance_Frame->setEnabled(false); ui->action_Capture_Screenshot->setEnabled(false); render_window->hide(); @@ -1172,6 +1206,7 @@ void GMainWindow::ShutdownGame() { // Disable status bar updates status_bar_update_timer.stop(); message_label->setVisible(false); + message_label_used_for_movie = false; emu_speed_label->setVisible(false); game_fps_label->setVisible(false); emu_frametime_label->setVisible(false); @@ -1545,12 +1580,6 @@ 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(); - } - PreventOSSleep(); emu_thread->SetRunning(true); @@ -1567,7 +1596,6 @@ void GMainWindow::OnStartGame() { ui->action_Cheats->setEnabled(true); ui->action_Load_Amiibo->setEnabled(true); ui->action_Report_Compatibility->setEnabled(true); - ui->action_Enable_Frame_Advancing->setEnabled(true); ui->action_Capture_Screenshot->setEnabled(true); discord_rpc->Update(); @@ -1851,144 +1879,81 @@ void GMainWindow::OnCreateGraphicsSurfaceViewer() { } void GMainWindow::OnRecordMovie() { - if (emulation_running) { - QMessageBox::StandardButton answer = QMessageBox::warning( - this, tr("Record Movie"), - tr("To keep consistency with the RNG, it is recommended to record the movie from game " - "start.
Are you sure you still want to record movies now?"), - QMessageBox::Yes | QMessageBox::No); - if (answer == QMessageBox::No) - return; - } - const QString path = - QFileDialog::getSaveFileName(this, tr("Record Movie"), UISettings::values.movie_record_path, - tr("Citra TAS Movie (*.ctm)")); - if (path.isEmpty()) + MovieRecordDialog dialog(this); + if (dialog.exec() != QDialog::Accepted) { 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; + movie_record_on_start = true; + movie_record_path = dialog.GetPath(); + movie_record_author = dialog.GetAuthor(); + + if (emulation_running) { // Restart game + BootGame(QString(game_path)); } - return true; + ui->action_Close_Movie->setEnabled(true); + ui->action_Save_Movie->setEnabled(true); } void GMainWindow::OnPlayMovie() { - if (emulation_running) { - QMessageBox::StandardButton answer = QMessageBox::warning( - this, tr("Play Movie"), - tr("To keep consistency with the RNG, it is recommended to play the movie from game " - "start.
Are you sure you still want to play movies now?"), - QMessageBox::Yes | QMessageBox::No); - if (answer == QMessageBox::No) - return; - } - - const QString path = - QFileDialog::getOpenFileName(this, tr("Play Movie"), UISettings::values.movie_playback_path, - tr("Citra TAS Movie (*.ctm)")); - if (path.isEmpty()) + MoviePlayDialog dialog(this, game_list); + if (dialog.exec() != QDialog::Accepted) { 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; - Core::Movie::GetInstance().PrepareForPlayback(path.toStdString()); - 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); + + movie_playback_on_start = true; + movie_playback_path = dialog.GetMoviePath(); + BootGame(dialog.GetGamePath()); + + ui->action_Close_Movie->setEnabled(true); + ui->action_Save_Movie->setEnabled(false); } -void GMainWindow::OnStopRecordingPlayback() { +void GMainWindow::OnCloseMovie() { if (movie_record_on_start) { QMessageBox::information(this, tr("Record Movie"), tr("Movie recording cancelled.")); movie_record_on_start = false; movie_record_path.clear(); + movie_record_author.clear(); } else { - const bool was_recording = Core::Movie::GetInstance().IsRecordingInput(); + const bool was_running = emu_thread && emu_thread->IsRunning(); + if (was_running) { + OnPauseGame(); + } + + const bool was_recording = + Core::Movie::GetInstance().GetPlayMode() == Core::Movie::PlayMode::Recording; Core::Movie::GetInstance().Shutdown(); if (was_recording) { QMessageBox::information(this, tr("Movie Saved"), tr("The movie is successfully saved.")); } + + if (was_running) { + OnStartGame(); + } + } + + ui->action_Close_Movie->setEnabled(false); + ui->action_Save_Movie->setEnabled(false); +} + +void GMainWindow::OnSaveMovie() { + const bool was_running = emu_thread && emu_thread->IsRunning(); + if (was_running) { + OnPauseGame(); + } + + if (Core::Movie::GetInstance().GetPlayMode() == Core::Movie::PlayMode::Recording) { + Core::Movie::GetInstance().SaveMovie(); + QMessageBox::information(this, tr("Movie Saved"), tr("The movie is successfully saved.")); + } else { + LOG_ERROR(Frontend, "Tried to save movie while movie is not being recorded"); + } + + if (was_running) { + OnStartGame(); } - ui->action_Record_Movie->setEnabled(true); - ui->action_Play_Movie->setEnabled(true); - ui->action_Stop_Recording_Playback->setEnabled(false); } void GMainWindow::OnCaptureScreenshot() { @@ -2067,6 +2032,32 @@ void GMainWindow::UpdateStatusBar() { return; } + // Update movie status + const u64 current = Core::Movie::GetInstance().GetCurrentInputIndex(); + const u64 total = Core::Movie::GetInstance().GetTotalInputCount(); + const auto play_mode = Core::Movie::GetInstance().GetPlayMode(); + if (play_mode == Core::Movie::PlayMode::Recording) { + message_label->setText(tr("Recording %1").arg(current)); + message_label->setVisible(true); + message_label_used_for_movie = true; + ui->action_Save_Movie->setEnabled(true); + } else if (play_mode == Core::Movie::PlayMode::Playing) { + message_label->setText(tr("Playing %1 / %2").arg(current).arg(total)); + message_label->setVisible(true); + message_label_used_for_movie = true; + ui->action_Save_Movie->setEnabled(false); + } else if (play_mode == Core::Movie::PlayMode::MovieFinished) { + message_label->setText(tr("Movie Finished")); + message_label->setVisible(true); + message_label_used_for_movie = true; + ui->action_Save_Movie->setEnabled(false); + } else if (message_label_used_for_movie) { // Clear the label if movie was just closed + message_label->setText(QString{}); + message_label->setVisible(false); + message_label_used_for_movie = false; + ui->action_Save_Movie->setEnabled(false); + } + auto results = Core::System::GetInstance().GetAndResetPerfStats(); if (Settings::values.use_frame_limit_alternate) { @@ -2178,6 +2169,7 @@ void GMainWindow::OnCoreError(Core::System::ResultStatus result, std::string det emu_thread->SetRunning(true); message_label->setText(status_message); message_label->setVisible(true); + message_label_used_for_movie = false; } } } @@ -2356,10 +2348,8 @@ void GMainWindow::OnLanguageChanged(const QString& locale) { } void GMainWindow::OnMoviePlaybackCompleted() { + OnPauseGame(); 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::UpdateWindowTitle() { diff --git a/src/citra_qt/main.h b/src/citra_qt/main.h index 241365a38..970f19cad 100644 --- a/src/citra_qt/main.h +++ b/src/citra_qt/main.h @@ -208,7 +208,8 @@ private slots: void OnCreateGraphicsSurfaceViewer(); void OnRecordMovie(); void OnPlayMovie(); - void OnStopRecordingPlayback(); + void OnCloseMovie(); + void OnSaveMovie(); void OnCaptureScreenshot(); #ifdef ENABLE_FFMPEG_VIDEO_DUMPER void OnStartVideoDumping(); @@ -224,7 +225,6 @@ private slots: void OnMouseActivity(); private: - bool ValidateMovie(const QString& path, u64 program_id = 0); Q_INVOKABLE void OnMoviePlaybackCompleted(); void UpdateStatusBar(); void LoadTranslation(); @@ -249,6 +249,7 @@ private: QLabel* game_fps_label = nullptr; QLabel* emu_frametime_label = nullptr; QTimer status_bar_update_timer; + bool message_label_used_for_movie = false; MultiplayerState* multiplayer_state = nullptr; std::unique_ptr config; @@ -267,6 +268,10 @@ private: // Movie bool movie_record_on_start = false; QString movie_record_path; + QString movie_record_author; + + bool movie_playback_on_start = false; + QString movie_playback_path; // Video dumping bool video_dumping_on_start = false; diff --git a/src/citra_qt/main.ui b/src/citra_qt/main.ui index 9fe3d2a3e..ab76cff2f 100644 --- a/src/citra_qt/main.ui +++ b/src/citra_qt/main.ui @@ -163,7 +163,10 @@ - + + + + @@ -318,36 +321,43 @@ - - true - - Record Movie + Record... - - true - - Play Movie + Play... - + + + Close + + + false - Stop Recording / Playback + Save without Closing + + + + + true + + + true + + + Read-Only Mode true - - false - Enable Frame Advancing diff --git a/src/citra_qt/movie/movie_play_dialog.cpp b/src/citra_qt/movie/movie_play_dialog.cpp new file mode 100644 index 000000000..0a389985a --- /dev/null +++ b/src/citra_qt/movie/movie_play_dialog.cpp @@ -0,0 +1,130 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include "citra_qt/game_list.h" +#include "citra_qt/game_list_p.h" +#include "citra_qt/movie/movie_play_dialog.h" +#include "citra_qt/uisettings.h" +#include "core/core.h" +#include "core/core_timing.h" +#include "core/hle/service/hid/hid.h" +#include "core/movie.h" +#include "ui_movie_play_dialog.h" + +MoviePlayDialog::MoviePlayDialog(QWidget* parent, GameList* game_list_) + : QDialog(parent), ui(std::make_unique()), game_list(game_list_) { + ui->setupUi(this); + + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + + connect(ui->filePathButton, &QToolButton::clicked, this, &MoviePlayDialog::OnToolButtonClicked); + connect(ui->filePath, &QLineEdit::editingFinished, this, &MoviePlayDialog::UpdateUIDisplay); + connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &MoviePlayDialog::accept); + connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &MoviePlayDialog::reject); + + if (Core::System::GetInstance().IsPoweredOn()) { + QString note_text; + note_text = tr("Current running game will be stopped."); + if (Core::Movie::GetInstance().GetPlayMode() == Core::Movie::PlayMode::Recording) { + note_text.append(tr("
Current recording will be discarded.")); + } + ui->note2Label->setText(note_text); + } +} + +MoviePlayDialog::~MoviePlayDialog() = default; + +QString MoviePlayDialog::GetMoviePath() const { + return ui->filePath->text(); +} + +QString MoviePlayDialog::GetGamePath() const { + const auto metadata = Core::Movie::GetInstance().GetMovieMetadata(GetMoviePath().toStdString()); + return game_list->FindGameByProgramID(metadata.program_id, GameListItemPath::FullPathRole); +} + +void MoviePlayDialog::OnToolButtonClicked() { + const QString path = + QFileDialog::getOpenFileName(this, tr("Play Movie"), UISettings::values.movie_playback_path, + tr("Citra TAS Movie (*.ctm)")); + if (path.isEmpty()) { + return; + } + ui->filePath->setText(path); + UISettings::values.movie_playback_path = path; + UpdateUIDisplay(); +} + +void MoviePlayDialog::UpdateUIDisplay() { + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + ui->gameLineEdit->clear(); + ui->authorLineEdit->clear(); + ui->rerecordCountLineEdit->clear(); + ui->lengthLineEdit->clear(); + ui->note1Label->setVisible(true); + + const auto path = GetMoviePath().toStdString(); + + const auto validation_result = Core::Movie::GetInstance().ValidateMovie(path); + if (validation_result == Core::Movie::ValidationResult::Invalid) { + ui->note1Label->setText(tr("Invalid movie file.")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + return; + } + + ui->note2Label->setVisible(true); + ui->infoGroupBox->setVisible(true); + + switch (validation_result) { + case Core::Movie::ValidationResult::OK: + ui->note1Label->setText(QString{}); + break; + case Core::Movie::ValidationResult::RevisionDismatch: + ui->note1Label->setText(tr("Revision dismatch, playback may desync.")); + break; + case Core::Movie::ValidationResult::InputCountDismatch: + ui->note1Label->setText(tr("Indicated length is incorrect, file may be corrupted.")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + break; + default: + UNREACHABLE(); + } + + const auto metadata = Core::Movie::GetInstance().GetMovieMetadata(path); + + // Format game title + const auto title = + game_list->FindGameByProgramID(metadata.program_id, GameListItemPath::TitleRole); + if (title.isEmpty()) { + ui->gameLineEdit->setText(tr("(unknown)")); + ui->note1Label->setText(tr("Game used in this movie is not in game list.")); + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + } else { + ui->gameLineEdit->setText(title); + } + + ui->authorLineEdit->setText(metadata.author.empty() ? tr("(unknown)") + : QString::fromStdString(metadata.author)); + ui->rerecordCountLineEdit->setText( + metadata.rerecord_count == 0 ? tr("(unknown)") : QString::number(metadata.rerecord_count)); + + // Format length + if (metadata.input_count == 0) { + ui->lengthLineEdit->setText(tr("(unknown)")); + } else { + if (metadata.input_count > + BASE_CLOCK_RATE_ARM11 * 24 * 60 * 60 / Service::HID::Module::pad_update_ticks) { + // More than a day + ui->lengthLineEdit->setText(tr("(>1 day)")); + } else { + const u64 msecs = Service::HID::Module::pad_update_ticks * metadata.input_count * 1000 / + BASE_CLOCK_RATE_ARM11; + ui->lengthLineEdit->setText( + QTime::fromMSecsSinceStartOfDay(msecs).toString(QStringLiteral("hh:mm:ss.zzz"))); + } + } +} diff --git a/src/citra_qt/movie/movie_play_dialog.h b/src/citra_qt/movie/movie_play_dialog.h new file mode 100644 index 000000000..dc4f344a5 --- /dev/null +++ b/src/citra_qt/movie/movie_play_dialog.h @@ -0,0 +1,30 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include + +class GameList; + +namespace Ui { +class MoviePlayDialog; +} + +class MoviePlayDialog : public QDialog { + Q_OBJECT + +public: + explicit MoviePlayDialog(QWidget* parent, GameList* game_list); + ~MoviePlayDialog() override; + + QString GetMoviePath() const; + QString GetGamePath() const; + +private: + void OnToolButtonClicked(); + void UpdateUIDisplay(); + + std::unique_ptr ui; + GameList* game_list; +}; diff --git a/src/citra_qt/movie/movie_play_dialog.ui b/src/citra_qt/movie/movie_play_dialog.ui new file mode 100644 index 000000000..ad9b595cd --- /dev/null +++ b/src/citra_qt/movie/movie_play_dialog.ui @@ -0,0 +1,136 @@ + + + MoviePlayDialog + + + + 0 + 0 + 600 + 100 + + + + Play Movie + + + + + + + + File: + + + + + + + + + + ... + + + + + + + + + false + + + + + + + Info + + + false + + + + + + Game: + + + + + + + true + + + + + + + Author: + + + + + + + true + + + + + + + Rerecord Count: + + + + + + + true + + + + + + + Length: + + + + + + + true + + + + + + + + + + Qt::Vertical + + + + + + + false + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + diff --git a/src/citra_qt/movie/movie_record_dialog.cpp b/src/citra_qt/movie/movie_record_dialog.cpp new file mode 100644 index 000000000..9b7967d9a --- /dev/null +++ b/src/citra_qt/movie/movie_record_dialog.cpp @@ -0,0 +1,61 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include "citra_qt/movie/movie_record_dialog.h" +#include "citra_qt/uisettings.h" +#include "core/core.h" +#include "core/movie.h" +#include "ui_movie_record_dialog.h" + +MovieRecordDialog::MovieRecordDialog(QWidget* parent) + : QDialog(parent), ui(std::make_unique()) { + ui->setupUi(this); + + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + + connect(ui->filePathButton, &QToolButton::clicked, this, + &MovieRecordDialog::OnToolButtonClicked); + connect(ui->filePath, &QLineEdit::editingFinished, this, &MovieRecordDialog::UpdateUIDisplay); + connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &MovieRecordDialog::accept); + connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &MovieRecordDialog::reject); + + QString note_text; + if (Core::System::GetInstance().IsPoweredOn()) { + note_text = tr("Current running game will be restarted."); + if (Core::Movie::GetInstance().GetPlayMode() == Core::Movie::PlayMode::Recording) { + note_text.append(tr("
Current recording will be discarded.")); + } + } else { + note_text = tr("Recording will start once you boot a game."); + } + ui->noteLabel->setText(note_text); +} + +MovieRecordDialog::~MovieRecordDialog() = default; + +QString MovieRecordDialog::GetPath() const { + return ui->filePath->text(); +} + +QString MovieRecordDialog::GetAuthor() const { + return ui->authorLineEdit->text(); +} + +void MovieRecordDialog::OnToolButtonClicked() { + const QString path = + QFileDialog::getSaveFileName(this, tr("Record Movie"), UISettings::values.movie_record_path, + tr("Citra TAS Movie (*.ctm)")); + if (path.isEmpty()) { + return; + } + ui->filePath->setText(path); + UISettings::values.movie_record_path = path; + UpdateUIDisplay(); +} + +void MovieRecordDialog::UpdateUIDisplay() { + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(!ui->filePath->text().isEmpty()); +} diff --git a/src/citra_qt/movie/movie_record_dialog.h b/src/citra_qt/movie/movie_record_dialog.h new file mode 100644 index 000000000..c91f1f414 --- /dev/null +++ b/src/citra_qt/movie/movie_record_dialog.h @@ -0,0 +1,27 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include + +namespace Ui { +class MovieRecordDialog; +} + +class MovieRecordDialog : public QDialog { + Q_OBJECT + +public: + explicit MovieRecordDialog(QWidget* parent); + ~MovieRecordDialog() override; + + QString GetPath() const; + QString GetAuthor() const; + +private: + void OnToolButtonClicked(); + void UpdateUIDisplay(); + + std::unique_ptr ui; +}; diff --git a/src/citra_qt/movie/movie_record_dialog.ui b/src/citra_qt/movie/movie_record_dialog.ui new file mode 100644 index 000000000..96298b8e4 --- /dev/null +++ b/src/citra_qt/movie/movie_record_dialog.ui @@ -0,0 +1,71 @@ + + + MovieRecordDialog + + + + 0 + 0 + 600 + 150 + + + + Record Movie + + + + + + + + File: + + + + + + + + + + ... + + + + + + + Author: + + + + + + + 32 + + + + + + + + + Qt::Vertical + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + diff --git a/src/core/core.cpp b/src/core/core.cpp index 95e4035bd..a11ec27ac 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -630,6 +630,7 @@ void System::serialize(Archive& ar, const unsigned int file_version) { // This needs to be set from somewhere - might as well be here! if (Archive::is_loading::value) { + timing->UnlockEventQueue(); Service::GSP::SetGlobalModule(*this); memory->SetDSP(*dsp_core); cheat_engine->Connect(); diff --git a/src/core/core_timing.cpp b/src/core/core_timing.cpp index 3d77932b6..4aa6d870e 100644 --- a/src/core/core_timing.cpp +++ b/src/core/core_timing.cpp @@ -49,6 +49,10 @@ TimingEventType* Timing::RegisterEvent(const std::string& name, TimedCallback ca void Timing::ScheduleEvent(s64 cycles_into_future, const TimingEventType* event_type, u64 userdata, std::size_t core_id) { + if (event_queue_locked) { + return; + } + ASSERT(event_type != nullptr); Timing::Timer* timer = nullptr; if (core_id == std::numeric_limits::max()) { @@ -74,6 +78,9 @@ void Timing::ScheduleEvent(s64 cycles_into_future, const TimingEventType* event_ } void Timing::UnscheduleEvent(const TimingEventType* event_type, u64 userdata) { + if (event_queue_locked) { + return; + } for (auto timer : timers) { auto itr = std::remove_if( timer->event_queue.begin(), timer->event_queue.end(), @@ -89,6 +96,9 @@ void Timing::UnscheduleEvent(const TimingEventType* event_type, u64 userdata) { } void Timing::RemoveEvent(const TimingEventType* event_type) { + if (event_queue_locked) { + return; + } for (auto timer : timers) { auto itr = std::remove_if(timer->event_queue.begin(), timer->event_queue.end(), [&](const Event& e) { return e.type == event_type; }); diff --git a/src/core/core_timing.h b/src/core/core_timing.h index aebe5c742..611122211 100644 --- a/src/core/core_timing.h +++ b/src/core/core_timing.h @@ -280,6 +280,11 @@ public: std::shared_ptr GetTimer(std::size_t cpu_id); + // Used after deserializing to unprotect the event queue. + void UnlockEventQueue() { + event_queue_locked = false; + } + private: // unordered_map stores each element separately as a linked list node so pointers to // elements remain stable regardless of rehashes/resizing. @@ -292,6 +297,10 @@ private: // under/overclocking the guest cpu double cpu_clock_scale = 1.0; + // When true, the event queue can't be modified. Used while deserializing to workaround + // destructor side effects. + bool event_queue_locked = false; + template void serialize(Archive& ar, const unsigned int file_version) { // event_types set during initialization of other things @@ -303,6 +312,9 @@ private: } else { ar& current_timer; } + if (Archive::is_loading::value) { + event_queue_locked = true; + } } friend class boost::serialization::access; }; diff --git a/src/core/hle/service/hid/hid.cpp b/src/core/hle/service/hid/hid.cpp index c3034b824..c59826551 100644 --- a/src/core/hle/service/hid/hid.cpp +++ b/src/core/hle/service/hid/hid.cpp @@ -12,7 +12,6 @@ #include "common/logging/log.h" #include "core/3ds.h" #include "core/core.h" -#include "core/core_timing.h" #include "core/hle/ipc_helpers.h" #include "core/hle/kernel/event.h" #include "core/hle/kernel/handle_table.h" @@ -55,11 +54,6 @@ void Module::serialize(Archive& ar, const unsigned int file_version) { } SERIALIZE_IMPL(Module) -// Updating period for each HID device. These empirical values are measured from a 11.2 3DS. -constexpr u64 pad_update_ticks = BASE_CLOCK_RATE_ARM11 / 234; -constexpr u64 accelerometer_update_ticks = BASE_CLOCK_RATE_ARM11 / 104; -constexpr u64 gyroscope_update_ticks = BASE_CLOCK_RATE_ARM11 / 101; - constexpr float accelerometer_coef = 512.0f; // measured from hw test result constexpr float gyroscope_coef = 14.375f; // got from hwtest GetGyroscopeLowRawToDpsCoefficient call diff --git a/src/core/hle/service/hid/hid.h b/src/core/hle/service/hid/hid.h index bdd106018..b364c4be8 100644 --- a/src/core/hle/service/hid/hid.h +++ b/src/core/hle/service/hid/hid.h @@ -13,6 +13,7 @@ #include "common/bit_field.h" #include "common/common_funcs.h" #include "common/common_types.h" +#include "core/core_timing.h" #include "core/frontend/input.h" #include "core/hle/service/service.h" #include "core/settings.h" @@ -299,6 +300,11 @@ public: const PadState& GetState() const; + // Updating period for each HID device. These empirical values are measured from a 11.2 3DS. + static constexpr u64 pad_update_ticks = BASE_CLOCK_RATE_ARM11 / 234; + static constexpr u64 accelerometer_update_ticks = BASE_CLOCK_RATE_ARM11 / 104; + static constexpr u64 gyroscope_update_ticks = BASE_CLOCK_RATE_ARM11 / 101; + private: void LoadInputDevices(); void UpdatePadCallback(u64 userdata, s64 cycles_late); diff --git a/src/core/movie.cpp b/src/core/movie.cpp index 97c96ba3c..49d1e654f 100644 --- a/src/core/movie.cpp +++ b/src/core/movie.cpp @@ -2,11 +2,14 @@ // Licensed under GPLv2 or any later version // Refer to the license.txt file included. +#include #include +#include #include #include #include #include +#include #include "common/bit_field.h" #include "common/common_types.h" #include "common/file_util.h" @@ -25,8 +28,6 @@ namespace Core { /*static*/ Movie Movie::s_instance; -enum class PlayMode { None, Recording, Playing }; - enum class ControllerStateType : u8 { PadAndCircle, Touch, @@ -117,24 +118,120 @@ struct CTMHeader { u64_le program_id; /// ID of the ROM being executed. Also called title_id std::array revision; /// Git hash of the revision this movie was created with u64_le clock_init_time; /// The init time of the system clock + u64_le id; /// Unique identifier of the movie, used to support separate savestate slots + std::array author; /// Author of the movie + u32_le rerecord_count; /// Number of rerecords when making the movie + u64_le input_count; /// Number of inputs (button and pad states) when making the movie - std::array reserved; /// Make heading 256 bytes so it has consistent size + std::array reserved; /// Make heading 256 bytes so it has consistent size }; static_assert(sizeof(CTMHeader) == 256, "CTMHeader should be 256 bytes"); #pragma pack(pop) -bool Movie::IsPlayingInput() const { - return play_mode == PlayMode::Playing; +static u64 GetInputCount(const std::vector& input) { + u64 input_count = 0; + for (std::size_t pos = 0; pos < input.size(); pos += sizeof(ControllerState)) { + if (input.size() < pos + sizeof(ControllerState)) { + break; + } + + ControllerState state; + std::memcpy(&state, input.data() + pos, sizeof(ControllerState)); + if (state.type == ControllerStateType::PadAndCircle) { + input_count++; + } + } + return input_count; } -bool Movie::IsRecordingInput() const { - return play_mode == PlayMode::Recording; + +template +void Movie::serialize(Archive& ar, const unsigned int file_version) { + // Only serialize what's needed to make savestates useful for TAS: + u64 _current_byte = static_cast(current_byte); + ar& _current_byte; + current_byte = static_cast(_current_byte); + + if (file_version > 0) { + ar& current_input; + } + + std::vector recorded_input_ = recorded_input; + ar& recorded_input_; + + ar& init_time; + + if (file_version > 0) { + if (Archive::is_loading::value) { + u64 savestate_movie_id; + ar& savestate_movie_id; + if (id != savestate_movie_id) { + if (savestate_movie_id == 0) { + throw std::runtime_error("You must close your movie to load this state"); + } else { + throw std::runtime_error("You must load the same movie to load this state"); + } + } + } else { + ar& id; + } + } + + // Whether the state was made in MovieFinished state + bool post_movie = play_mode == PlayMode::MovieFinished; + if (file_version > 0) { + ar& post_movie; + } + + if (Archive::is_loading::value && id != 0) { + if (!read_only) { + recorded_input = std::move(recorded_input_); + } + + if (post_movie) { + play_mode = PlayMode::MovieFinished; + return; + } + + if (read_only) { + if (play_mode == PlayMode::Recording) { + SaveMovie(); + } + if (recorded_input_.size() >= recorded_input.size()) { + throw std::runtime_error("Future event savestate not allowed in R/O mode"); + } + // Ensure that the current movie and savestate movie are in the same timeline + if (std::mismatch(recorded_input_.begin(), recorded_input_.end(), + recorded_input.begin()) + .first != recorded_input_.end()) { + throw std::runtime_error("Timeline mismatch not allowed in R/O mode"); + } + + play_mode = PlayMode::Playing; + total_input = GetInputCount(recorded_input); + } else { + play_mode = PlayMode::Recording; + rerecord_count++; + } + } +} + +SERIALIZE_IMPL(Movie) + +Movie::PlayMode Movie::GetPlayMode() const { + return play_mode; +} + +u64 Movie::GetCurrentInputIndex() const { + return current_input; +} +u64 Movie::GetTotalInputCount() const { + return total_input; } void Movie::CheckInputEnd() { if (current_byte + sizeof(ControllerState) > recorded_input.size()) { LOG_INFO(Movie, "Playback finished"); - play_mode = PlayMode::None; - init_time = 0; + play_mode = PlayMode::MovieFinished; playback_completion_callback(); } } @@ -143,6 +240,7 @@ void Movie::Play(Service::HID::PadState& pad_state, s16& circle_pad_x, s16& circ ControllerState s; std::memcpy(&s, &recorded_input[current_byte], sizeof(ControllerState)); current_byte += sizeof(ControllerState); + current_input++; if (s.type != ControllerStateType::PadAndCircle) { LOG_ERROR(Movie, @@ -270,6 +368,8 @@ void Movie::Record(const ControllerState& controller_state) { void Movie::Record(const Service::HID::PadState& pad_state, const s16& circle_pad_x, const s16& circle_pad_y) { + current_input++; + ControllerState s; s.type = ControllerStateType::PadAndCircle; @@ -358,21 +458,13 @@ u64 Movie::GetOverrideInitTime() const { return init_time; } -Movie::ValidationResult Movie::ValidateHeader(const CTMHeader& header, u64 program_id) const { +Movie::ValidationResult Movie::ValidateHeader(const CTMHeader& header) const { if (header_magic_bytes != header.filetype) { LOG_ERROR(Movie, "Playback file does not have valid header"); return ValidationResult::Invalid; } std::string revision = fmt::format("{:02x}", fmt::join(header.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"); @@ -382,6 +474,12 @@ Movie::ValidationResult Movie::ValidateHeader(const CTMHeader& header, u64 progr return ValidationResult::OK; } +Movie::ValidationResult Movie::ValidateInput(const std::vector& input, + u64 expected_count) const { + return GetInputCount(input) == expected_count ? ValidationResult::OK + : ValidationResult::InputCountDismatch; +} + void Movie::SaveMovie() { LOG_INFO(Movie, "Saving recorded movie to '{}'", record_movie_file); FileUtil::IOFile save_record(record_movie_file, "wb"); @@ -393,9 +491,15 @@ void Movie::SaveMovie() { CTMHeader header = {}; header.filetype = header_magic_bytes; + header.program_id = program_id; header.clock_init_time = init_time; + header.id = id; - Core::System::GetInstance().GetAppLoader().ReadProgramId(header.program_id); + std::memcpy(header.author.data(), record_movie_author.data(), + std::min(header.author.size(), record_movie_author.size())); + + header.rerecord_count = rerecord_count; + header.input_count = GetInputCount(recorded_input); std::string rev_bytes; CryptoPP::StringSource(Common::g_scm_rev, true, @@ -410,8 +514,11 @@ void Movie::SaveMovie() { } } -void Movie::StartPlayback(const std::string& movie_file, - std::function completion_callback) { +void Movie::SetPlaybackCompletionCallback(std::function completion_callback) { + playback_completion_callback = completion_callback; +} + +void Movie::StartPlayback(const std::string& movie_file) { LOG_INFO(Movie, "Loading Movie for playback"); FileUtil::IOFile save_record(movie_file, "rb"); const u64 size = save_record.GetSize(); @@ -421,20 +528,49 @@ void Movie::StartPlayback(const std::string& movie_file, save_record.ReadArray(&header, 1); if (ValidateHeader(header) != ValidationResult::Invalid) { play_mode = PlayMode::Playing; + record_movie_file = movie_file; + + std::array author{}; // Add a null terminator + std::memcpy(author.data(), header.author.data(), header.author.size()); + record_movie_author = author.data(); + + rerecord_count = header.rerecord_count; + total_input = header.input_count; + recorded_input.resize(size - sizeof(CTMHeader)); save_record.ReadArray(recorded_input.data(), recorded_input.size()); + current_byte = 0; - playback_completion_callback = completion_callback; + current_input = 0; + id = header.id; + program_id = header.program_id; + + LOG_INFO(Movie, "Loaded Movie, ID: {:016X}", id); } } 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"); +void Movie::StartRecording(const std::string& movie_file, const std::string& author) { play_mode = PlayMode::Recording; record_movie_file = movie_file; + record_movie_author = author; + rerecord_count = 1; + + // Generate a random ID + CryptoPP::AutoSeededRandomPool rng; + rng.GenerateBlock(reinterpret_cast(&id), sizeof(id)); + + // Get program ID + program_id = 0; + Core::System::GetInstance().GetAppLoader().ReadProgramId(program_id); + + LOG_INFO(Movie, "Enabling Movie recording, ID: {:016X}", id); +} + +void Movie::SetReadOnly(bool read_only_) { + read_only = read_only_; } static boost::optional ReadHeader(const std::string& movie_file) { @@ -469,25 +605,51 @@ void Movie::PrepareForRecording() { : Settings::values.init_time); } -Movie::ValidationResult Movie::ValidateMovie(const std::string& movie_file, u64 program_id) const { +Movie::ValidationResult Movie::ValidateMovie(const std::string& movie_file) const { LOG_INFO(Movie, "Validating Movie file '{}'", movie_file); - auto header = ReadHeader(movie_file); - if (header == boost::none) - return ValidationResult::Invalid; - return ValidateHeader(header.value(), program_id); + FileUtil::IOFile save_record(movie_file, "rb"); + const u64 size = save_record.GetSize(); + + if (!save_record || size <= sizeof(CTMHeader)) { + return ValidationResult::Invalid; + } + + CTMHeader header; + save_record.ReadArray(&header, 1); + + if (header_magic_bytes != header.filetype) { + return ValidationResult::Invalid; + } + + auto result = ValidateHeader(header); + if (result != ValidationResult::OK) { + return result; + } + + if (!header.input_count) { // Probably created by an older version. + return ValidationResult::OK; + } + + std::vector input(size - sizeof(header)); + save_record.ReadArray(input.data(), input.size()); + return ValidateInput(input, header.input_count); } -u64 Movie::GetMovieProgramID(const std::string& movie_file) const { +Movie::MovieMetadata Movie::GetMovieMetadata(const std::string& movie_file) const { auto header = ReadHeader(movie_file); if (header == boost::none) - return 0; + return {}; - return static_cast(header.value().program_id); + std::array author{}; // Add a null terminator + std::memcpy(author.data(), header->author.data(), header->author.size()); + + return {header->program_id, std::string{author.data()}, header->rerecord_count, + header->input_count}; } void Movie::Shutdown() { - if (IsRecordingInput()) { + if (play_mode == PlayMode::Recording) { SaveMovie(); } @@ -495,16 +657,18 @@ void Movie::Shutdown() { recorded_input.resize(0); record_movie_file.clear(); current_byte = 0; + current_input = 0; init_time = 0; + id = 0; } template void Movie::Handle(Targs&... Fargs) { - if (IsPlayingInput()) { + if (play_mode == PlayMode::Playing) { ASSERT(current_byte + sizeof(ControllerState) <= recorded_input.size()); Play(Fargs...); CheckInputEnd(); - } else if (IsRecordingInput()) { + } else if (play_mode == PlayMode::Recording) { Record(Fargs...); } } diff --git a/src/core/movie.h b/src/core/movie.h index e578b909c..d4b615876 100644 --- a/src/core/movie.h +++ b/src/core/movie.h @@ -24,14 +24,14 @@ union PadState; namespace Core { struct CTMHeader; struct ControllerState; -enum class PlayMode; class Movie { public: + enum class PlayMode { None, Recording, Playing, MovieFinished }; enum class ValidationResult { OK, RevisionDismatch, - GameDismatch, + InputCountDismatch, Invalid, }; /** @@ -42,9 +42,21 @@ public: return s_instance; } - void StartPlayback( - const std::string& movie_file, std::function completion_callback = [] {}); - void StartRecording(const std::string& movie_file); + void SetPlaybackCompletionCallback(std::function completion_callback); + void StartPlayback(const std::string& movie_file); + void StartRecording(const std::string& movie_file, const std::string& author); + + /** + * Sets the read-only status. + * When true, movies will be opened in read-only mode. Loading a state will resume playback + * from that state. + * When false, movies will be opened in read/write mode. Loading a state will start recording + * from that state (rerecording). To start rerecording without loading a state, one can save + * and then immediately load while in R/W. + * + * The default is true. + */ + void SetReadOnly(bool read_only); /// Prepare to override the clock before playing back movies void PrepareForPlayback(const std::string& movie_file); @@ -52,11 +64,23 @@ public: /// Prepare to override the clock before recording movies void PrepareForRecording(); - ValidationResult ValidateMovie(const std::string& movie_file, u64 program_id = 0) const; + ValidationResult ValidateMovie(const std::string& movie_file) const; /// Get the init time that would override the one in the settings u64 GetOverrideInitTime() const; - u64 GetMovieProgramID(const std::string& movie_file) const; + + struct MovieMetadata { + u64 program_id; + std::string author; + u32 rerecord_count; + u64 input_count; + }; + MovieMetadata GetMovieMetadata(const std::string& movie_file) const; + + /// Get the current movie's unique ID. Used to provide separate savestate slots for movies. + u64 GetCurrentMovieID() const { + return id; + } void Shutdown(); @@ -96,8 +120,16 @@ 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; + PlayMode GetPlayMode() const; + + u64 GetCurrentInputIndex() const; + u64 GetTotalInputCount() const; + + /** + * Saves the movie immediately, in its current state. + * This is called in Shutdown. + */ + void SaveMovie(); private: static Movie s_instance; @@ -123,26 +155,33 @@ 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); - ValidationResult ValidateHeader(const CTMHeader& header, u64 program_id = 0) const; - - void SaveMovie(); + ValidationResult ValidateHeader(const CTMHeader& header) const; + ValidationResult ValidateInput(const std::vector& input, u64 expected_count) const; PlayMode play_mode; + std::string record_movie_file; + std::string record_movie_author; + + u64 init_time; // Clock init time override for RNG consistency + std::vector recorded_input; - u64 init_time; - std::function playback_completion_callback; std::size_t current_byte = 0; + u64 current_input = 0; + // Total input count of the current movie being played. Not used for recording. + u64 total_input = 0; + + u64 id = 0; // ID of the current movie loaded + u64 program_id = 0; + u32 rerecord_count = 1; + bool read_only = true; + + std::function playback_completion_callback = [] {}; template - void serialize(Archive& ar, const unsigned int) { - // Only serialize what's needed to make savestates useful for TAS: - u64 _current_byte = static_cast(current_byte); - ar& _current_byte; - current_byte = static_cast(_current_byte); - ar& recorded_input; - ar& init_time; - } + void serialize(Archive& ar, const unsigned int file_version); friend class boost::serialization::access; }; -} // namespace Core \ No newline at end of file +} // namespace Core + +BOOST_CLASS_VERSION(Core::Movie, 1) diff --git a/src/core/perf_stats.cpp b/src/core/perf_stats.cpp index e5b01f086..5db1df403 100644 --- a/src/core/perf_stats.cpp +++ b/src/core/perf_stats.cpp @@ -169,6 +169,10 @@ void FrameLimiter::DoFrameLimiting(microseconds current_system_time_us) { previous_walltime = now; } +bool FrameLimiter::IsFrameAdvancing() const { + return frame_advancing_enabled; +} + void FrameLimiter::SetFrameAdvancing(bool value) { const bool was_enabled = frame_advancing_enabled.exchange(value); if (was_enabled && !value) { diff --git a/src/core/perf_stats.h b/src/core/perf_stats.h index 9038e4ca2..e90c4c1ac 100644 --- a/src/core/perf_stats.h +++ b/src/core/perf_stats.h @@ -90,6 +90,7 @@ public: void DoFrameLimiting(std::chrono::microseconds current_system_time_us); + bool IsFrameAdvancing() const; /** * Sets whether frame advancing is enabled or not. * Note: The frontend must cancel frame advancing before shutting down in order diff --git a/src/core/savestate.cpp b/src/core/savestate.cpp index e470257f2..1ab245570 100644 --- a/src/core/savestate.cpp +++ b/src/core/savestate.cpp @@ -11,6 +11,7 @@ #include "common/zstd_compression.h" #include "core/cheats/cheats.h" #include "core/core.h" +#include "core/movie.h" #include "core/savestate.h" #include "network/network.h" #include "video_core/video_core.h" @@ -37,8 +38,15 @@ static_assert(sizeof(CSTHeader) == 256, "CSTHeader should be 256 bytes"); constexpr std::array header_magic_bytes{{'C', 'S', 'T', 0x1B}}; std::string GetSaveStatePath(u64 program_id, u32 slot) { - return fmt::format("{}{:016X}.{:02d}.cst", FileUtil::GetUserPath(FileUtil::UserPath::StatesDir), - program_id, slot); + const u64 movie_id = Movie::GetInstance().GetCurrentMovieID(); + if (movie_id) { + return fmt::format("{}{:016X}.movie{:016X}.{:02d}.cst", + FileUtil::GetUserPath(FileUtil::UserPath::StatesDir), program_id, + movie_id, slot); + } else { + return fmt::format("{}{:016X}.{:02d}.cst", + FileUtil::GetUserPath(FileUtil::UserPath::StatesDir), program_id, slot); + } } std::vector ListSaveStates(u64 program_id) {