using System; using System.Collections.Generic; using System.IO; using BizHawk.Client.Common.MovieConversionExtensions; using BizHawk.Emulation.Common; using BizHawk.Emulation.Cores; using BizHawk.Emulation.Cores.Nintendo.Gameboy; namespace BizHawk.Client.Common { public enum MovieEndAction { Stop, Pause, Record, Finish } public class MovieSession : IMovieSession { private readonly Action _pauseCallback; private readonly Action _modeChangedCallback; private readonly Action _messageCallback; private readonly Action _popupCallback; private IMovie _queuedMovie; private IEmulator _emulator = new NullEmulator(); // Previous saved core preferences. Stored here so that when a movie // overrides the values, they can be restored to user preferences private readonly IDictionary _preferredCores = new Dictionary(); public MovieSession( Action messageCallback, Action popupCallback, Action pauseCallback, Action modeChangedCallback) { _messageCallback = messageCallback; _popupCallback = popupCallback; _pauseCallback = pauseCallback ?? throw new ArgumentNullException($"{nameof(pauseCallback)} cannot be null."); _modeChangedCallback = modeChangedCallback ?? throw new ArgumentNullException($"{nameof(modeChangedCallback)} CannotUnloadAppDomainException be null."); Movie = MovieService.Create(); } public IMovie Movie { get; private set; } public bool ReadOnly { get; set; } = true; public bool NewMovieQueued => _queuedMovie != null; public string QueuedSyncSettings => _queuedMovie.SyncSettingsJson; public IMovieController MovieController { get; set; } = new Bk2Controller("", NullController.Instance.Definition); public MultitrackRecorder MultiTrack { get; } = new MultitrackRecorder(); public IController CurrentInput { get { if (Movie.IsPlayingOrRecording() && _emulator.Frame > 0) { return Movie.GetInputState(_emulator.Frame - 1); } return null; } } public IController PreviousFrame { get { if (Movie.IsPlayingOrRecording() && _emulator.Frame > 1) { return Movie.GetInputState(_emulator.Frame - 2); } return null; } } public void RecreateMovieController(ControllerDefinition definition) { MovieController = new Bk2Controller(definition); } public IMovieController GenerateMovieController(ControllerDefinition definition = null) { // TODO: expose Movie.LogKey and pass in here return new Bk2Controller("", definition ?? MovieController.Definition); } public void HandleFrameBefore() { if (!Movie.IsActive()) { LatchInputToUser(); } else if (Movie.IsFinished()) { if (_emulator.Frame < Movie.FrameCount) // This scenario can happen from rewinding (suddenly we are back in the movie, so hook back up to the movie { Movie.SwitchToPlay(); LatchInputToLog(); } else { LatchInputToUser(); } } else if (Movie.IsPlaying()) { LatchInputToLog(); if (Movie.IsRecording()) // The movie end situation can cause the switch to record mode, in that case we need to capture some input for this frame { HandleFrameLoopForRecordMode(); } else { // Movie may go into finished mode as a result from latching if (!Movie.IsFinished()) { if (Global.InputManager.ClientControls.IsPressed("Scrub Input")) { LatchInputToUser(); ClearFrame(); } else if (Global.Config.MoviePlaybackPokeMode) { LatchInputToUser(); var lg = Movie.LogGeneratorInstance(Global.InputManager.MovieOutputHardpoint); if (!lg.IsEmpty) { LatchInputToUser(); Movie.PokeFrame(_emulator.Frame, Global.InputManager.MovieOutputHardpoint); } else { // Why, this was already done? LatchInputToLog(); } } } } } else if (Movie.IsRecording()) { HandleFrameLoopForRecordMode(); } } public void HandleFrameAfter() { if (Movie is ITasMovie tasMovie) { tasMovie.GreenzoneCurrentFrame(); if (tasMovie.IsPlaying() && _emulator.Frame >= tasMovie.InputLogLength) { HandleFrameLoopForRecordMode(); } } else if (Movie.Mode == MovieMode.Play && _emulator.Frame >= Movie.InputLogLength) { HandlePlaybackEnd(); } } public void HandleSaveState(TextWriter writer) { if (Movie.IsActive()) { Movie.WriteInputLog(writer); } } public bool CheckSavestateTimeline(TextReader reader) { if (Movie.IsActive() && ReadOnly) { var result = Movie.CheckTimeLines(reader, out var errorMsg); if (!result) { Output(errorMsg); return false; } } return true; } public bool HandleLoadState(TextReader reader) { if (Movie.NotActive()) { return true; } if (ReadOnly) { if (Movie.IsRecording()) { Movie.SwitchToPlay(); } else if (Movie.IsPlaying()) { LatchInputToLog(); } else if (Movie.IsFinished()) { LatchInputToUser(); } } else { if (Movie.IsFinished()) { Movie.StartNewRecording(); } else if (Movie.IsPlaying()) { Movie.SwitchToRecord(); } var result = Movie.ExtractInputLog(reader, out var errorMsg); if (!result) { Output(errorMsg); return false; } } return true; } /// is and . does not match . public void QueueNewMovie(IMovie movie, bool record, string systemId) { if (movie.IsActive() && movie.Changes) { movie.Save(); } if (!record) // The semantics of record is that we are starting a new movie, and even wiping a pre-existing movie with the same path, but non-record means we are loading an existing movie into playback mode { movie.Load(false); if (movie.SystemID != systemId) { throw new MoviePlatformMismatchException( $"Movie system Id ({movie.SystemID}) does not match the currently loaded platform ({systemId}), unable to load"); } } // Note: this populates MovieControllerAdapter's Type with the appropriate controller // Don't set it to a movie instance of the adapter or you will lose the definition! Global.InputManager.RewireInputChain(); if (!record) { var preference = systemId; if (preference == "GBC") { // We want to treat GBC the same as GB preference = "GB"; } if (Global.Config.PreferredCores.ContainsKey(preference)) { _preferredCores[preference] = Global.Config.PreferredCores[preference]; Global.Config.PreferredCores[preference] = movie.Core; } } if (record) // This is a hack really, we need to set the movie to its proper state so that it will be considered active later { movie.SwitchToRecord(); } else { movie.SwitchToPlay(); } _queuedMovie = movie; } public void RunQueuedMovie(bool recordMode, IEmulator emulator) { _emulator = emulator; foreach (var previousPref in _preferredCores) { Global.Config.PreferredCores[previousPref.Key] = previousPref.Value; } Movie = _queuedMovie; _queuedMovie = null; MultiTrack.Restart(_emulator.ControllerDefinition.PlayerCount); Movie.ProcessSavestate(_emulator); Movie.ProcessSram(_emulator); if (recordMode) { Movie.StartNewRecording(); ReadOnly = false; } else { Movie.StartNewPlayback(); } } public void ToggleMultitrack() { if (Movie.IsActive()) { if (Global.Config.VBAStyleMovieLoadState) { Output("Multi-track can not be used in Full Movie Loadstates mode"); } else { MultiTrack.IsActive ^= true; MultiTrack.SelectNone(); Output(MultiTrack.IsActive ? "MultiTrack Enabled" : "MultiTrack Disabled"); } } else { Output("MultiTrack cannot be enabled while not recording."); } } public void StopMovie(bool saveChanges = true) { if (Movie.IsActive()) { var message = "Movie "; if (Movie.IsRecording()) { message += "recording "; } else if (Movie.IsPlaying()) { message += "playback "; } message += "stopped."; var result = Movie.Stop(saveChanges); if (result) { Output($"{Path.GetFileName(Movie.Filename)} written to disk."); } Output(message); ReadOnly = true; } MultiTrack.Restart(_emulator.ControllerDefinition.PlayerCount); _modeChangedCallback(); // TODO: we aren't ready for this line, keeping the old movie hanging around masks a lot of Tastudio problems // Uncommenting this can cause drawing crashes in tastudio since it depends on a ITasMovie and doesn't have one between closing and opening a rom //Movie = MovieService.Create(); } public void ConvertToTasProj() { Movie.Save(); Movie = Movie.ToTasMovie(); Movie.Save(); Movie.SwitchToPlay(); } private void ClearFrame() { if (Movie.IsPlaying()) { Movie.ClearFrame(_emulator.Frame); Output($"Scrubbed input at frame {_emulator.Frame}"); } } private void PopupMessage(string message) { _popupCallback?.Invoke(message); } private void Output(string message) { _messageCallback?.Invoke(message); } private void LatchInputToMultitrackUser() { var rewiredSource = Global.InputManager.MultitrackRewiringAdapter; if (MultiTrack.IsActive) { rewiredSource.PlayerSource = 1; rewiredSource.PlayerTargetMask = 1 << MultiTrack.CurrentPlayer; if (MultiTrack.RecordAll) { rewiredSource.PlayerTargetMask = unchecked((int)0xFFFFFFFF); } if (Movie.InputLogLength > _emulator.Frame) { var input = Movie.GetInputState(_emulator.Frame); MovieController.SetFrom(input); } MovieController.SetPlayerFrom(rewiredSource, MultiTrack.CurrentPlayer); } } private void LatchInputToUser() { MovieController.SetFrom(Global.InputManager.MovieInputSourceAdapter); } // Latch input from the input log, if available private void LatchInputToLog() { var input = Movie.GetInputState(_emulator.Frame); // adelikat: TODO: this is likely the source of frame 0 TAStudio bugs, I think the intent is to check if the movie is 0 length? if (_emulator.Frame == 0) // Hacky { HandleFrameAfter(); // Frame 0 needs to be handled. } if (input == null) { HandleFrameAfter(); return; } MovieController.SetFrom(input); if (MultiTrack.IsActive) { Global.InputManager.MultitrackRewiringAdapter.Source = MovieController; } } private void HandlePlaybackEnd() { if (Movie.Core == CoreNames.Gambatte) { var movieCycles = Convert.ToUInt64(Movie.HeaderEntries[HeaderKeys.CycleCount]); var coreCycles = ((Gameboy)_emulator).CycleCount; if (movieCycles != (ulong)coreCycles) { PopupMessage($"Cycle count in the movie ({movieCycles}) doesn't match the emulated value ({coreCycles})."); } } // TODO: mainform callback to update on mode change switch (Global.Config.MovieEndAction) { case MovieEndAction.Stop: Movie.Stop(); break; case MovieEndAction.Record: Movie.SwitchToRecord(); break; case MovieEndAction.Pause: Movie.FinishedMode(); _pauseCallback(); break; default: case MovieEndAction.Finish: Movie.FinishedMode(); break; } _modeChangedCallback(); } private void HandleFrameLoopForRecordMode() { // we don't want TasMovie to latch user input outside its internal recording mode, so limit it to autohold if (Movie is ITasMovie && Movie.IsPlaying()) { MovieController.SetFromSticky(Global.InputManager.AutofireStickyXorAdapter); } else { if (MultiTrack.IsActive) { LatchInputToMultitrackUser(); } else { LatchInputToUser(); } } // the movie session makes sure that the correct input has been read and merged to its MovieControllerAdapter; // this has been wired to Global.MovieOutputHardpoint in RewireInputChain Movie.RecordFrame(_emulator.Frame, Global.InputManager.MovieOutputHardpoint); } } }