diff --git a/src/BizHawk.Client.Common/BizHawk.Client.Common.csproj b/src/BizHawk.Client.Common/BizHawk.Client.Common.csproj index fae488fd55..ae44f5f6a6 100644 --- a/src/BizHawk.Client.Common/BizHawk.Client.Common.csproj +++ b/src/BizHawk.Client.Common/BizHawk.Client.Common.csproj @@ -4,6 +4,7 @@ + true disable diff --git a/src/BizHawk.Client.Common/config/RewindConfig.cs b/src/BizHawk.Client.Common/config/RewindConfig.cs index 36bc5b43bf..de56f35c0d 100644 --- a/src/BizHawk.Client.Common/config/RewindConfig.cs +++ b/src/BizHawk.Client.Common/config/RewindConfig.cs @@ -7,6 +7,13 @@ /// bool UseCompression { get; } + /// + /// Gets a value indicating whether or not to delta compress savestates before storing them + /// + /// + // TODO: This is in here for frontend reasons, but the buffer itself doesn't interact with this. + bool UseDelta { get; } + /// /// Buffer space to use in MB /// @@ -29,6 +36,7 @@ public class RewindConfig : IRewindSettings { public bool UseCompression { get; set; } + public bool UseDelta { get; set; } public bool Enabled { get; set; } = true; public long BufferSize { get; set; } = 512; // in mb public int TargetFrameLength { get; set; } = 600; diff --git a/src/BizHawk.Client.Common/rewind/ZeldaWinder.cs b/src/BizHawk.Client.Common/rewind/ZeldaWinder.cs new file mode 100644 index 0000000000..ecdb257f8a --- /dev/null +++ b/src/BizHawk.Client.Common/rewind/ZeldaWinder.cs @@ -0,0 +1,308 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using BizHawk.Common; +using BizHawk.Emulation.Common; + +namespace BizHawk.Client.Common +{ + /// + /// A rewinder that uses Zelda compression, built on top of a ring buffer + /// + public class ZeldaWinder : IRewinder + { + private const int IS_GAP = unchecked((int)0x80000000); + + private readonly ZwinderBuffer _buffer; + private readonly IStatable _stateSource; + + private byte[] _master = new byte[0]; + private int _masterFrame = -1; + private int _masterLength = 0; + private byte[] _scratch = new byte[0]; + private int _count; + + private Task _activeTask = null; + private bool _active; + + private void Sync() + { + _activeTask?.Wait(); + _activeTask = null; + } + private void Work(Action work) + { + _activeTask = Task.Run(work); + } + + public ZeldaWinder(IStatable stateSource, IRewindSettings settings) + { + _buffer = new ZwinderBuffer(settings); + _stateSource = stateSource; + _active = true; + } + + /// + /// How many states are actually in the state ringbuffer + /// + public int Count { get { Sync(); return _count; } } + + public float FullnessRatio { get { Sync(); return _buffer.Used / (float)_buffer.Size; } } + + /// + /// Total size of the _buffer + /// + /// + public long Size => _buffer.Size; + + /// + /// TODO: This is not a frequency, it's the reciprocal + /// + public int RewindFrequency { get { Sync(); return _buffer.RewindFrequency; } } + + public bool Active + { + get { Sync(); return _active; } + private set { Sync(); _active = value; } + } + + public void Suspend() + { + Active = false; + } + + public void Resume() + { + Active = true; + } + + public void Dispose() + { + Sync(); + _buffer.Dispose(); + } + + public void Clear() + { + Sync(); + _buffer.InvalidateEnd(0); + } + + public unsafe void Capture(int frame) + { + Sync(); + if (!_active) + return; + if (_masterFrame == -1) + { + var sss = new SaveStateStream(this); + _stateSource.SaveStateBinary(new BinaryWriter(sss)); + (_master, _scratch) = (_scratch, _master); + _masterLength = (int)sss.Position; + _masterFrame = frame; + _count++; + return; + } + if (!_buffer.WillCapture(_masterFrame)) + return; + + { + var sss = new SaveStateStream(this); + _stateSource.SaveStateBinary(new BinaryWriter(sss)); + + Work(() => + { + _buffer.Capture(_masterFrame, underlyingStream_ => + { + var zeldas = SpanStream.GetOrBuild(underlyingStream_); + if (_master.Length < _scratch.Length) + { + var replacement = new byte[_scratch.Length]; + Array.Copy(_master, replacement, _master.Length); + _master = replacement; + } + + var lengthHolder = _masterLength; + var lengthHolderSpan = new ReadOnlySpan(&lengthHolder, 4); + + zeldas.Write(lengthHolderSpan); + + fixed (byte* older_ = _master) + fixed (byte* newer_ = _scratch) + { + int* older = (int*)older_; + int* newer = (int*)newer_; + int lastIndex = (Math.Min(_masterLength, (int)sss.Position) + 3) / 4; + int lastOldIndex = (_masterLength + 3) / 4; + int* olderEnd = older + lastIndex; + + int* from = older; + int* to = older; + + while (older < olderEnd) + { + if (*older++ == *newer++) + { + if (to < from) + { + // Save on [to, from] + lengthHolder = (int)(from - to); + zeldas.Write(lengthHolderSpan); + zeldas.Write(new ReadOnlySpan(to, lengthHolder * 4)); + } + to = older; + } + else + { + if (from < to) + { + // encode gap [from, to] + lengthHolder = (int)(to - from) | IS_GAP; + zeldas.Write(lengthHolderSpan); + } + from = older; + } + } + if (from < to) + { + // encode gap [from, to] + lengthHolder = (int)(to - from) | IS_GAP; + zeldas.Write(lengthHolderSpan); + } + if (lastOldIndex > lastIndex) + { + from += lastOldIndex - lastIndex; + } + if (to < from) + { + // Save on [to, from] + lengthHolder = (int)(from - to); + zeldas.Write(lengthHolderSpan); + zeldas.Write(new ReadOnlySpan(to, lengthHolder * 4)); + } + } + + (_master, _scratch) = (_scratch, _master); + _masterLength = (int)sss.Position; + _masterFrame = frame; + _count++; + }); + }); + } + } + + private unsafe void RefillMaster(ZwinderBuffer.StateInformation state) + { + var lengthHolder = 0; + var lengthHolderSpan = new Span(&lengthHolder, 4); + var zeldas = SpanStream.GetOrBuild(state.GetReadStream()); + zeldas.Read(lengthHolderSpan); + _masterLength = lengthHolder; + fixed (byte* buffer_ = _master) + { + int* buffer = (int*)buffer_; + while (zeldas.Read(lengthHolderSpan) == 4) + { + if ((lengthHolder & IS_GAP) != 0) + { + buffer += lengthHolder & ~IS_GAP; + } + else + { + zeldas.Read(new Span(buffer, lengthHolder * 4)); + buffer += lengthHolder; + } + } + } + _masterFrame = state.Frame; + } + + public bool Rewind(int frameToAvoid) + { + Sync(); + if (!_active || _count == 0) + return false; + + if (_masterFrame == frameToAvoid && _count > 1) + { + var index = _buffer.Count - 1; + RefillMaster(_buffer.GetState(index)); + _buffer.InvalidateEnd(index); + _stateSource.LoadStateBinary(new BinaryReader(new MemoryStream(_master, 0, _masterLength, false))); + _count--; + } + else + { + _stateSource.LoadStateBinary(new BinaryReader(new MemoryStream(_master, 0, _masterLength, false))); + Work(() => + { + var index = _buffer.Count - 1; + if (index >= 0) + { + RefillMaster(_buffer.GetState(index)); + _buffer.InvalidateEnd(index); + } + else + { + _masterFrame = -1; + } + _count--; + }); + } + return true; + } + + private class SaveStateStream : Stream, ISpanStream + { + public SaveStateStream(ZeldaWinder owner) + { + _owner = owner; + } + private ZeldaWinder _owner; + private byte[] _dest + { + get => _owner._scratch; + set => _owner._scratch = value; + } + private int _position; + 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) + { + Write(new ReadOnlySpan(buffer, offset, count)); + } + private void MaybeResize(int requestedSize) + { + if (requestedSize > _dest.Length) + { + var replacement = new byte[(Math.Max(_dest.Length * 2, requestedSize) + 3) & ~3]; + Array.Copy(_dest, replacement, _dest.Length); + _dest = replacement; + } + } + public void Write(ReadOnlySpan buffer) + { + var requestedSize = _position + buffer.Length; + MaybeResize(requestedSize); + buffer.CopyTo(new Span(_dest, _position, buffer.Length)); + _position = requestedSize; + } + public override void WriteByte(byte value) + { + var requestedSize = _position + 1; + MaybeResize(requestedSize); + _dest[_position] = value; + _position = requestedSize; + } + public int Read(Span buffer) => throw new IOException(); + } + } +} diff --git a/src/BizHawk.Client.Common/rewind/ZwinderBuffer.cs b/src/BizHawk.Client.Common/rewind/ZwinderBuffer.cs index 789a54e134..710e2cd2d2 100644 --- a/src/BizHawk.Client.Common/rewind/ZwinderBuffer.cs +++ b/src/BizHawk.Client.Common/rewind/ZwinderBuffer.cs @@ -164,6 +164,17 @@ namespace BizHawk.Client.Common return frameDiff >= ComputeIdealRewindInterval(); } + /// + /// Predict whether Capture() will capture a state. Useful if expensive work needs to happen before + /// Capture() is called. + /// + /// The same frame number to be passed to capture + /// Whether capture will happen, assuming Capture() is passed the same frame and force = false + public bool WillCapture(int frame) + { + return ShouldCapture(frame); + } + /// /// Maybe captures a state, if the conditions are favorable /// diff --git a/src/BizHawk.Client.EmuHawk/MainForm.cs b/src/BizHawk.Client.EmuHawk/MainForm.cs index 0ffc66c84a..893597f09f 100644 --- a/src/BizHawk.Client.EmuHawk/MainForm.cs +++ b/src/BizHawk.Client.EmuHawk/MainForm.cs @@ -949,7 +949,9 @@ namespace BizHawk.Client.EmuHawk { Rewinder?.Dispose(); Rewinder = Emulator.HasSavestates() && Config.Rewind.Enabled - ? new Zwinder(Emulator.AsStatable(), Config.Rewind) + ? Config.Rewind.UseDelta + ? new ZeldaWinder(Emulator.AsStatable(), Config.Rewind) + : new Zwinder(Emulator.AsStatable(), Config.Rewind) : null; AddOnScreenMessage(Rewinder?.Active == true ? "Rewind started" : "Rewind disabled"); } diff --git a/src/BizHawk.Client.EmuHawk/config/RewindConfig.Designer.cs b/src/BizHawk.Client.EmuHawk/config/RewindConfig.Designer.cs old mode 100644 new mode 100755 index 99492c84d5..2e2182b23c --- a/src/BizHawk.Client.EmuHawk/config/RewindConfig.Designer.cs +++ b/src/BizHawk.Client.EmuHawk/config/RewindConfig.Designer.cs @@ -41,6 +41,7 @@ this.label6 = new BizHawk.WinForms.Controls.LocLabelEx(); this.FullnessLabel = new BizHawk.WinForms.Controls.LocLabelEx(); this.groupBox4 = new System.Windows.Forms.GroupBox(); + this.cbDeltaCompression = new System.Windows.Forms.CheckBox(); this.TargetFrameLengthNumeric = new System.Windows.Forms.NumericUpDown(); this.label2 = new BizHawk.WinForms.Controls.LocLabelEx(); this.EstTimeLabel = new BizHawk.WinForms.Controls.LocLabelEx(); @@ -118,7 +119,7 @@ this.UseCompression.Name = "UseCompression"; this.UseCompression.Size = new System.Drawing.Size(306, 17); this.UseCompression.TabIndex = 5; - this.UseCompression.Text = "Use compression (economizes buffer usage at cost of CPU)"; + this.UseCompression.Text = "Use zlib compression (economizes buffer usage at cost of CPU)"; this.UseCompression.UseVisualStyleBackColor = true; this.UseCompression.CheckedChanged += new System.EventHandler(this.UseCompression_CheckedChanged); // @@ -183,6 +184,7 @@ // // groupBox4 // + this.groupBox4.Controls.Add(this.cbDeltaCompression); this.groupBox4.Controls.Add(this.TargetFrameLengthNumeric); this.groupBox4.Controls.Add(this.label2); this.groupBox4.Controls.Add(this.label4); @@ -202,11 +204,21 @@ this.groupBox4.Controls.Add(this.StateSizeLabel); this.groupBox4.Location = new System.Drawing.Point(12, 12); this.groupBox4.Name = "groupBox4"; - this.groupBox4.Size = new System.Drawing.Size(371, 205); + this.groupBox4.Size = new System.Drawing.Size(371, 218); this.groupBox4.TabIndex = 2; this.groupBox4.TabStop = false; this.groupBox4.Text = "RewindSettings"; // + // cbDeltaCompression + // + this.cbDeltaCompression.AutoSize = true; + this.cbDeltaCompression.Location = new System.Drawing.Point(15, 193); + this.cbDeltaCompression.Name = "cbDeltaCompression"; + this.cbDeltaCompression.Size = new System.Drawing.Size(149, 17); + this.cbDeltaCompression.TabIndex = 35; + this.cbDeltaCompression.Text = "Use delta compression (economizes buffer usage at cost of CPU)"; + this.cbDeltaCompression.UseVisualStyleBackColor = true; + // // TargetFrameLengthNumeric // this.TargetFrameLengthNumeric.Location = new System.Drawing.Point(125, 135); @@ -538,5 +550,6 @@ private BizHawk.WinForms.Controls.LocLabelEx label20; private System.Windows.Forms.NumericUpDown TargetFrameLengthNumeric; private BizHawk.WinForms.Controls.LocLabelEx label2; + private System.Windows.Forms.CheckBox cbDeltaCompression; } } \ No newline at end of file diff --git a/src/BizHawk.Client.EmuHawk/config/RewindConfig.cs b/src/BizHawk.Client.EmuHawk/config/RewindConfig.cs old mode 100644 new mode 100755 index a5c064afef..4de55031b7 --- a/src/BizHawk.Client.EmuHawk/config/RewindConfig.cs +++ b/src/BizHawk.Client.EmuHawk/config/RewindConfig.cs @@ -47,6 +47,7 @@ namespace BizHawk.Client.EmuHawk RewindEnabledBox.Checked = _config.Rewind.Enabled; UseCompression.Checked = _config.Rewind.UseCompression; + cbDeltaCompression.Checked = _config.Rewind.UseDelta; BufferSizeUpDown.Value = Math.Max(_config.Rewind.BufferSize, BufferSizeUpDown.Minimum); TargetFrameLengthNumeric.Value = Math.Max(_config.Rewind.TargetFrameLength, TargetFrameLengthNumeric.Minimum); StateSizeLabel.Text = FormatKB(_avgStateSize); @@ -111,6 +112,7 @@ namespace BizHawk.Client.EmuHawk _config.Rewind.Enabled = PutRewindSetting(_config.Rewind.Enabled, RewindEnabledBox.Checked); _config.Rewind.BufferSize = PutRewindSetting(_config.Rewind.BufferSize, (int)BufferSizeUpDown.Value); _config.Rewind.TargetFrameLength = PutRewindSetting(_config.Rewind.TargetFrameLength, (int)TargetFrameLengthNumeric.Value); + _config.Rewind.UseDelta = PutRewindSetting(_config.Rewind.UseDelta, cbDeltaCompression.Checked); // These settings are not used by DoRewindSettings _config.Savestates.CompressionLevelNormal = (int)nudCompression.Value;