Rewrite TasStateManager (#2274)
* zwinder state manager wip * stuff * fixes * slightly better defaults * stuff * re-delete TasStateManager - re-added by my bad rebase attempt * add back in saving of a default.tasproj, we are a lot of refactors away from not having to do this * Make zwinder loadstate a factory method * progress on wiring up Zwinder to movie shenanigans * zwinder now survives save/load * hack for settings to exist * fix test * shenangians for frame 0, add some asserts to the unit test * Add crappy impl of integrity check for zwinderstatemanager * remove Any() from the IStateManager contract, since it should always have at least 1 state * move ZwinderStateManagerSettings to its own file * use NonState, it's there and I suppose this was the intent * add a test * don't attempt to capture states if we aren't "currentt" * ugh * small cleanup * ZwinderStateManagerSettings - implement necessary copy constructor * wire up Settings updating, get rid of Settings setter, add some documentation to IStateManager * shenanigans to fix Savestate settings UI * Play around with "high priority" It's really a mess because there's no information feeding between high priority and normal priority on what captures should take place... * this fixes the branch gap problem * bump tasproj version to 1.1, warn user and gracefully handle loading an incompatible version * if a movie starts from savestate, stuff the anchored state into TasStateManager instead of a frame zero state * ZwinderBuffer - remove some unused usings Co-authored-by: nattthebear <goyuken@gmail.com>
This commit is contained in:
parent
fcbe8333a6
commit
357d87239b
|
@ -7,7 +7,7 @@
|
|||
public bool MoviesOnDisk { get; }
|
||||
public int MovieCompressionLevel { get; }
|
||||
public bool VBAStyleMovieLoadState { get; }
|
||||
TasStateManagerSettings DefaultTasStateManagerSettings { get; }
|
||||
ZwinderStateManagerSettings DefaultTasStateManagerSettings { get; }
|
||||
}
|
||||
|
||||
public class MovieConfig : IMovieConfig
|
||||
|
@ -18,6 +18,6 @@
|
|||
public int MovieCompressionLevel { get; set; } = 2;
|
||||
public bool VBAStyleMovieLoadState { get; set; }
|
||||
|
||||
public TasStateManagerSettings DefaultTasStateManagerSettings { get; set; } = new TasStateManagerSettings();
|
||||
public ZwinderStateManagerSettings DefaultTasStateManagerSettings { get; set; } = new ZwinderStateManagerSettings();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -135,7 +135,7 @@ namespace BizHawk.Client.Common
|
|||
}
|
||||
}
|
||||
|
||||
tas.TasStateManager.Settings = old.TasStateManager.Settings;
|
||||
tas.TasStateManager.UpdateSettings(old.TasStateManager.Settings);
|
||||
|
||||
tas.Save();
|
||||
return tas;
|
||||
|
@ -176,7 +176,7 @@ namespace BizHawk.Client.Common
|
|||
tas.Subtitles.Add(sub);
|
||||
}
|
||||
|
||||
tas.TasStateManager.Settings = old.TasStateManager.Settings;
|
||||
tas.TasStateManager.UpdateSettings(old.TasStateManager.Settings);
|
||||
|
||||
tas.Save();
|
||||
return tas;
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BizHawk.Common.NumberExtensions;
|
||||
|
||||
namespace BizHawk.Client.Common
|
||||
{
|
||||
|
@ -17,5 +19,40 @@ namespace BizHawk.Client.Common
|
|||
{
|
||||
return MovieExtensions.Contains(ext.ToLower().Replace(".", ""));
|
||||
}
|
||||
|
||||
public static bool IsCurrentTasVersion(string movieVersion)
|
||||
{
|
||||
var actual = ParseTasMovieVersion(movieVersion);
|
||||
return actual.HawkFloatEquality(TasMovie.CurrentVersion);
|
||||
}
|
||||
|
||||
internal static double ParseTasMovieVersion(string movieVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(movieVersion))
|
||||
{
|
||||
return 1.0F;
|
||||
}
|
||||
|
||||
var split = movieVersion
|
||||
.ToLower()
|
||||
.Split(new[] {"tasproj"}, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (split.Length == 1)
|
||||
{
|
||||
return 1.0F;
|
||||
}
|
||||
|
||||
var versionStr = split[1]
|
||||
.Trim()
|
||||
.Replace("v", "");
|
||||
|
||||
var result = double.TryParse(versionStr, out double version);
|
||||
if (result)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return 1.0F;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -311,7 +311,7 @@ namespace BizHawk.Client.Common
|
|||
return new Bk2Movie(this, path);
|
||||
}
|
||||
|
||||
private void PopupMessage(string message)
|
||||
public void PopupMessage(string message)
|
||||
{
|
||||
_popupCallback?.Invoke(message);
|
||||
}
|
||||
|
|
|
@ -78,5 +78,7 @@ namespace BizHawk.Client.Common
|
|||
IMovie Get(string path);
|
||||
|
||||
string BackupDirectory { get; set; }
|
||||
|
||||
void PopupMessage(string message);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,8 @@ namespace BizHawk.Client.Common
|
|||
IStringLog VerificationLog { get; }
|
||||
int LastEditedFrame { get; }
|
||||
|
||||
Action<int> GreenzoneInvalidated { get; set; }
|
||||
|
||||
string DisplayValue(int frame, string buttonName);
|
||||
void FlagChanges();
|
||||
void ClearChanges();
|
||||
|
|
|
@ -9,60 +9,40 @@ namespace BizHawk.Client.Common
|
|||
{
|
||||
/// <summary>
|
||||
/// Retrieves the savestate for the given frame,
|
||||
/// If this frame does not have a state currently, will return an empty array
|
||||
/// If this frame does not have a state currently, will return an empty array.false
|
||||
/// Try not to use this as it is not fast.
|
||||
/// </summary>
|
||||
/// <returns>A savestate for the given frame or an empty array if there isn't one</returns>
|
||||
byte[] this[int frame] { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attaches a core to the given state manager instance, this must be done and
|
||||
/// it must be done only once, a state manager can not and should not exist for more
|
||||
/// than the lifetime of the core
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown if attempting to attach a core when one is already attached
|
||||
/// or if the given core does not meet all required dependencies
|
||||
/// </exception>
|
||||
void Attach(IEmulator emulator);
|
||||
|
||||
TasStateManagerSettings Settings { get; set; }
|
||||
|
||||
Action<int> InvalidateCallback { set; }
|
||||
ZwinderStateManagerSettings Settings { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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 "green-zone" management
|
||||
/// </summary>
|
||||
void Capture(bool force = false);
|
||||
void Capture(int frame, IBinaryStateable source, bool force = false);
|
||||
|
||||
// TODO: should this be used for markers?
|
||||
//void CaptureHighPriority(int frame, IBinaryStateable source);
|
||||
|
||||
bool HasState(int frame);
|
||||
|
||||
/// <summary>
|
||||
/// Clears out all savestates after the given frame number
|
||||
/// Clears out all savestates after or at the given frame number
|
||||
/// </summary>
|
||||
bool Invalidate(int frame);
|
||||
|
||||
// Remove all states, but not the frame 0 state
|
||||
void Clear();
|
||||
|
||||
void Save(BinaryWriter bw);
|
||||
|
||||
void Load(BinaryReader br);
|
||||
|
||||
/// <summary>
|
||||
/// Get a nearby state. The returned frame must be less (but not equal to???) the passed frame.
|
||||
/// This may not fail; the StateManager strongly holds a frame 0 state to ensure there's always a possible result.
|
||||
/// </summary>
|
||||
/// <param name="frame"></param>
|
||||
/// <returns></returns>
|
||||
KeyValuePair<int, byte[]> GetStateClosestToFrame(int frame);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true iff Count > 0
|
||||
/// TODO: Surely this is always true because the frame 0 state is always retained?
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
bool Any();
|
||||
/// <returns>This stream may be consumed only once, and before any other calls to statemanager occur</returns>
|
||||
KeyValuePair<int, Stream> GetStateClosestToFrame(int frame);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total number of states currently held by the state manager
|
||||
|
@ -77,14 +57,20 @@ namespace BizHawk.Client.Common
|
|||
int Last { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adjust internal state saving logic based on changes to Settings
|
||||
/// Updates the internal state saving logic settings
|
||||
/// </summary>
|
||||
void UpdateStateFrequency();
|
||||
void UpdateSettings(ZwinderStateManagerSettings settings);
|
||||
|
||||
/// <summary>
|
||||
/// Directly remove a state from the given frame, if it exists
|
||||
/// Should only be called by pruning operations
|
||||
/// Serializes the current state of the instance for persisting to disk
|
||||
/// </summary>
|
||||
bool Remove(int frame);
|
||||
void SaveStateHistory(BinaryWriter bw);
|
||||
|
||||
/// <summary>
|
||||
/// Enables the instance to be used. An instance of <see cref="IStateManager"/> should not
|
||||
/// be useable until this method is called
|
||||
/// </summary>
|
||||
/// <param name="frameZeroState"></param>
|
||||
void Engage(byte[] frameZeroState);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,163 +0,0 @@
|
|||
/****************************************************************************************
|
||||
|
||||
Algorithm by r57shell & feos, 2018
|
||||
|
||||
_zeros is the key to GREENZONE DECAY PATTERN.
|
||||
|
||||
In a 16 element example, we evaluate these bitwise numbers to count zeros on the right.
|
||||
First element is always assumed to be 16, which has all 4 bits set to 0. Each right zero
|
||||
means that we lower the priority of a state that goes at that index. Priority changes
|
||||
depending on current frame and amount of states. States with biggest priority get erased
|
||||
first. With a 4-bit pattern and no initial gap between states, total frame coverage is
|
||||
about 5 times state count.
|
||||
|
||||
Initial state gap can screw up our patterns, so do all the calculations like the gap
|
||||
isn't there, and take it back into account afterwards. The algo only works with integral
|
||||
greenzone, so we make it think it is integral by reducing the frame numbers. Before any
|
||||
decay logic starts for each state, we check if it has a marker on it (in which case we
|
||||
don't drop it) or appears inside the state gap (in which case we forcibly drop it). This
|
||||
step doesn't involve numbers reduction.
|
||||
|
||||
_zeros values are essentially the values of rshiftby here:
|
||||
bitwise view frame rshiftby priority
|
||||
00010000 0 4 1
|
||||
00000001 1 0 15
|
||||
00000010 2 1 7
|
||||
00000011 3 0 13
|
||||
00000100 4 2 3
|
||||
00000101 5 0 11
|
||||
00000110 6 1 5
|
||||
00000111 7 0 9
|
||||
00001000 8 3 1
|
||||
00001001 9 0 7
|
||||
00001010 10 1 3
|
||||
00001011 11 0 5
|
||||
00001100 12 2 1
|
||||
00001101 13 0 3
|
||||
00001110 14 1 1
|
||||
00001111 15 0 1
|
||||
|
||||
*****************************************************************************************/
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BizHawk.Client.Common
|
||||
{
|
||||
// TODO: interface me
|
||||
internal class StateManagerDecay
|
||||
{
|
||||
private readonly ITasMovie _movie;
|
||||
private readonly TasStateManager _tsm;
|
||||
|
||||
private List<int> _zeros; // number of ending zeros in binary representation of the index
|
||||
private int _bits; // max number of bits for which to calculate _zeros
|
||||
private int _mask; // to mask index into _zeros, to prevent accessing out of range
|
||||
|
||||
private int _step; // initial gap between states
|
||||
|
||||
public StateManagerDecay(ITasMovie movie, TasStateManager tsm)
|
||||
{
|
||||
_movie = movie;
|
||||
_tsm = tsm;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This will strategically remove states based on their alignment with the state gap (and its multiples) and their distance from the current frame.
|
||||
/// </summary>
|
||||
public void Trigger(int currentEmulatedFrame, int statesToDecay)
|
||||
{
|
||||
int baseStateIndex = _tsm.GetStateIndexByFrame(currentEmulatedFrame);
|
||||
int baseStateFrame = _tsm.GetStateFrameByIndex(baseStateIndex) / _step; // reduce to step integral TODO: do we actually want this?
|
||||
// key: priority value: frame
|
||||
List<KeyValuePair<int, int>> decayPriorities = new List<KeyValuePair<int, int>>();
|
||||
|
||||
for (int currentStateIndex = 1; currentStateIndex < _tsm.Count; currentStateIndex++)
|
||||
{
|
||||
int currentFrame = _tsm.GetStateFrameByIndex(currentStateIndex);
|
||||
|
||||
if (_movie.Markers.IsMarker(currentFrame + 1))
|
||||
continue;
|
||||
if (currentFrame + 1 == _movie.LastEditedFrame)
|
||||
continue;
|
||||
|
||||
// not aligned to state gap at all
|
||||
if (currentFrame % _step > 0)
|
||||
{
|
||||
if (_tsm.Remove(currentFrame))
|
||||
statesToDecay--;
|
||||
if (statesToDecay == 0)
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
// reduce to step integral for the decay logic
|
||||
currentFrame /= _step;
|
||||
int zeroCount = _zeros[currentFrame & _mask];
|
||||
int priority = (baseStateFrame - currentFrame) >> zeroCount;
|
||||
decayPriorities.Add(new KeyValuePair<int, int>(priority, currentFrame * _step));
|
||||
}
|
||||
|
||||
// optimization: if we are only removing 1 state, don't bother sorting the whole list
|
||||
if (statesToDecay == 1)
|
||||
{
|
||||
int highestPriority = decayPriorities[0].Key;
|
||||
int toRemove = decayPriorities[0].Value;
|
||||
for (int i = 1; i < decayPriorities.Count; i++)
|
||||
{
|
||||
if (decayPriorities[i].Key > highestPriority)
|
||||
{
|
||||
highestPriority = decayPriorities[i].Key;
|
||||
toRemove = decayPriorities[i].Value;
|
||||
}
|
||||
}
|
||||
if (!_tsm.Remove(toRemove))
|
||||
throw new System.Exception("Failed to remove state."); // should never happen
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
// reverse sort; high priority to remove comes first
|
||||
decayPriorities.Sort((p2, p1) => p1.Key.CompareTo(p2.Key));
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
while (statesToDecay > 0 && index < decayPriorities.Count)
|
||||
{
|
||||
if (_tsm.Remove(decayPriorities[index].Value))
|
||||
statesToDecay--;
|
||||
index++;
|
||||
}
|
||||
|
||||
// we're very sorry about failing to find states to remove, but we can't go beyond capacity, so remove at least something
|
||||
while (statesToDecay > 0)
|
||||
{
|
||||
if (_tsm.Remove(_tsm.GetStateFrameByIndex(1)))
|
||||
statesToDecay--;
|
||||
else // This should never happen, but just in case, we don't want to let memory usage continue to climb.
|
||||
throw new System.Exception("Failed to remove states.");
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateSettings(int step, int bits)
|
||||
{
|
||||
_step = step;
|
||||
_bits = bits;
|
||||
_mask = (1 << _bits) - 1;
|
||||
_zeros = new List<int> { _bits };
|
||||
|
||||
for (int i = 1; i < (1 << _bits); i++)
|
||||
{
|
||||
_zeros.Add(0);
|
||||
|
||||
for (int j = i; j > 0; j >>= 1)
|
||||
{
|
||||
if ((j & 1) > 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_zeros[i]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -27,6 +27,7 @@ namespace BizHawk.Client.Common
|
|||
if (this.IsRecording())
|
||||
{
|
||||
TasStateManager.Invalidate(frame + 1);
|
||||
GreenzoneInvalidated(frame + 1);
|
||||
}
|
||||
|
||||
if (frame != 0)
|
||||
|
@ -49,6 +50,7 @@ namespace BizHawk.Client.Common
|
|||
|
||||
LagLog.RemoveFrom(frame);
|
||||
TasStateManager.Invalidate(frame);
|
||||
GreenzoneInvalidated(frame);
|
||||
Markers.TruncateAt(frame);
|
||||
|
||||
ChangeLog.SetGeneralRedo();
|
||||
|
|
|
@ -43,7 +43,7 @@ namespace BizHawk.Client.Common
|
|||
|
||||
if (TasStateManager.Settings.SaveStateHistory && !isBackup)
|
||||
{
|
||||
bs.PutLump(BinaryStateLump.StateHistory, bw => TasStateManager.Save(bw));
|
||||
bs.PutLump(BinaryStateLump.StateHistory, bw => TasStateManager.SaveStateHistory(bw));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -56,7 +56,7 @@ namespace BizHawk.Client.Common
|
|||
private void ClearTasprojExtras()
|
||||
{
|
||||
LagLog.Clear();
|
||||
TasStateManager.Clear();
|
||||
TasStateManager?.Clear();
|
||||
Markers.Clear();
|
||||
ChangeLog.Clear();
|
||||
}
|
||||
|
@ -64,9 +64,17 @@ namespace BizHawk.Client.Common
|
|||
protected override void LoadFields(ZipStateLoader bl, bool preload)
|
||||
{
|
||||
LoadBk2Fields(bl, preload);
|
||||
|
||||
if (!preload)
|
||||
{
|
||||
LoadTasprojExtras(bl);
|
||||
if (MovieService.IsCurrentTasVersion(Header[HeaderKeys.MovieVersion]))
|
||||
{
|
||||
LoadTasprojExtras(bl);
|
||||
}
|
||||
else
|
||||
{
|
||||
Session.PopupMessage("The current .tasproj is compatible with this version of BizHawk! .tasproj features failed to load.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -82,7 +90,8 @@ namespace BizHawk.Client.Common
|
|||
var json = tr.ReadToEnd();
|
||||
try
|
||||
{
|
||||
TasStateManager.Settings = JsonConvert.DeserializeObject<TasStateManagerSettings>(json);
|
||||
var settings = JsonConvert.DeserializeObject<ZwinderStateManagerSettings>(json);
|
||||
TasStateManager.UpdateSettings(settings);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
@ -160,7 +169,7 @@ namespace BizHawk.Client.Common
|
|||
{
|
||||
bl.GetLump(BinaryStateLump.StateHistory, false, delegate(BinaryReader br, long length)
|
||||
{
|
||||
TasStateManager.Load(br);
|
||||
TasStateManager = ZwinderStateManager.Create(br, TasStateManager.Settings);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,16 +12,18 @@ namespace BizHawk.Client.Common
|
|||
public new const string Extension = "tasproj";
|
||||
private IInputPollable _inputPollable;
|
||||
|
||||
public const double CurrentVersion = 1.1;
|
||||
|
||||
/// <exception cref="InvalidOperationException">loaded core does not implement <see cref="IStatable"/></exception>
|
||||
internal TasMovie(IMovieSession session, string path) : base(session, path)
|
||||
{
|
||||
Branches = new TasBranchCollection(this);
|
||||
ChangeLog = new TasMovieChangeLog(this);
|
||||
TasStateManager = new TasStateManager(this, session.Settings.DefaultTasStateManagerSettings);
|
||||
Header[HeaderKeys.MovieVersion] = "BizHawk v2.0 Tasproj v1.0";
|
||||
Header[HeaderKeys.MovieVersion] = $"BizHawk v2.0 Tasproj v{CurrentVersion}";
|
||||
Markers = new TasMovieMarkerList(this);
|
||||
Markers.CollectionChanged += Markers_CollectionChanged;
|
||||
Markers.Add(0, "Power on");
|
||||
TasStateManager = new ZwinderStateManager();
|
||||
}
|
||||
|
||||
public override void Attach(IEmulator emulator)
|
||||
|
@ -37,7 +39,17 @@ namespace BizHawk.Client.Common
|
|||
}
|
||||
|
||||
_inputPollable = emulator.AsInputPollable();
|
||||
TasStateManager.Attach(emulator);
|
||||
|
||||
if (StartsFromSavestate)
|
||||
{
|
||||
TasStateManager.Engage(BinarySavestate);
|
||||
}
|
||||
else
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
emulator.AsStatable().SaveStateBinary(new BinaryWriter(ms));
|
||||
TasStateManager.Engage(ms.ToArray());
|
||||
}
|
||||
|
||||
base.Attach(emulator);
|
||||
|
||||
|
@ -71,7 +83,9 @@ namespace BizHawk.Client.Common
|
|||
public TasLagLog LagLog { get; } = new TasLagLog();
|
||||
|
||||
public override string PreferredExtension => Extension;
|
||||
public IStateManager TasStateManager { get; }
|
||||
public IStateManager TasStateManager { get; private set; }
|
||||
|
||||
public Action<int> GreenzoneInvalidated { get; set; }
|
||||
|
||||
public ITasMovieRecord this[int index]
|
||||
{
|
||||
|
@ -111,6 +125,7 @@ namespace BizHawk.Client.Common
|
|||
{
|
||||
var anyLagInvalidated = LagLog.RemoveFrom(frame);
|
||||
var anyStateInvalidated = TasStateManager.Invalidate(frame + 1);
|
||||
GreenzoneInvalidated(frame + 1);
|
||||
if (anyLagInvalidated || anyStateInvalidated)
|
||||
{
|
||||
Changes = true;
|
||||
|
@ -172,7 +187,7 @@ namespace BizHawk.Client.Common
|
|||
|
||||
if (!TasStateManager.HasState(Emulator.Frame))
|
||||
{
|
||||
TasStateManager.Capture(Emulator.Frame == LastEditedFrame - 1);
|
||||
TasStateManager.Capture(Emulator.Frame, Emulator.AsStatable(), Emulator.Frame == LastEditedFrame - 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -270,6 +285,7 @@ namespace BizHawk.Client.Common
|
|||
{
|
||||
LagLog.RemoveFrom(timelineBranchFrame.Value);
|
||||
TasStateManager.Invalidate(timelineBranchFrame.Value);
|
||||
GreenzoneInvalidated(timelineBranchFrame.Value);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
|
@ -1,378 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using BizHawk.Common.NumberExtensions;
|
||||
using BizHawk.Emulation.Common;
|
||||
|
||||
namespace BizHawk.Client.Common
|
||||
{
|
||||
/// <summary>
|
||||
/// Captures savestates and manages the logic of adding, retrieving,
|
||||
/// invalidating/clearing of states. Also does memory management and limiting of states
|
||||
/// </summary>
|
||||
public class TasStateManager : IStateManager
|
||||
{
|
||||
private const int MinFrequency = 1;
|
||||
private const int MaxFrequency = 16;
|
||||
|
||||
private IStatable _core;
|
||||
private IEmulator _emulator;
|
||||
|
||||
private StateManagerDecay _decay;
|
||||
private readonly ITasMovie _movie;
|
||||
|
||||
private SortedList<int, byte[]> _states = new SortedList<int, byte[]>();
|
||||
private double _expectedStateSizeInMb;
|
||||
|
||||
private ulong _used;
|
||||
private int _stateFrequency;
|
||||
|
||||
private int MaxStates => (int)(Settings.CapacityMb / _expectedStateSizeInMb + 1);
|
||||
private int FileStateGap => 1 << Settings.FileStateGap;
|
||||
|
||||
/// <exception cref="InvalidOperationException">loaded core expects savestate size of <c>0 B</c></exception>
|
||||
public TasStateManager(ITasMovie movie, TasStateManagerSettings settings)
|
||||
{
|
||||
_movie = movie;
|
||||
Settings = new TasStateManagerSettings(settings);
|
||||
|
||||
if (_movie.StartsFromSavestate)
|
||||
{
|
||||
SetState(0, _movie.BinarySavestate);
|
||||
}
|
||||
}
|
||||
|
||||
public void Attach(IEmulator emulator)
|
||||
{
|
||||
if (!emulator.HasSavestates())
|
||||
{
|
||||
throw new InvalidOperationException($"A core must be able to provide an {nameof(IStatable)} service");
|
||||
}
|
||||
|
||||
_emulator = emulator;
|
||||
_core = emulator.AsStatable();
|
||||
|
||||
_decay = new StateManagerDecay(_movie, this);
|
||||
|
||||
_expectedStateSizeInMb = _core.CloneSavestate().Length / (double)(1024 * 1024);
|
||||
if (_expectedStateSizeInMb.HawkFloatEquality(0))
|
||||
{
|
||||
throw new InvalidOperationException("Savestate size can not be zero!");
|
||||
}
|
||||
|
||||
// don't erase states if they exist already (already loaded)
|
||||
if ((_states == null) || (_states.Capacity == 0)) { _states = new SortedList<int, byte[]>(MaxStates); }
|
||||
|
||||
UpdateStateFrequency();
|
||||
}
|
||||
|
||||
public Action<int> InvalidateCallback { get; set; }
|
||||
|
||||
public TasStateManagerSettings Settings { get; set; }
|
||||
|
||||
public byte[] this[int frame]
|
||||
{
|
||||
get
|
||||
{
|
||||
if (frame == 0)
|
||||
{
|
||||
return InitialState;
|
||||
}
|
||||
|
||||
if (_states.ContainsKey(frame))
|
||||
{
|
||||
return _states[frame];
|
||||
}
|
||||
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
public int Count => _states.Count;
|
||||
|
||||
public int Last => _states.Count > 0
|
||||
? _states.Last().Key
|
||||
: 0;
|
||||
|
||||
private byte[] InitialState =>
|
||||
_movie.StartsFromSavestate
|
||||
? _movie.BinarySavestate
|
||||
: _states[0];
|
||||
|
||||
public bool Any()
|
||||
{
|
||||
if (_movie.StartsFromSavestate)
|
||||
{
|
||||
return _states.Count > 0;
|
||||
}
|
||||
|
||||
return _states.Count > 1;
|
||||
}
|
||||
|
||||
public void UpdateStateFrequency()
|
||||
{
|
||||
_stateFrequency = ((int)(_expectedStateSizeInMb * 1024 / Settings.MemStateGapDividerKB))
|
||||
.Clamp(MinFrequency, MaxFrequency);
|
||||
|
||||
_decay.UpdateSettings(_stateFrequency, 6);
|
||||
LimitStateCount();
|
||||
}
|
||||
|
||||
public void Capture(bool force = false)
|
||||
{
|
||||
bool shouldCapture;
|
||||
int frame = _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 = true;
|
||||
}
|
||||
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 (IsMarkerState(frame))
|
||||
{
|
||||
shouldCapture = true; // Markers should always get priority
|
||||
}
|
||||
else
|
||||
{
|
||||
shouldCapture = frame % _stateFrequency == 0;
|
||||
}
|
||||
|
||||
if (shouldCapture)
|
||||
{
|
||||
SetState(frame, (byte[])_core.SaveStateBinary().Clone(), skipRemoval: false);
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
if (_states.Any())
|
||||
{
|
||||
// For power-on movies, we can't lose frame 0;
|
||||
byte[] power = null;
|
||||
if (!_movie.StartsFromSavestate)
|
||||
{
|
||||
power = _states[0];
|
||||
}
|
||||
|
||||
_states.Clear();
|
||||
|
||||
if (power != null)
|
||||
{
|
||||
SetState(0, power);
|
||||
_used = (ulong)power.Length;
|
||||
}
|
||||
|
||||
_movie.FlagChanges();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasState(int frame)
|
||||
{
|
||||
if (_movie.StartsFromSavestate && frame == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _states.ContainsKey(frame);
|
||||
}
|
||||
|
||||
/// <returns>true iff any frames were invalidated</returns>
|
||||
public bool Invalidate(int frame)
|
||||
{
|
||||
if (!Any()) return false;
|
||||
if (frame == 0) frame = 1; // Never invalidate frame 0
|
||||
var statesToRemove = _states.Where(s => s.Key >= frame).ToList();
|
||||
foreach (var state in statesToRemove) Remove(state.Key);
|
||||
InvalidateCallback?.Invoke(frame);
|
||||
return statesToRemove.Count != 0;
|
||||
}
|
||||
|
||||
public bool Remove(int frame)
|
||||
{
|
||||
int index = _states.IndexOfKey(frame);
|
||||
|
||||
if (frame < 1 || index < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var state = _states[frame];
|
||||
|
||||
_used -= (ulong)state.Length;
|
||||
|
||||
_states.RemoveAt(index);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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<int> 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]);
|
||||
}
|
||||
}
|
||||
|
||||
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<int, byte[]> GetStateClosestToFrame(int frame)
|
||||
{
|
||||
var s = _states.LastOrDefault(state => state.Key < frame);
|
||||
if (s.Key > 0)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
return new KeyValuePair<int, byte[]>(0, InitialState);
|
||||
}
|
||||
|
||||
public int GetStateIndexByFrame(int frame)
|
||||
{
|
||||
return _states.IndexOfKey(GetStateClosestToFrame(frame).Key);
|
||||
}
|
||||
|
||||
public int GetStateFrameByIndex(int index)
|
||||
{
|
||||
return _states.Keys[index];
|
||||
}
|
||||
|
||||
private bool IsMarkerState(int frame)
|
||||
{
|
||||
return _movie.Markers.IsMarker(frame + 1);
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
else
|
||||
{
|
||||
_used += (ulong)state.Length;
|
||||
_states.Add(frame, state);
|
||||
}
|
||||
}
|
||||
|
||||
// Deletes states to follow the state storage size limits.
|
||||
// Used after changing the settings too.
|
||||
private void LimitStateCount()
|
||||
{
|
||||
if (Count + 1 > MaxStates)
|
||||
{
|
||||
_decay.Trigger(_emulator.Frame, Count + 1 - MaxStates);
|
||||
}
|
||||
}
|
||||
|
||||
private List<int> ExcludeStates()
|
||||
{
|
||||
List<int> ret = new List<int>();
|
||||
ulong saveUsed = _used;
|
||||
|
||||
// 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 (IsMarkerState(frame) || frame % FileStateGap < _stateFrequency)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ret.Add(i);
|
||||
|
||||
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 (IsMarkerState(GetStateFrameByIndex(index)));
|
||||
|
||||
if (index >= _states.Count)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
ret.Add(index);
|
||||
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);
|
||||
}
|
||||
|
||||
saveUsed -= (ulong)_states.Values[index].Length;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
using System.ComponentModel;
|
||||
|
||||
namespace BizHawk.Client.Common
|
||||
{
|
||||
public class TasStateManagerSettings
|
||||
{
|
||||
public TasStateManagerSettings()
|
||||
{
|
||||
DiskSaveCapacityMb = 512;
|
||||
CapacityMb = 512;
|
||||
DiskCapacityMb = 1; // not working yet
|
||||
MemStateGapDividerKB = 64;
|
||||
FileStateGap = 4;
|
||||
}
|
||||
|
||||
public TasStateManagerSettings(TasStateManagerSettings settings)
|
||||
{
|
||||
DiskSaveCapacityMb = settings.DiskSaveCapacityMb;
|
||||
CapacityMb = settings.CapacityMb;
|
||||
DiskCapacityMb = settings.DiskCapacityMb;
|
||||
MemStateGapDividerKB = settings.MemStateGapDividerKB;
|
||||
FileStateGap = settings.FileStateGap;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether or not to save state history information to disk
|
||||
/// </summary>
|
||||
[DisplayName("Save History")]
|
||||
[Description("Whether or not to use savestate history")]
|
||||
public bool SaveStateHistory => DiskSaveCapacityMb != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the size limit to use when saving the TAS project to disk.
|
||||
/// </summary>
|
||||
[DisplayName("Save Capacity (in megabytes)")]
|
||||
[Description("The size limit to use when saving the tas project to disk.")]
|
||||
public int DiskSaveCapacityMb { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total amount of memory to devote to state history in megabytes
|
||||
/// </summary>
|
||||
[DisplayName("Capacity (in megabytes)")]
|
||||
[Description("The size limit of the state history buffer. When this limit is reached it will start moving to disk.")]
|
||||
public int CapacityMb { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total amount of disk space to devote to state history in megabytes
|
||||
/// </summary>
|
||||
[DisplayName("Disk Capacity (in megabytes)")]
|
||||
[Description("The size limit of the state history buffer on the disk. When this limit is reached it will start removing previous savestates")]
|
||||
public int DiskCapacityMb { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the divider that determines memory state gap
|
||||
/// </summary>
|
||||
[DisplayName("Divider for memory state interval")]
|
||||
[Description("The actual state gap in frames is calculated as ExpectedStateSizeMB * 1024 / div")]
|
||||
public int MemStateGapDividerKB { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the amount of states to skip during project saving
|
||||
/// </summary>
|
||||
[DisplayName("State interval for .tasproj")]
|
||||
[Description("The actual state gap in frames is calculated as Nth power on 2")]
|
||||
public int FileStateGap { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,339 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using BizHawk.Emulation.Common;
|
||||
|
||||
namespace BizHawk.Client.Common
|
||||
{
|
||||
public class ZwinderStateManager : IStateManager
|
||||
{
|
||||
private static readonly byte[] NonState = new byte[0];
|
||||
|
||||
private byte[] _originalState;
|
||||
private readonly ZwinderBuffer _current;
|
||||
private readonly ZwinderBuffer _recent;
|
||||
private readonly ZwinderBuffer _highPriority;
|
||||
private readonly List<KeyValuePair<int, byte[]>> _ancient = new List<KeyValuePair<int, byte[]>>();
|
||||
private readonly int _ancientInterval;
|
||||
|
||||
public ZwinderStateManager(ZwinderStateManagerSettings settings)
|
||||
{
|
||||
Settings = settings;
|
||||
|
||||
_current = new ZwinderBuffer(new RewindConfig
|
||||
{
|
||||
UseCompression = settings.CurrentUseCompression,
|
||||
BufferSize = settings.CurrentBufferSize,
|
||||
TargetFrameLength = settings.CurrentTargetFrameLength
|
||||
});
|
||||
_recent = new ZwinderBuffer(new RewindConfig
|
||||
{
|
||||
UseCompression = settings.RecentUseCompression,
|
||||
BufferSize = settings.RecentBufferSize,
|
||||
TargetFrameLength = settings.RecentTargetFrameLength
|
||||
});
|
||||
|
||||
_highPriority = new ZwinderBuffer(new RewindConfig
|
||||
{
|
||||
UseCompression = settings.PriorityUseCompression,
|
||||
BufferSize = settings.PriorityBufferSize,
|
||||
TargetFrameLength = settings.PriorityTargetFrameLength
|
||||
});
|
||||
|
||||
_ancientInterval = settings.AncientStateInterval;
|
||||
_originalState = NonState;
|
||||
}
|
||||
|
||||
public ZwinderStateManager()
|
||||
:this(new ZwinderStateManagerSettings())
|
||||
{
|
||||
}
|
||||
|
||||
public void Engage(byte[] frameZeroState)
|
||||
{
|
||||
_originalState = (byte[])frameZeroState.Clone();
|
||||
}
|
||||
|
||||
private ZwinderStateManager(ZwinderBuffer current, ZwinderBuffer recent, ZwinderBuffer highPriority, byte[] frameZeroState, int ancientInterval)
|
||||
{
|
||||
_originalState = (byte[])frameZeroState.Clone();
|
||||
_current = current;
|
||||
_recent = recent;
|
||||
_highPriority = highPriority;
|
||||
_ancientInterval = ancientInterval;
|
||||
}
|
||||
|
||||
public byte[] this[int frame]
|
||||
{
|
||||
get
|
||||
{
|
||||
var kvp = GetStateClosestToFrame(frame + 1);
|
||||
if (kvp.Key != frame)
|
||||
return NonState;
|
||||
var ms = new MemoryStream();
|
||||
kvp.Value.CopyTo(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: private set, refactor LoadTasprojExtras to hold onto a settings object and pass it in to Create() method
|
||||
public ZwinderStateManagerSettings Settings { get; set; }
|
||||
|
||||
public int Count => _current.Count + _recent.Count + _highPriority.Count + _ancient.Count + 1;
|
||||
|
||||
private class StateInfo
|
||||
{
|
||||
public int Frame { get; }
|
||||
public Func<Stream> Read { get; }
|
||||
public StateInfo(ZwinderBuffer.StateInformation si)
|
||||
{
|
||||
Frame = si.Frame;
|
||||
Read = si.GetReadStream;
|
||||
}
|
||||
public StateInfo(KeyValuePair<int, byte[]> kvp)
|
||||
:this(kvp.Key, kvp.Value)
|
||||
{
|
||||
}
|
||||
public StateInfo(int frame, byte[] data)
|
||||
{
|
||||
Frame = frame;
|
||||
Read = () => new MemoryStream(data, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerate all states, excepting high priority, in reverse order
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
private IEnumerable<StateInfo> NormalStates()
|
||||
{
|
||||
for (var i = _current.Count - 1; i >= 0; i--)
|
||||
{
|
||||
yield return new StateInfo(_current.GetState(i));
|
||||
}
|
||||
for (var i = _recent.Count - 1; i >= 0; i--)
|
||||
{
|
||||
yield return new StateInfo(_recent.GetState(i));
|
||||
}
|
||||
for (var i = _ancient.Count - 1; i >= 0; i--)
|
||||
{
|
||||
yield return new StateInfo(_ancient[i]);
|
||||
}
|
||||
yield return new StateInfo(0, _originalState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerate high priority states in reverse order
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
private IEnumerable<StateInfo> HighPriorityStates()
|
||||
{
|
||||
for (var i = _highPriority.Count - 1; i >= 0; i--)
|
||||
{
|
||||
yield return new StateInfo(_highPriority.GetState(i));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerate all states in reverse order
|
||||
/// </summary>
|
||||
private IEnumerable<StateInfo> AllStates()
|
||||
{
|
||||
var l1 = NormalStates().GetEnumerator();
|
||||
var l2 = HighPriorityStates().GetEnumerator();
|
||||
var l1More = l1.MoveNext();
|
||||
var l2More = l2.MoveNext();
|
||||
while (l1More || l2More)
|
||||
{
|
||||
if (l1More)
|
||||
{
|
||||
if (l2More)
|
||||
{
|
||||
if (l1.Current.Frame > l2.Current.Frame)
|
||||
{
|
||||
yield return l1.Current;
|
||||
l1More = l1.MoveNext();
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return l2.Current;
|
||||
l2More = l2.MoveNext();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return l1.Current;
|
||||
l1More = l1.MoveNext();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return l2.Current;
|
||||
l2More = l2.MoveNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int Last => AllStates().First().Frame;
|
||||
|
||||
public void Capture(int frame, IBinaryStateable source, bool force = false)
|
||||
{
|
||||
if (frame <= Last)
|
||||
{
|
||||
CaptureHighPriority(frame, source);
|
||||
}
|
||||
|
||||
_current.Capture(frame,
|
||||
s => source.SaveStateBinary(new BinaryWriter(s)),
|
||||
index =>
|
||||
{
|
||||
var state = _current.GetState(index);
|
||||
_recent.Capture(state.Frame,
|
||||
s => state.GetReadStream().CopyTo(s),
|
||||
index2 =>
|
||||
{
|
||||
var state2 = _recent.GetState(index2);
|
||||
var from = _ancient.Count > 0 ? _ancient[_ancient.Count - 1].Key : 0;
|
||||
if (state2.Frame - from >= _ancientInterval)
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
state2.GetReadStream().CopyTo(ms);
|
||||
_ancient.Add(new KeyValuePair<int, byte[]>(state2.Frame, ms.ToArray()));
|
||||
}
|
||||
});
|
||||
},
|
||||
force);
|
||||
}
|
||||
|
||||
public void CaptureHighPriority(int frame, IBinaryStateable source)
|
||||
{
|
||||
_highPriority.Capture(frame, s => source.SaveStateBinary(new BinaryWriter(s)));
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_current.InvalidateEnd(0);
|
||||
_recent.InvalidateEnd(0);
|
||||
_highPriority.InvalidateEnd(0);
|
||||
_ancient.Clear();
|
||||
}
|
||||
|
||||
public KeyValuePair<int, Stream> GetStateClosestToFrame(int frame)
|
||||
{
|
||||
if (frame <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(frame));
|
||||
|
||||
var si = AllStates().First(s => s.Frame < frame);
|
||||
return new KeyValuePair<int, Stream>(si.Frame, si.Read());
|
||||
}
|
||||
|
||||
public bool HasState(int frame)
|
||||
{
|
||||
return AllStates().Any(s => s.Frame == frame);
|
||||
}
|
||||
|
||||
private bool InvalidateHighPriority(int frame)
|
||||
{
|
||||
for (var i = 0; i < _highPriority.Count; i++)
|
||||
{
|
||||
if (_highPriority.GetState(i).Frame >= frame)
|
||||
{
|
||||
_highPriority.InvalidateEnd(i);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool InvalidateNormal(int frame)
|
||||
{
|
||||
for (var i = 0; i < _ancient.Count; i++)
|
||||
{
|
||||
if (_ancient[i].Key >= frame)
|
||||
{
|
||||
_ancient.RemoveRange(i, _ancient.Count - i);
|
||||
_recent.InvalidateEnd(0);
|
||||
_current.InvalidateEnd(0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < _recent.Count; i++)
|
||||
{
|
||||
if (_recent.GetState(i).Frame >= frame)
|
||||
{
|
||||
_recent.InvalidateEnd(i);
|
||||
_current.InvalidateEnd(0);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < _current.Count; i++)
|
||||
{
|
||||
if (_current.GetState(i).Frame >= frame)
|
||||
{
|
||||
_current.InvalidateEnd(i);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void UpdateSettings(ZwinderStateManagerSettings settings) => Settings = settings;
|
||||
|
||||
public bool Invalidate(int frame)
|
||||
{
|
||||
if (frame <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(frame));
|
||||
var b1 = InvalidateNormal(frame);
|
||||
var b2 = InvalidateHighPriority(frame);
|
||||
return b1 || b2;
|
||||
}
|
||||
|
||||
public static ZwinderStateManager Create(BinaryReader br, ZwinderStateManagerSettings settings)
|
||||
{
|
||||
var current = ZwinderBuffer.Create(br);
|
||||
var recent = ZwinderBuffer.Create(br);
|
||||
var highPriority = ZwinderBuffer.Create(br);
|
||||
|
||||
var original = br.ReadBytes(br.ReadInt32());
|
||||
|
||||
var ancientInterval = br.ReadInt32();
|
||||
|
||||
var ret = new ZwinderStateManager(current, recent, highPriority, original, ancientInterval)
|
||||
{
|
||||
Settings = settings
|
||||
};
|
||||
|
||||
var ancientCount = br.ReadInt32();
|
||||
for (var i = 0; i < ancientCount; i++)
|
||||
{
|
||||
var key = br.ReadInt32();
|
||||
var length = br.ReadInt32();
|
||||
var data = br.ReadBytes(length);
|
||||
ret._ancient.Add(new KeyValuePair<int, byte[]>(key, data));
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
public void SaveStateHistory(BinaryWriter bw)
|
||||
{
|
||||
_current.SaveStateBinary(bw);
|
||||
_recent.SaveStateBinary(bw);
|
||||
_highPriority.SaveStateBinary(bw);
|
||||
|
||||
bw.Write(_originalState.Length);
|
||||
bw.Write(_originalState);
|
||||
|
||||
bw.Write(_ancientInterval);
|
||||
|
||||
bw.Write(_ancient.Count);
|
||||
foreach (var s in _ancient)
|
||||
{
|
||||
bw.Write(s.Key);
|
||||
bw.Write(s.Value.Length);
|
||||
bw.Write(s.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
using System.ComponentModel;
|
||||
|
||||
namespace BizHawk.Client.Common
|
||||
{
|
||||
public class ZwinderStateManagerSettings
|
||||
{
|
||||
public ZwinderStateManagerSettings() { }
|
||||
|
||||
public ZwinderStateManagerSettings(ZwinderStateManagerSettings settings)
|
||||
{
|
||||
CurrentUseCompression = settings.CurrentUseCompression;
|
||||
CurrentBufferSize = settings.CurrentBufferSize;
|
||||
CurrentTargetFrameLength = settings.CurrentTargetFrameLength;
|
||||
|
||||
RecentUseCompression = settings.RecentUseCompression;
|
||||
RecentBufferSize = settings.RecentBufferSize;
|
||||
RecentTargetFrameLength = settings.RecentTargetFrameLength;
|
||||
|
||||
PriorityUseCompression = settings.PriorityUseCompression;
|
||||
PriorityBufferSize = settings.PriorityBufferSize;
|
||||
PriorityTargetFrameLength = settings.PriorityTargetFrameLength;
|
||||
|
||||
AncientStateInterval = settings.AncientStateInterval;
|
||||
SaveStateHistory = settings.SaveStateHistory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Buffer settings when navigating near now
|
||||
/// </summary>
|
||||
[DisplayName("Current - Use Compression")]
|
||||
public bool CurrentUseCompression { get; set; }
|
||||
|
||||
[DisplayName("Current - Buffer Size")]
|
||||
[Description("Max amount of buffer space to use in MB")]
|
||||
public int CurrentBufferSize { get; set; } = 64;
|
||||
|
||||
[DisplayName("Current - Target Frame Length")]
|
||||
[Description("Desired frame length (number of emulated frames you can go back before running out of buffer)")]
|
||||
public int CurrentTargetFrameLength { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Buffer settings when navigating directly before the Current buffer
|
||||
/// </summary>
|
||||
[DisplayName("Recent - Use Compression")]
|
||||
public bool RecentUseCompression { get; set; }
|
||||
|
||||
[DisplayName("Recent - Buffer Size")]
|
||||
[Description("Max amount of buffer space to use in MB")]
|
||||
public int RecentBufferSize { get; set; } = 64;
|
||||
|
||||
[DisplayName("Recent - Target Frame Length")]
|
||||
[Description("Desired frame length (number of emulated frames you can go back before running out of buffer)")]
|
||||
public int RecentTargetFrameLength { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Priority States for special use cases
|
||||
/// </summary>
|
||||
[DisplayName("Priority - Use Compression")]
|
||||
public bool PriorityUseCompression { get; set; }
|
||||
|
||||
[DisplayName("Priority - Buffer Size")]
|
||||
[Description("Max amount of buffer space to use in MB")]
|
||||
public int PriorityBufferSize { get; set; } = 64;
|
||||
|
||||
[DisplayName("Priority - Target Frame Length")]
|
||||
[Description("Desired frame length (number of emulated frames you can go back before running out of buffer)")]
|
||||
public int PriorityTargetFrameLength { get; set; } = 10000;
|
||||
|
||||
[DisplayName("Ancient State Interval")]
|
||||
[Description("How often to maintain states when outside of Current and Recent intervals")]
|
||||
public int AncientStateInterval { get; set; } = 5000;
|
||||
|
||||
[DisplayName("Save Savestate History")]
|
||||
[Description("Whether or not to save savestate history into .tasproj files")]
|
||||
public bool SaveStateHistory { get; set; } = true;
|
||||
}
|
||||
}
|
|
@ -1,14 +1,11 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
|
||||
using BizHawk.Common;
|
||||
using BizHawk.Emulation.Common;
|
||||
|
||||
namespace BizHawk.Client.Common
|
||||
{
|
||||
public class ZwinderBuffer : IBinaryStateable
|
||||
public class ZwinderBuffer
|
||||
{
|
||||
/*
|
||||
Main goals:
|
||||
|
@ -234,24 +231,53 @@ namespace BizHawk.Client.Common
|
|||
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");
|
||||
// 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");
|
||||
|
||||
// LoadStateBodyBinary(reader);
|
||||
// }
|
||||
|
||||
private void LoadStateBodyBinary(BinaryReader reader)
|
||||
{
|
||||
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();
|
||||
_states[i].Start = reader.ReadInt64();
|
||||
_states[i].Size = reader.ReadInt32();
|
||||
_states[i].Frame = reader.ReadInt32();
|
||||
}
|
||||
_firstStateIndex = reader.ReadInt32();
|
||||
_nextStateIndex = reader.ReadInt32();
|
||||
}
|
||||
|
||||
public static ZwinderBuffer Create(BinaryReader reader)
|
||||
{
|
||||
var size = reader.ReadInt64();
|
||||
var sizeMask = reader.ReadInt64();
|
||||
var targetFrameLength = reader.ReadInt32();
|
||||
var useCompression = reader.ReadBoolean();
|
||||
var ret = new ZwinderBuffer(new RewindConfig
|
||||
{
|
||||
BufferSize = (int)(size >> 20),
|
||||
TargetFrameLength = targetFrameLength,
|
||||
UseCompression = useCompression
|
||||
});
|
||||
if (ret.Size != size || ret._sizeMask != sizeMask)
|
||||
{
|
||||
throw new InvalidOperationException("Bad format");
|
||||
}
|
||||
ret.LoadStateBodyBinary(reader);
|
||||
return ret;
|
||||
}
|
||||
|
||||
private class SaveStateStream : Stream, ISpanStream
|
||||
{
|
||||
/// <summary>
|
||||
|
|
|
@ -2,9 +2,11 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using BizHawk.Client.Common;
|
||||
using BizHawk.Emulation.Common;
|
||||
using BizHawk.Client.EmuHawk.Properties;
|
||||
|
||||
namespace BizHawk.Client.EmuHawk
|
||||
|
@ -193,9 +195,8 @@ namespace BizHawk.Client.EmuHawk
|
|||
}
|
||||
|
||||
Movie.LoadBranch(branch);
|
||||
var stateInfo = new KeyValuePair<int, byte[]>(branch.Frame, branch.CoreData);
|
||||
Tastudio.LoadState(stateInfo);
|
||||
Movie.TasStateManager.Capture(true);
|
||||
Tastudio.LoadState(new KeyValuePair<int, Stream>(branch.Frame, new MemoryStream(branch.CoreData, false)));
|
||||
Movie.TasStateManager.Capture(Tastudio.Emulator.Frame, Tastudio.Emulator.AsStatable(), true);
|
||||
QuickBmpFile.Copy(new BitmapBufferVideoProvider(branch.CoreFrameBuffer), Tastudio.VideoProvider);
|
||||
|
||||
if (Tastudio.Settings.OldControlSchemeForBranches && Tastudio.TasPlaybackBox.RecordingMode)
|
||||
|
|
|
@ -86,7 +86,7 @@
|
|||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.CancelButton = this.CancelBtn;
|
||||
this.ClientSize = new System.Drawing.Size(365, 301);
|
||||
this.ClientSize = new System.Drawing.Size(400, 301);
|
||||
this.Controls.Add(this.SettingsPropertyGrid);
|
||||
this.Controls.Add(this.DefaultsButton);
|
||||
this.Controls.Add(this.OkBtn);
|
||||
|
|
|
@ -7,10 +7,10 @@ namespace BizHawk.Client.EmuHawk
|
|||
{
|
||||
public partial class DefaultGreenzoneSettings : Form
|
||||
{
|
||||
private readonly Action<TasStateManagerSettings> _saveSettings;
|
||||
private TasStateManagerSettings _settings;
|
||||
private readonly Action<ZwinderStateManagerSettings> _saveSettings;
|
||||
private ZwinderStateManagerSettings _settings;
|
||||
|
||||
public DefaultGreenzoneSettings(TasStateManagerSettings settings, Action<TasStateManagerSettings> saveSettings)
|
||||
public DefaultGreenzoneSettings(ZwinderStateManagerSettings settings, Action<ZwinderStateManagerSettings> saveSettings)
|
||||
{
|
||||
InitializeComponent();
|
||||
Icon = Properties.Resources.TAStudioIcon;
|
||||
|
@ -33,7 +33,7 @@ namespace BizHawk.Client.EmuHawk
|
|||
|
||||
private void DefaultsButton_Click(object sender, EventArgs e)
|
||||
{
|
||||
_settings = new TasStateManagerSettings();
|
||||
_settings = new ZwinderStateManagerSettings();
|
||||
SettingsPropertyGrid.SelectedObject = _settings;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -321,7 +321,7 @@ namespace BizHawk.Client.EmuHawk
|
|||
&& (Clipboard.GetDataObject()?.GetDataPresent(DataFormats.StringFormat) ?? false);
|
||||
|
||||
ClearGreenzoneMenuItem.Enabled =
|
||||
CurrentTasMovie != null && CurrentTasMovie.TasStateManager.Any();
|
||||
CurrentTasMovie != null && CurrentTasMovie.TasStateManager.Count > 1;
|
||||
|
||||
GreenzoneICheckSeparator.Visible =
|
||||
StateHistoryIntegrityCheckMenuItem.Visible =
|
||||
|
@ -997,18 +997,23 @@ namespace BizHawk.Client.EmuHawk
|
|||
UpdateChangesIndicator();
|
||||
}
|
||||
|
||||
private void UpdateStateSettings(ZwinderStateManagerSettings settings)
|
||||
{
|
||||
Config.Movies.DefaultTasStateManagerSettings = settings;
|
||||
CurrentTasMovie.TasStateManager.UpdateSettings(settings);
|
||||
}
|
||||
|
||||
private void StateHistorySettingsMenuItem_Click(object sender, EventArgs e)
|
||||
{
|
||||
new DefaultGreenzoneSettings(
|
||||
CurrentTasMovie.TasStateManager.Settings,
|
||||
s => { CurrentTasMovie.TasStateManager.Settings = s; })
|
||||
new ZwinderStateManagerSettings(Config.Movies.DefaultTasStateManagerSettings),
|
||||
UpdateStateSettings)
|
||||
{
|
||||
Location = this.ChildPointToScreen(TasView),
|
||||
Text = "Savestate History Settings",
|
||||
Owner = Owner
|
||||
}.ShowDialog();
|
||||
|
||||
CurrentTasMovie.TasStateManager.UpdateStateFrequency();
|
||||
UpdateChangesIndicator();
|
||||
}
|
||||
|
||||
|
@ -1027,7 +1032,7 @@ namespace BizHawk.Client.EmuHawk
|
|||
private void DefaultStateSettingsMenuItem_Click(object sender, EventArgs e)
|
||||
{
|
||||
new DefaultGreenzoneSettings(
|
||||
new TasStateManagerSettings(Config.Movies.DefaultTasStateManagerSettings),
|
||||
new ZwinderStateManagerSettings(Config.Movies.DefaultTasStateManagerSettings),
|
||||
s => { Config.Movies.DefaultTasStateManagerSettings = s; })
|
||||
{
|
||||
Location = this.ChildPointToScreen(TasView),
|
||||
|
|
|
@ -54,7 +54,7 @@ namespace BizHawk.Client.EmuHawk
|
|||
|
||||
// Simply getting the last state doesn't work if that state is the frame.
|
||||
// display isn't saved in the state, need to emulate to frame
|
||||
var lastState = CurrentTasMovie.TasStateManager.GetStateClosestToFrame(frame);
|
||||
var lastState = CurrentTasMovie.TasStateManager.GetStateClosestToFrame(frame <= 0 ? 1 : frame);
|
||||
if (lastState.Key > Emulator.Frame)
|
||||
{
|
||||
LoadState(lastState);
|
||||
|
|
|
@ -540,7 +540,7 @@ namespace BizHawk.Client.EmuHawk
|
|||
_engaged = false;
|
||||
var newMovie = (ITasMovie)MovieSession.Get(file.FullName);
|
||||
newMovie.BindMarkersToInput = Settings.BindMarkersToInput;
|
||||
newMovie.TasStateManager.InvalidateCallback = GreenzoneInvalidated;
|
||||
newMovie.GreenzoneInvalidated = GreenzoneInvalidated;
|
||||
|
||||
if (!HandleMovieLoadStuff(newMovie))
|
||||
{
|
||||
|
@ -595,9 +595,9 @@ namespace BizHawk.Client.EmuHawk
|
|||
var filename = DefaultTasProjName(); // TODO don't do this, take over any mainform actions that can crash without a filename
|
||||
var tasMovie = (ITasMovie)MovieSession.Get(filename);
|
||||
tasMovie.BindMarkersToInput = Settings.BindMarkersToInput;
|
||||
|
||||
|
||||
tasMovie.TasStateManager.InvalidateCallback = GreenzoneInvalidated;
|
||||
|
||||
tasMovie.GreenzoneInvalidated = GreenzoneInvalidated;
|
||||
tasMovie.PropertyChanged += TasMovie_OnPropertyChanged;
|
||||
|
||||
tasMovie.PopulateWithDefaultHeaderValues(
|
||||
|
@ -611,7 +611,6 @@ namespace BizHawk.Client.EmuHawk
|
|||
tasMovie.Save();
|
||||
if (HandleMovieLoadStuff(tasMovie))
|
||||
{
|
||||
CurrentTasMovie.TasStateManager.Capture(); // Capture frame 0 always.
|
||||
}
|
||||
|
||||
// clear all selections
|
||||
|
@ -657,7 +656,6 @@ namespace BizHawk.Client.EmuHawk
|
|||
ResumeLayout();
|
||||
if (result)
|
||||
{
|
||||
CurrentTasMovie.TasStateManager.Capture(); // Capture frame 0 always.
|
||||
BookMarkControl.UpdateTextColumnWidth();
|
||||
MarkerControl.UpdateTextColumnWidth();
|
||||
}
|
||||
|
@ -910,7 +908,7 @@ namespace BizHawk.Client.EmuHawk
|
|||
|
||||
_unpauseAfterSeeking = (fromRewinding || WasRecording) && !MainForm.EmulatorPaused;
|
||||
TastudioPlayMode();
|
||||
var closestState = CurrentTasMovie.TasStateManager.GetStateClosestToFrame(frame);
|
||||
var closestState = CurrentTasMovie.TasStateManager.GetStateClosestToFrame(frame <= 0 ? 1 : frame);
|
||||
if (closestState.Value.Length > 0 && (frame < Emulator.Frame || closestState.Key > Emulator.Frame))
|
||||
{
|
||||
LoadState(closestState);
|
||||
|
@ -960,9 +958,9 @@ namespace BizHawk.Client.EmuHawk
|
|||
}
|
||||
}
|
||||
|
||||
public void LoadState(KeyValuePair<int, byte[]> state)
|
||||
public void LoadState(KeyValuePair<int, Stream> state)
|
||||
{
|
||||
StatableEmulator.LoadStateBinary(state.Value);
|
||||
StatableEmulator.LoadStateBinary(new BinaryReader(state.Value));
|
||||
|
||||
if (state.Key == 0 && CurrentTasMovie.StartsFromSavestate)
|
||||
{
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
using BizHawk.Client.Common;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace BizHawk.Common.Tests.Client.Common.Movie
|
||||
{
|
||||
[TestClass]
|
||||
public class MovieServiceTests
|
||||
{
|
||||
[TestMethod]
|
||||
[DataRow(null, 1.0)]
|
||||
[DataRow("", 1.0)]
|
||||
[DataRow(" ", 1.0)]
|
||||
[DataRow("NonsenseString", 1.0)]
|
||||
[DataRow("BizHawk v2.0", 1.0)]
|
||||
[DataRow("BizHawk v2.0 Tasproj v1.0", 1.0)]
|
||||
[DataRow("BizHawk v2.0 Tasproj v1.1", 1.1)]
|
||||
public void ParseTasMovieVersion(string movieVersion, double expected)
|
||||
{
|
||||
var actual = MovieService.ParseTasMovieVersion(movieVersion);
|
||||
Assert.AreEqual(expected, actual);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
using System.IO;
|
||||
using BizHawk.Client.Common;
|
||||
using BizHawk.Emulation.Common;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace BizHawk.Common.Tests.Client.Common.Movie
|
||||
{
|
||||
[TestClass]
|
||||
public class ZwinderStateManagerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void SaveCreateRoundTrip()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
var zw = new ZwinderStateManager();
|
||||
zw.SaveStateHistory(new BinaryWriter(ms));
|
||||
var buff = ms.ToArray();
|
||||
var rms = new MemoryStream(buff, false);
|
||||
|
||||
var zw2 = ZwinderStateManager.Create(new BinaryReader(rms), new ZwinderStateManagerSettings());
|
||||
|
||||
// TODO: we could assert more things here to be thorough
|
||||
Assert.IsNotNull(zw2);
|
||||
Assert.AreEqual(zw.Settings.CurrentBufferSize, zw2.Settings.CurrentBufferSize);
|
||||
Assert.AreEqual(zw.Settings.RecentBufferSize, zw2.Settings.RecentBufferSize);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SomethingSomething()
|
||||
{
|
||||
var ss = new StateSource { PaddingData = new byte[1000] };
|
||||
var zw = new ZwinderStateManager(new ZwinderStateManagerSettings
|
||||
{
|
||||
CurrentUseCompression = false,
|
||||
CurrentBufferSize = 1,
|
||||
CurrentTargetFrameLength = 10000,
|
||||
|
||||
RecentUseCompression = false,
|
||||
RecentBufferSize = 1,
|
||||
RecentTargetFrameLength = 100000,
|
||||
|
||||
AncientStateInterval = 50000
|
||||
});
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
ss.SaveStateBinary(new BinaryWriter(ms));
|
||||
zw.Engage(ms.ToArray());
|
||||
}
|
||||
for (int frame = 0; frame <= 10440; frame++)
|
||||
{
|
||||
ss.Frame = frame;
|
||||
zw.Capture(frame, ss);
|
||||
}
|
||||
var kvp = zw.GetStateClosestToFrame(10440);
|
||||
var actual = StateSource.GetFrameNumberInState(kvp.Value);
|
||||
Assert.AreEqual(kvp.Key, actual);
|
||||
Assert.IsTrue(actual < 10440);
|
||||
}
|
||||
|
||||
private class StateSource : IBinaryStateable
|
||||
{
|
||||
public int Frame { get; set; }
|
||||
public byte[] PaddingData { get; set; } = new byte[0];
|
||||
public void LoadStateBinary(BinaryReader reader)
|
||||
{
|
||||
Frame = reader.ReadInt32();
|
||||
reader.Read(PaddingData, 0, PaddingData.Length);
|
||||
}
|
||||
|
||||
public void SaveStateBinary(BinaryWriter writer)
|
||||
{
|
||||
writer.Write(Frame);
|
||||
writer.Write(PaddingData);
|
||||
}
|
||||
|
||||
public static int GetFrameNumberInState(Stream stream)
|
||||
{
|
||||
var ss = new StateSource();
|
||||
ss.LoadStateBinary(new BinaryReader(stream));
|
||||
return ss.Frame;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue