diff --git a/src/BizHawk.Client.Common/movie/bk2/StringLogs.cs b/src/BizHawk.Client.Common/movie/bk2/StringLogs.cs index f84e17b7bec..517bee596b1 100644 --- a/src/BizHawk.Client.Common/movie/bk2/StringLogs.cs +++ b/src/BizHawk.Client.Common/movie/bk2/StringLogs.cs @@ -37,6 +37,9 @@ public static IStringLog MakeStringLog() } } + if (newLog.Count != currentLog.Count) + return Math.Min(newLog.Count, currentLog.Count); + return null; } diff --git a/src/BizHawk.Client.Common/movie/interfaces/ITasMovie.cs b/src/BizHawk.Client.Common/movie/interfaces/ITasMovie.cs index ab9ab063ba5..9ea994305be 100644 --- a/src/BizHawk.Client.Common/movie/interfaces/ITasMovie.cs +++ b/src/BizHawk.Client.Common/movie/interfaces/ITasMovie.cs @@ -21,6 +21,11 @@ public interface ITasMovie : IMovie, INotifyPropertyChanged, IDisposable int LastEditedFrame { get; } bool LastEditWasRecording { get; } + /// + /// Called whenever the movie is modified in a way that could invalidate savestates in the movie's state history. + /// Called regardless of whether any states were actually invalidated. + /// The parameter is the last frame number who's savestate (if it exists) is still valid. That is, the first of the modified frames. + /// Action GreenzoneInvalidated { get; set; } string DisplayValue(int frame, string buttonName); @@ -43,10 +48,16 @@ public interface ITasMovie : IMovie, INotifyPropertyChanged, IDisposable void InsertInput(int frame, IEnumerable inputStates); void InsertEmptyFrame(int frame, int count = 1); - int CopyOverInput(int frame, IEnumerable inputStates); + void CopyOverInput(int frame, IEnumerable inputStates); void RemoveFrame(int frame); void RemoveFrames(ICollection frames); + + /// + /// Remove all frames between removeStart and removeUpTo (excluding removeUpTo). + /// + /// The first frame to remove. + /// The frame after the last frame to remove. void RemoveFrames(int removeStart, int removeUpTo); void SetFrame(int frame, string source); @@ -55,5 +66,14 @@ public interface ITasMovie : IMovie, INotifyPropertyChanged, IDisposable void CopyVerificationLog(IEnumerable log); bool IsReserved(int frame); + + /// + /// Sometimes we will be doing a whole bunch of small operations together. (e.g. large painting undo, large Lua edit) + /// This avoids using the greenzone invalidated callback for each one, making things run smoother. + /// Note that this is different from undo batching. + /// For one, we don't undo batch an undo itself. + /// For two, undo batches might span a significant amount of time during which users want to atually see edits or auto-restore in TAStudio. + /// + void SingleInvalidation(Action action); } } diff --git a/src/BizHawk.Client.Common/movie/tasproj/TasMovie.Editing.cs b/src/BizHawk.Client.Common/movie/tasproj/TasMovie.Editing.cs index 9f6714414e0..e5bbfeecb4b 100644 --- a/src/BizHawk.Client.Common/movie/tasproj/TasMovie.Editing.cs +++ b/src/BizHawk.Client.Common/movie/tasproj/TasMovie.Editing.cs @@ -9,76 +9,60 @@ internal partial class TasMovie { public IMovieChangeLog ChangeLog { get; set; } + // In each editing method we do things in this order: + // 1) Any special logic (such as short-circuit) + // 2) Begin an undo batch, if needed. + // 3) Edit the movie. + // 4) End the undo batch, if needed. + // 5) Call InvalidateAfter. + + // InvalidateAfter being last ensures that the GreenzoneInvalidated callback sees all edits. + public override void RecordFrame(int frame, IController source) { - // RetroEdit: This check is questionable; recording at frame 0 is valid and should be reversible. - // Also, frame - 1, why? - // Is the precondition compensating for frame - 1 reindexing? - if (frame != 0) - { - ChangeLog.AddGeneralUndo(frame - 1, frame - 1, $"Record Frame: {frame}"); - } - + ChangeLog.AddGeneralUndo(frame, frame, $"Record Frame: {frame}"); SetFrameAt(frame, Bk2LogEntryGenerator.GenerateLogEntry(source)); - - Changes = true; + ChangeLog.SetGeneralRedo(); LagLog[frame] = _inputPollable.IsLagFrame; + LastEditWasRecording = true; - if (this.IsRecording()) - { - LastEditWasRecording = true; - InvalidateAfter(frame); - } - - if (frame != 0) - { - ChangeLog.SetGeneralRedo(); - } + InvalidateAfter(frame); } public override void Truncate(int frame) { - bool endBatch = ChangeLog.BeginNewBatch($"Truncate Movie: {frame}", true); - ChangeLog.AddGeneralUndo(frame, InputLogLength - 1); + if (frame >= Log.Count - 1) return; - if (frame < Log.Count - 1) - { - Changes = true; - } + bool endBatch = ChangeLog.BeginNewBatch($"Truncate Movie: {frame}", true); + ChangeLog.AddGeneralUndo(frame, InputLogLength - 1); base.Truncate(frame); + ChangeLog.SetGeneralRedo(); - LagLog.RemoveFrom(frame); - TasStateManager.InvalidateAfter(frame); - Markers.TruncateAt(frame); // must happen before the invalidation - GreenzoneInvalidated?.Invoke(frame); + Markers.TruncateAt(frame); - ChangeLog.SetGeneralRedo(); - if (endBatch) - { - ChangeLog.EndBatch(); - } + if (endBatch) ChangeLog.EndBatch(); + + InvalidateAfter(frame); } public override void PokeFrame(int frame, IController source) { ChangeLog.AddGeneralUndo(frame, frame, $"Set Frame At: {frame}"); - base.PokeFrame(frame, source); - InvalidateAfter(frame); - ChangeLog.SetGeneralRedo(); + + InvalidateAfter(frame); } public void SetFrame(int frame, string source) { ChangeLog.AddGeneralUndo(frame, frame, $"Set Frame At: {frame}"); - SetFrameAt(frame, source); - InvalidateAfter(frame); - ChangeLog.SetGeneralRedo(); + + InvalidateAfter(frame); } public void ClearFrame(int frame) @@ -87,12 +71,10 @@ public void ClearFrame(int frame) if (GetInputLogEntry(frame) == empty) return; ChangeLog.AddGeneralUndo(frame, frame, $"Clear Frame: {frame}"); - SetFrameAt(frame, empty); - Changes = true; + ChangeLog.SetGeneralRedo(); InvalidateAfter(frame); - ChangeLog.SetGeneralRedo(); } private void ShiftBindedMarkers(int frame, int offset) @@ -110,69 +92,58 @@ public void RemoveFrame(int frame) public void RemoveFrames(ICollection frames) { - if (frames.Count is not 0) - { - // Separate the given frames into contiguous blocks - // and process each block independently - List framesToDelete = frames - .Where(fr => fr >= 0 && fr < InputLogLength) - .Order().ToList(); + if (frames.Count is 0) return; + // Separate the given frames into contiguous blocks + // and process each block independently + List framesToDelete = frames + .Where(fr => fr >= 0 && fr < InputLogLength) + .Order().ToList(); + + SingleInvalidation(() => + { int alreadyDeleted = 0; + bool endBatch = ChangeLog.BeginNewBatch($"Delete {framesToDelete.Count} frames from {framesToDelete[0]}-{framesToDelete[framesToDelete.Count - 1]}", true); for (int i = 1; i <= framesToDelete.Count; i++) { if (i == framesToDelete.Count || framesToDelete[i] - framesToDelete[i - 1] != 1) { - // Each block is logged as an individual ChangeLog entry RemoveFrames(framesToDelete[alreadyDeleted] - alreadyDeleted, framesToDelete[i - 1] + 1 - alreadyDeleted); alreadyDeleted = i; } } - } + if (endBatch) ChangeLog.EndBatch(); + }); } - /// - /// Remove all frames between removeStart and removeUpTo (excluding removeUpTo). - /// - /// The first frame to remove. - /// The frame after the last frame to remove. public void RemoveFrames(int removeStart, int removeUpTo) { - // Log.GetRange() might be preferrable, but Log's type complicates that. - string[] removedInputs = new string[removeUpTo - removeStart]; - Log.CopyTo(removeStart, removedInputs, 0, removedInputs.Length); - - // Pre-process removed markers for the ChangeLog. - List removedMarkers = new List(); + bool endBatch = ChangeLog.BeginNewBatch($"Remove frames {removeStart}-{removeUpTo - 1}", true); if (BindMarkersToInput) { - bool wasRecording = ChangeLog.IsRecording; - ChangeLog.IsRecording = false; - // O(n^2) removal time, but removing many binded markers in a deleted section is probably rare. - removedMarkers = Markers.Where(m => m.Frame >= removeStart && m.Frame < removeUpTo).ToList(); - foreach (var marker in removedMarkers) + List markersToRemove = Markers.Where(m => m.Frame >= removeStart && m.Frame < removeUpTo).ToList(); + foreach (var marker in markersToRemove) { Markers.Remove(marker); } - - ChangeLog.IsRecording = wasRecording; } - - Log.RemoveRange(removeStart, removeUpTo - removeStart); - ShiftBindedMarkers(removeUpTo, removeStart - removeUpTo); - Changes = true; - InvalidateAfter(removeStart); + // Log.GetRange() might be preferrable, but Log's type complicates that. + string[] removedInputs = new string[removeUpTo - removeStart]; + Log.CopyTo(removeStart, removedInputs, 0, removedInputs.Length); + Log.RemoveRange(removeStart, removeUpTo - removeStart); ChangeLog.AddRemoveFrames( removeStart, removeUpTo, removedInputs.ToList(), - removedMarkers, - $"Remove frames {removeStart}-{removeUpTo - 1}" + BindMarkersToInput ); + if (endBatch) ChangeLog.EndBatch(); + + InvalidateAfter(removeStart); } public void InsertInput(int frame, string inputState) @@ -184,18 +155,14 @@ public void InsertInput(int frame, string inputState) public void InsertInput(int frame, IEnumerable inputLog) { Log.InsertRange(frame, inputLog); - ShiftBindedMarkers(frame, inputLog.Count()); + ChangeLog.AddInsertInput(frame, inputLog.ToList(), BindMarkersToInput, $"Insert {inputLog.Count()} frame(s) at {frame}"); - Changes = true; InvalidateAfter(frame); - - ChangeLog.AddInsertInput(frame, inputLog.ToList(), $"Insert {inputLog.Count()} frame(s) at {frame}"); } public void InsertInput(int frame, IEnumerable inputStates) { - // ChangeLog is done in the InsertInput call. var inputLog = new List(); foreach (var input in inputStates) @@ -206,47 +173,30 @@ public void InsertInput(int frame, IEnumerable inputStates) InsertInput(frame, inputLog); // Sets the ChangeLog } - public int CopyOverInput(int frame, IEnumerable inputStates) + public void CopyOverInput(int frame, IEnumerable inputStates) { - int firstChangedFrame = -1; - ChangeLog.BeginNewBatch($"Copy Over Input: {frame}"); - var states = inputStates.ToList(); + bool endBatch = ChangeLog.BeginNewBatch($"Copy Over Input: {frame}", true); + if (Log.Count < states.Count + frame) { - firstChangedFrame = Log.Count; ExtendMovieForEdit(states.Count + frame - Log.Count); } ChangeLog.AddGeneralUndo(frame, frame + states.Count - 1, $"Copy Over Input: {frame}"); - for (int i = 0; i < states.Count; i++) { - if (Log.Count <= frame + i) - { - break; - } - - var entry = Bk2LogEntryGenerator.GenerateLogEntry(states[i]); - if ((firstChangedFrame == -1 || firstChangedFrame > frame + i) && Log[frame + i] != entry) - { - firstChangedFrame = frame + i; - } - - Log[frame + i] = entry; + Log[frame + i] = Bk2LogEntryGenerator.GenerateLogEntry(states[i]); } + int firstChangedFrame = ChangeLog.SetGeneralRedo(); + + if (endBatch) ChangeLog.EndBatch(); - ChangeLog.EndBatch(); - Changes = true; if (firstChangedFrame != -1) { - // TODO: Throw out the undo action if there are no changes. InvalidateAfter(firstChangedFrame); } - - ChangeLog.SetGeneralRedo(); - return firstChangedFrame; } public void InsertEmptyFrame(int frame, int count = 1) @@ -254,40 +204,30 @@ public void InsertEmptyFrame(int frame, int count = 1) frame = Math.Min(frame, Log.Count); Log.InsertRange(frame, Enumerable.Repeat(Bk2LogEntryGenerator.EmptyEntry(Session.MovieController), count)); - ShiftBindedMarkers(frame, count); + ChangeLog.AddInsertFrames(frame, count, BindMarkersToInput, $"Insert {count} empty frame(s) at {frame}"); - Changes = true; InvalidateAfter(frame); - - ChangeLog.AddInsertFrames(frame, count, $"Insert {count} empty frame(s) at {frame}"); } private void ExtendMovieForEdit(int numFrames) { - bool endBatch = ChangeLog.BeginNewBatch("Auto-Extend Movie", true); int oldLength = InputLogLength; - ChangeLog.AddGeneralUndo(oldLength, oldLength + numFrames - 1); - Session.MovieController.SetFrom(Session.StickySource); - - // account for autohold. needs autohold pattern to be already recorded in the current frame + // account for autohold TODO: What about auto-fire? + string inputs = Bk2LogEntryGenerator.GenerateLogEntry(Session.StickySource); for (int i = 0; i < numFrames; i++) { - Log.Add(Bk2LogEntryGenerator.GenerateLogEntry(Session.MovieController)); + Log.Add(inputs); } - Changes = true; - - ChangeLog.SetGeneralRedo(); - if (endBatch) - { - ChangeLog.EndBatch(); - } + ChangeLog.AddExtend(oldLength, numFrames, inputs); } public void ToggleBoolState(int frame, string buttonName) { + bool endBatch = ChangeLog.BeginNewBatch($"Toggle {buttonName}: {frame}", true); + if (frame >= Log.Count) // Insert blank frames up to this point { ExtendMovieForEdit(frame - Log.Count + 1); @@ -297,43 +237,51 @@ public void ToggleBoolState(int frame, string buttonName) adapter.SetBool(buttonName, !adapter.IsPressed(buttonName)); Log[frame] = Bk2LogEntryGenerator.GenerateLogEntry(adapter); - Changes = true; - InvalidateAfter(frame); + ChangeLog.AddBoolToggle(frame, buttonName, !adapter.IsPressed(buttonName)); + + if (endBatch) ChangeLog.EndBatch(); - ChangeLog.AddBoolToggle(frame, buttonName, !adapter.IsPressed(buttonName), $"Toggle {buttonName}: {frame}"); + InvalidateAfter(frame); } public void SetBoolState(int frame, string buttonName, bool val) { + bool endBatch = ChangeLog.BeginNewBatch($"Set {buttonName}({(val ? "On" : "Off")}): {frame}", true); + + bool extended = false; if (frame >= Log.Count) // Insert blank frames up to this point { ExtendMovieForEdit(frame - Log.Count + 1); + extended = true; } var adapter = GetInputState(frame); var old = adapter.IsPressed(buttonName); - adapter.SetBool(buttonName, val); - - Log[frame] = Bk2LogEntryGenerator.GenerateLogEntry(adapter); if (old != val) { - InvalidateAfter(frame); - Changes = true; - ChangeLog.AddBoolToggle(frame, buttonName, old, $"Set {buttonName}({(val ? "On" : "Off")}): {frame}"); + adapter.SetBool(buttonName, val); + Log[frame] = Bk2LogEntryGenerator.GenerateLogEntry(adapter); + ChangeLog.AddBoolToggle(frame, buttonName, old); } + + if (endBatch) ChangeLog.EndBatch(); + + if (old != val || extended) InvalidateAfter(frame); } public void SetBoolStates(int frame, int count, string buttonName, bool val) { + bool endBatch = ChangeLog.BeginNewBatch($"Set {buttonName}({(val ? "On" : "Off")}): {frame}-{frame + count - 1}", true); + + int firstChangedFrame = -1; if (Log.Count < frame + count) { + firstChangedFrame = Log.Count; ExtendMovieForEdit(frame + count - Log.Count); } - ChangeLog.AddGeneralUndo(frame, frame + count - 1, $"Set {buttonName}({(val ? "On" : "Off")}): {frame}-{frame + count - 1}"); - - int changed = -1; + ChangeLog.AddGeneralUndo(frame, frame + count - 1); for (int i = 0; i < count; i++) { var adapter = GetInputState(frame + i); @@ -342,52 +290,56 @@ public void SetBoolStates(int frame, int count, string buttonName, bool val) Log[frame + i] = Bk2LogEntryGenerator.GenerateLogEntry(adapter); - if (changed == -1 && old != val) + if (firstChangedFrame == -1 && old != val) { - changed = frame + i; + firstChangedFrame = frame + i; } } + ChangeLog.SetGeneralRedo(); - if (changed != -1) - { - InvalidateAfter(changed); - Changes = true; - } + if (endBatch) ChangeLog.EndBatch(); - ChangeLog.SetGeneralRedo(); + if (firstChangedFrame != -1) InvalidateAfter(firstChangedFrame); } public void SetAxisState(int frame, string buttonName, int val) { + bool endBatch = ChangeLog.BeginNewBatch($"Set {buttonName}({val}): {frame}", true); + + bool extended = false; if (frame >= Log.Count) // Insert blank frames up to this point { ExtendMovieForEdit(frame - Log.Count + 1); + extended = true; } var adapter = GetInputState(frame); var old = adapter.AxisValue(buttonName); - adapter.SetAxis(buttonName, val); - - Log[frame] = Bk2LogEntryGenerator.GenerateLogEntry(adapter); if (old != val) { - InvalidateAfter(frame); - Changes = true; - ChangeLog.AddAxisChange(frame, buttonName, old, val, $"Set {buttonName}({val}): {frame}"); + adapter.SetAxis(buttonName, val); + Log[frame] = Bk2LogEntryGenerator.GenerateLogEntry(adapter); + ChangeLog.AddAxisChange(frame, buttonName, old, val); } + + if (endBatch) ChangeLog.EndBatch(); + + if (old != val || extended) InvalidateAfter(frame); } public void SetAxisStates(int frame, int count, string buttonName, int val) { + bool endBatch = ChangeLog.BeginNewBatch($"Set {buttonName}({val}): {frame}-{frame + count - 1}", true); + + int firstChangedFrame = -1; if (frame + count >= Log.Count) // Insert blank frames up to this point { - ExtendMovieForEdit(frame - Log.Count + 1); + firstChangedFrame = Log.Count; + ExtendMovieForEdit(frame + count - Log.Count); } - ChangeLog.AddGeneralUndo(frame, frame + count - 1, $"Set {buttonName}({val}): {frame}-{frame + count - 1}"); - - int changed = -1; + ChangeLog.AddGeneralUndo(frame, frame + count - 1); for (int i = 0; i < count; i++) { var adapter = GetInputState(frame + i); @@ -396,19 +348,32 @@ public void SetAxisStates(int frame, int count, string buttonName, int val) Log[frame + i] = Bk2LogEntryGenerator.GenerateLogEntry(adapter); - if (changed == -1 && old != val) + if (firstChangedFrame == -1 && old != val) { - changed = frame + i; + firstChangedFrame = frame + i; } } + ChangeLog.SetGeneralRedo(); - if (changed != -1) + if (endBatch) ChangeLog.EndBatch(); + + if (firstChangedFrame != -1) InvalidateAfter(firstChangedFrame); + } + + private bool _suspendInvalidation; + private int _minInvalidationFrame; + public void SingleInvalidation(Action action) + { + bool wasSuspending = _suspendInvalidation; + if (!wasSuspending) _minInvalidationFrame = int.MaxValue; + _suspendInvalidation = true; + try { action(); } + finally { _suspendInvalidation = wasSuspending; } + + if (!wasSuspending && _minInvalidationFrame != int.MaxValue) { - InvalidateAfter(changed); - Changes = true; + InvalidateAfter(_minInvalidationFrame); } - - ChangeLog.SetGeneralRedo(); } } } diff --git a/src/BizHawk.Client.Common/movie/tasproj/TasMovie.History.cs b/src/BizHawk.Client.Common/movie/tasproj/TasMovie.History.cs index 3a19ed4d7eb..b87f0cb4a7b 100644 --- a/src/BizHawk.Client.Common/movie/tasproj/TasMovie.History.cs +++ b/src/BizHawk.Client.Common/movie/tasproj/TasMovie.History.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics; using System.Linq; namespace BizHawk.Client.Common @@ -7,28 +8,58 @@ public interface IMovieChangeLog { List Names { get; } int UndoIndex { get; } - string NextUndoStepName { get; } + + /// + /// Gets or sets a value indicating whether the movie is recording action history. + /// This is not intended to turn off the ChangeLog, but to disable the normal recording process. + /// Use this to manually control the ChangeLog. (Useful for disabling the ChangeLog during undo/redo). + /// bool IsRecording { get; set; } void Clear(int upTo = -1); + + /// + /// All changes made between calling Begin and End will be one Undo. + /// If already recording in a batch, calls EndBatch. + /// + /// The name of the batch + /// If set and a batch is in progress, a new batch will not be created. + /// Returns true if a new batch was started; otherwise false. bool BeginNewBatch(string name = "", bool keepOldBatch = false); + + /// + /// Ends the current undo batch. Future changes will be one undo each. + /// If not already recording a batch, does nothing. + /// void EndBatch(); - int Undo(); - int Redo(); + + /// + /// Combine the last two undo actions, making them part of one batch action. + /// + void MergeLastActions(); + + /// + /// Undoes the most recent action batch, if any exist. + /// + void Undo(); + + /// + /// Redoes the most recent undo, if any exist. + /// + void Redo(); bool CanUndo { get; } bool CanRedo { get; } - int PreviousUndoFrame { get; } - int PreviousRedoFrame { get; } int MaxSteps { get; set; } - void AddGeneralUndo(int first, int last, string name = "", bool force = false); - void SetGeneralRedo(bool force = false); - void AddBoolToggle(int frame, string button, bool oldState, string name = "", bool force = false); - void AddAxisChange(int frame, string button, int oldState, int newState, string name = "", bool force = false); - void AddMarkerChange(TasMovieMarker newMarker, int oldPosition = -1, string oldMessage = "", string name = "", bool force = false); - void AddInputBind(int frame, bool isDelete, string name = "", bool force = false); - void AddInsertFrames(int frame, int count, string name = "", bool force = false); - void AddInsertInput(int frame, List newInputs, string name = "", bool force = false); - void AddRemoveFrames(int removeStart, int removeUpTo, List oldInputs, List removedMarkers, string name = "", bool force = false); + void AddGeneralUndo(int first, int last, string name = ""); + int SetGeneralRedo(); + void AddBoolToggle(int frame, string button, bool oldState, string name = ""); + void AddAxisChange(int frame, string button, int oldState, int newState, string name = ""); + void AddMarkerChange(TasMovieMarker newMarker, int oldPosition = -1, string oldMessage = "", string name = ""); + void AddInputBind(int frame, bool isDelete, string name = ""); + void AddInsertFrames(int frame, int count, bool bindMarkers, string name = ""); + void AddInsertInput(int frame, List newInputs, bool bindMarkers, string name = ""); + void AddRemoveFrames(int removeStart, int removeUpTo, List oldInputs, bool bindMarkers, string name = ""); + void AddExtend(int originalLength, int count, string inputs); } public class TasMovieChangeLog : IMovieChangeLog @@ -64,11 +95,6 @@ public int MaxSteps } } - /// - /// Gets or sets a value indicating whether the movie is recording action history. - /// This is not intended to turn off the ChangeLog, but to disable the normal recording process. - /// Use this to manually control the ChangeLog. (Useful for disabling the ChangeLog during undo/redo). - /// public bool IsRecording { get; set; } = true; public void Clear(int upTo = -1) @@ -104,18 +130,13 @@ private void TruncateLog(int from) if (_recordingBatch) { + // This means we are adding new actions to a batch that was undone while still in progress. + // So start a new one. _recordingBatch = false; BeginNewBatch(); } } - /// - /// All changes made between calling Begin and End will be one Undo. - /// If already recording in a batch, calls EndBatch. - /// - /// The name of the batch - /// If set and a batch is in progress, a new batch will not be created. - /// Returns true if a new batch was started; otherwise false. public bool BeginNewBatch(string name = "", bool keepOldBatch = false) { if (!IsRecording) @@ -138,7 +159,7 @@ public bool BeginNewBatch(string name = "", bool keepOldBatch = false) if (ret) { - ret = AddMovieAction(name); + AddMovieAction(name); } _recordingBatch = true; @@ -146,10 +167,6 @@ public bool BeginNewBatch(string name = "", bool keepOldBatch = false) return ret; } - /// - /// Ends the current undo batch. Future changes will be one undo each. - /// If not already recording a batch, does nothing. - /// public void EndBatch() { if (!IsRecording || !_recordingBatch) @@ -171,114 +188,73 @@ public void EndBatch() } } - /// - /// Undoes the most recent action batch, if any exist. - /// - /// Returns the frame which the movie needs to rewind to. - public int Undo() + public void MergeLastActions() { - if (UndoIndex == -1) - { - return _movie.InputLogLength; - } - - List batch = _history[UndoIndex]; - for (int i = batch.Count - 1; i >= 0; i--) - { - batch[i].Undo(_movie); - } + Debug.Assert(UndoIndex + 1 == _history.Count, "Don't merge the last actions if they aren't the last actions."); + Debug.Assert(_history.Count > 1, "Don't merge when there aren't actions to merge."); + _history[_history.Count - 2].AddRange(_history[_history.Count - 1]); + _history.RemoveAt(_history.Count - 1); + Names.RemoveAt(Names.Count - 1); UndoIndex--; - - _recordingBatch = false; - return batch.TrueForAll(static a => a is MovieActionMarker) ? _movie.InputLogLength : PreviousUndoFrame; } - /// - /// Redoes the most recent undo, if any exist. - /// - /// Returns the frame which the movie needs to rewind to. - public int Redo() + public void Undo() { - if (UndoIndex == _history.Count - 1) + if (UndoIndex == -1) { - return _movie.InputLogLength; + return; } - UndoIndex++; List batch = _history[UndoIndex]; - foreach (IMovieAction b in batch) - { - b.Redo(_movie); - } - - _recordingBatch = false; - return batch.TrueForAll(static a => a is MovieActionMarker) ? _movie.InputLogLength : PreviousRedoFrame; - } - - public bool CanUndo => UndoIndex > -1; - public bool CanRedo => UndoIndex < _history.Count - 1; + UndoIndex--; - public string NextUndoStepName - { - get + bool wasRecording = IsRecording; + IsRecording = false; + _movie.SingleInvalidation(() => { - if (Names.Count == 0 || UndoIndex < 0) + for (int i = batch.Count - 1; i >= 0; i--) { - return null; + batch[i].Undo(_movie); } - - return Names[UndoIndex]; - } + }); + IsRecording = wasRecording; } - public int PreviousUndoFrame + public void Redo() { - get + if (UndoIndex == _history.Count - 1) { - if (UndoIndex == _history.Count - 1) - { - return _movie.InputLogLength; - } - - if (_history[UndoIndex + 1].Count == 0) - { - return _movie.InputLogLength; - } - - return _history[UndoIndex + 1].Min(a => a.FirstFrame); + return; } - } - public int PreviousRedoFrame - { - get - { - if (UndoIndex == -1) - { - return _movie.InputLogLength; - } + UndoIndex++; + List batch = _history[UndoIndex]; - if (_history[UndoIndex].Count == 0) + bool wasRecording = IsRecording; + IsRecording = false; + _movie.SingleInvalidation(() => + { + foreach (IMovieAction b in batch) { - return _movie.InputLogLength; + b.Redo(_movie); } - - return _history[UndoIndex].Min(a => a.FirstFrame); - } + }); + IsRecording = wasRecording; } - private bool AddMovieAction(string name) + public bool CanUndo => UndoIndex > -1; + public bool CanRedo => UndoIndex < _history.Count - 1; + + private void AddMovieAction(string name) { if (UndoIndex + 1 != _history.Count) { TruncateLog(UndoIndex + 1); } if (name.Length is 0) name = $"Undo step {_totalSteps}"; - bool ret = false; if (!_recordingBatch) { - ret = true; _history.Add(new List(1)); Names.Add(name); _totalSteps += 1; @@ -291,19 +267,16 @@ private bool AddMovieAction(string name) { _history.RemoveAt(0); Names.RemoveAt(0); - ret = false; } } - - return ret; } // TODO: These probably aren't the best way to handle undo/redo. private int _lastGeneral; - public void AddGeneralUndo(int first, int last, string name = "", bool force = false) + public void AddGeneralUndo(int first, int last, string name = "") { - if (IsRecording || force) + if (IsRecording) { AddMovieAction(name); LatestBatch.Add(new MovieAction(first, last, _movie)); @@ -311,35 +284,48 @@ public void AddGeneralUndo(int first, int last, string name = "", bool force = f } } - public void SetGeneralRedo(bool force = false) + public int SetGeneralRedo() { - if (IsRecording || force) + if (IsRecording) { - ((MovieAction) LatestBatch[_lastGeneral]).SetRedoLog(_movie); + Debug.Assert(_lastGeneral == LatestBatch.Count - 1, "GeneralRedo should not see changes from other undo actions."); + int changed = ((MovieAction) LatestBatch[_lastGeneral]).SetRedoLog(_movie); + if (changed == -1) + { + LatestBatch.RemoveAt(_lastGeneral); + if (LatestBatch.Count == 0 && !_recordingBatch) + { + // Remove this undo item + _recordingBatch = true; + EndBatch(); + } + } + return changed; } + return -1; } - public void AddBoolToggle(int frame, string button, bool oldState, string name = "", bool force = false) + public void AddBoolToggle(int frame, string button, bool oldState, string name = "") { - if (IsRecording || force) + if (IsRecording) { AddMovieAction(name); LatestBatch.Add(new MovieActionFrameEdit(frame, button, oldState, !oldState)); } } - public void AddAxisChange(int frame, string button, int oldState, int newState, string name = "", bool force = false) + public void AddAxisChange(int frame, string button, int oldState, int newState, string name = "") { - if (IsRecording || force) + if (IsRecording) { AddMovieAction(name); LatestBatch.Add(new MovieActionFrameEdit(frame, button, oldState, newState)); } } - public void AddMarkerChange(TasMovieMarker newMarker, int oldPosition = -1, string oldMessage = "", string name = "", bool force = false) + public void AddMarkerChange(TasMovieMarker newMarker, int oldPosition = -1, string oldMessage = "", string name = "") { - if (IsRecording || force) + if (IsRecording) { name = oldPosition == -1 ? $"Set Marker at frame {newMarker.Frame}" @@ -350,39 +336,48 @@ public void AddMarkerChange(TasMovieMarker newMarker, int oldPosition = -1, stri } } - public void AddInputBind(int frame, bool isDelete, string name = "", bool force = false) + public void AddInputBind(int frame, bool isDelete, string name = "") { - if (IsRecording || force) + if (IsRecording) { AddMovieAction(name); LatestBatch.Add(new MovieActionBindInput(_movie, frame, isDelete)); } } - public void AddInsertFrames(int frame, int count, string name = "", bool force = false) + public void AddInsertFrames(int frame, int count, bool bindMarkers, string name = "") { - if (IsRecording || force) + if (IsRecording) { AddMovieAction(name); - LatestBatch.Add(new MovieActionInsertFrames(frame, count)); + LatestBatch.Add(new MovieActionInsertFrames(frame, bindMarkers, count)); } } - public void AddInsertInput(int frame, List newInputs, string name = "", bool force = false) + public void AddInsertInput(int frame, List newInputs, bool bindMarkers, string name = "") { - if (IsRecording || force) + if (IsRecording) { AddMovieAction(name); - LatestBatch.Add(new MovieActionInsertFrames(frame, newInputs)); + LatestBatch.Add(new MovieActionInsertFrames(frame, bindMarkers, newInputs)); } } - public void AddRemoveFrames(int removeStart, int removeUpTo, List oldInputs, List removedMarkers, string name = "", bool force = false) + public void AddRemoveFrames(int removeStart, int removeUpTo, List oldInputs, bool bindMarkers, string name = "") { - if (IsRecording || force) + if (IsRecording) { AddMovieAction(name); - LatestBatch.Add(new MovieActionRemoveFrames(removeStart, removeUpTo, oldInputs, removedMarkers)); + LatestBatch.Add(new MovieActionRemoveFrames(removeStart, removeUpTo, oldInputs, bindMarkers)); + } + } + + public void AddExtend(int originalLength, int count, string inputs) + { + if (IsRecording) + { + AddMovieAction("extend movie"); + LatestBatch.Add(new MovieActionExtend(originalLength, count, inputs)); } } } @@ -425,20 +420,26 @@ public MovieAction(int firstFrame, int lastFrame, ITasMovie movie) _bindMarkers = movie.BindMarkersToInput; } - public void SetRedoLog(ITasMovie movie) + /// Returns the first frame that has changed, or -1 if no changes. + public int SetRedoLog(ITasMovie movie) { _redoLength = Math.Min(LastFrame + 1, movie.InputLogLength) - FirstFrame; _newLog = new List(_redoLength); + int changed = Math.Min(_redoLength, _undoLength); for (int i = 0; i < _redoLength; i++) { - _newLog.Add(movie.GetInputLogEntry(FirstFrame + i)); + string newEntry = movie.GetInputLogEntry(FirstFrame + i); + _newLog.Add(newEntry); + if (i < changed && newEntry != _oldLog[i]) changed = i; } + + if (changed == _redoLength && changed == _undoLength) return -1; + else return changed + FirstFrame; } public void Undo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; + bool wasBinding = movie.BindMarkersToInput; movie.BindMarkersToInput = _bindMarkers; if (_redoLength != Length) @@ -455,15 +456,12 @@ public void Undo(ITasMovie movie) { movie.SetFrame(FirstFrame + i, _oldLog[i]); } - - movie.ChangeLog.IsRecording = wasRecording; - movie.BindMarkersToInput = _bindMarkers; + movie.BindMarkersToInput = wasBinding; } public void Redo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; + bool wasBinding = movie.BindMarkersToInput; movie.BindMarkersToInput = _bindMarkers; if (_undoLength != Length) @@ -480,9 +478,7 @@ public void Redo(ITasMovie movie) { movie.SetFrame(FirstFrame + i, _newLog[i]); } - - movie.ChangeLog.IsRecording = wasRecording; - movie.BindMarkersToInput = _bindMarkers; + movie.BindMarkersToInput = wasBinding; } } @@ -512,9 +508,6 @@ public MovieActionMarker(TasMovieMarker marker, int oldPosition = -1, string old public void Undo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; - if (FirstFrame == -1) // Action: Place marker { movie.Markers.Remove(movie.Markers.Get(LastFrame)); @@ -528,15 +521,10 @@ public void Undo(ITasMovie movie) movie.Markers.Move(LastFrame, FirstFrame); movie.Markers.Get(LastFrame).Message = _oldMessage; } - - movie.ChangeLog.IsRecording = wasRecording; } public void Redo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; - if (FirstFrame == -1) // Action: Place marker { movie.Markers.Add(LastFrame, _oldMessage); @@ -550,8 +538,6 @@ public void Redo(ITasMovie movie) movie.Markers.Move(FirstFrame, LastFrame); movie.Markers.Get(LastFrame).Message = _newMessage; } - - movie.ChangeLog.IsRecording = wasRecording; } } @@ -585,9 +571,6 @@ public MovieActionFrameEdit(int frame, string button, int oldS, int newS) public void Undo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; - if (_isAxis) { movie.SetAxisState(FirstFrame, _buttonName, _oldState); @@ -596,15 +579,10 @@ public void Undo(ITasMovie movie) { movie.SetBoolState(FirstFrame, _buttonName, _oldState == 1); } - - movie.ChangeLog.IsRecording = wasRecording; } public void Redo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; - if (_isAxis) { movie.SetAxisState(FirstFrame, _buttonName, _newState); @@ -613,8 +591,6 @@ public void Redo(ITasMovie movie) { movie.SetBoolState(FirstFrame, _buttonName, _newState == 1); } - - movie.ChangeLog.IsRecording = wasRecording; } } @@ -658,9 +634,6 @@ public MovieActionPaint(int startFrame, int endFrame, string button, int newS, I public void Undo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; - if (_isAxis) { for (int i = 0; i < _oldState.Count; i++) @@ -675,15 +648,10 @@ public void Undo(ITasMovie movie) movie.SetBoolState(FirstFrame + i, _buttonName, _oldState[i] == 1); } } - - movie.ChangeLog.IsRecording = wasRecording; } public void Redo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; - if (_isAxis) { movie.SetAxisStates(FirstFrame, LastFrame - FirstFrame + 1, _buttonName, _newState); @@ -692,8 +660,6 @@ public void Redo(ITasMovie movie) { movie.SetBoolStates(FirstFrame, LastFrame - FirstFrame + 1, _buttonName, _newState == 1); } - - movie.ChangeLog.IsRecording = wasRecording; } } @@ -717,8 +683,6 @@ public MovieActionBindInput(ITasMovie movie, int frame, bool isDelete) public void Undo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; movie.BindMarkersToInput = _bindMarkers; if (_delete) // Insert @@ -731,15 +695,11 @@ public void Undo(ITasMovie movie) movie.RemoveFrame(FirstFrame); movie.LagLog.RemoveHistoryAt(FirstFrame + 1); } - - movie.ChangeLog.IsRecording = wasRecording; movie.BindMarkersToInput = _bindMarkers; } public void Redo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; movie.BindMarkersToInput = _bindMarkers; if (_delete) @@ -752,8 +712,6 @@ public void Redo(ITasMovie movie) movie.InsertInput(FirstFrame, _log); movie.LagLog.InsertHistoryAt(FirstFrame + 1, true); } - - movie.ChangeLog.IsRecording = wasRecording; movie.BindMarkersToInput = _bindMarkers; } } @@ -764,39 +722,42 @@ public class MovieActionInsertFrames : IMovieAction public int LastFrame { get; } private readonly int _count; private readonly bool _onlyEmpty; + private readonly bool _bindMarkers; private readonly List _newInputs; - public MovieActionInsertFrames(int frame, int count) + public MovieActionInsertFrames(int frame, bool bindMarkers, int count) { FirstFrame = frame; LastFrame = frame + count; _count = count; _onlyEmpty = true; + _bindMarkers = bindMarkers; } - public MovieActionInsertFrames(int frame, List newInputs) + public MovieActionInsertFrames(int frame, bool bindMarkers, List newInputs) { FirstFrame = frame; LastFrame = frame + newInputs.Count; _onlyEmpty = false; _newInputs = newInputs; + _bindMarkers = bindMarkers; } public void Undo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; + bool wasBinding = movie.BindMarkersToInput; + movie.BindMarkersToInput = _bindMarkers; movie.RemoveFrames(FirstFrame, LastFrame); - movie.ChangeLog.IsRecording = wasRecording; + movie.BindMarkersToInput = wasBinding; } public void Redo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; + bool wasBinding = movie.BindMarkersToInput; + movie.BindMarkersToInput = _bindMarkers; if (_onlyEmpty) { @@ -807,7 +768,7 @@ public void Redo(ITasMovie movie) movie.InsertInput(FirstFrame, _newInputs); } - movie.ChangeLog.IsRecording = wasRecording; + movie.BindMarkersToInput = wasBinding; } } @@ -817,36 +778,69 @@ public class MovieActionRemoveFrames : IMovieAction public int LastFrame { get; } private readonly List _oldInputs; - private readonly List _removedMarkers; + private readonly bool _bindMarkers; - public MovieActionRemoveFrames(int removeStart, int removeUpTo, List oldInputs, List removedMarkers) + public MovieActionRemoveFrames(int removeStart, int removeUpTo, List oldInputs, bool bindMarkers) { FirstFrame = removeStart; LastFrame = removeUpTo; _oldInputs = oldInputs; - _removedMarkers = removedMarkers; + _bindMarkers = bindMarkers; } public void Undo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; + bool wasBinding = movie.BindMarkersToInput; + movie.BindMarkersToInput = _bindMarkers; movie.InsertInput(FirstFrame, _oldInputs); - movie.Markers.AddRange(_removedMarkers); - - movie.ChangeLog.IsRecording = wasRecording; + movie.BindMarkersToInput = wasBinding; } public void Redo(ITasMovie movie) { - bool wasRecording = movie.ChangeLog.IsRecording; - movie.ChangeLog.IsRecording = false; + bool wasBinding = movie.BindMarkersToInput; + movie.BindMarkersToInput = _bindMarkers; movie.RemoveFrames(FirstFrame, LastFrame); - movie.ChangeLog.IsRecording = wasRecording; + movie.BindMarkersToInput = wasBinding; + } + } + + + public class MovieActionExtend : IMovieAction + { + public int FirstFrame { get; } + public int LastFrame => FirstFrame + _count - 1; + + private int _count; + private string _inputs; + + public MovieActionExtend(int currentEndOfMovie, int count, string inputs) + { + FirstFrame = currentEndOfMovie; + _count = count; + _inputs = inputs; + } + + public void Undo(ITasMovie movie) + { + bool wasMarkerBound = movie.BindMarkersToInput; + movie.BindMarkersToInput = false; + + movie.RemoveFrames(FirstFrame, LastFrame + 1); + movie.BindMarkersToInput = wasMarkerBound; + } + + public void Redo(ITasMovie movie) + { + bool wasMarkerBound = movie.BindMarkersToInput; + movie.BindMarkersToInput = false; + + movie.InsertInput(FirstFrame, Enumerable.Repeat(_inputs, _count)); + movie.BindMarkersToInput = wasMarkerBound; } } } diff --git a/src/BizHawk.Client.Common/movie/tasproj/TasMovie.cs b/src/BizHawk.Client.Common/movie/tasproj/TasMovie.cs index b0bf0a1268f..28d585cdd22 100644 --- a/src/BizHawk.Client.Common/movie/tasproj/TasMovie.cs +++ b/src/BizHawk.Client.Common/movie/tasproj/TasMovie.cs @@ -121,21 +121,24 @@ public override void StartNewRecording() // Removes lag log and greenzone after this frame private void InvalidateAfter(int frame) { - var anyLagInvalidated = LagLog.RemoveFrom(frame); - var anyStateInvalidated = TasStateManager.InvalidateAfter(frame); - GreenzoneInvalidated?.Invoke(frame); - if (anyLagInvalidated || anyStateInvalidated) + if (_suspendInvalidation) { - Changes = true; + _minInvalidationFrame = Math.Min(_minInvalidationFrame, frame); + return; } - LastEditedFrame = frame; - LastEditWasRecording = false; // We can set it here; it's only used in the GreenzoneInvalidated action. + LagLog.RemoveFrom(frame); + var anyStateInvalidated = TasStateManager.InvalidateAfter(frame); + Changes = true; + LastEditedFrame = frame; if (anyStateInvalidated && IsCountingRerecords) { Rerecords++; } + + GreenzoneInvalidated?.Invoke(frame); + LastEditWasRecording = false; // We can set it here; it's only used in the GreenzoneInvalidated action. } public void InvalidateEntireGreenzone() @@ -149,7 +152,7 @@ public void InvalidateEntireGreenzone() /// public string DisplayValue(int frame, string buttonName) { - if (_displayCache.Frame != frame) + if (_displayCache.Frame != frame || Log.Count == 1) { _displayCache.Controller ??= new Bk2Controller(Session.MovieController.Definition, LogKey); _displayCache.Controller.SetFromMnemonic(Log[frame]); @@ -293,14 +296,12 @@ public void LoadBranch(TasBranch branch) Log?.Dispose(); Log = branch.InputLog.Clone(); - InvalidateAfter(divergentPoint ?? branch.InputLog.Count); - if (BindMarkersToInput) // pretty critical not to erase them { Markers = branch.Markers.DeepClone(); } - Changes = true; + InvalidateAfter(divergentPoint ?? branch.InputLog.Count); } public event PropertyChangedEventHandler PropertyChanged; diff --git a/src/BizHawk.Client.EmuHawk/MainForm.cs b/src/BizHawk.Client.EmuHawk/MainForm.cs index 91783b6aa80..d96de3b53dc 100644 --- a/src/BizHawk.Client.EmuHawk/MainForm.cs +++ b/src/BizHawk.Client.EmuHawk/MainForm.cs @@ -1045,6 +1045,7 @@ private set InitializeFpsData(); } + if (value != _emulatorPaused) Tools.OnPauseToggle(value); _emulatorPaused = value; } } diff --git a/src/BizHawk.Client.EmuHawk/tools/Lua/Libraries/TAStudioLuaLibrary.cs b/src/BizHawk.Client.EmuHawk/tools/Lua/Libraries/TAStudioLuaLibrary.cs index be91c0e09fc..b53d2c4472b 100644 --- a/src/BizHawk.Client.EmuHawk/tools/Lua/Libraries/TAStudioLuaLibrary.cs +++ b/src/BizHawk.Client.EmuHawk/tools/Lua/Libraries/TAStudioLuaLibrary.cs @@ -314,8 +314,10 @@ public void ApplyInputChanges() _luaLibsImpl.IsUpdateSupressed = true; - Tastudio.ApiHawkBatchEdit(() => + Tastudio.StopRecordingOnNextEdit = false; + Tastudio.CurrentTasMovie.SingleInvalidation(() => { + Tastudio.CurrentTasMovie.ChangeLog.BeginNewBatch("tastudio.applyinputchanges"); int size = _changeList.Count; for (int i = 0; i < size; i++) @@ -334,17 +336,28 @@ public void ApplyInputChanges() } break; case LuaChangeTypes.InsertFrames: - Tastudio.InsertNumFrames(_changeList[i].Frame, _changeList[i].Number); + Tastudio.CurrentTasMovie.InsertEmptyFrame(_changeList[i].Frame, _changeList[i].Number); break; case LuaChangeTypes.DeleteFrames: - Tastudio.DeleteFrames(_changeList[i].Frame, _changeList[i].Number); + int endExclusive = _changeList[i].Frame + _changeList[i].Number; + endExclusive = Math.Min(Tastudio.CurrentTasMovie.InputLogLength, endExclusive); + if (_changeList[i].Frame < endExclusive) + { + Tastudio.CurrentTasMovie.RemoveFrames(_changeList[i].Frame, endExclusive); + } break; case LuaChangeTypes.ClearFrames: - Tastudio.ClearFrames(_changeList[i].Frame, _changeList[i].Number); + endExclusive = _changeList[i].Frame + _changeList[i].Number; + endExclusive = Math.Min(Tastudio.CurrentTasMovie.InputLogLength, endExclusive); + for (int j = _changeList[i].Frame; j < endExclusive; j++) + { + Tastudio.CurrentTasMovie.ClearFrame(j); + } break; } } _changeList.Clear(); + Tastudio.CurrentTasMovie.ChangeLog.EndBatch(); }); _luaLibsImpl.IsUpdateSupressed = false; @@ -471,7 +484,6 @@ public void RemoveMarker(int frame) if (marker != null) { Tastudio.CurrentTasMovie.Markers.Remove(marker); - Tastudio.RefreshDialog(); } } } @@ -491,7 +503,6 @@ public void SetMarker(int frame, string message = null) else { Tastudio.CurrentTasMovie.Markers.Add(frame, message1); - Tastudio.RefreshDialog(); } } } @@ -544,7 +555,7 @@ public void ClearIconCache() } [LuaMethodExample("tastudio.ongreenzoneinvalidated( function( currentindex )\r\n\tconsole.log( \"Called whenever the greenzone is invalidated.\" );\r\nend );")] - [LuaMethod("ongreenzoneinvalidated", "Called whenever the greenzone is invalidated. Your callback can have 1 parameter, which will be the index of the last row before the invalidated ones.")] + [LuaMethod("ongreenzoneinvalidated", "Called whenever the movie is modified in a way that could invalidate savestates in the movie's state history. Called regardless of whether any states were actually invalidated. Your callback can have 1 parameter, which will be the last frame before the invalidated ones. That is, the first of the modified frames.")] public void OnGreenzoneInvalidated(LuaFunction luaf) { if (Engaged()) diff --git a/src/BizHawk.Client.EmuHawk/tools/TAStudio/MarkerControl.cs b/src/BizHawk.Client.EmuHawk/tools/TAStudio/MarkerControl.cs index 0c2b854477c..dd2054df43d 100644 --- a/src/BizHawk.Client.EmuHawk/tools/TAStudio/MarkerControl.cs +++ b/src/BizHawk.Client.EmuHawk/tools/TAStudio/MarkerControl.cs @@ -157,7 +157,6 @@ private void RemoveMarkerToolStripMenuItem_Click(object sender, EventArgs e) if (!MarkerView.AnyRowsSelected) return; foreach (var i in MarkerView.SelectedRows.Select(index => Markers[index]).ToList()) Markers.Remove(i); MarkerView.RowCount = Markers.Count; - Tastudio.RefreshDialog(); } public void UpdateMarkerCount() @@ -193,7 +192,6 @@ public void AddMarker(int frame, bool editText = false) Markers.Add(marker); var index = Markers.IndexOf(marker); MarkerView.MakeIndexVisible(index); - Tastudio.RefreshDialog(); } public void UpdateTextColumnWidth() @@ -251,7 +249,6 @@ public void EditMarkerFramePopUp(TasMovieMarker marker) Markers.Move(marker.Frame, promptValue); UpdateTextColumnWidth(); UpdateValues(); - Tastudio.RefreshDialog(); } public void UpdateValues() diff --git a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.IToolForm.cs b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.IToolForm.cs index 6c09b298233..a5e13e94086 100644 --- a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.IToolForm.cs +++ b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.IToolForm.cs @@ -21,6 +21,8 @@ public partial class TAStudio : IToolForm private int _lastRefresh; private bool _doPause; + private bool _extended; + private bool _extendNeedsMerge; private void UpdateProgressBar() { @@ -55,7 +57,8 @@ private void UpdateProgressBar() protected override void UpdateBefore() { - if (CurrentTasMovie.IsAtEnd()) + _extended = CurrentTasMovie.IsAtEnd() && !CurrentTasMovie.IsRecording(); + if (_extended) { CurrentTasMovie.RecordFrame(CurrentTasMovie.Emulator.Frame, MovieSession.StickySource); } @@ -108,6 +111,18 @@ protected override void UpdateAfter() protected override void FastUpdateAfter() { + // Recording multiple frames, or auto-extending the movie, while unpaused should count as a single undo action. + // And do this before stopping the seek, which could puase. + if (!MainForm.EmulatorPaused && (CurrentTasMovie.IsRecording() || _extended)) + { + if (_extendNeedsMerge) CurrentTasMovie.ChangeLog.MergeLastActions(); + _extendNeedsMerge = true; + } + else + { + _extendNeedsMerge = false; + } + if (_seekingTo != -1 && Emulator.Frame >= _seekingTo) { StopSeeking(); diff --git a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.ListView.cs b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.ListView.cs index d4045de546d..6072887b8f9 100644 --- a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.ListView.cs +++ b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.ListView.cs @@ -27,7 +27,6 @@ public partial class TAStudio private int _startRow; private int _batchEditMinFrame = -1; private bool _batchEditing; - private bool _editIsFromLua; // Editing analog input private string _axisEditColumn = ""; @@ -378,7 +377,6 @@ private void TasView_ColumnClick(object sender, InputRoll.ColumnClickEventArgs e if (columnName == FrameColumnName) { CurrentTasMovie.Markers.Add(TasView.SelectionEndIndex!.Value, ""); - RefreshDialog(); } else if (columnName != CursorColumnName) { @@ -405,10 +403,13 @@ private void TasView_ColumnClick(object sender, InputRoll.ColumnClickEventArgs e else { BoolPatterns[ControllerType.BoolButtons.IndexOf(buttonName)].Reset(); - foreach (var index in TasView.SelectedRows) + CurrentTasMovie.SingleInvalidation(() => { - CurrentTasMovie.SetBoolState(index, buttonName, BoolPatterns[ControllerType.BoolButtons.IndexOf(buttonName)].GetNextValue()); - } + foreach (var index in TasView.SelectedRows) + { + CurrentTasMovie.SetBoolState(index, buttonName, BoolPatterns[ControllerType.BoolButtons.IndexOf(buttonName)].GetNextValue()); + } + }); } } else @@ -784,22 +785,6 @@ private bool EndBatchEdit() return false; } - public void ApiHawkBatchEdit(Action action) - { - // This is only caled from Lua. - _editIsFromLua = true; - BeginBatchEdit(); - try - { - action(); - } - finally - { - EndBatchEdit(); - _editIsFromLua = false; - } - } - /// /// Disables recording mode, ensures we are in the greenzone, and does autorestore if needed. /// If a mouse button is down, only tracks the edit so we can do this stuff on mouse up. @@ -829,11 +814,12 @@ public bool FrameEdited(int frame) } else { - if (!_editIsFromLua) + if (StopRecordingOnNextEdit) { // Lua users will want to preserve recording mode. TastudioPlayMode(true); } + StopRecordingOnNextEdit = true; if (Emulator.Frame > frame) { @@ -867,11 +853,18 @@ public bool FrameEdited(int frame) RefreshDialog(); return true; } - else if (TasView.RowCount != CurrentTasMovie.InputLogLength + 1) + else { - // Row count must always be kept up to date even if last row is not directly visible. - TasView.RowCount = CurrentTasMovie.InputLogLength + 1; - return true; + if (_undoForm != null && !_undoForm.IsDisposed) + { + _undoForm.UpdateValues(); + } + if (TasView.RowCount != CurrentTasMovie.InputLogLength + 1) + { + // Row count must always be kept up to date even if last row is not directly visible. + TasView.RowCount = CurrentTasMovie.InputLogLength + 1; + return true; + } } } @@ -1099,6 +1092,30 @@ private void TasView_PointedCellChanged(object sender, InputRoll.CellEventArgs e } else if (_rightClickFrame != -1) { + FramePaint(frame, startVal, endVal); + } + // Left-click + else if (TasView.IsPaintDown && !string.IsNullOrEmpty(_startBoolDrawColumn)) + { + BoolPaint(frame, startVal, endVal); + } + else if (TasView.IsPaintDown && !string.IsNullOrEmpty(_startAxisDrawColumn)) + { + AxisPaint(frame, startVal, endVal); + } + + CurrentTasMovie.IsCountingRerecords = wasCountingRerecords; + + if (MouseButtonHeld) + { + TasView.MakeIndexVisible(TasView.CurrentCell.RowIndex.Value); // todo: limit scrolling speed + SetTasViewRowCount(); // refreshes + } + } + + private void FramePaint(int frame, int startVal, int endVal) + { + CurrentTasMovie.SingleInvalidation(() => { if (frame > CurrentTasMovie.InputLogLength - _rightClickInput.Length) { frame = CurrentTasMovie.InputLogLength - _rightClickInput.Length; @@ -1202,11 +1219,12 @@ private void TasView_PointedCellChanged(object sender, InputRoll.CellEventArgs e { _suppressContextMenu = true; } - } + }); + } - // Left-click - else if (TasView.IsPaintDown && !string.IsNullOrEmpty(_startBoolDrawColumn)) - { + private void BoolPaint(int frame, int startVal, int endVal) + { + CurrentTasMovie.SingleInvalidation(() => { CurrentTasMovie.IsCountingRerecords = false; for (int i = startVal; i <= endVal; i++) // Inclusive on both ends (drawing up or down) @@ -1227,9 +1245,12 @@ private void TasView_PointedCellChanged(object sender, InputRoll.CellEventArgs e CurrentTasMovie.SetBoolState(i, _startBoolDrawColumn, setVal); // Notice it uses new row, old column, you can only paint across a single column } - } + }); + } - else if (TasView.IsPaintDown && !string.IsNullOrEmpty(_startAxisDrawColumn)) + private void AxisPaint(int frame, int startVal, int endVal) + { + CurrentTasMovie.SingleInvalidation(() => { CurrentTasMovie.IsCountingRerecords = false; @@ -1250,15 +1271,7 @@ private void TasView_PointedCellChanged(object sender, InputRoll.CellEventArgs e CurrentTasMovie.SetAxisState(i, _startAxisDrawColumn, setVal); // Notice it uses new row, old column, you can only paint across a single column } - } - - CurrentTasMovie.IsCountingRerecords = wasCountingRerecords; - - if (MouseButtonHeld) - { - TasView.MakeIndexVisible(TasView.CurrentCell.RowIndex.Value); // todo: limit scrolling speed - SetTasViewRowCount(); // refreshes - } + }); } private void TasView_MouseMove(object sender, MouseEventArgs e) @@ -1339,6 +1352,7 @@ public void EditAnalogProgrammatically(KeyEventArgs e) return; } + // TODO: properly handle axis editing batches BeginBatchEdit(); int value = CurrentTasMovie.GetAxisState(_axisEditRow, _axisEditColumn); diff --git a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.MenuItems.cs b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.MenuItems.cs index 9abd391c161..2e78e58b4a0 100644 --- a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.MenuItems.cs +++ b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.MenuItems.cs @@ -277,42 +277,30 @@ private void EditSubMenu_DropDownOpened(object sender, EventArgs e) GreenzoneICheckSeparator.Visible = StateHistoryIntegrityCheckMenuItem.Visible = VersionInfo.DeveloperBuild; + + UndoMenuItem.Enabled = CurrentTasMovie.ChangeLog.CanUndo; + RedoMenuItem.Enabled = CurrentTasMovie.ChangeLog.CanRedo; } private void UndoMenuItem_Click(object sender, EventArgs e) { - if (CurrentTasMovie.ChangeLog.Undo() < Emulator.Frame) - { - GoToFrame(CurrentTasMovie.ChangeLog.PreviousUndoFrame); - } - else - { - RefreshDialog(); - } - - // Currently I don't have a way to easily detect when CanUndo changes, so this button should be enabled always. - // UndoMenuItem.Enabled = CurrentTasMovie.ChangeLog.CanUndo; - RedoMenuItem.Enabled = CurrentTasMovie.ChangeLog.CanRedo; + CurrentTasMovie.ChangeLog.Undo(); + _extendNeedsMerge = false; } private void RedoMenuItem_Click(object sender, EventArgs e) { - if (CurrentTasMovie.ChangeLog.Redo() < Emulator.Frame) - { - GoToFrame(CurrentTasMovie.ChangeLog.PreviousRedoFrame); - } - else - { - RefreshDialog(); - } - - // Currently I don't have a way to easily detect when CanUndo changes, so this button should be enabled always. - // UndoMenuItem.Enabled = CurrentTasMovie.ChangeLog.CanUndo; - RedoMenuItem.Enabled = CurrentTasMovie.ChangeLog.CanRedo; + CurrentTasMovie.ChangeLog.Redo(); } private void ShowUndoHistoryMenuItem_Click(object sender, EventArgs e) { + if (_undoForm != null && !_undoForm.IsDisposed) + { + // We could just BringToFront, but closing is probably better since the new one will appear in the expected screen location. + _undoForm.Close(); + } + _undoForm = new UndoHistoryForm(this) { Owner = this }; _undoForm.Show(); _undoForm.UpdateValues(); @@ -487,18 +475,17 @@ private void CutMenuItem_Click(object sender, EventArgs e) } Clipboard.SetDataObject(sb.ToString()); - BeginBatchEdit(); // movie's RemoveFrames may make multiple separate invalidations CurrentTasMovie.RemoveFrames(list); - EndBatchEdit(); SetSplicer(); } } private void ClearFramesMenuItem_Click(object sender, EventArgs e) { - if (TasView.Focused && TasView.AnyRowsSelected) + if (!TasView.Focused || !TasView.AnyRowsSelected) return; + + CurrentTasMovie.SingleInvalidation(() => { - BeginBatchEdit(); CurrentTasMovie.ChangeLog.BeginNewBatch($"Clear frames {TasView.SelectionStartIndex}-{TasView.SelectionEndIndex}"); foreach (int frame in TasView.SelectedRows) { @@ -506,8 +493,7 @@ private void ClearFramesMenuItem_Click(object sender, EventArgs e) } CurrentTasMovie.ChangeLog.EndBatch(); - EndBatchEdit(); - } + }); } private void DeleteFramesMenuItem_Click(object sender, EventArgs e) @@ -523,9 +509,7 @@ private void DeleteFramesMenuItem_Click(object sender, EventArgs e) return; } - BeginBatchEdit(); // movie's RemoveFrames may make multiple separate invalidations CurrentTasMovie.RemoveFrames(TasView.SelectedRows.ToArray()); - EndBatchEdit(); SetTasViewRowCount(); SetSplicer(); } @@ -547,22 +531,28 @@ private void CloneFramesXTimesMenuItem_Click(object sender, EventArgs e) private void CloneFramesXTimes(int timesToClone) { - BeginBatchEdit(); - for (int i = 0; i < timesToClone; i++) - { - if (TasView.Focused && TasView.AnyRowsSelected) - { - var framesToInsert = TasView.SelectedRows; - var insertionFrame = Math.Min((TasView.SelectionEndIndex ?? 0) + 1, CurrentTasMovie.InputLogLength); + if (!TasView.Focused || !TasView.AnyRowsSelected) return; - var inputLog = framesToInsert - .Select(frame => CurrentTasMovie.GetInputLogEntry(frame)) - .ToList(); + var framesToInsert = TasView.SelectedRows; + var insertionFrame = Math.Min((TasView.SelectionEndIndex ?? 0) + 1, CurrentTasMovie.InputLogLength); + + var inputLog = framesToInsert + .Select(CurrentTasMovie.GetInputLogEntry) + .ToList(); + CurrentTasMovie.SingleInvalidation(() => + { + string batchName = $"Clone {inputLog.Count} frames starting at {TasView.FirstSelectedRowIndex}"; + if (timesToClone != 1) batchName += $" {timesToClone} times"; + CurrentTasMovie.ChangeLog.BeginNewBatch(batchName); + + for (int i = 0; i < timesToClone; i++) + { CurrentTasMovie.InsertInput(insertionFrame, inputLog); } - } - EndBatchEdit(); + + CurrentTasMovie.ChangeLog.EndBatch(); + }); } private void InsertFrameMenuItem_Click(object sender, EventArgs e) @@ -581,7 +571,7 @@ private void InsertNumFramesMenuItem_Click(object sender, EventArgs e) using var framesPrompt = new FramesPrompt(); if (framesPrompt.ShowDialogOnScreen().IsOk()) { - InsertNumFrames(insertionFrame, framesPrompt.Frames); + CurrentTasMovie.InsertEmptyFrame(insertionFrame, framesPrompt.Frames); } } } @@ -622,7 +612,6 @@ private void RemoveMarkersMenuItem_Click(object sender, EventArgs e) { CurrentTasMovie.Markers.RemoveAll(m => TasView.IsRowSelected(m.Frame)); MarkerControl.UpdateMarkerCount(); - RefreshDialog(); } private void ClearGreenzoneMenuItem_Click(object sender, EventArgs e) diff --git a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.cs b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.cs index b7ddb20d4ca..07712656610 100644 --- a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.cs +++ b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.cs @@ -64,6 +64,11 @@ public static Icon ToolIcon [ConfigPersist] public Font TasViewFont { get; set; } = new Font("Arial", 8.25F, FontStyle.Bold, GraphicsUnit.Point, 0); + /// + /// This is meant to be used by Lua. + /// + public bool StopRecordingOnNextEdit = true; + public class TAStudioSettings { public TAStudioSettings() @@ -585,7 +590,15 @@ private bool StartNewMovieWrapper(ITasMovie movie, bool isNew) movie.BindMarkersToInput = Settings.BindMarkersToInput; movie.GreenzoneInvalidated = (f) => _ = FrameEdited(f); movie.ChangeLog.MaxSteps = Settings.MaxUndoSteps; + movie.PropertyChanged += TasMovie_OnPropertyChanged; + System.Collections.Specialized.NotifyCollectionChangedEventHandler refreshOnMarker = (_, _) => RefreshDialog(); + movie.Markers.CollectionChanged += refreshOnMarker; + this.Disposed += (s, e) => + { + movie.PropertyChanged -= TasMovie_OnPropertyChanged; + movie.Markers.CollectionChanged -= refreshOnMarker; + }; SuspendLayout(); WantsToControlStopMovie = false; @@ -877,6 +890,13 @@ public void TogglePause() MainForm.TogglePause(); } + public override void OnPauseToggle(bool newPauseState) + { + // Consecutively recorded frames are merged into one undo action, until we pause. + // Then a new undo action should be used. + if (newPauseState) _extendNeedsMerge = false; + } + private void SetSplicer() { // TODO: columns selected? @@ -887,45 +907,6 @@ private void SetSplicer() SplicerStatusLabel.Text = temp; } - public void InsertNumFrames(int insertionFrame, int numberOfFrames) - { - if (insertionFrame <= CurrentTasMovie.InputLogLength) - { - CurrentTasMovie.InsertEmptyFrame(insertionFrame, numberOfFrames); - } - } - - public void DeleteFrames(int beginningFrame, int numberOfFrames) - { - if (beginningFrame < CurrentTasMovie.InputLogLength) - { - // movie's RemoveFrames might do multiple separate invalidations - BeginBatchEdit(); - - int[] framesToRemove = Enumerable.Range(beginningFrame, numberOfFrames).ToArray(); - CurrentTasMovie.RemoveFrames(framesToRemove); - SetSplicer(); - - EndBatchEdit(); - } - } - - public void ClearFrames(int beginningFrame, int numberOfFrames) - { - if (beginningFrame < CurrentTasMovie.InputLogLength) - { - BeginBatchEdit(); - - int last = Math.Min(beginningFrame + numberOfFrames, CurrentTasMovie.InputLogLength); - for (int i = beginningFrame; i < last; i++) - { - CurrentTasMovie.ClearFrame(i); - } - - EndBatchEdit(); - } - } - private void Tastudio_Closing(object sender, FormClosingEventArgs e) { if (!_initialized) @@ -1059,7 +1040,7 @@ private void TasView_CellDropped(object sender, InputRoll.CellEventArgs e) if (e.NewCell?.RowIndex != null && !CurrentTasMovie.Markers.IsMarker(e.NewCell.RowIndex.Value)) { CurrentTasMovie.Markers.Move(e.OldCell.RowIndex.Value, e.NewCell.RowIndex.Value); - RefreshDialog(); + RefreshDialog(); // Marker move might have been rejected so we need to manually refresh. } } diff --git a/src/BizHawk.Client.EmuHawk/tools/TAStudio/UndoHistoryForm.cs b/src/BizHawk.Client.EmuHawk/tools/TAStudio/UndoHistoryForm.cs index 32892bc021e..77767a8d65f 100644 --- a/src/BizHawk.Client.EmuHawk/tools/TAStudio/UndoHistoryForm.cs +++ b/src/BizHawk.Client.EmuHawk/tools/TAStudio/UndoHistoryForm.cs @@ -10,7 +10,7 @@ public partial class UndoHistoryForm : Form private const string UndoColumnName = "Undo Step"; private readonly TAStudio _tastudio; - private string _lastUndoAction; + private int _lastUndoAction; private IMovieChangeLog Log => _tastudio.CurrentTasMovie.ChangeLog; public UndoHistoryForm(TAStudio owner) @@ -50,14 +50,14 @@ private void HistoryView_QueryItemBkColor(int index, RollColumn column, ref Colo public void UpdateValues() { HistoryView.RowCount = Log.Names.Count; - if (AutoScrollCheck.Checked && _lastUndoAction != Log.NextUndoStepName) + if (AutoScrollCheck.Checked && _lastUndoAction != Log.UndoIndex) { - HistoryView.ScrollToIndex(Log.UndoIndex); + HistoryView.ScrollToIndex(Math.Max(Log.UndoIndex, 0)); HistoryView.DeselectAll(); HistoryView.SelectRow(Log.UndoIndex - 1, true); } - _lastUndoAction = Log.NextUndoStepName; + _lastUndoAction = Log.UndoIndex; HistoryView.Refresh(); } @@ -71,13 +71,11 @@ private void ClearButton_Click(object sender, EventArgs e) private void UndoButton_Click(object sender, EventArgs e) { _tastudio.UndoExternal(); - _tastudio.RefreshDialog(); } private void RedoButton_Click(object sender, EventArgs e) { _tastudio.RedoExternal(); - _tastudio.RefreshDialog(); } private int SelectedItem @@ -85,20 +83,16 @@ private int SelectedItem private void UndoToHere(int index) { - int earliestFrame = int.MaxValue; - while (Log.UndoIndex > index) + _tastudio.CurrentTasMovie.SingleInvalidation(() => { - int frame = Log.Undo(); - if (frame < earliestFrame) - earliestFrame = frame; - } + while (Log.UndoIndex > index) + { + // Although we have a reference to the Log, TAStudio needs to do a little extra on undo. + _tastudio.UndoExternal(); + } + }); UpdateValues(); - - // potentially rewind, then update display for TAStudio - if (_tastudio.Emulator.Frame > earliestFrame) - _tastudio.GoToFrame(earliestFrame); - _tastudio.RefreshDialog(); } private void HistoryView_DoubleClick(object sender, EventArgs e) @@ -136,20 +130,15 @@ private void UndoHereMenuItem_Click(object sender, EventArgs e) private void RedoHereMenuItem_Click(object sender, EventArgs e) { - int earliestFrame = int.MaxValue; - while (Log.UndoIndex < SelectedItem) + _tastudio.CurrentTasMovie.SingleInvalidation(() => { - int frame = Log.Redo(); - if (earliestFrame < frame) - earliestFrame = frame; - } + while (Log.UndoIndex < SelectedItem) + { + Log.Redo(); + } + }); UpdateValues(); - - // potentially rewind, then update display for TAStudio - if (_tastudio.Emulator.Frame > earliestFrame) - _tastudio.GoToFrame(earliestFrame); - _tastudio.RefreshDialog(); } private void ClearHistoryToHereMenuItem_Click(object sender, EventArgs e) diff --git a/src/BizHawk.Client.EmuHawk/tools/ToolFormBase.cs b/src/BizHawk.Client.EmuHawk/tools/ToolFormBase.cs index cb1f8e64b86..176a21d712b 100644 --- a/src/BizHawk.Client.EmuHawk/tools/ToolFormBase.cs +++ b/src/BizHawk.Client.EmuHawk/tools/ToolFormBase.cs @@ -136,5 +136,7 @@ protected override void OnLoad(EventArgs e) } public virtual void HandleHotkeyUpdate() { } + + public virtual void OnPauseToggle(bool newPauseState) { } } } diff --git a/src/BizHawk.Client.EmuHawk/tools/ToolManager.cs b/src/BizHawk.Client.EmuHawk/tools/ToolManager.cs index 1b5a3612411..bec31d413cb 100644 --- a/src/BizHawk.Client.EmuHawk/tools/ToolManager.cs +++ b/src/BizHawk.Client.EmuHawk/tools/ToolManager.cs @@ -804,6 +804,17 @@ public void HandleHotkeyUpdate() } } + public void OnPauseToggle(bool newPauseState) + { + foreach (var tool in _tools) + { + if (tool.IsActive && tool is ToolFormBase toolForm) + { + toolForm.OnPauseToggle(newPauseState); + } + } + } + private static readonly IList PossibleToolTypeNames = EmuHawk.ReflectionCache.Types.Select(t => t.AssemblyQualifiedName).ToList(); public bool IsAvailable(Type tool) diff --git a/src/BizHawk.Tests.Client.Common/BizHawk.Tests.Client.Common.csproj b/src/BizHawk.Tests.Client.Common/BizHawk.Tests.Client.Common.csproj index 79cc055084d..86e6f698719 100644 --- a/src/BizHawk.Tests.Client.Common/BizHawk.Tests.Client.Common.csproj +++ b/src/BizHawk.Tests.Client.Common/BizHawk.Tests.Client.Common.csproj @@ -1,4 +1,4 @@ - + net48 diff --git a/src/BizHawk.Tests.Client.Common/Movie/FakeEmulator.cs b/src/BizHawk.Tests.Client.Common/Movie/FakeEmulator.cs new file mode 100644 index 00000000000..4c2661b4b52 --- /dev/null +++ b/src/BizHawk.Tests.Client.Common/Movie/FakeEmulator.cs @@ -0,0 +1,75 @@ +using System.IO; + +using BizHawk.Common; +using BizHawk.Emulation.Common; + +namespace BizHawk.Tests.Client.Common.Movie +{ + internal class FakeEmulator : IEmulator, IStatable, IInputPollable + { + private BasicServiceProvider _serviceProvider; + public IEmulatorServiceProvider ServiceProvider => _serviceProvider; + + private static readonly ControllerDefinition _cd = new ControllerDefinition("fake controller") + { + BoolButtons = { "A", "B" }, + } + .AddAxis("Stick", (-100).RangeTo(100), 0) + .MakeImmutable(); + + static FakeEmulator() + { + _cd.BuildMnemonicsCache("fake"); + } + + public ControllerDefinition ControllerDefinition => _cd; + + public int Frame { get; set; } + + public string SystemId => "fake"; + + public bool DeterministicEmulation => true; + + public bool AvoidRewind => false; + + public int LagCount { get; set; } + public bool IsLagFrame { get; set; } + +#pragma warning disable CA1065 // Do not raise exceptions in unexpected locations + public IInputCallbackSystem InputCallbacks => throw new NotImplementedException(); +#pragma warning restore CA1065 // Do not raise exceptions in unexpected locations + + public FakeEmulator() + { + _serviceProvider = new(this); + } + + public void Dispose() { } + public bool FrameAdvance(IController controller, bool render, bool renderSound = true) + { + Frame++; + return true; + } + + public void LoadStateBinary(BinaryReader reader) + { + Frame = reader.ReadInt32(); + LagCount = reader.ReadInt32(); + IsLagFrame = reader.ReadBoolean(); + } + + public void ResetCounters() + { + Frame = 0; + LagCount = 0; + IsLagFrame = false; + } + + public void SaveStateBinary(BinaryWriter writer) + { + writer.Write(Frame); + writer.Write(LagCount); + writer.Write(IsLagFrame); + } + } +} diff --git a/src/BizHawk.Tests.Client.Common/Movie/FakeMovieSession.cs b/src/BizHawk.Tests.Client.Common/Movie/FakeMovieSession.cs new file mode 100644 index 00000000000..7d863ec47e9 --- /dev/null +++ b/src/BizHawk.Tests.Client.Common/Movie/FakeMovieSession.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.IO; + +using BizHawk.Client.Common; +using BizHawk.Emulation.Common; + +namespace BizHawk.Tests.Client.Common.Movie +{ + internal class FakeMovieSession : IMovieSession + { + public IMovieConfig Settings { get; set; } + + public required IMovie Movie { get; set; } + + public bool ReadOnly { get => false; set { } } + + public bool NewMovieQueued => throw new NotImplementedException(); + + public string QueuedSyncSettings => throw new NotImplementedException(); + + public string QueuedCoreName => throw new NotImplementedException(); + + public string QueuedSysID => throw new NotImplementedException(); + + public IDictionary UserBag { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + + public IMovieController MovieController { get; } + + public IController StickySource { get; set; } + public IController MovieIn { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public IInputAdapter MovieOut => throw new NotImplementedException(); + + public string BackupDirectory { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public FakeMovieSession(IEmulator emulator) + { + Settings = new MovieConfig(); + StickySource = new Bk2Controller(emulator.ControllerDefinition); + MovieController = new Bk2Controller(emulator.ControllerDefinition); + } + + public void AbortQueuedMovie() => throw new NotImplementedException(); + public bool CheckSavestateTimeline(TextReader reader) => throw new NotImplementedException(); + public IMovieController GenerateMovieController(ControllerDefinition? definition = null, string? logKey = null) => throw new NotImplementedException(); + public IMovie Get(string path, bool loadMovie = false) => throw new NotImplementedException(); + public void HandleFrameAfter(bool ignoreMovieEndAction) => throw new NotImplementedException(); + public void HandleFrameBefore() => throw new NotImplementedException(); + public bool HandleLoadState(TextReader reader) => throw new NotImplementedException(); + public void HandleSaveState(TextWriter writer) => throw new NotImplementedException(); + public void PopupMessage(string message) => throw new NotImplementedException(); + public void QueueNewMovie(IMovie movie, string systemId, string loadedRomHash, PathEntryCollection pathEntries, IDictionary preferredCores) => throw new NotImplementedException(); + public void RunQueuedMovie(bool recordMode, IEmulator emulator) => throw new NotImplementedException(); + public void StopMovie(bool saveChanges = true) => Movie.Stop(); + } +} diff --git a/src/BizHawk.Tests.Client.Common/Movie/MovieUndoTests.cs b/src/BizHawk.Tests.Client.Common/Movie/MovieUndoTests.cs new file mode 100644 index 00000000000..5254da19946 --- /dev/null +++ b/src/BizHawk.Tests.Client.Common/Movie/MovieUndoTests.cs @@ -0,0 +1,440 @@ +using BizHawk.Client.Common; + +namespace BizHawk.Tests.Client.Common.Movie +{ + [TestClass] + public class MovieUndoTests + { + // Our test looks for the first actually different frame and compares with the value returned by Undo. + // Some operations (e.g. RemoveFrames) don't check for which frames were actually edited. (Should they? Would that give bad performance?) + // So we should ensure the first frame we touch is actually changed. + private void ValidateActionCanUndoAndRedo(ITasMovie movie, Action action, int expectedUndoItems = 1, bool skipUndoIndexCheck = false) + { + IStringLog originalLog = movie.GetLogEntries().Clone(); + int originalUndoLength = movie.ChangeLog.UndoIndex; + action(); + + IStringLog changedLog = movie.GetLogEntries().Clone(); + int changedUndoLength = movie.ChangeLog.UndoIndex; + int firstEditedFrame = originalLog.DivergentPoint(changedLog) ?? movie.InputLogLength; + + if (!skipUndoIndexCheck) + Assert.AreEqual(originalUndoLength + expectedUndoItems, changedUndoLength); + + Action? oldInvalidated = movie.GreenzoneInvalidated; + int invalidateFrame = int.MaxValue; + movie.GreenzoneInvalidated = (f) => + { + oldInvalidated?.Invoke(f); + invalidateFrame = Math.Min(invalidateFrame, f); + }; + + // undo + for (int i = 0; i < expectedUndoItems; i++) + movie.ChangeLog.Undo(); + Assert.AreEqual(firstEditedFrame, invalidateFrame); + Assert.IsNull(originalLog.DivergentPoint(movie.GetLogEntries())); + + // redo + invalidateFrame = int.MaxValue; + for (int i = 0; i < expectedUndoItems; i++) + movie.ChangeLog.Redo(); + Assert.AreEqual(firstEditedFrame, invalidateFrame); + Assert.IsNull(changedLog.DivergentPoint(movie.GetLogEntries())); + + movie.GreenzoneInvalidated = oldInvalidated; + } + + [TestMethod] + public void SetBool() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + + ValidateActionCanUndoAndRedo(movie, () => + { + movie.SetBoolState(2, "A", true); + }); + } + + [TestMethod] + public void ExtendsMovie() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + + ValidateActionCanUndoAndRedo(movie, () => + { + movie.SetBoolState(8, "A", true); + }); + movie.ChangeLog.Undo(); + ValidateActionCanUndoAndRedo(movie, () => + { + movie.SetBoolStates(8, 2, "A", true); + }); + movie.ChangeLog.Undo(); + ValidateActionCanUndoAndRedo(movie, () => + { + movie.SetAxisState(8, "Stick", 10); + }); + movie.ChangeLog.Undo(); + ValidateActionCanUndoAndRedo(movie, () => + { + movie.SetAxisStates(8, 2, "Stick", 10); + }); + movie.ChangeLog.Undo(); + } + + [TestMethod] + public void SetAxis() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + + ValidateActionCanUndoAndRedo(movie, () => + { + movie.SetAxisState(2, "Stick", 20); + }); + } + + [TestMethod] + public void InsertFrame() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + movie.SetBoolState(2, "A", true); + movie.SetBoolState(3, "B", true); + + ValidateActionCanUndoAndRedo(movie, () => + { + movie.InsertEmptyFrame(3); + }); + } + + [TestMethod] + public void DeleteFrame() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + movie.SetBoolState(2, "A", true); + movie.SetBoolState(4, "B", true); + + ValidateActionCanUndoAndRedo(movie, () => + { + movie.RemoveFrame(3); + }); + } + + [TestMethod] + public void DeleteFrames() + { + ITasMovie movie = TasMovieTests.MakeMovie(10); + movie.SetBoolState(2, "A", true); + movie.SetBoolState(4, "B", true); + + // both overloads + ValidateActionCanUndoAndRedo(movie, () => + { + movie.RemoveFrames(1, 4); + }); + movie.ChangeLog.Undo(); + + ValidateActionCanUndoAndRedo(movie, () => + { + movie.RemoveFrames([ 1, 2, 3, 5 ]); + }); + } + + [TestMethod] + public void CloneFrame() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + movie.SetBoolState(2, "A", true); + movie.SetBoolState(3, "B", true); + + ValidateActionCanUndoAndRedo(movie, () => + { + movie.InsertInput(2, movie.GetInputLogEntry(3)); + }); + } + + [TestMethod] + public void MultipleEdits() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + + ValidateActionCanUndoAndRedo(movie, () => + { + movie.SetBoolState(2, "A", true); + movie.SetBoolState(3, "B", true); + }, 2); + } + + [TestMethod] + public void BatchedEdit() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + + ValidateActionCanUndoAndRedo(movie, () => + { + movie.ChangeLog.BeginNewBatch(); + movie.SetBoolState(2, "A", true); + movie.SetBoolState(3, "B", true); + movie.ChangeLog.EndBatch(); + }); + } + + [TestMethod] + public void RecordFrameAtEnd() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + + ValidateActionCanUndoAndRedo(movie, () => + { + Bk2Controller controller = new Bk2Controller(movie.Emulator.ControllerDefinition); + controller.SetBool("A", true); + movie.RecordFrame(5, controller); + }); + } + + [TestMethod] + public void RecordFrameInMiddle() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + + ValidateActionCanUndoAndRedo(movie, () => + { + Bk2Controller controller = new Bk2Controller(movie.Emulator.ControllerDefinition); + controller.SetBool("A", true); + movie.RecordFrame(2, controller); + }); + } + + [TestMethod] + public void RecordFrameZero() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + + ValidateActionCanUndoAndRedo(movie, () => + { + Bk2Controller controller = new Bk2Controller(movie.Emulator.ControllerDefinition); + controller.SetBool("A", true); + movie.RecordFrame(0, controller); + }); + } + + [TestMethod] + public void MarkersGetMoved() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + movie.BindMarkersToInput = true; + movie.Markers.Add(3, "a"); + movie.InsertEmptyFrame(2); + + movie.ChangeLog.Undo(); + Assert.AreEqual(3, movie.Markers[0].Frame); + + movie.ChangeLog.Redo(); + Assert.AreEqual(4, movie.Markers[0].Frame); + } + + [TestMethod] + public void MarkersGetUndeleted() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + movie.BindMarkersToInput = true; + movie.Markers.Add(3, "a"); + movie.RemoveFrame(3); + + movie.ChangeLog.Undo(); + Assert.AreEqual(1, movie.Markers.Count); + Assert.AreEqual(3, movie.Markers[0].Frame); + Assert.AreEqual("a", movie.Markers[0].Message); + + movie.ChangeLog.Redo(); + Assert.AreEqual(0, movie.Markers.Count); + } + + [TestMethod] + public void MarkersUnaffectedByMovieExtension() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + movie.BindMarkersToInput = true; + movie.Markers.Add(5, "a"); + movie.Markers.Add(8, "b"); + movie.SetBoolState(10, "A", true); + + movie.ChangeLog.Undo(); + Assert.AreEqual(2, movie.Markers.Count); + Assert.AreEqual(5, movie.Markers[0].Frame); + Assert.AreEqual(8, movie.Markers[1].Frame); + + movie.ChangeLog.Redo(); + Assert.AreEqual(2, movie.Markers.Count); + Assert.AreEqual(5, movie.Markers[0].Frame); + Assert.AreEqual(8, movie.Markers[1].Frame); + } + + [TestMethod] + public void AllOperationsRespectBatching() + { + ITasMovie movie = TasMovieTests.MakeMovie(10); + + // Some actions can move markers. + movie.Markers.Add(9, ""); + movie.BindMarkersToInput = true; + + Bk2Controller controllerA = new Bk2Controller(movie.Emulator.ControllerDefinition); + controllerA.SetBool("A", true); + string entryA = Bk2LogEntryGenerator.GenerateLogEntry(controllerA); + + int beginIndex = 0; + TasMovieTests.TestAllOperations(movie, + () => + { + beginIndex = movie.ChangeLog.UndoIndex; + movie.ChangeLog.BeginNewBatch(); + }, + () => + { + movie.SetFrame(0, entryA); + movie.ChangeLog.EndBatch(); + + Assert.AreEqual(1, movie.ChangeLog.UndoIndex - beginIndex); + }); + } + + [TestMethod] + public void AllOperationsGiveOneUndo() + { + ITasMovie movie = TasMovieTests.MakeMovie(10); + + // Some actions can move markers. + movie.Markers.Add(9, ""); + movie.BindMarkersToInput = true; + + int beginIndex = 0; + TasMovieTests.TestAllOperations(movie, + () => beginIndex = movie.ChangeLog.UndoIndex, + () => Assert.AreEqual(1, movie.ChangeLog.UndoIndex - beginIndex) + ); + } + + [TestMethod] + public void WorkWithFullUndoHistory() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + movie.ChangeLog.MaxSteps = 3; + + movie.SetBoolState(0, "A", true); + movie.SetBoolState(1, "A", true); + movie.SetBoolState(2, "A", true); + + ValidateActionCanUndoAndRedo(movie, () => + { + movie.SetBoolState(10, "A", true); + }, 1, true); + } + + [TestMethod] + public void UndoingMidBatchRetainsBatchState() + { + ITasMovie movie = TasMovieTests.MakeMovie(8); + movie.ChangeLog.BeginNewBatch(); + + movie.SetBoolState(1, "A", true); + movie.SetBoolState(2, "A", true); + movie.ChangeLog.Undo(); + + ValidateActionCanUndoAndRedo(movie, () => + { + movie.SetBoolState(3, "A", true); + movie.SetBoolState(4, "A", true); + + movie.ChangeLog.EndBatch(); + }, 1, true); + + Assert.AreEqual(1, movie.ChangeLog.UndoIndex); + } + + [TestMethod] + public void UndoRedoProduceSingleInvalidation() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + int invalidations = 0; + movie.GreenzoneInvalidated = (_) => invalidations++; + + movie.ChangeLog.BeginNewBatch(); + movie.SetBoolState(1, "A", true); + movie.SetBoolState(2, "B", true); + movie.ChangeLog.EndBatch(); + + invalidations = 0; + movie.ChangeLog.Undo(); + Assert.AreEqual(1, invalidations); + + invalidations = 0; + movie.ChangeLog.Redo(); + Assert.AreEqual(1, invalidations); + } + + [TestMethod] + public void InsertRespectsMarkerBinding() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + movie.Markers.Add(3, "a"); + + movie.BindMarkersToInput = true; + movie.InsertEmptyFrame(1); + movie.BindMarkersToInput = false; + + movie.ChangeLog.Undo(); + Assert.AreEqual(3, movie.Markers[0].Frame); + Assert.IsFalse(movie.BindMarkersToInput); + + movie.ChangeLog.Redo(); + Assert.AreEqual(4, movie.Markers[0].Frame); + Assert.IsFalse(movie.BindMarkersToInput); + } + + [TestMethod] + public void DeleteRespectsMarkerBinding() + { + ITasMovie movie = TasMovieTests.MakeMovie(5); + movie.Markers.Add(3, "a"); + + movie.BindMarkersToInput = true; + movie.RemoveFrame(1); + movie.BindMarkersToInput = false; + + movie.ChangeLog.Undo(); + Assert.AreEqual(3, movie.Markers[0].Frame); + Assert.IsFalse(movie.BindMarkersToInput); + + movie.ChangeLog.Redo(); + Assert.AreEqual(2, movie.Markers[0].Frame); + Assert.IsFalse(movie.BindMarkersToInput); + } + + [TestMethod] + public void GeneralRespectsMarkerBinding() + { + // This was just a silly bug. + ITasMovie movie = TasMovieTests.MakeMovie(5); + movie.Markers.Add(3, "a"); + + Bk2Controller controllerA = new Bk2Controller(movie.Emulator.ControllerDefinition); + controllerA.SetBool("A", true); + + movie.BindMarkersToInput = true; + movie.PokeFrame(1, controllerA); + movie.BindMarkersToInput = false; + movie.InsertEmptyFrame(1); + + movie.ChangeLog.Undo(); + movie.ChangeLog.Undo(); + Assert.AreEqual(3, movie.Markers[0].Frame); + Assert.IsFalse(movie.BindMarkersToInput); + + movie.ChangeLog.Redo(); + movie.ChangeLog.Redo(); + Assert.AreEqual(3, movie.Markers[0].Frame); + Assert.IsFalse(movie.BindMarkersToInput); + } + } +} diff --git a/src/BizHawk.Tests.Client.Common/Movie/TasMovieTests.cs b/src/BizHawk.Tests.Client.Common/Movie/TasMovieTests.cs new file mode 100644 index 00000000000..fcb0d9a7f3b --- /dev/null +++ b/src/BizHawk.Tests.Client.Common/Movie/TasMovieTests.cs @@ -0,0 +1,97 @@ +using BizHawk.Client.Common; + +namespace BizHawk.Tests.Client.Common.Movie +{ + [TestClass] + public class TasMovieTests + { + internal static TasMovie MakeMovie(int numberOfFrames) + { + FakeEmulator emu = new FakeEmulator(); + FakeMovieSession session = new(emu) { Movie = null! }; + TasMovie movie = new(session, "/fake/path"); + session.Movie = movie; + + movie.Attach(emu); + movie.InsertEmptyFrame(0, numberOfFrames); + + return movie; + } + + public static void TestAllOperations(ITasMovie movie, Action PreOperation, Action PostOperation) + { + Bk2Controller controllerEmpty = new Bk2Controller(movie.Emulator.ControllerDefinition); + string entryEmpty = Bk2LogEntryGenerator.GenerateLogEntry(controllerEmpty); + Bk2Controller controllerA = new Bk2Controller(movie.Emulator.ControllerDefinition); + controllerA.SetBool("A", true); + string entryA = Bk2LogEntryGenerator.GenerateLogEntry(controllerA); + + // Make sure all operations actually do something. + movie.SetBoolState(3, "A", true); + + void TestForSingleOperation(Action a) + { + PreOperation(); + a(); + PostOperation(); + movie.ChangeLog.Undo(); + } + + TestForSingleOperation(() => movie.RecordFrame(1, controllerA)); + TestForSingleOperation(() => movie.Truncate(3)); + TestForSingleOperation(() => movie.PokeFrame(1, controllerA)); + TestForSingleOperation(() => movie.SetFrame(1, entryA)); + TestForSingleOperation(() => movie.ClearFrame(3)); + + TestForSingleOperation(() => movie.InsertInput(2, entryA)); + TestForSingleOperation(() => movie.InsertInput(2, [ entryA, entryEmpty, entryA, entryEmpty ])); + TestForSingleOperation(() => movie.InsertInput(2, [ controllerA, controllerEmpty, controllerA, controllerEmpty ])); + + TestForSingleOperation(() => movie.RemoveFrame(2)); + TestForSingleOperation(() => movie.RemoveFrames(2, 4)); + TestForSingleOperation(() => movie.RemoveFrames([ 2, 4, 6 ])); + + TestForSingleOperation(() => movie.CopyOverInput(2, [ controllerA, controllerEmpty ])); + TestForSingleOperation(() => movie.InsertEmptyFrame(2, 2)); + + TestForSingleOperation(() => movie.ToggleBoolState(2, "B")); + TestForSingleOperation(() => movie.SetBoolState(3, "B", true)); + TestForSingleOperation(() => movie.SetBoolStates(3, 2, "B", true)); + + TestForSingleOperation(() => movie.SetAxisState(2, "Stick", 10)); + TestForSingleOperation(() => movie.SetAxisStates(3, 2, "Stick", 20)); + + // actions that can also extend the movie + TestForSingleOperation(() => movie.CopyOverInput(9, [ controllerA, controllerEmpty, controllerA ])); + TestForSingleOperation(() => movie.ToggleBoolState(15, "B")); + TestForSingleOperation(() => movie.SetBoolState(15, "B", true)); + TestForSingleOperation(() => movie.SetBoolStates(15, 2, "B", true)); + TestForSingleOperation(() => movie.SetAxisState(15, "Stick", 10)); + TestForSingleOperation(() => movie.SetAxisStates(15, 2, "Stick", 20)); + } + + [TestMethod] + public void AllOperationsFlagChanges() + { + ITasMovie movie = MakeMovie(10); + + TestAllOperations(movie, + movie.ClearChanges, + () => Assert.IsTrue(movie.Changes) + ); + } + + [TestMethod] + public void AllOperationsProduceSingleInvalidation() + { + ITasMovie movie = MakeMovie(10); + int invalidations = 0; + movie.GreenzoneInvalidated = (_) => invalidations++; + + TestAllOperations(movie, + () => invalidations = 0, + () => Assert.AreEqual(1, invalidations) + ); + } + } +}