using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Drawing; using BizHawk.Common; using BizHawk.Common.NumberExtensions; using BizHawk.Emulation.Common; using BizHawk.Emulation.Common.IEmulatorExtensions; namespace BizHawk.Client.Common { /// /// Captures savestates and manages the logic of adding, retrieving, /// invalidating/clearing of states. Also does memory management and limiting of states /// public class TasStateManager : IDisposable { // TODO: pass this in, and find a solution to a stale reference (this is instantiated BEFORE a new core instance is made, making this one stale if it is simply set in the constructor private IStatable Core => Global.Emulator.AsStatable(); public Action InvalidateCallback { get; set; } private void CallInvalidateCallback(int index) { InvalidateCallback?.Invoke(index); } internal NDBDatabase NdbDatabase { get; set; } private Guid _guid = Guid.NewGuid(); private SortedList _states = new SortedList(); private string StatePath { get { var basePath = PathManager.MakeAbsolutePath(Global.Config.PathEntries["Global", "TAStudio states"].Path, null); return Path.Combine(basePath, _guid.ToString()); } } private bool _isMountedForWrite; private readonly TasMovie _movie; private StateManagerDecay _decay; private ulong _expectedStateSize; private int _stateFrequency; private readonly int _minFrequency = 1; private readonly int _maxFrequency = 16; private int _maxStates => (int)(Settings.Cap / _expectedStateSize) + (int)((ulong)Settings.DiskCapacitymb * 1024 * 1024 / _expectedStateSize); private int _fileStateGap => 1 << Settings.FileStateGap; public TasStateManager(TasMovie movie) { _movie = movie; Settings = new TasStateManagerSettings(Global.Config.DefaultTasProjSettings); if (_movie.StartsFromSavestate) { SetState(0, _movie.BinarySavestate); } _decay = new StateManagerDecay(this); } public void Dispose() { // States and BranchStates don't need cleaning because they would only contain an ndbdatabase entry which was demolished by the below NdbDatabase?.Dispose(); } public void UpdateStateFrequency() { _stateFrequency = NumberExtensions.Clamp( ((int)_expectedStateSize / Settings.MemStateGapDivider / 1024), _minFrequency, _maxFrequency); _decay.UpdateSettings(_maxStates, _stateFrequency, 4); } /// /// Mounts this instance for write access. Prior to that it's read-only /// public void MountWriteAccess() { if (_isMountedForWrite) { return; } int limit = 0; _isMountedForWrite = true; _expectedStateSize = (ulong)Core.SaveStateBinary().Length; UpdateStateFrequency(); if (_expectedStateSize > 0) { limit = _maxStates; } _states = new SortedList(limit); if (_expectedStateSize > int.MaxValue) { throw new InvalidOperationException(); } NdbDatabase = new NDBDatabase(StatePath, Settings.DiskCapacitymb * 1024 * 1024, (int)_expectedStateSize); } public TasStateManagerSettings Settings { get; set; } /// /// Retrieves the savestate for the given frame, /// If this frame does not have a state currently, will return an empty array /// /// A savestate for the given frame or an empty array if there isn't one public KeyValuePair this[int frame] { get { if (frame == 0) { return new KeyValuePair(0, InitialState); } if (_states.ContainsKey(frame)) { return new KeyValuePair(frame, _states[frame].State); } return new KeyValuePair(-1, new byte[0]); } } public byte[] InitialState { get { if (_movie.StartsFromSavestate) { return _movie.BinarySavestate; } return _states[0].State; } } /// /// Requests that the current emulator state be captured /// Unless force is true, the state may or may not be captured depending on the logic employed by "greenzone" management /// public void Capture(bool force = false) { bool shouldCapture; int frame = Global.Emulator.Frame; if (_movie.StartsFromSavestate && frame == 0) // Never capture frame 0 on savestate anchored movies since we have it anyway { shouldCapture = false; } else if (force) { shouldCapture = force; } else if (frame == 0) // For now, long term, TasMovie should have a .StartState property, and a tasproj file for the start state in non-savestate anchored movies { shouldCapture = true; } else if (StateIsMarker(frame)) { shouldCapture = true; // Markers shoudl always get priority } else { shouldCapture = frame % _stateFrequency == 0; } if (shouldCapture) { SetState(frame, (byte[])Core.SaveStateBinary().Clone(), skipRemoval: false); } } private void MoveStateToDisk(int index) { Used -= (ulong)_states[index].Length; _states[index].MoveToDisk(); } private void MoveStateToMemory(int index) { _states[index].MoveToRAM(); Used += (ulong)_states[index].Length; } internal void SetState(int frame, byte[] state, bool skipRemoval = true) { if (!skipRemoval) // skipRemoval: false only when capturing new states { LimitStateCount(); // Remove before adding so this state won't be removed. } if (_states.ContainsKey(frame)) { _states[frame].State = state; } else { Used += (ulong)state.Length; _states.Add(frame, new StateManagerState(this, state, frame)); } } public bool HasState(int frame) { if (_movie.StartsFromSavestate && frame == 0) { return true; } return _states.ContainsKey(frame); } /// /// Clears out all savestates after the given frame number /// public bool Invalidate(int frame) { bool anyInvalidated = false; if (Any()) { if (frame == 0) // Never invalidate frame 0 { frame = 1; } List> statesToRemove = _states.Where(s => s.Key >= frame).ToList(); anyInvalidated = statesToRemove.Any(); foreach (var state in statesToRemove) { RemoveState(state.Key); } CallInvalidateCallback(frame); } return anyInvalidated; } public bool StateIsMarker(int frame) { if (frame == -1) { return false; } return _movie.Markers.IsMarker(frame + 1); } public bool RemoveState(int frame) { int index = _states.IndexOfKey(frame); if (frame < 1 || index < 1) { return false; } StateManagerState state = _states.Values[index]; if (state.IsOnDisk) { state.Dispose(); } else { Used -= (ulong)state.Length; } _states.RemoveAt(index); return true; } /// /// Deletes states to follow the state storage size limits. /// Used after changing the settings too. /// public void LimitStateCount() { if (StateCount + 1 > _maxStates || DiskUsed > (ulong)Settings.DiskCapacitymb * 1024 * 1024) { _decay.Trigger(StateCount + 1 - _maxStates); } } private List ExcludeStates() { List ret = new List(); ulong saveUsed = Used + DiskUsed; // respect state gap no matter how small the resulting size will be // still leave marker states for (int i = 1; i < _states.Count; i++) { int frame = GetStateFrameByIndex(i); if (StateIsMarker(frame) || frame % _fileStateGap < _stateFrequency) { continue; } ret.Add(i); if (_states.Values[i].IsOnDisk) { saveUsed -= _expectedStateSize; } else { saveUsed -= (ulong)_states.Values[i].Length; } } // if the size is still too big, exclude states form the beginning // still leave marker states int index = 0; while (saveUsed > (ulong)Settings.DiskSaveCapacitymb * 1024 * 1024) { do { if (++index >= _states.Count) { break; } } while (StateIsMarker(GetStateFrameByIndex(index))); if (index >= _states.Count) { break; } ret.Add(index); if (_states.Values[index].IsOnDisk) { saveUsed -= _expectedStateSize; } else { saveUsed -= (ulong)_states.Values[index].Length; } } // if there are enough markers to still be over the limit, remove marker frames index = 0; while (saveUsed > (ulong)Settings.DiskSaveCapacitymb * 1024 * 1024) { if (!ret.Contains(++index)) { ret.Add(index); } if (_states.Values[index].IsOnDisk) { saveUsed -= _expectedStateSize; } else { saveUsed -= (ulong)_states.Values[index].Length; } } return ret; } public void ClearStateHistory() { if (_states.Any()) { StateManagerState power = _states.Values.First(s => s.Frame == 0); _states.Clear(); SetState(0, power.State); Used = (ulong)power.State.Length; NdbDatabase?.Clear(); } } // Map: // 4 bytes - total savestate count // [Foreach state] // 4 bytes - frame // 4 bytes - length of savestate // 0 - n savestate public void Save(BinaryWriter bw) { List noSave = ExcludeStates(); bw.Write(_states.Count - noSave.Count); for (int i = 0; i < _states.Count; i++) { if (noSave.Contains(i)) { continue; } bw.Write(_states.Keys[i]); bw.Write(_states.Values[i].Length); bw.Write(_states.Values[i].State); } } public void Load(BinaryReader br) { _states.Clear(); try { int nstates = br.ReadInt32(); for (int i = 0; i < nstates; i++) { int frame = br.ReadInt32(); int len = br.ReadInt32(); byte[] data = br.ReadBytes(len); // whether we should allow state removal check here is an interesting question // nothing was edited yet, so it might make sense to show the project untouched first SetState(frame, data); } } catch (EndOfStreamException) { } } public KeyValuePair GetStateClosestToFrame(int frame) { var s = _states.LastOrDefault(state => state.Key < frame); return this[s.Key]; } /// /// Returns index of the state right above the given frame /// /// /// public int GetStateIndexByFrame(int frame) { return _states.IndexOfKey(GetStateClosestToFrame(frame).Key); } /// /// Returns frame of the state at the given index /// /// /// public int GetStateFrameByIndex(int index) { // feos: this is called super often by decay // this method is hundred times faster than _states.ElementAt(index).Key return _states.Keys[index]; } private ulong _used; private ulong Used { get { return _used; } set { // TODO: Shouldn't we throw an exception? Debug.Fail only runs in debug mode? if (value > 0xf000000000000000) { System.Diagnostics.Debug.Fail("ulong Used underfow!"); } else { _used = value; } } } private ulong DiskUsed { get { if (NdbDatabase == null) { return 0; } return (ulong)NdbDatabase.Consumed; } } public int StateCount => _states.Count; public int LastEditedFrame => _movie.LastEditedFrame; public bool Any() { if (_movie.StartsFromSavestate) { return _states.Count > 0; } return _states.Count > 1; } public int LastKey { get { if (_states.Count == 0) { return 0; } return _states.Last().Key; } } public int LastStatedFrame { get { if (StateCount > 0) { return LastKey; } return 0; } } private int FindState(StateManagerState s) { if (!_states.ContainsValue(s)) { return -1; } return s.Frame; } } }