diff --git a/src/BizHawk.Client.Common/rewind/IRewinder.cs b/src/BizHawk.Client.Common/rewind/IRewinder.cs index 66ec52a1a1..aa7e0b9fe9 100644 --- a/src/BizHawk.Client.Common/rewind/IRewinder.cs +++ b/src/BizHawk.Client.Common/rewind/IRewinder.cs @@ -12,7 +12,10 @@ namespace BizHawk.Client.Common bool Active { get; } void Capture(int frame); - bool Rewind(int frames); + /// + /// Rewind 1 saved frame, if possible + /// + bool Rewind(); void Suspend(); void Resume(); diff --git a/src/BizHawk.Client.Common/rewind/Zwinder.cs b/src/BizHawk.Client.Common/rewind/Zwinder.cs index d073481ca6..e15c89f778 100644 --- a/src/BizHawk.Client.Common/rewind/Zwinder.cs +++ b/src/BizHawk.Client.Common/rewind/Zwinder.cs @@ -18,33 +18,21 @@ namespace BizHawk.Client.Common 3. No delta compression. Keep it simple. If there are cores that benefit heavily from delta compression, we should maintain a separate rewinder alongside this one that is customized for those cores. */ + + private readonly ZwinderBuffer _buffer; + private readonly IBinaryStateable _stateSource; + public Zwinder(IBinaryStateable stateSource, IRewindSettings settings) { - long targetSize = settings.BufferSize * 1024 * 1024; - if (settings.TargetFrameLength < 1) - { - throw new ArgumentOutOfRangeException(nameof(settings.TargetFrameLength)); - } - - Size = 1L << (int)Math.Floor(Math.Log(targetSize, 2)); - _sizeMask = Size - 1; - _buffer = new byte[Size]; - Active = true; + _buffer = new ZwinderBuffer(settings); _stateSource = stateSource; - _targetFrameLength = settings.TargetFrameLength; - _states = new StateInfo[StateMask + 1]; - _useCompression = settings.UseCompression; + Active = true; } - /// - /// Number of states that could be in the state ringbuffer, Mask for the state ringbuffer - /// - private const int StateMask = 16383; - /// /// How many states are actually in the state ringbuffer /// - public int Count => (_nextStateIndex - _firstStateIndex) & StateMask; + public int Count => _buffer.Count; public float FullnessRatio => Used / (float)Size; @@ -52,21 +40,13 @@ namespace BizHawk.Client.Common /// total number of bytes used /// /// - public long Used => Count == 0 - ? 0 - : (_states[HeadStateIndex].Start - + _states[HeadStateIndex].Size - - _states[_firstStateIndex].Start - ) & _sizeMask; + public long Used => _buffer.Used; /// /// Total size of the _buffer /// /// - public long Size { get; } - - private readonly long _sizeMask; - private byte[] _buffer; + public long Size => _buffer.Size; private readonly int _targetFrameLength; @@ -77,110 +57,28 @@ namespace BizHawk.Client.Common public int Frame; } - private StateInfo[] _states; - private int _firstStateIndex; - private int _nextStateIndex; - private int HeadStateIndex => (_nextStateIndex - 1) & StateMask; - - private readonly bool _useCompression; - - private IBinaryStateable _stateSource; - /// /// TODO: This is not a frequency, it's the reciprocal /// - public int RewindFrequency => ComputeIdealRewindInterval(); + public int RewindFrequency => _buffer.RewindFrequency; public bool Active { get; private set; } - private int ComputeIdealRewindInterval() - { - if (Count == 0) - { - return 1; // shrug - } - - // assume that the most recent state size is representative of stuff - var sizeRatio = Size / (float)_states[HeadStateIndex].Size; - var frameRatio = _targetFrameLength / sizeRatio; - - var idealInterval = (int)Math.Round(frameRatio); - return Math.Max(idealInterval, 1); - } - - private bool ShouldCapture(int frame) - { - if (Count == 0) - { - return true; - } - - var frameDiff = frame - _states[HeadStateIndex].Frame; - if (frameDiff < 1) - // non-linear time is from a combination of other state changing mechanisms and the rewinder - // not much we can say here, so just take a state - return true; - - return frameDiff >= ComputeIdealRewindInterval(); - } - public void Capture(int frame) { - if (!Active || !ShouldCapture(frame)) + if (!Active) return; - - var start = (_states[HeadStateIndex].Start + _states[HeadStateIndex].Size) & _sizeMask; - var initialMaxSize = Count > 0 - ? (_states[_firstStateIndex].Start - start) & _sizeMask - : Size; - Func notifySizeReached = () => - { - if (Count == 0) - throw new IOException("A single state must not be larger than the buffer"); - _firstStateIndex = (_firstStateIndex + 1) & StateMask; - return Count > 0 - ? (_states[_firstStateIndex].Start - start) & _sizeMask - : Size; - }; - var stream = new SaveStateStream(_buffer, start, _sizeMask, initialMaxSize, notifySizeReached); - - if (_useCompression) - { - using var compressor = new DeflateStream(stream, CompressionLevel.Fastest, leaveOpen: true); - _stateSource.SaveStateBinary(new BinaryWriter(compressor)); - } - else - { - _stateSource.SaveStateBinary(new BinaryWriter(stream)); - } - - _states[_nextStateIndex].Frame = frame; - _states[_nextStateIndex].Start = start; - _states[_nextStateIndex].Size = (int)stream.Length; - _nextStateIndex = (_nextStateIndex + 1) & StateMask; - - Console.WriteLine($"Size: {Size >> 20}MiB, Used: {Used >> 20}MiB, States: {Count}"); + _buffer.Capture(frame, s => _stateSource.SaveStateBinary(new BinaryWriter(s))); } - public bool Rewind(int frames) + public bool Rewind() { - if (!Active) + if (!Active || Count == 0) return false; - // this is supposed to rewind to the previous saved frame - // It's only ever called with a value of 1 from the frontend? - - frames = Math.Min(frames, Count); - if (frames == 0) - return false; // no states saved - int loadIndex = (_nextStateIndex - frames) & StateMask; - - var stream = new LoadStateStream(_buffer, _states[loadIndex].Start, _states[loadIndex].Size, _sizeMask); - _stateSource.LoadStateBinary(_useCompression - ? new BinaryReader(new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true)) - : new BinaryReader(stream)); - - _nextStateIndex = loadIndex; - Console.WriteLine($"Size: {Size >> 20}MiB, Used: {Used >> 20}MiB, States: {Count}"); + var index = Count - 1; + var state = _buffer.GetState(index); + _stateSource.LoadStateBinary(new BinaryReader(state.GetReadStream())); + _buffer.InvalidateEnd(index); return true; } @@ -194,159 +92,9 @@ namespace BizHawk.Client.Common Active = true; } - public void Dispose() { - _buffer = null; - _states = null; - _stateSource = null; - } - - private class SaveStateStream : Stream - { - /// - /// - /// - /// The ringbuffer to write into - /// Offset into the buffer to start writing (and treat as position 0 in the stream) - /// Buffer size mask, used to wrap values in the ringbuffer correctly - /// - /// If the stream will exceed this size, notifySizeReached must be called before clobbering any data - /// - /// - /// The callback that will be called when notifySize is about to be exceeded. Can either return a new larger notifySize, - /// or abort processing with an IOException. This must fail if size is going to exceed buffer.Length, as nothing else - /// is preventing that case. - /// - public SaveStateStream(byte[] buffer, long offset, long mask, long notifySize, Func notifySizeReached) - { - _buffer = buffer; - _offset = offset; - _mask = mask; - _notifySize = notifySize; - _notifySizeReached = notifySizeReached; - } - - private readonly byte[] _buffer; - private readonly long _offset; - private readonly long _mask; - private long _position; - private long _notifySize; - private readonly Func _notifySizeReached; - - public override bool CanRead => false; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => _position; - - public override long Position { get => _position; set => throw new IOException(); } - - public override void Flush() {} - - public override int Read(byte[] buffer, int offset, int count) => throw new IOException(); - public override long Seek(long offset, SeekOrigin origin) => throw new IOException(); - public override void SetLength(long value) => throw new IOException(); - - public override void Write(byte[] buffer, int offset, int count) - { - long requestedSize = _position + count; - while (requestedSize > _notifySize) - _notifySize = _notifySizeReached(); - long n = count; - if (n > 0) - { - var start = (_position + _offset) & _mask; - var end = (start + n) & _mask; - if (end < start) - { - long m = _buffer.LongLength - start; - Array.Copy(buffer, offset, _buffer, start, m); - offset += (int)m; - n -= m; - _position += m; - start = 0; - } - if (n > 0) - { - Array.Copy(buffer, offset, _buffer, start, n); - _position += n; - } - } - } - - public override void WriteByte(byte value) - { - long requestedSize = _position + 1; - while (requestedSize > _notifySize) - _notifySize = _notifySizeReached(); - _buffer[(_position++ + _offset) & _mask] = value; - } - } - - private class LoadStateStream : Stream - { - public LoadStateStream(byte[] buffer, long offset, long size, long mask) - { - _buffer = buffer; - _offset = offset; - _size = size; - _mask = mask; - } - - private readonly byte[] _buffer; - private readonly long _offset; - private readonly long _size; - private long _position; - private readonly long _mask; - - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => _size; - public override long Position - { - get => _position; - set => throw new IOException(); - } - public override void Flush() - {} - - public override int Read(byte[] buffer, int offset, int count) - { - long n = Math.Min(_size - _position, count); - int ret = (int)n; - if (n > 0) - { - var start = (_position + _offset) & _mask; - var end = (start + n) & _mask; - if (end < start) - { - long m = _buffer.LongLength - start; - Array.Copy(_buffer, start, buffer, offset, m); - offset += (int)m; - n -= m; - _position += m; - start = 0; - } - if (n > 0) - { - Array.Copy(_buffer, start, buffer, offset, n); - _position += n; - } - } - return ret; - } - - public override int ReadByte() - { - return _position < _size - ? _buffer[(_position++ + _offset) & _mask] - : -1; - } - - public override long Seek(long offset, SeekOrigin origin) => throw new IOException(); - public override void SetLength(long value) => throw new IOException(); - public override void Write(byte[] buffer, int offset, int count) => throw new IOException(); + // this possess no resources to dispose of, but other IRewinder impls might } } } diff --git a/src/BizHawk.Client.Common/rewind/ZwinderBuffer.cs b/src/BizHawk.Client.Common/rewind/ZwinderBuffer.cs new file mode 100644 index 0000000000..cb692e1fa9 --- /dev/null +++ b/src/BizHawk.Client.Common/rewind/ZwinderBuffer.cs @@ -0,0 +1,400 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using BizHawk.Emulation.Common; + +namespace BizHawk.Client.Common +{ + public class ZwinderBuffer : IBinaryStateable + { + /* + Main goals: + 1. No copies, ever. States are deposited directly to, and read directly from, one giant ring buffer. + As a consequence, there is no multi-threading because there is nothing to thread. + 2. Support for arbitrary and changeable state sizes. Frequency is calculated dynamically. + 3. No delta compression. Keep it simple. If there are cores that benefit heavily from delta compression, we should + maintain a separate rewinder alongside this one that is customized for those cores. + */ + public ZwinderBuffer(IRewindSettings settings) + { + long targetSize = settings.BufferSize * 1024 * 1024; + if (settings.TargetFrameLength < 1) + { + throw new ArgumentOutOfRangeException(nameof(settings.TargetFrameLength)); + } + + Size = 1L << (int)Math.Floor(Math.Log(targetSize, 2)); + _sizeMask = Size - 1; + _buffer = new byte[Size]; + _targetFrameLength = settings.TargetFrameLength; + _states = new StateInfo[StateMask + 1]; + _useCompression = settings.UseCompression; + } + + /// + /// Number of states that could be in the state ringbuffer, Mask for the state ringbuffer + /// + private const int StateMask = 16383; + + /// + /// How many states are actually in the state ringbuffer + /// + public int Count => (_nextStateIndex - _firstStateIndex) & StateMask; + + public float FullnessRatio => Used / (float)Size; + + /// + /// total number of bytes used + /// + /// + public long Used => Count == 0 + ? 0 + : (_states[HeadStateIndex].Start + + _states[HeadStateIndex].Size + - _states[_firstStateIndex].Start + ) & _sizeMask; + + /// + /// Total size of the _buffer + /// + /// + public long Size { get; } + + private readonly long _sizeMask; + private readonly byte[] _buffer; + + private readonly int _targetFrameLength; + + private struct StateInfo + { + public long Start; + public int Size; + public int Frame; + } + + private readonly StateInfo[] _states; + private int _firstStateIndex; + private int _nextStateIndex; + private int HeadStateIndex => (_nextStateIndex - 1) & StateMask; + + private readonly bool _useCompression; + + /// + /// TODO: This is not a frequency, it's the reciprocal + /// + public int RewindFrequency => ComputeIdealRewindInterval(); + + private int ComputeIdealRewindInterval() + { + if (Count == 0) + { + return 1; // shrug + } + + // assume that the most recent state size is representative of stuff + var sizeRatio = Size / (float)_states[HeadStateIndex].Size; + var frameRatio = _targetFrameLength / sizeRatio; + + var idealInterval = (int)Math.Round(frameRatio); + return Math.Max(idealInterval, 1); + } + + private bool ShouldCapture(int frame) + { + if (Count == 0) + { + return true; + } + + var frameDiff = frame - _states[HeadStateIndex].Frame; + if (frameDiff < 1) + // non-linear time is from a combination of other state changing mechanisms and the rewinder + // not much we can say here, so just take a state + return true; + + return frameDiff >= ComputeIdealRewindInterval(); + } + + /// + /// Maybe captures a state, if the conditions are favorable + /// + /// frame number to capture + /// will be called with the stream if capture is to be performed + /// + /// If provided, will be called with the index of states that are about to be removed. This will happen during + /// calls to Write() inside `callback`, and any reuse of the old state will have to happen immediately + /// + public void Capture(int frame, Action callback, Action indexInvalidated = null, bool force = false) + { + if (!force && !ShouldCapture(frame)) + return; + + var start = (_states[HeadStateIndex].Start + _states[HeadStateIndex].Size) & _sizeMask; + var initialMaxSize = Count > 0 + ? (_states[_firstStateIndex].Start - start) & _sizeMask + : Size; + Func notifySizeReached = () => + { + if (Count == 0) + throw new IOException("A single state must not be larger than the buffer"); + indexInvalidated?.Invoke(0); + _firstStateIndex = (_firstStateIndex + 1) & StateMask; + return Count > 0 + ? (_states[_firstStateIndex].Start - start) & _sizeMask + : Size; + }; + var stream = new SaveStateStream(_buffer, start, _sizeMask, initialMaxSize, notifySizeReached); + + if (_useCompression) + { + using var compressor = new DeflateStream(stream, CompressionLevel.Fastest, leaveOpen: true); + callback(compressor); + } + else + { + callback(stream); + } + + _states[_nextStateIndex].Frame = frame; + _states[_nextStateIndex].Start = start; + _states[_nextStateIndex].Size = (int)stream.Length; + _nextStateIndex = (_nextStateIndex + 1) & StateMask; + + Console.WriteLine($"Size: {Size >> 20}MiB, Used: {Used >> 20}MiB, States: {Count}"); + } + + private Stream MakeLoadStream(int index) + { + Stream stream = new LoadStateStream(_buffer, _states[index].Start, _states[index].Size, _sizeMask); + if (_useCompression) + stream = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true); + return stream; + } + + public class StateInformation + { + private readonly int _index; + public int Frame => _parent._states[_index].Frame; + public int Size => _parent._states[_index].Size; + private readonly ZwinderBuffer _parent; + public Stream GetReadStream() + { + return _parent.MakeLoadStream(_index); + } + internal StateInformation(ZwinderBuffer parent, int index) + { + _index = index; + _parent = parent; + } + } + + /// + /// Retrieve information about a state from 0..Count - 1. + /// The information contained within is valid only until the collection is modified. + /// + /// + /// + public StateInformation GetState(int index) + { + if ((uint)index >= (uint)Count) + throw new IndexOutOfRangeException(); + return new StateInformation(this, (index + _firstStateIndex) & StateMask); + } + + /// + /// Invalidate states from GetState(index) on to the end of the buffer, so that Count == index afterwards + /// + /// + public void InvalidateEnd(int index) + { + if ((uint)index > (uint)Count) + throw new IndexOutOfRangeException(); + _nextStateIndex = (index + _firstStateIndex) & StateMask; + Console.WriteLine($"Size: {Size >> 20}MiB, Used: {Used >> 20}MiB, States: {Count}"); + } + + public void SaveStateBinary(BinaryWriter writer) + { + writer.Write(Size); + writer.Write(_sizeMask); + writer.Write(_targetFrameLength); + writer.Write(_useCompression); + + writer.Write(_buffer); + foreach (var s in _states) + { + writer.Write(s.Start); + writer.Write(s.Size); + writer.Write(s.Frame); + } + writer.Write(_firstStateIndex); + writer.Write(_nextStateIndex); + } + + public void LoadStateBinary(BinaryReader reader) + { + if (reader.ReadInt64() != Size) throw new InvalidOperationException("Bad format"); + if (reader.ReadInt64() != _sizeMask) throw new InvalidOperationException("Bad format"); + if (reader.ReadInt32() != _targetFrameLength) throw new InvalidOperationException("Bad format"); + if (reader.ReadBoolean() != _useCompression) throw new InvalidOperationException("Bad format"); + + reader.Read(_buffer, 0, _buffer.Length); + for (var i = 0; i < _states.Length; i++) + { + _states[i].Start = reader.Read(); + _states[i].Size = reader.Read(); + _states[i].Frame = reader.Read(); + } + _firstStateIndex = reader.ReadInt32(); + _nextStateIndex = reader.ReadInt32(); + } + + private class SaveStateStream : Stream + { + /// + /// + /// + /// The ringbuffer to write into + /// Offset into the buffer to start writing (and treat as position 0 in the stream) + /// Buffer size mask, used to wrap values in the ringbuffer correctly + /// + /// If the stream will exceed this size, notifySizeReached must be called before clobbering any data + /// + /// + /// The callback that will be called when notifySize is about to be exceeded. Can either return a new larger notifySize, + /// or abort processing with an IOException. This must fail if size is going to exceed buffer.Length, as nothing else + /// is preventing that case. + /// + public SaveStateStream(byte[] buffer, long offset, long mask, long notifySize, Func notifySizeReached) + { + _buffer = buffer; + _offset = offset; + _mask = mask; + _notifySize = notifySize; + _notifySizeReached = notifySizeReached; + } + + private readonly byte[] _buffer; + private readonly long _offset; + private readonly long _mask; + private long _position; + private long _notifySize; + private readonly Func _notifySizeReached; + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => _position; + + public override long Position { get => _position; set => throw new IOException(); } + + public override void Flush() {} + + public override int Read(byte[] buffer, int offset, int count) => throw new IOException(); + public override long Seek(long offset, SeekOrigin origin) => throw new IOException(); + public override void SetLength(long value) => throw new IOException(); + + public override void Write(byte[] buffer, int offset, int count) + { + long requestedSize = _position + count; + while (requestedSize > _notifySize) + _notifySize = _notifySizeReached(); + long n = count; + if (n > 0) + { + var start = (_position + _offset) & _mask; + var end = (start + n) & _mask; + if (end < start) + { + long m = _buffer.LongLength - start; + Array.Copy(buffer, offset, _buffer, start, m); + offset += (int)m; + n -= m; + _position += m; + start = 0; + } + if (n > 0) + { + Array.Copy(buffer, offset, _buffer, start, n); + _position += n; + } + } + } + + public override void WriteByte(byte value) + { + long requestedSize = _position + 1; + while (requestedSize > _notifySize) + _notifySize = _notifySizeReached(); + _buffer[(_position++ + _offset) & _mask] = value; + } + } + + private class LoadStateStream : Stream + { + public LoadStateStream(byte[] buffer, long offset, long size, long mask) + { + _buffer = buffer; + _offset = offset; + _size = size; + _mask = mask; + } + + private readonly byte[] _buffer; + private readonly long _offset; + private readonly long _size; + private long _position; + private readonly long _mask; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _size; + public override long Position + { + get => _position; + set => throw new IOException(); + } + public override void Flush() + {} + + public override int Read(byte[] buffer, int offset, int count) + { + long n = Math.Min(_size - _position, count); + int ret = (int)n; + if (n > 0) + { + var start = (_position + _offset) & _mask; + var end = (start + n) & _mask; + if (end < start) + { + long m = _buffer.LongLength - start; + Array.Copy(_buffer, start, buffer, offset, m); + offset += (int)m; + n -= m; + _position += m; + start = 0; + } + if (n > 0) + { + Array.Copy(_buffer, start, buffer, offset, n); + _position += n; + } + } + return ret; + } + + public override int ReadByte() + { + return _position < _size + ? _buffer[(_position++ + _offset) & _mask] + : -1; + } + + public override long Seek(long offset, SeekOrigin origin) => throw new IOException(); + public override void SetLength(long value) => throw new IOException(); + public override void Write(byte[] buffer, int offset, int count) => throw new IOException(); + } + } +} diff --git a/src/BizHawk.Client.EmuHawk/MainForm.cs b/src/BizHawk.Client.EmuHawk/MainForm.cs index aa5a73189c..b818896663 100644 --- a/src/BizHawk.Client.EmuHawk/MainForm.cs +++ b/src/BizHawk.Client.EmuHawk/MainForm.cs @@ -3823,6 +3823,7 @@ namespace BizHawk.Client.EmuHawk CommitCoreSettingsToConfig(); Rewinder?.Dispose(); + Rewinder = null; if (MovieSession.Movie.IsActive()) // Note: this must be called after CommitCoreSettingsToConfig() { @@ -4392,7 +4393,7 @@ namespace BizHawk.Client.EmuHawk if (isRewinding) { - runFrame = Rewinder.Rewind(1) && Emulator.Frame > 1; + runFrame = Rewinder.Rewind() && Emulator.Frame > 1; if (runFrame && MovieSession.Movie.IsRecording()) {