diff --git a/.global.editorconfig.ini b/.global.editorconfig.ini index c32a5d5daa..fafb4c043b 100644 --- a/.global.editorconfig.ini +++ b/.global.editorconfig.ini @@ -120,6 +120,15 @@ dotnet_diagnostic.CA2229.severity = silent # Opt in to preview features before using them dotnet_diagnostic.CA2252.severity = silent # CSharpDetectPreviewFeatureAnalyzer very slow +## Nullable rules; generics are a bit wonky and I have no idea why we're allowed to configure these to be not errors or why they aren't errors by default. + +# Nullability of reference types in value doesn't match target type. +dotnet_diagnostic.CS8619.severity = error +# Make Foo an error for class Foo where T : class. Use `where T : class?` if Foo should be allowed. +dotnet_diagnostic.CS8634.severity = error +# Nullability of type argument doesn't match 'notnull' constraint. +dotnet_diagnostic.CS8714.severity = error + ## .NET DocumentationAnalyzers style rules # Place text in paragraphs diff --git a/src/BizHawk.Client.Common/Api/Classes/EmuClientApi.cs b/src/BizHawk.Client.Common/Api/Classes/EmuClientApi.cs index fbc5c2636f..43578f215b 100644 --- a/src/BizHawk.Client.Common/Api/Classes/EmuClientApi.cs +++ b/src/BizHawk.Client.Common/Api/Classes/EmuClientApi.cs @@ -158,9 +158,19 @@ namespace BizHawk.Client.Common public void RebootCore() => _mainForm.RebootCore(); + // TODO: Change return type to FileWriteResult. public void SaveRam() => _mainForm.FlushSaveRAM(); - public void SaveState(string name) => _mainForm.SaveState(Path.Combine(_config.PathEntries.SaveStateAbsolutePath(Game.System), $"{name}.State"), name, fromLua: false); + // TODO: Change return type to FileWriteResult. + // We may wish to change more than that, since we have a mostly-dupicate ISaveStateApi.Save, neither has documentation indicating what the differences are. + public void SaveState(string name) + { + FileWriteResult result = _mainForm.SaveState(Path.Combine(_config.PathEntries.SaveStateAbsolutePath(Game.System), $"{name}.State"), name); + if (result.Exception != null && result.Exception is not UnlessUsingApiException) + { + throw result.Exception; + } + } public int ScreenHeight() => _displayManager.GetPanelNativeSize().Height; diff --git a/src/BizHawk.Client.Common/Api/Classes/MovieApi.cs b/src/BizHawk.Client.Common/Api/Classes/MovieApi.cs index 6f6bbe5a4f..8042fadd0c 100644 --- a/src/BizHawk.Client.Common/Api/Classes/MovieApi.cs +++ b/src/BizHawk.Client.Common/Api/Classes/MovieApi.cs @@ -53,6 +53,7 @@ namespace BizHawk.Client.Common return Bk2LogEntryGenerator.GenerateLogEntry(_movieSession.Movie.GetInputState(frame)); } + // TODO: Change return type to FileWriteResult public void Save(string filename) { if (_movieSession.Movie.NotActive()) @@ -70,7 +71,8 @@ namespace BizHawk.Client.Common } _movieSession.Movie.Filename = filename; } - _movieSession.Movie.Save(); + FileWriteResult result = _movieSession.Movie.Save(); + if (result.Exception != null) throw result.Exception; } public IReadOnlyDictionary GetHeader() diff --git a/src/BizHawk.Client.Common/Api/Classes/SaveStateApi.cs b/src/BizHawk.Client.Common/Api/Classes/SaveStateApi.cs index 9bbe860e9c..271f036ed2 100644 --- a/src/BizHawk.Client.Common/Api/Classes/SaveStateApi.cs +++ b/src/BizHawk.Client.Common/Api/Classes/SaveStateApi.cs @@ -39,8 +39,17 @@ namespace BizHawk.Client.Common return _mainForm.LoadQuickSave(slotNum, suppressOSD: suppressOSD); } - public void Save(string path, bool suppressOSD) => _mainForm.SaveState(path, path, true, suppressOSD); + // TODO: Change return type FileWriteResult. + public void Save(string path, bool suppressOSD) + { + FileWriteResult result = _mainForm.SaveState(path, path, suppressOSD); + if (result.Exception != null && result.Exception is not UnlessUsingApiException) + { + throw result.Exception; + } + } + // TODO: Change return type to FileWriteResult. public void SaveSlot(int slotNum, bool suppressOSD) { if (slotNum is < 0 or > 10) throw new ArgumentOutOfRangeException(paramName: nameof(slotNum), message: ERR_MSG_NOT_A_SLOT); @@ -49,7 +58,11 @@ namespace BizHawk.Client.Common LogCallback(ERR_MSG_USE_SLOT_10); slotNum = 10; } - _mainForm.SaveQuickSave(slotNum, suppressOSD: suppressOSD, fromLua: true); + FileWriteResult result = _mainForm.SaveQuickSave(slotNum, suppressOSD: suppressOSD); + if (result.Exception != null && result.Exception is not UnlessUsingApiException) + { + throw result.Exception; + } } } } diff --git a/src/BizHawk.Client.Common/DialogControllerExtensions.cs b/src/BizHawk.Client.Common/DialogControllerExtensions.cs index 768dd0a6e2..aa607e9507 100644 --- a/src/BizHawk.Client.Common/DialogControllerExtensions.cs +++ b/src/BizHawk.Client.Common/DialogControllerExtensions.cs @@ -1,9 +1,17 @@ #nullable enable using System.Collections.Generic; +using System.Diagnostics; namespace BizHawk.Client.Common { + public enum TryAgainResult + { + Saved, + IgnoredFailure, + Canceled, + } + public static class DialogControllerExtensions { public static void AddOnScreenMessage(this IDialogParent dialogParent, string message, int? duration = null) @@ -60,6 +68,47 @@ namespace BizHawk.Client.Common EMsgBoxIcon? icon = null) => dialogParent.DialogController.ShowMessageBox3(owner: dialogParent, text: text, caption: caption, icon: icon); + public static void ErrorMessageBox( + this IDialogParent dialogParent, + FileWriteResult fileResult, + string? prefixMessage = null) + { + Debug.Assert(fileResult.IsError && fileResult.Exception != null, "Error box must have an error."); + + string prefix = prefixMessage ?? ""; + dialogParent.ModalMessageBox( + text: $"{prefix}\n{fileResult.UserFriendlyErrorMessage()}\n{fileResult.Exception!.Message}", + caption: "Error", + icon: EMsgBoxIcon.Error); + } + + /// + /// If the action fails, asks the user if they want to try again. + /// The user will be repeatedly asked if they want to try again until either success or the user says no. + /// + /// Returns true on success or if the user said no. Returns false if the user said cancel. + public static TryAgainResult DoWithTryAgainBox( + this IDialogParent dialogParent, + Func action, + string message) + { + FileWriteResult fileResult = action(); + while (fileResult.IsError) + { + string prefix = message ?? ""; + bool? askResult = dialogParent.ModalMessageBox3( + text: $"{prefix} Do you want to try again?\n\nError details:" + + $"{fileResult.UserFriendlyErrorMessage()}\n{fileResult.Exception!.Message}", + caption: "Error", + icon: EMsgBoxIcon.Error); + if (askResult == null) return TryAgainResult.Canceled; + if (askResult == false) return TryAgainResult.IgnoredFailure; + if (askResult == true) fileResult = action(); + } + + return TryAgainResult.Saved; + } + /// Creates and shows a System.Windows.Forms.OpenFileDialog or equivalent with the receiver () as its parent /// OpenFileDialog.RestoreDirectory (isn't this useless when specifying ? keeping it for backcompat) /// OpenFileDialog.Filter diff --git a/src/BizHawk.Client.Common/FileWriteResult.cs b/src/BizHawk.Client.Common/FileWriteResult.cs new file mode 100644 index 0000000000..c11193e347 --- /dev/null +++ b/src/BizHawk.Client.Common/FileWriteResult.cs @@ -0,0 +1,131 @@ +#nullable enable + +using System.Diagnostics; + +namespace BizHawk.Client.Common +{ + public enum FileWriteEnum + { + Success, + // Failures during a FileWriter write. + FailedToOpen, + FailedDuringWrite, + FailedToDeleteOldBackup, + FailedToMakeBackup, + FailedToDeleteOldFile, + FailedToRename, + Aborted, + // Failures from other sources + FailedToDeleteGeneric, + FailedToMoveForSwap, + } + + /// + /// Provides information about the success or failure of an attempt to write to a file. + /// + public class FileWriteResult + { + public readonly FileWriteEnum Error = FileWriteEnum.Success; + public readonly Exception? Exception; + internal readonly FileWritePaths Paths; + + public bool IsError => Error != FileWriteEnum.Success; + + public FileWriteResult(FileWriteEnum error, FileWritePaths writer, Exception? exception) + { + Error = error; + Exception = exception; + Paths = writer; + } + + public FileWriteResult() : this(FileWriteEnum.Success, new("", ""), null) { } + + /// + /// Converts this instance to a different generic type. + /// The new instance will take the value given only if this instance has no error. + /// + /// The value of the new instance. Ignored if this instance has an error. + public FileWriteResult Convert(T value) where T : class + { + if (Error == FileWriteEnum.Success) return new(value, Paths); + else return new(this); + } + + public FileWriteResult(FileWriteResult other) : this(other.Error, other.Paths, other.Exception) { } + + public string UserFriendlyErrorMessage() + { + Debug.Assert(!IsError || (Exception != null), "FileWriteResult with an error should have an exception."); + + switch (Error) + { + // We include the full path since the user may not have explicitly given a directory and may not know what it is. + case FileWriteEnum.Success: + return $"The file \"{Paths.Final}\" was written successfully."; + case FileWriteEnum.FailedToOpen: + if (Paths.Final != Paths.Temp) + { + return $"The temporary file \"{Paths.Temp}\" could not be opened."; + } + return $"The file \"{Paths.Final}\" could not be created."; + case FileWriteEnum.FailedDuringWrite: + return $"An error occurred while writing the file."; // No file name here; it should be deleted. + case FileWriteEnum.Aborted: + return "The operation was aborted."; + + case FileWriteEnum.FailedToDeleteGeneric: + return $"The file \"{Paths.Final}\" could not be deleted."; + //case FileWriteEnum.FailedToDeleteForSwap: + // return $"Failed to swap files. Unable to write to \"{Paths.Final}\""; + case FileWriteEnum.FailedToMoveForSwap: + return $"Failed to swap files. Unable to rename \"{Paths.Temp}\" to \"{Paths.Final}\""; + } + + string success = $"The file was created successfully at \"{Paths.Temp}\" but could not be moved"; + switch (Error) + { + case FileWriteEnum.FailedToDeleteOldBackup: + return $"{success}. Unable to remove old backup file \"{Paths.Backup}\"."; + case FileWriteEnum.FailedToMakeBackup: + return $"{success}. Unable to create backup. Failed to move \"{Paths.Final}\" to \"{Paths.Backup}\"."; + case FileWriteEnum.FailedToDeleteOldFile: + return $"{success}. Unable to remove the old file \"{Paths.Final}\"."; + case FileWriteEnum.FailedToRename: + return $"{success} to \"{Paths.Final}\"."; + default: + return "unreachable"; + } + } + } + + /// + /// Provides information about the success or failure of an attempt to write to a file. + /// If successful, also provides a related object instance. + /// + public class FileWriteResult : FileWriteResult where T : class // Note: "class" also means "notnull". + { + /// + /// Value will be null if is true. + /// Otherwise, Value will not be null. + /// + public readonly T? Value = default; + + internal FileWriteResult(FileWriteEnum error, FileWritePaths paths, Exception? exception) : base(error, paths, exception) { } + + internal FileWriteResult(T value, FileWritePaths paths) : base(FileWriteEnum.Success, paths, null) + { + Debug.Assert(value != null, "Should not give a null value on success. Use the non-generic type if there is no value."); + Value = value; + } + + public FileWriteResult(FileWriteResult other) : base(other.Error, other.Paths, other.Exception) { } + } + + /// + /// This only exists as a way to avoid changing the API behavior. + /// + public class UnlessUsingApiException : Exception + { + public UnlessUsingApiException(string message) : base(message) { } + } +} diff --git a/src/BizHawk.Client.Common/FileWriter.cs b/src/BizHawk.Client.Common/FileWriter.cs new file mode 100644 index 0000000000..227ebb0168 --- /dev/null +++ b/src/BizHawk.Client.Common/FileWriter.cs @@ -0,0 +1,241 @@ +#nullable enable + +using System.Diagnostics; +using System.IO; +using System.Threading; + +using BizHawk.Common.StringExtensions; + +namespace BizHawk.Client.Common +{ + public class FileWritePaths(string final, string temp) + { + public readonly string Final = final; + public readonly string Temp = temp; + public string? Backup; + } + + /// + /// Provides a mechanism for safely overwriting files, by using a temporary file that only replaces the original after writing has been completed. + /// Optionally makes a backup of the original file. + /// + public class FileWriter : IDisposable + { + + private FileStream? _stream; // is never null until this.Dispose() + public FileStream Stream + { + get => _stream ?? throw new ObjectDisposedException("Cannot access a disposed FileStream."); + } + public FileWritePaths Paths; + + public bool UsingTempFile => Paths.Temp != Paths.Final; + + private bool _finished = false; + + private FileWriter(FileWritePaths paths, FileStream stream) + { + Paths = paths; + _stream = stream; + } + + public static FileWriteResult Write(string path, byte[] bytes, string? backupPath = null) + { + FileWriteResult createResult = Create(path); + if (createResult.IsError) return createResult; + + try + { + createResult.Value!.Stream.Write(bytes); + } + catch (Exception ex) + { + return new(FileWriteEnum.FailedDuringWrite, createResult.Value!.Paths, ex); + } + + return createResult.Value.CloseAndDispose(backupPath); + } + + public static FileWriteResult Write(string path, Action writeCallback, string? backupPath = null) + { + FileWriteResult createResult = Create(path); + if (createResult.IsError) return createResult; + + try + { + writeCallback(createResult.Value!.Stream); + } + catch (Exception ex) + { + return new(FileWriteEnum.FailedDuringWrite, createResult.Value!.Paths, ex); + } + + return createResult.Value.CloseAndDispose(backupPath); + } + + /// + /// Create a FileWriter instance, or return an error if unable to access the file. + /// + public static FileWriteResult Create(string path) + { + string writePath = path; + // If the file already exists, we will write to a temporary location first and preserve the old one until we're done. + if (File.Exists(path)) + { + writePath = path.InsertBeforeLast('.', ".saving", out bool inserted); + if (!inserted) writePath = $"{path}.saving"; + + // The file might already exist, if a prior file write failed. + // Maybe the user should have dealt with this on the previously failed save. + // But we want to support plain old "try again", so let's ignore that. + } + FileWritePaths paths = new(path, writePath); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + FileStream fs = new(writePath, FileMode.Create, FileAccess.Write); + return new(new FileWriter(paths, fs), paths); + } + catch (Exception ex) // There are many exception types that file operations might raise. + { + return new(FileWriteEnum.FailedToOpen, paths, ex); + } + } + + /// + /// This method must be called after writing has finished and must not be called twice. + /// Dispose will be called regardless of the result. + /// + /// If not null, renames the original file to this path. + /// If called twice. + public FileWriteResult CloseAndDispose(string? backupPath = null) + { + // In theory it might make sense to allow the user to try again if we fail inside this method. + // If we implement that, it is probably best to make a static method that takes a FileWriteResult. + // So even then, this method should not ever be called twice. + if (_finished) throw new InvalidOperationException("Cannot close twice."); + + _finished = true; + Dispose(); + + Paths.Backup = backupPath; + if (!UsingTempFile) + { + // The chosen file did not already exist, so there is nothing to back up and nothing to rename. + return new(FileWriteEnum.Success, Paths, null); + } + + try + { + // When everything goes right, this is all we need. + File.Replace(Paths.Temp, Paths.Final, backupPath); + return new(FileWriteEnum.Success, Paths, null); + } + catch + { + // When things go wrong, we have to do a lot of work in order to + // figure out what went wrong and tell the user. + return FindTheError(); + } + } + + private FileWriteResult FindTheError() + { + // It is an unfortunate reality that .NET provides horrible exception messages + // when using File.Replace(source, destination, backup). They are not only + // unhelpful by not telling which file operation failed, but can also be a lie. + // File.Move isn't great either. + // So, we will split this into multiple parts and subparts. + + // 1) Handle backup file, if necessary + // a) Delete the old backup, if it exists. We check existence here to avoid DirectoryNotFound errors. + // If this fails, return that failure. + // If it succeeded but the file somehow still exists, report that error. + // b) Ensure the target directory exists. + // Rename the original file, and similarly report any errors. + // 2) Handle renaming of temp file, the same way renaming of original for backup was done. + + if (Paths.Backup != null) + { + try { DeleteIfExists(Paths.Backup); } + catch (Exception ex) { return new(FileWriteEnum.FailedToDeleteOldBackup, Paths, ex); } + if (!TryWaitForFileToVanish(Paths.Backup)) return new(FileWriteEnum.FailedToDeleteOldBackup, Paths, new Exception("The file was supposedly deleted but is still there.")); + + try { MoveFile(Paths.Final, Paths.Backup); } + catch (Exception ex) { return new(FileWriteEnum.FailedToMakeBackup, Paths, ex); } + if (!TryWaitForFileToVanish(Paths.Final)) return new(FileWriteEnum.FailedToMakeBackup, Paths, new Exception("The file was supposedly moved but is still in the orignal location.")); + } + + try { DeleteIfExists(Paths.Final); } + catch (Exception ex) { return new(FileWriteEnum.FailedToDeleteOldFile, Paths, ex); } + if (!TryWaitForFileToVanish(Paths.Final)) return new(FileWriteEnum.FailedToDeleteOldFile, Paths, new Exception("The file was supposedly deleted but is still there.")); + + try { MoveFile(Paths.Temp, Paths.Final); } + catch (Exception ex) { return new(FileWriteEnum.FailedToRename, Paths, ex); } + if (!TryWaitForFileToVanish(Paths.Temp)) return new(FileWriteEnum.FailedToRename, Paths, new Exception("The file was supposedly moved but is still in the orignal location.")); + + return new(FileWriteEnum.Success, Paths, null); + } + + /// + /// Closes and deletes the file. Use if there was an error while writing. + /// Do not call after this. + /// + public void Abort() + { + if (_dispoed) throw new ObjectDisposedException("Cannot use a disposed file stream."); + _finished = true; + Dispose(); + + try + { + // Delete because the file is almost certainly useless and just clutter. + File.Delete(Paths.Temp); + } + catch { /* eat? this is probably not very important */ } + } + + private bool _dispoed; + public void Dispose() + { + if (_dispoed) return; + _dispoed = true; + + _stream!.Dispose(); + _stream = null; + + // The caller should call CloseAndDispose and handle potential failure. + Debug.Assert(_finished, $"{nameof(FileWriteResult)} should not be disposed before calling {nameof(CloseAndDispose)}"); + } + + + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + + private static void MoveFile(string source, string destination) + { + FileInfo file = new(destination); + file.Directory.Create(); + File.Move(source, destination); + } + + /// + /// Supposedly it is possible for File.Delete to return before the file has actually been deleted. + /// And File.Move too, I guess. + /// + private static bool TryWaitForFileToVanish(string path) + { + for (var i = 25; i != 0; i--) + { + if (!File.Exists(path)) return true; + Thread.Sleep(10); + } + return false; + } + } +} diff --git a/src/BizHawk.Client.Common/FrameworkZipWriter.cs b/src/BizHawk.Client.Common/FrameworkZipWriter.cs index e57bcfef63..a8c9fcd652 100644 --- a/src/BizHawk.Client.Common/FrameworkZipWriter.cs +++ b/src/BizHawk.Client.Common/FrameworkZipWriter.cs @@ -1,3 +1,5 @@ +#nullable enable + using System.IO; using System.IO.Compression; @@ -7,18 +9,19 @@ namespace BizHawk.Client.Common { public class FrameworkZipWriter : IZipWriter { - private ZipArchive _archive; + private ZipArchive? _archive; - private FileStream _fs; + private FileWriter? _fs; - private Zstd _zstd; + private Zstd? _zstd; private readonly CompressionLevel _level; private readonly int _zstdCompressionLevel; - public FrameworkZipWriter(string path, int compressionLevel) + private Exception? _writeException = null; + private bool _disposed; + + private FrameworkZipWriter(int compressionLevel) { - _fs = new(path, FileMode.Create, FileAccess.Write); - _archive = new(_fs, ZipArchiveMode.Create, leaveOpen: true); if (compressionLevel == 0) _level = CompressionLevel.NoCompression; else if (compressionLevel < 5) @@ -32,38 +35,95 @@ namespace BizHawk.Client.Common _zstdCompressionLevel = compressionLevel * 2 + 1; } - public void WriteItem(string name, Action callback, bool zstdCompress) + public static FileWriteResult Create(string path, int compressionLevel) { - // don't compress with deflate if we're already compressing with zstd - // this won't produce meaningful compression, and would just be a timesink - using var stream = _archive.CreateEntry(name, zstdCompress ? CompressionLevel.NoCompression : _level).Open(); + FileWriteResult fs = FileWriter.Create(path); + if (fs.IsError) return new(fs); - if (zstdCompress) + FrameworkZipWriter ret = new(compressionLevel); + ret._fs = fs.Value!; + ret._archive = new(ret._fs.Stream, ZipArchiveMode.Create, leaveOpen: true); + + return fs.Convert(ret); + } + + public FileWriteResult CloseAndDispose(string? backupPath = null) + { + if (_archive == null || _fs == null) throw new ObjectDisposedException("Cannot use disposed ZipWriter."); + + // We actually have to do this here since it has to be done before the file stream is closed. + _archive.Dispose(); + _archive = null; + + FileWriteResult ret; + if (_writeException == null) { - using var z = _zstd.CreateZstdCompressionStream(stream, _zstdCompressionLevel); - callback(z); + ret = _fs.CloseAndDispose(backupPath); } else { - callback(stream); + ret = new(FileWriteEnum.FailedDuringWrite, _fs.Paths, _writeException); + _fs.Abort(); + } + + // And since we have to close stuff, there's really no point in not disposing here. + Dispose(); + return ret; + } + + public void Abort() + { + if (_archive == null || _fs == null) throw new ObjectDisposedException("Cannot use disposed ZipWriter."); + + _archive.Dispose(); + _archive = null; + + _fs.Abort(); + + Dispose(); + } + + public void WriteItem(string name, Action callback, bool zstdCompress) + { + if (_archive == null || _zstd == null) throw new ObjectDisposedException("Cannot use disposed ZipWriter."); + if (_writeException != null) return; + + try + { + // don't compress with deflate if we're already compressing with zstd + // this won't produce meaningful compression, and would just be a timesink + using var stream = _archive.CreateEntry(name, zstdCompress ? CompressionLevel.NoCompression : _level).Open(); + + if (zstdCompress) + { + using var z = _zstd.CreateZstdCompressionStream(stream, _zstdCompressionLevel); + callback(z); + } + else + { + callback(stream); + } + } + catch (Exception ex) + { + _writeException = ex; + // We aren't returning the failure until closing. Should we? I don't want to refactor that much calling code without a good reason. } } public void Dispose() { - if (_archive != null) - { - _archive.Dispose(); - _archive = null; - } - _fs?.Flush(flushToDisk: true); - _fs?.Dispose(); + if (_disposed) return; + _disposed = true; + + // _archive should already be disposed by CloseAndDispose, but just in case + _archive?.Dispose(); + _archive = null; + _zstd!.Dispose(); + _zstd = null; + + _fs!.Dispose(); _fs = null; - if (_zstd != null) - { - _zstd.Dispose(); - _zstd = null; - } } } } diff --git a/src/BizHawk.Client.Common/IMainFormForApi.cs b/src/BizHawk.Client.Common/IMainFormForApi.cs index 790179a330..e9580cf3cc 100644 --- a/src/BizHawk.Client.Common/IMainFormForApi.cs +++ b/src/BizHawk.Client.Common/IMainFormForApi.cs @@ -48,7 +48,7 @@ namespace BizHawk.Client.Common void EnableRewind(bool enabled); /// only referenced from EmuClientApi - bool FlushSaveRAM(bool autosave = false); + FileWriteResult FlushSaveRAM(bool autosave = false); /// only referenced from EmuClientApi void FrameAdvance(bool discardApiHawkSurfaces = true); @@ -89,10 +89,16 @@ namespace BizHawk.Client.Common /// only referenced from bool RestartMovie(); - /// only referenced from - void SaveQuickSave(int slot, bool suppressOSD = false, bool fromLua = false); + FileWriteResult SaveQuickSave(int slot, bool suppressOSD = false); - void SaveState(string path, string userFriendlyStateName, bool fromLua = false, bool suppressOSD = false); + /// + /// Creates a savestate and writes it to a file. + /// + /// The path of the file to write. + /// The name to use for the state on the client's HUD. + /// If true, the client HUD will not show a success message. + /// Returns a value indicating if there was an error and (if there was) why. + FileWriteResult SaveState(string path, string userFriendlyStateName, bool suppressOSD = false); void SeekFrameAdvance(); diff --git a/src/BizHawk.Client.Common/IZipWriter.cs b/src/BizHawk.Client.Common/IZipWriter.cs index fa1de4e651..dc67fde2f5 100644 --- a/src/BizHawk.Client.Common/IZipWriter.cs +++ b/src/BizHawk.Client.Common/IZipWriter.cs @@ -5,5 +5,18 @@ namespace BizHawk.Client.Common public interface IZipWriter : IDisposable { void WriteItem(string name, Action callback, bool zstdCompress); + + /// + /// This method must be called after writing has finished and must not be called twice. + /// Dispose will be called regardless of the result. + /// + /// If not null, renames the original file to this path. + FileWriteResult CloseAndDispose(string backupPath = null); + + /// + /// Closes and deletes the file. Use if there was an error while writing. + /// Do not call after this. + /// + void Abort(); } } diff --git a/src/BizHawk.Client.Common/SaveSlotManager.cs b/src/BizHawk.Client.Common/SaveSlotManager.cs index ea95e684f8..774e982103 100644 --- a/src/BizHawk.Client.Common/SaveSlotManager.cs +++ b/src/BizHawk.Client.Common/SaveSlotManager.cs @@ -69,31 +69,65 @@ namespace BizHawk.Client.Common public bool IsRedo(IMovie movie, int slot) => slot is >= 1 and <= 10 && movie is not ITasMovie && _redo[slot - 1]; - public void SwapBackupSavestate(IMovie movie, string path, int currentSlot) + /// + /// Takes the .state and .bak files and swaps them + /// + public FileWriteResult SwapBackupSavestate(IMovie movie, string path, int currentSlot) { - // Takes the .state and .bak files and swaps them + string backupPath = $"{path}.bak"; + string tempPath = $"{path}.bak.tmp"; + var state = new FileInfo(path); - var backup = new FileInfo($"{path}.bak"); - var temp = new FileInfo($"{path}.bak.tmp"); + var backup = new FileInfo(backupPath); if (!state.Exists || !backup.Exists) { - return; + return new(); } - if (temp.Exists) + // Delete old temp file if it exists. + try { - temp.Delete(); + if (File.Exists(tempPath)) File.Delete(tempPath); + } + catch (Exception ex) + { + return new(FileWriteEnum.FailedToDeleteGeneric, new(tempPath, ""), ex); } - backup.CopyTo($"{path}.bak.tmp"); - backup.Delete(); - state.CopyTo($"{path}.bak"); - state.Delete(); - temp.CopyTo(path); - temp.Delete(); + // Move backup to temp. + try + { + backup.MoveTo(tempPath); + } + catch (Exception ex) + { + return new(FileWriteEnum.FailedToMoveForSwap, new(tempPath, backupPath), ex); + } + // Move current to backup. + try + { + state.MoveTo(backupPath); + } + catch (Exception ex) + { + // Attempt to restore the backup + try { backup.MoveTo(backupPath); } catch { /* eat? unlikely to fail here */ } + return new(FileWriteEnum.FailedToMoveForSwap, new(backupPath, path), ex); + } + // Move backup to current. + try + { + backup.MoveTo(path); + } + catch (Exception ex) + { + // Should we attempt to restore? Unlikely to fail here since we've already touched all files. + return new(FileWriteEnum.FailedToMoveForSwap, new(path, tempPath), ex); + } ToggleRedo(movie, currentSlot); + return new(); } } } diff --git a/src/BizHawk.Client.Common/config/ConfigService.cs b/src/BizHawk.Client.Common/config/ConfigService.cs index 70286ddedb..357d02ecc4 100644 --- a/src/BizHawk.Client.Common/config/ConfigService.cs +++ b/src/BizHawk.Client.Common/config/ConfigService.cs @@ -104,19 +104,14 @@ namespace BizHawk.Client.Common return config ?? new T(); } - public static void Save(string filepath, object config) + public static FileWriteResult Save(string filepath, object config) { - var file = new FileInfo(filepath); - try + return FileWriter.Write(filepath, (fs) => { - using var writer = file.CreateText(); + using var writer = new StreamWriter(fs); var w = new JsonTextWriter(writer) { Formatting = Formatting.Indented }; Serializer.Serialize(w, config); - } - catch - { - /* Eat it */ - } + }); } // movie 1.0 header stuff diff --git a/src/BizHawk.Client.Common/lua/LuaFileList.cs b/src/BizHawk.Client.Common/lua/LuaFileList.cs index 13784bf6f8..15b2493c0a 100644 --- a/src/BizHawk.Client.Common/lua/LuaFileList.cs +++ b/src/BizHawk.Client.Common/lua/LuaFileList.cs @@ -102,9 +102,8 @@ namespace BizHawk.Client.Common return true; } - public void Save(string path) + public FileWriteResult Save(string path) { - using var sw = new StreamWriter(path); var sb = new StringBuilder(); var saveDirectory = Path.GetDirectoryName(Path.GetFullPath(path)); foreach (var file in this) @@ -123,10 +122,19 @@ namespace BizHawk.Client.Common } } - sw.Write(sb.ToString()); + FileWriteResult result = FileWriter.Write(path, (fs) => + { + using var sw = new StreamWriter(fs); + sw.Write(sb.ToString()); + }); - Filename = path; - Changes = false; + if (!result.IsError) + { + Filename = path; + Changes = false; + } + + return result; } } } diff --git a/src/BizHawk.Client.Common/movie/MovieConversionExtensions.cs b/src/BizHawk.Client.Common/movie/MovieConversionExtensions.cs index 945a216c7a..c04a1c57c0 100644 --- a/src/BizHawk.Client.Common/movie/MovieConversionExtensions.cs +++ b/src/BizHawk.Client.Common/movie/MovieConversionExtensions.cs @@ -71,7 +71,7 @@ namespace BizHawk.Client.Common return bk2; } - public static ITasMovie ConvertToSavestateAnchoredMovie(this ITasMovie old, int frame, byte[] savestate) + public static FileWriteResult ConvertToSavestateAnchoredMovie(this ITasMovie old, int frame, byte[] savestate) { string newFilename = ConvertFileNameToTasMovie(old.Filename); @@ -115,11 +115,11 @@ namespace BizHawk.Client.Common } } - tas.Save(); - return tas; + FileWriteResult saveResult = tas.Save(); + return saveResult.Convert(tas); } - public static ITasMovie ConvertToSaveRamAnchoredMovie(this ITasMovie old, byte[] saveRam) + public static FileWriteResult ConvertToSaveRamAnchoredMovie(this ITasMovie old, byte[] saveRam) { string newFilename = ConvertFileNameToTasMovie(old.Filename); @@ -133,7 +133,6 @@ namespace BizHawk.Client.Common foreach (var (k, v) in old.HeaderEntries) tas.HeaderEntries[k] = v; - tas.StartsFromSaveRam = true; tas.SyncSettingsJson = old.SyncSettingsJson; foreach (string comment in old.Comments) @@ -146,8 +145,8 @@ namespace BizHawk.Client.Common tas.Subtitles.Add(sub); } - tas.Save(); - return tas; + FileWriteResult saveResult = tas.Save(); + return saveResult.Convert(tas); } #pragma warning disable RCS1224 // private but for unit test diff --git a/src/BizHawk.Client.Common/movie/MovieSession.cs b/src/BizHawk.Client.Common/movie/MovieSession.cs index eaaeef1fdc..52055e4ce9 100644 --- a/src/BizHawk.Client.Common/movie/MovieSession.cs +++ b/src/BizHawk.Client.Common/movie/MovieSession.cs @@ -244,8 +244,9 @@ namespace BizHawk.Client.Common public void AbortQueuedMovie() => _queuedMovie = null; - public void StopMovie(bool saveChanges = true) + public FileWriteResult StopMovie(bool saveChanges = true) { + FileWriteResult/*?*/ result = null; if (Movie.IsActive()) { var message = "Movie "; @@ -262,8 +263,17 @@ namespace BizHawk.Client.Common if (saveChanges && Movie.Changes) { - Movie.Save(); - Output($"{Path.GetFileName(Movie.Filename)} written to disk."); + result = Movie.Save(); + if (result.IsError) + { + Output($"Failed to write {Path.GetFileName(Movie.Filename)} to disk."); + Output(result.UserFriendlyErrorMessage()); + return result; + } + else + { + Output($"{Path.GetFileName(Movie.Filename)} written to disk."); + } } Movie.Stop(); @@ -279,6 +289,8 @@ namespace BizHawk.Client.Common } Movie = null; + + return result ?? new(); } public IMovie Get(string path, bool loadMovie) @@ -373,6 +385,8 @@ namespace BizHawk.Client.Common switch (Settings.MovieEndAction) { case MovieEndAction.Stop: + // Technically this can save the movie, but it'd be weird to be in that situation. + // Do we want that? StopMovie(); break; case MovieEndAction.Record: diff --git a/src/BizHawk.Client.Common/movie/bk2/Bk2Movie.HeaderApi.cs b/src/BizHawk.Client.Common/movie/bk2/Bk2Movie.HeaderApi.cs index afb9e7dda0..d965543a8a 100644 --- a/src/BizHawk.Client.Common/movie/bk2/Bk2Movie.HeaderApi.cs +++ b/src/BizHawk.Client.Common/movie/bk2/Bk2Movie.HeaderApi.cs @@ -49,26 +49,7 @@ namespace BizHawk.Client.Common } } - public bool StartsFromSaveRam - { - // ReSharper disable SimplifyConditionalTernaryExpression - get => Header.TryGetValue(HeaderKeys.StartsFromSaveram, out var s) ? bool.Parse(s) : false; - // ReSharper restore SimplifyConditionalTernaryExpression - set - { - if (value) - { - if (!Header.ContainsKey(HeaderKeys.StartsFromSaveram)) - { - Header.Add(HeaderKeys.StartsFromSaveram, "True"); - } - } - else - { - Header.Remove(HeaderKeys.StartsFromSaveram); - } - } - } + public bool StartsFromSaveRam => SaveRam != null; public override string GameName { diff --git a/src/BizHawk.Client.Common/movie/bk2/Bk2Movie.IO.cs b/src/BizHawk.Client.Common/movie/bk2/Bk2Movie.IO.cs index 99eb6026ac..48e2414392 100644 --- a/src/BizHawk.Client.Common/movie/bk2/Bk2Movie.IO.cs +++ b/src/BizHawk.Client.Common/movie/bk2/Bk2Movie.IO.cs @@ -1,3 +1,5 @@ +#nullable enable + using System.Globalization; using System.IO; @@ -10,25 +12,25 @@ namespace BizHawk.Client.Common { public partial class Bk2Movie { - public void Save() + public FileWriteResult Save() { - Write(Filename); + return Write(Filename); } - public void SaveBackup() + public FileWriteResult SaveBackup() { if (string.IsNullOrWhiteSpace(Filename)) { - return; + return new(); } - var backupName = Filename.InsertBeforeLast('.', insert: $".{DateTime.Now:yyyy-MM-dd HH.mm.ss}", out _); + string backupName = Filename.InsertBeforeLast('.', insert: $".{DateTime.Now:yyyy-MM-dd HH.mm.ss}", out _); backupName = Path.Combine(Session.BackupDirectory, Path.GetFileName(backupName)); - Write(backupName, isBackup: true); + return Write(backupName, isBackup: true); } - protected virtual void Write(string fn, bool isBackup = false) + protected virtual FileWriteResult Write(string fn, bool isBackup = false) { SetCycleValues(); // EmulatorVersion used to store the unchanging original emulator version. @@ -39,13 +41,27 @@ namespace BizHawk.Client.Common Header[HeaderKeys.EmulatorVersion] = VersionInfo.GetEmuVersion(); Directory.CreateDirectory(Path.GetDirectoryName(fn)!); - using var bs = new ZipStateSaver(fn, Session.Settings.MovieCompressionLevel); - AddLumps(bs, isBackup); + var createResult = ZipStateSaver.Create(fn, Session.Settings.MovieCompressionLevel); + if (createResult.IsError) return createResult; - if (!isBackup) + ZipStateSaver saver = createResult.Value!; + try + { + AddLumps(saver, isBackup); + } + catch (Exception ex) + { + saver.Abort(); + return new(FileWriteEnum.FailedDuringWrite, createResult.Paths, ex); + } + + FileWriteResult result = saver.CloseAndDispose(); + if (!isBackup && !result.IsError) { Changes = false; } + + return result; } public void SetCycleValues() //TODO IEmulator should not be an instance prop of movies, it should be passed in to every call (i.e. from MovieService) --yoshi @@ -129,7 +145,7 @@ namespace BizHawk.Client.Common bl.GetLump(BinaryStateLump.SyncSettings, abort: false, tr => { - string line; + string? line; while ((line = tr.ReadLine()) != null) { if (!string.IsNullOrWhiteSpace(line)) diff --git a/src/BizHawk.Client.Common/movie/import/IMovieImport.cs b/src/BizHawk.Client.Common/movie/import/IMovieImport.cs index 91a30f734f..c183d980ec 100644 --- a/src/BizHawk.Client.Common/movie/import/IMovieImport.cs +++ b/src/BizHawk.Client.Common/movie/import/IMovieImport.cs @@ -67,7 +67,11 @@ namespace BizHawk.Client.Common Result.Movie.Hash = hash; } - Result.Movie.Save(); + if (Result.Movie.Save().IsError) + { + Result.Errors.Add($"Could not write the file {newFileName}"); + return Result; + } } return Result; diff --git a/src/BizHawk.Client.Common/movie/interfaces/IMovie.cs b/src/BizHawk.Client.Common/movie/interfaces/IMovie.cs index 8644dcffdd..d7d38d97f0 100644 --- a/src/BizHawk.Client.Common/movie/interfaces/IMovie.cs +++ b/src/BizHawk.Client.Common/movie/interfaces/IMovie.cs @@ -65,19 +65,19 @@ namespace BizHawk.Client.Common byte[] SaveRam { get; set; } bool StartsFromSavestate { get; set; } - bool StartsFromSaveRam { get; set; } + bool StartsFromSaveRam { get; } string LogKey { get; set; } /// /// Forces the creation of a backup file of the current movie state /// - void SaveBackup(); + FileWriteResult SaveBackup(); /// /// Instructs the movie to save the current contents to Filename /// - void Save(); + FileWriteResult Save(); /// updates the and headers from the currently loaded core void SetCycleValues(); diff --git a/src/BizHawk.Client.Common/movie/interfaces/IMovieSession.cs b/src/BizHawk.Client.Common/movie/interfaces/IMovieSession.cs index 908f3b75ed..3ab9a23158 100644 --- a/src/BizHawk.Client.Common/movie/interfaces/IMovieSession.cs +++ b/src/BizHawk.Client.Common/movie/interfaces/IMovieSession.cs @@ -83,7 +83,7 @@ namespace BizHawk.Client.Common /// clears the queued movie void AbortQueuedMovie(); - void StopMovie(bool saveChanges = true); + FileWriteResult StopMovie(bool saveChanges = true); /// /// Create a new (Tas)Movie with the given path as filename. If is true, diff --git a/src/BizHawk.Client.Common/movie/tasproj/TasMovie.cs b/src/BizHawk.Client.Common/movie/tasproj/TasMovie.cs index b0bf0a1268..3c0a5894a3 100644 --- a/src/BizHawk.Client.Common/movie/tasproj/TasMovie.cs +++ b/src/BizHawk.Client.Common/movie/tasproj/TasMovie.cs @@ -207,7 +207,9 @@ namespace BizHawk.Client.Common // We are in record mode so replace the movie log with the one from the savestate if (Session.Settings.EnableBackupMovies && MakeBackup && Log.Count != 0) { - SaveBackup(); + // TODO: This isn't ideal, but making it ideal would mean a big refactor. + FileWriteResult saveResult = SaveBackup(); + if (saveResult.Exception != null) throw saveResult.Exception; MakeBackup = false; } diff --git a/src/BizHawk.Client.Common/savestates/SavestateFile.cs b/src/BizHawk.Client.Common/savestates/SavestateFile.cs index 252a578a59..57d908bc65 100644 --- a/src/BizHawk.Client.Common/savestates/SavestateFile.cs +++ b/src/BizHawk.Client.Common/savestates/SavestateFile.cs @@ -50,14 +50,16 @@ namespace BizHawk.Client.Common _userBag = userBag; } - public void Create(string filename, SaveStateConfig config) + public FileWriteResult Create(string filename, SaveStateConfig config, bool makeBackup) { - // the old method of text savestate save is now gone. - // a text savestate is just like a binary savestate, but with a different core lump - using var bs = new ZipStateSaver(filename, config.CompressionLevelNormal); + FileWriteResult createResult = ZipStateSaver.Create(filename, config.CompressionLevelNormal); + if (createResult.IsError) return createResult; + var bs = createResult.Value!; using (new SimpleTime("Save Core")) { + // the old method of text savestate save is now gone. + // a text savestate is just like a binary savestate, but with a different core lump if (config.Type == SaveStateType.Text) { bs.PutLump(BinaryStateLump.CorestateText, tw => _statable.SaveStateText(tw)); @@ -112,6 +114,9 @@ namespace BizHawk.Client.Common { bs.PutLump(BinaryStateLump.LagLog, tw => ((ITasMovie) _movieSession.Movie).LagLog.Save(tw)); } + + makeBackup = makeBackup && config.MakeBackups; + return bs.CloseAndDispose(makeBackup ? $"{filename}.bak" : null); } public bool Load(string path, IDialogParent dialogParent) diff --git a/src/BizHawk.Client.Common/savestates/ZipStateSaver.cs b/src/BizHawk.Client.Common/savestates/ZipStateSaver.cs index 92b5cbb8aa..127739a3c9 100644 --- a/src/BizHawk.Client.Common/savestates/ZipStateSaver.cs +++ b/src/BizHawk.Client.Common/savestates/ZipStateSaver.cs @@ -1,3 +1,5 @@ +#nullable enable + using System.IO; using BizHawk.Common; @@ -21,9 +23,9 @@ namespace BizHawk.Client.Common sw.WriteLine(VersionInfo.GetEmuVersion()); } - public ZipStateSaver(string path, int compressionLevel) + private ZipStateSaver(FrameworkZipWriter zip) { - _zip = new FrameworkZipWriter(path, compressionLevel); + _zip = zip; // we put these in every zip, so we know where they came from // a bit redundant for movie files given their headers, but w/e @@ -31,6 +33,35 @@ namespace BizHawk.Client.Common PutLump(BinaryStateLump.BizVersion, WriteEmuVersion, false); } + public static FileWriteResult Create(string path, int compressionLevel) + { + FileWriteResult result = FrameworkZipWriter.Create(path, compressionLevel); + if (result.IsError) return new(result); + else return result.Convert(new ZipStateSaver(result.Value!)); + } + + /// + /// This method must be called after writing has finished and must not be called twice. + /// Dispose will be called regardless of the result. + /// + /// If not null, renames the original file to this path. + public FileWriteResult CloseAndDispose(string? backupPath = null) + { + FileWriteResult result = _zip.CloseAndDispose(backupPath); + Dispose(); + return result; + } + + /// + /// Closes and deletes the file. Use if there was an error while writing. + /// Do not call after this. + /// + public void Abort() + { + _zip.Abort(); + Dispose(); + } + public void PutLump(BinaryStateLump lump, Action callback, bool zstdCompress = true) { _zip.WriteItem(lump.WriteName, callback, zstdCompress); diff --git a/src/BizHawk.Client.Common/tools/CheatList.cs b/src/BizHawk.Client.Common/tools/CheatList.cs index a9cca688a9..5de61df7a3 100644 --- a/src/BizHawk.Client.Common/tools/CheatList.cs +++ b/src/BizHawk.Client.Common/tools/CheatList.cs @@ -88,20 +88,10 @@ namespace BizHawk.Client.Common return file.Exists && Load(domains, file.FullName, false); } - public void NewList(string defaultFileName, bool autosave = false) + public void NewList(string defaultFileName) { _defaultFileName = defaultFileName; - if (autosave && _changes && _cheatList.Count is not 0) - { - if (string.IsNullOrEmpty(CurrentFileName)) - { - CurrentFileName = _defaultFileName; - } - - Save(); - } - _cheatList.Clear(); CurrentFileName = ""; Changes = false; @@ -220,7 +210,7 @@ namespace BizHawk.Client.Common public bool IsActive(MemoryDomain domain, long address) => _cheatList.Exists(cheat => !cheat.IsSeparator && cheat.Enabled && cheat.Domain == domain && cheat.Contains(address)); - public void SaveOnClose() + public FileWriteResult SaveOnClose() { if (_config.AutoSaveOnClose) { @@ -231,17 +221,27 @@ namespace BizHawk.Client.Common CurrentFileName = _defaultFileName; } - SaveFile(CurrentFileName); + return SaveFile(CurrentFileName); } else if (_cheatList.Count is 0 && !string.IsNullOrWhiteSpace(CurrentFileName)) { - File.Delete(CurrentFileName); + try + { + File.Delete(CurrentFileName); + } + catch (Exception ex) + { + return new(FileWriteEnum.FailedToDeleteGeneric, new(CurrentFileName, ""), ex); + } _config.Recent.Remove(CurrentFileName); + return new(); } } + + return new(); } - public bool Save() + public FileWriteResult Save() { if (string.IsNullOrWhiteSpace(CurrentFileName)) { @@ -251,54 +251,51 @@ namespace BizHawk.Client.Common return SaveFile(CurrentFileName); } - public bool SaveFile(string path) + public FileWriteResult SaveFile(string path) { - try + var sb = new StringBuilder(); + + foreach (var cheat in _cheatList) { - new FileInfo(path).Directory?.Create(); - var sb = new StringBuilder(); - - foreach (var cheat in _cheatList) + if (cheat.IsSeparator) { - if (cheat.IsSeparator) - { - sb.AppendLine("----"); - } - else - { - // Set to hex for saving - var tempCheatType = cheat.Type; - - cheat.SetType(WatchDisplayType.Hex); - - sb - .Append(cheat.AddressStr).Append('\t') - .Append(cheat.ValueStr).Append('\t') - .Append(cheat.Compare is null ? "N" : cheat.CompareStr).Append('\t') - .Append(cheat.Domain != null ? cheat.Domain.Name : "").Append('\t') - .Append(cheat.Enabled ? '1' : '0').Append('\t') - .Append(cheat.Name).Append('\t') - .Append(cheat.SizeAsChar).Append('\t') - .Append(cheat.TypeAsChar).Append('\t') - .Append(cheat.BigEndian is true ? '1' : '0').Append('\t') - .Append(cheat.ComparisonType).Append('\t') - .AppendLine(); - - cheat.SetType(tempCheatType); - } + sb.AppendLine("----"); } + else + { + // Set to hex for saving + var tempCheatType = cheat.Type; - File.WriteAllText(path, sb.ToString()); + cheat.SetType(WatchDisplayType.Hex); + sb + .Append(cheat.AddressStr).Append('\t') + .Append(cheat.ValueStr).Append('\t') + .Append(cheat.Compare is null ? "N" : cheat.CompareStr).Append('\t') + .Append(cheat.Domain != null ? cheat.Domain.Name : "").Append('\t') + .Append(cheat.Enabled ? '1' : '0').Append('\t') + .Append(cheat.Name).Append('\t') + .Append(cheat.SizeAsChar).Append('\t') + .Append(cheat.TypeAsChar).Append('\t') + .Append(cheat.BigEndian is true ? '1' : '0').Append('\t') + .Append(cheat.ComparisonType).Append('\t') + .AppendLine(); + + cheat.SetType(tempCheatType); + } + } + FileWriteResult result = FileWriter.Write(path, (fs) => + { + StreamWriter sw = new(fs); + sw.Write(sb.ToString()); + }); + if (!result.IsError) + { CurrentFileName = path; _config.Recent.Add(CurrentFileName); Changes = false; - return true; - } - catch - { - return false; } + return result; } public bool Load(IMemoryDomains domains, string path, bool append) diff --git a/src/BizHawk.Client.Common/tools/Watch/WatchList/WatchList.cs b/src/BizHawk.Client.Common/tools/Watch/WatchList/WatchList.cs index 0a3e12d913..eb5afeb1de 100644 --- a/src/BizHawk.Client.Common/tools/Watch/WatchList/WatchList.cs +++ b/src/BizHawk.Client.Common/tools/Watch/WatchList/WatchList.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -338,39 +339,37 @@ namespace BizHawk.Client.Common } } - public bool Save() + public FileWriteResult Save() { if (string.IsNullOrWhiteSpace(CurrentFileName)) { - return false; + return new(); } - using (var sw = new StreamWriter(CurrentFileName)) + var sb = new StringBuilder(); + sb.Append("SystemID ").AppendLine(_systemId); + + foreach (var watch in _watchList) { - var sb = new StringBuilder(); - sb.Append("SystemID ").AppendLine(_systemId); - - foreach (var watch in _watchList) - { - sb.AppendLine(watch.ToString()); - } - - sw.WriteLine(sb.ToString()); + sb.AppendLine(watch.ToString()); } - Changes = false; - return true; + FileWriteResult result = FileWriter.Write(CurrentFileName, (fs) => + { + using var sw = new StreamWriter(fs); + sw.WriteLine(sb.ToString()); + }); + + if (!result.IsError) Changes = false; + return result; } - public bool SaveAs(FileInfo file) + public FileWriteResult SaveAs(FileInfo file) { - if (file != null) - { - CurrentFileName = file.FullName; - return Save(); - } + Debug.Assert(file != null, "Cannot save as without a file name."); - return false; + CurrentFileName = file.FullName; + return Save(); } private bool LoadFile(string path, bool append) diff --git a/src/BizHawk.Client.EmuHawk/IMainFormForTools.cs b/src/BizHawk.Client.EmuHawk/IMainFormForTools.cs index 3a2649872b..1e5268bf1d 100644 --- a/src/BizHawk.Client.EmuHawk/IMainFormForTools.cs +++ b/src/BizHawk.Client.EmuHawk/IMainFormForTools.cs @@ -20,9 +20,6 @@ namespace BizHawk.Client.EmuHawk // TODO: remove? or does anything ever need access to the FirmwareManager FirmwareManager FirmwareManager { get; } - /// only referenced from - bool GameIsClosing { get; } - /// only referenced from bool HoldFrameAdvance { get; set; } diff --git a/src/BizHawk.Client.EmuHawk/MainForm.Events.cs b/src/BizHawk.Client.EmuHawk/MainForm.Events.cs index 1d4d25ce0d..c311ce3f7e 100644 --- a/src/BizHawk.Client.EmuHawk/MainForm.Events.cs +++ b/src/BizHawk.Client.EmuHawk/MainForm.Events.cs @@ -270,7 +270,7 @@ namespace BizHawk.Client.EmuHawk } private void QuickSavestateMenuItem_Click(object sender, EventArgs e) - => SaveQuickSave(int.Parse(((ToolStripMenuItem) sender).Text)); + => SaveQuickSaveAndShowError(int.Parse(((ToolStripMenuItem) sender).Text)); private void SaveNamedStateMenuItem_Click(object sender, EventArgs e) => SaveStateAs(); @@ -306,7 +306,7 @@ namespace BizHawk.Client.EmuHawk => SavestateCurrentSlot(); private void SavestateCurrentSlot() - => SaveQuickSave(Config.SaveSlot); + => SaveQuickSaveAndShowError(Config.SaveSlot); private void LoadCurrentSlotMenuItem_Click(object sender, EventArgs e) => LoadstateCurrentSlot(); @@ -316,7 +316,7 @@ namespace BizHawk.Client.EmuHawk private void FlushSaveRAMMenuItem_Click(object sender, EventArgs e) { - FlushSaveRAM(); + ShowMessageIfError(() => FlushSaveRAM(), "Failed to flush saveram!"); } private void ReadonlyMenuItem_Click(object sender, EventArgs e) @@ -976,8 +976,15 @@ namespace BizHawk.Client.EmuHawk private void SaveConfigMenuItem_Click(object sender, EventArgs e) { - SaveConfig(); - AddOnScreenMessage("Saved settings"); + FileWriteResult result = SaveConfig(); + if (result.IsError) + { + this.ErrorMessageBox(result); + } + else + { + AddOnScreenMessage("Saved settings"); + } } private void SaveConfigAsMenuItem_Click(object sender, EventArgs e) @@ -989,8 +996,15 @@ namespace BizHawk.Client.EmuHawk initFileName: file); if (result is not null) { - SaveConfig(result); - AddOnScreenMessage("Copied settings"); + FileWriteResult saveResult = SaveConfig(result); + if (saveResult.IsError) + { + this.ErrorMessageBox(saveResult); + } + else + { + AddOnScreenMessage("Copied settings"); + } } } @@ -1375,8 +1389,15 @@ namespace BizHawk.Client.EmuHawk private void UndoSavestateContextMenuItem_Click(object sender, EventArgs e) { var slot = Config.SaveSlot; - _stateSlots.SwapBackupSavestate(MovieSession.Movie, $"{SaveStatePrefix()}.QuickSave{slot % 10}.State", slot); - AddOnScreenMessage($"Save slot {slot} restored."); + FileWriteResult swapResult = _stateSlots.SwapBackupSavestate(MovieSession.Movie, $"{SaveStatePrefix()}.QuickSave{slot % 10}.State", slot); + if (swapResult.IsError) + { + this.ErrorMessageBox(swapResult, "Failed to swap state files."); + } + else + { + AddOnScreenMessage($"Save slot {slot} restored."); + } } private void ClearSramContextMenuItem_Click(object sender, EventArgs e) @@ -1446,7 +1467,7 @@ namespace BizHawk.Client.EmuHawk if (sender == Slot9StatusButton) slot = 9; if (sender == Slot0StatusButton) slot = 10; - if (e.Button is MouseButtons.Right) SaveQuickSave(slot); + if (e.Button is MouseButtons.Right) SaveQuickSaveAndShowError(slot); else if (e.Button is MouseButtons.Left && HasSlot(slot)) _ = LoadQuickSave(slot); } diff --git a/src/BizHawk.Client.EmuHawk/MainForm.Hotkey.cs b/src/BizHawk.Client.EmuHawk/MainForm.Hotkey.cs index 86c5da113c..2c1001a494 100644 --- a/src/BizHawk.Client.EmuHawk/MainForm.Hotkey.cs +++ b/src/BizHawk.Client.EmuHawk/MainForm.Hotkey.cs @@ -10,7 +10,7 @@ namespace BizHawk.Client.EmuHawk { void SelectAndSaveToSlot(int slot) { - SaveQuickSave(slot); + SaveQuickSaveAndShowError(slot); Config.SaveSlot = slot; UpdateStatusSlots(); } @@ -76,7 +76,7 @@ namespace BizHawk.Client.EmuHawk LoadMostRecentROM(); break; case "Flush SaveRAM": - FlushSaveRAM(); + FlushSaveRAMMenuItem_Click(null, null); break; case "Display FPS": ToggleFps(); diff --git a/src/BizHawk.Client.EmuHawk/MainForm.Movie.cs b/src/BizHawk.Client.EmuHawk/MainForm.Movie.cs index f97519c365..b9e7f04f94 100644 --- a/src/BizHawk.Client.EmuHawk/MainForm.Movie.cs +++ b/src/BizHawk.Client.EmuHawk/MainForm.Movie.cs @@ -125,7 +125,14 @@ namespace BizHawk.Client.EmuHawk } else { - MovieSession.StopMovie(saveChanges); + FileWriteResult saveResult = MovieSession.StopMovie(saveChanges); + if (saveResult.IsError) + { + this.ShowMessageBox( + $"Failed to save movie.\n{saveResult.UserFriendlyErrorMessage()}\n{saveResult.Exception.Message}", + "Error", + EMsgBoxIcon.Error); + } SetMainformMovieInfo(); } } diff --git a/src/BizHawk.Client.EmuHawk/MainForm.cs b/src/BizHawk.Client.EmuHawk/MainForm.cs index 6a8555ef85..512d725f35 100644 --- a/src/BizHawk.Client.EmuHawk/MainForm.cs +++ b/src/BizHawk.Client.EmuHawk/MainForm.cs @@ -26,12 +26,8 @@ using BizHawk.Client.Common; using BizHawk.Emulation.Common; using BizHawk.Emulation.Cores; -using BizHawk.Emulation.Cores.Computers.AppleII; -using BizHawk.Emulation.Cores.Computers.Commodore64; using BizHawk.Emulation.Cores.Computers.DOS; using BizHawk.Emulation.Cores.Consoles.Nintendo.QuickNES; -using BizHawk.Emulation.Cores.Consoles.SNK; -using BizHawk.Emulation.Cores.Nintendo.GBA; using BizHawk.Emulation.Cores.Nintendo.NES; using BizHawk.Emulation.Cores.Nintendo.SNES; @@ -867,20 +863,23 @@ namespace BizHawk.Client.EmuHawk closingArgs.Cancel = true; return; } + // StopAv would be handled in CloseGame, but since we've asked the user about it, best to handle it now. StopAv(); } - if (!Tools.AskSave()) + TryAgainResult configSaveResult = this.DoWithTryAgainBox(() => SaveConfig(), "Failed to save config file."); + if (configSaveResult == TryAgainResult.Canceled) { closingArgs.Cancel = true; return; } + if (!CloseGame()) + { + closingArgs.Cancel = true; + return; + } Tools.Close(); - MovieSession.StopMovie(); - // zero 03-nov-2015 - close game after other steps. tools might need to unhook themselves from a core. - CloseGame(); - SaveConfig(); } private readonly bool _suppressSyncSettingsWarning; @@ -1846,6 +1845,7 @@ namespace BizHawk.Client.EmuHawk // countdown for saveram autoflushing public int AutoFlushSaveRamIn { get; set; } + private bool AutoFlushSaveRamFailed; private void SetStatusBar() { @@ -2046,42 +2046,30 @@ namespace BizHawk.Client.EmuHawk return; } + byte[] sram = null; try { - byte[] sram; - - // some cores might not know how big the saveram ought to be, so just send it the whole file - if (Emulator is AppleII or C64 or DOSBox or MGBAHawk or NeoGeoPort or NES { BoardName: "FDS" }) - { - sram = File.ReadAllBytes(saveramToLoad.FullName); - } - else - { - var oldRam = Emulator.AsSaveRam().CloneSaveRam(); - if (oldRam is null) - { - // we have a SaveRAM file, but the current core does not have save ram. - // just skip loading the saveram file in that case - return; - } - - // why do we silently truncate\pad here instead of warning\erroring? - sram = new byte[oldRam.Length]; - using var fs = saveramToLoad.OpenRead(); - _ = fs.Read(sram, 0, sram.Length); - } - - Emulator.AsSaveRam().StoreSaveRam(sram); + sram = File.ReadAllBytes(saveramToLoad.FullName); } - catch (IOException e) + catch (Exception e) { - AddOnScreenMessage("An error occurred while loading Sram"); + AddOnScreenMessage("An IO error occurred while loading Sram"); + Console.Error.WriteLine(e); + } + + try + { + if (sram != null) Emulator.AsSaveRam().StoreSaveRam(sram); + } + catch (Exception e) + { + AddOnScreenMessage("The core threw an error while loading Sram"); Console.Error.WriteLine(e); } } } - public bool FlushSaveRAM(bool autosave = false) + public FileWriteResult FlushSaveRAM(bool autosave = false) { if (Emulator.HasSaveRam()) { @@ -2095,53 +2083,11 @@ namespace BizHawk.Client.EmuHawk path = Config.PathEntries.SaveRamAbsolutePath(Game, MovieSession.Movie); } - var file = new FileInfo(path); - var newPath = $"{path}.new"; - var newFile = new FileInfo(newPath); - var backupPath = $"{path}.bak"; - var backupFile = new FileInfo(backupPath); - - var saveram = Emulator.AsSaveRam().CloneSaveRam(); - if (saveram == null) - return true; - - try - { - Directory.CreateDirectory(file.DirectoryName!); - using (var fs = File.Create(newPath)) - { - fs.Write(saveram, 0, saveram.Length); - fs.Flush(flushToDisk: true); - } - - if (file.Exists) - { - if (Config.BackupSaveram) - { - if (backupFile.Exists) - { - backupFile.Delete(); - } - - file.MoveTo(backupPath); - } - else - { - file.Delete(); - } - } - - newFile.MoveTo(path); - } - catch (IOException e) - { - AddOnScreenMessage("Failed to flush saveram!"); - Console.Error.WriteLine(e); - return false; - } + var saveram = Emulator.AsSaveRam().CloneSaveRam()!; + return FileWriter.Write(path, saveram, $"{path}.bak"); } - return true; + return new(); } private void RewireSound() @@ -2255,7 +2201,7 @@ namespace BizHawk.Client.EmuHawk if (!LoadRom(romPath, new LoadRomArgs(ioa), out var failureIsFromAskSave)) { - if (failureIsFromAskSave) AddOnScreenMessage("ROM loading cancelled; a tool had unsaved changes"); + if (failureIsFromAskSave) AddOnScreenMessage("ROM loading cancelled due to unsaved changes"); else if (ioa is OpenAdvanced_LibretroNoGame || File.Exists(romPath)) AddOnScreenMessage("ROM loading failed"); else Config.RecentRoms.HandleLoadError(this, romPath, rom); } @@ -2584,7 +2530,7 @@ namespace BizHawk.Client.EmuHawk public SettingsAdapter GetSettingsAdapterForLoadedCoreUntyped() => new(Emulator, static () => true, HandlePutCoreSettings, MayPutCoreSyncSettings, HandlePutCoreSyncSettings); - private void SaveConfig(string path = "") + private FileWriteResult SaveConfig(string path = "") { if (Config.SaveWindowPosition) { @@ -2610,7 +2556,7 @@ namespace BizHawk.Client.EmuHawk } CommitCoreSettingsToConfig(); - ConfigService.Save(path, Config); + return ConfigService.Save(path, Config); } private void ToggleFps() @@ -2831,8 +2777,16 @@ namespace BizHawk.Client.EmuHawk { if (MovieSession.Movie.IsActive()) { - MovieSession.Movie.Save(); - AddOnScreenMessage($"{MovieSession.Movie.Filename} saved."); + FileWriteResult result = MovieSession.Movie.Save(); + if (result.IsError) + { + AddOnScreenMessage($"Failed to save {MovieSession.Movie.Filename}."); + AddOnScreenMessage(result.UserFriendlyErrorMessage()); + } + else + { + AddOnScreenMessage($"{MovieSession.Movie.Filename} saved."); + } } } @@ -3192,8 +3146,22 @@ namespace BizHawk.Client.EmuHawk AutoFlushSaveRamIn--; if (AutoFlushSaveRamIn <= 0) { - FlushSaveRAM(true); - AutoFlushSaveRamIn = Config.FlushSaveRamFrames; + FileWriteResult result = FlushSaveRAM(true); + if (result.IsError) + { + // For autosave, allow one failure before bothering the user. + if (AutoFlushSaveRamFailed) + { + this.ErrorMessageBox(result, "Failed to flush saveram!"); + } + AutoFlushSaveRamFailed = true; + AutoFlushSaveRamIn = Math.Min(600, Config.FlushSaveRamFrames); + } + else + { + AutoFlushSaveRamFailed = false; + AutoFlushSaveRamIn = Config.FlushSaveRamFrames; + } } } // why not skip audio if the user doesn't want sound @@ -3747,6 +3715,12 @@ namespace BizHawk.Client.EmuHawk private bool LoadRomInternal(string path, LoadRomArgs args, out bool failureIsFromAskSave) { failureIsFromAskSave = false; + if (!CloseGame()) + { + failureIsFromAskSave = true; + return false; + } + if (path == null) throw new ArgumentNullException(nameof(path)); if (args == null) @@ -3774,12 +3748,6 @@ namespace BizHawk.Client.EmuHawk // it is then up to the core itself to override its own local DeterministicEmulation setting bool deterministic = args.Deterministic ?? MovieSession.NewMovieQueued; - if (!Tools.AskSave()) - { - failureIsFromAskSave = true; - return false; - } - var loader = new RomLoader(Config, this) { ChooseArchive = LoadArchiveChooser, @@ -3794,12 +3762,6 @@ namespace BizHawk.Client.EmuHawk loader.OnLoadSettings += CoreSettings; loader.OnLoadSyncSettings += CoreSyncSettings; - // this also happens in CloseGame(). But it needs to happen here since if we're restarting with the same core, - // any settings changes that we made need to make it back to config before we try to instantiate that core with - // the new settings objects - CommitCoreSettingsToConfig(); // adelikat: I Think by reordering things, this isn't necessary anymore - CloseGame(); - var nextComm = CreateCoreComm(); IOpenAdvanced ioa = args.OpenAdvanced; @@ -3977,7 +3939,7 @@ namespace BizHawk.Client.EmuHawk if (previousRom != CurrentlyOpenRom) { - CheatList.NewList(Tools.GenerateDefaultCheatFilename(), autosave: true); + CheatList.NewList(Tools.GenerateDefaultCheatFilename()); if (Config.Cheats.LoadFileByGame && Emulator.HasMemoryDomains()) { if (CheatList.AttemptToLoadCheatFile(Emulator.AsMemoryDomains())) @@ -3994,7 +3956,7 @@ namespace BizHawk.Client.EmuHawk } else { - CheatList.NewList(Tools.GenerateDefaultCheatFilename(), autosave: true); + CheatList.NewList(Tools.GenerateDefaultCheatFilename()); } } @@ -4031,7 +3993,7 @@ namespace BizHawk.Client.EmuHawk DisplayManager.UpdateGlobals(Config, Emulator); DisplayManager.Blank(); ExtToolManager.BuildToolStrip(); - CheatList.NewList("", autosave: true); + CheatList.NewList(""); OnRomChanged(); return false; } @@ -4084,74 +4046,96 @@ namespace BizHawk.Client.EmuHawk } } - // whats the difference between these two methods?? - // its very tricky. rename to be more clear or combine them. - // This gets called whenever a core related thing is changed. - // Like reboot core. - private void CloseGame(bool clearSram = false) + /// + /// This closes the game but does not set things up for using the client with the new null emulator. + /// This method should only be called (outside of ) if the caller is about to load a new game with no user interaction between close and load. + /// + /// True if the game was closed. False if the user cancelled due to unsaved changes. + private bool CloseGame(bool clearSram = false) { - GameIsClosing = true; + CommitCoreSettingsToConfig(); // Must happen before stopping the movie, since it checks for active movie. + + if (!Tools.AskSave()) + { + return false; + } + // There is a cheats tool, but cheats can be active while the "cheats tool" is not. And have auto-save option. + TryAgainResult cheatSaveResult = this.DoWithTryAgainBox(CheatList.SaveOnClose, "Failed to save cheats."); + if (cheatSaveResult == TryAgainResult.Canceled) return false; + + // If TAStudio is open, we already asked about saving the movie. + if (!Tools.IsLoaded()) + { + TryAgainResult saveMovieResult = this.DoWithTryAgainBox(() => MovieSession.StopMovie(), "Failed to save movie."); + if (saveMovieResult == TryAgainResult.Canceled) return false; + } + if (clearSram) { var path = Config.PathEntries.SaveRamAbsolutePath(Game, MovieSession.Movie); if (File.Exists(path)) { - File.Delete(path); - AddOnScreenMessage("SRAM cleared."); + TryAgainResult clearResult = this.DoWithTryAgainBox(() => { + try + { + File.Delete(path); + AddOnScreenMessage("SRAM cleared."); + return new(); + } + catch (Exception ex) + { + return new(FileWriteEnum.FailedToDeleteGeneric, new(path, ""), ex); + } + }, "Failed to clear SRAM."); + if (clearResult == TryAgainResult.Canceled) + { + return false; + } } } else if (Emulator.HasSaveRam()) { - while (true) - { - if (FlushSaveRAM()) break; - - var result = ShowMessageBox3( - owner: this, - "Failed flushing the game's Save RAM to your disk.\n" + - "Do you want to try again?", - "IOError while writing SaveRAM", - EMsgBoxIcon.Error); - - if (result is false) break; - if (result is null) return; - } + TryAgainResult flushResult = this.DoWithTryAgainBox( + () => FlushSaveRAM(), + "Failed flushing the game's Save RAM to your disk."); + if (flushResult == TryAgainResult.Canceled) return false; } + TryAgainResult stateSaveResult = this.DoWithTryAgainBox(AutoSaveStateIfConfigured, "Failed to auto-save state."); + if (stateSaveResult == TryAgainResult.Canceled) return false; + StopAv(); - AutoSaveStateIfConfigured(); - CommitCoreSettingsToConfig(); DisableRewind(); - if (MovieSession.Movie.IsActive()) // Note: this must be called after CommitCoreSettingsToConfig() - { - StopMovie(); - } - RA?.Stop(); - CheatList.SaveOnClose(); Emulator.Dispose(); Emulator = new NullEmulator(); Game = GameInfo.NullInstance; InputManager.SyncControls(Emulator, MovieSession, Config); RewireSound(); RebootStatusBarIcon.Visible = false; - GameIsClosing = false; + + return true; } - private void AutoSaveStateIfConfigured() + private FileWriteResult AutoSaveStateIfConfigured() { - if (Config.AutoSaveLastSaveSlot && Emulator.HasSavestates()) SavestateCurrentSlot(); + if (Config.AutoSaveLastSaveSlot && Emulator.HasSavestates()) + { + return SaveQuickSave(Config.SaveSlot); + } + + return new(); } - public bool GameIsClosing { get; private set; } // Lets tools make better decisions when being called by CloseGame - + /// + /// This closes the current ROM, closes tools that require emulator services, and sets things up for the user to interact with the client having no loaded ROM. + /// + /// True if SRAM should be deleted instead of saved. public void CloseRom(bool clearSram = false) { - // This gets called after Close Game gets called. - // Tested with NESHawk and SMB3 (U) if (Tools.AskSave()) { CloseGame(clearSram); @@ -4161,7 +4145,7 @@ namespace BizHawk.Client.EmuHawk PauseOnFrame = null; CurrentlyOpenRom = null; CurrentlyOpenRomArgs = null; - CheatList.NewList("", autosave: true); + CheatList.NewList(""); OnRomChanged(); } } @@ -4308,29 +4292,53 @@ namespace BizHawk.Client.EmuHawk return LoadState(path: path, userFriendlyStateName: quickSlotName, suppressOSD: suppressOSD); } - public void SaveState(string path, string userFriendlyStateName, bool fromLua = false, bool suppressOSD = false) + private FileWriteResult SaveStateInternal(string path, string userFriendlyStateName, bool suppressOSD, bool isQuickSave) { if (!Emulator.HasSavestates()) { - return; + return new(FileWriteEnum.Aborted, new("", ""), new UnlessUsingApiException("The current emulator does not support savestates.")); + } + + if (isQuickSave) + { + var handled = false; + if (QuicksaveSave is not null) + { + BeforeQuickSaveEventArgs args = new(userFriendlyStateName); + QuicksaveSave(this, args); + handled = args.Handled; + } + if (handled) + { + // I suppose this is a success? But we have no path. + return new(); + } } if (ToolControllingSavestates is { } tool) { - tool.SaveState(); - return; + if (isQuickSave) tool.SaveQuickSave(SlotToInt(userFriendlyStateName)); + else tool.SaveState(); + // assume success by the tool: state was created, but not as a file. So no path. + return new(); } if (MovieSession.Movie.IsActive() && Emulator.Frame > MovieSession.Movie.FrameCount) { - AddOnScreenMessage("Cannot savestate after movie end!"); - return; + const string errmsg = "Cannot savestate after movie end!"; + AddOnScreenMessage(errmsg); + // Failed to create state due to limitations of our movie handling code. + return new(FileWriteEnum.Aborted, new("", ""), new UnlessUsingApiException(errmsg)); } - try + FileWriteResult result = new SavestateFile(Emulator, MovieSession, MovieSession.UserBag) + .Create(path, Config.Savestates, isQuickSave); + if (result.IsError) + { + AddOnScreenMessage($"Unable to save state {path}"); + } + else { - new SavestateFile(Emulator, MovieSession, MovieSession.UserBag).Create(path, Config.Savestates); - if (SavestateSaved is not null) { StateSavedEventArgs args = new(userFriendlyStateName); @@ -4338,63 +4346,42 @@ namespace BizHawk.Client.EmuHawk } RA?.OnSaveState(path); + if (Tools.Has()) + { + Tools.LuaConsole.LuaImp.CallSaveStateEvent(userFriendlyStateName); + } + if (!suppressOSD) { AddOnScreenMessage($"Saved state: {userFriendlyStateName}"); } } - catch (IOException) - { - AddOnScreenMessage($"Unable to save state {path}"); - } - if (!fromLua) - { - UpdateStatusSlots(); - } + UpdateStatusSlots(); + + return result; + } + + public FileWriteResult SaveState(string path, string userFriendlyStateName, bool suppressOSD = false) + { + return SaveStateInternal(path, userFriendlyStateName, suppressOSD, false); } // TODO: should backup logic be stuffed in into Client.Common.SaveStateManager? - public void SaveQuickSave(int slot, bool suppressOSD = false, bool fromLua = false) + public FileWriteResult SaveQuickSave(int slot, bool suppressOSD = false) { - if (!Emulator.HasSavestates()) - { - return; - } var quickSlotName = $"QuickSave{slot % 10}"; - var handled = false; - if (QuicksaveSave is not null) - { - BeforeQuickSaveEventArgs args = new(quickSlotName); - QuicksaveSave(this, args); - handled = args.Handled; - } - if (handled) - { - return; - } - - if (ToolControllingSavestates is { } tool) - { - tool.SaveQuickSave(SlotToInt(quickSlotName)); - return; - } - var path = $"{SaveStatePrefix()}.{quickSlotName}.State"; - new FileInfo(path).Directory?.Create(); - // Make backup first - if (Config.Savestates.MakeBackups) - { - Util.TryMoveBackupFile(path, $"{path}.bak"); - } + return SaveStateInternal(path, quickSlotName, suppressOSD, true); + } - SaveState(path, quickSlotName, fromLua, suppressOSD); - - if (Tools.Has()) - { - Tools.LuaConsole.LuaImp.CallSaveStateEvent(quickSlotName); - } + /// + /// Runs and displays a pop up message if there was an error. + /// + private void SaveQuickSaveAndShowError(int slot) + { + ShowMessageIfError(() => SaveQuickSave(slot), "Quick save failed."); } public bool EnsureCoreIsAccurate() @@ -4457,12 +4444,17 @@ namespace BizHawk.Client.EmuHawk var path = Config.PathEntries.SaveStateAbsolutePath(Game.System); new FileInfo(path).Directory?.Create(); - var result = this.ShowFileSaveDialog( + var shouldSaveResult = this.ShowFileSaveDialog( fileExt: "State", filter: EmuHawkSaveStatesFSFilterSet, initDir: path, initFileName: $"{SaveStatePrefix()}.QuickSave0.State"); - if (result is not null) SaveState(path: result, userFriendlyStateName: result); + if (shouldSaveResult is not null) + { + ShowMessageIfError( + () => SaveState(path: shouldSaveResult, userFriendlyStateName: shouldSaveResult), + "Unable to save state."); + } if (Tools.IsLoaded()) { @@ -4752,6 +4744,15 @@ namespace BizHawk.Client.EmuHawk _ => null, }; + public void ShowMessageIfError(Func action, string message) + { + FileWriteResult result = action(); + if (result.IsError) + { + this.ErrorMessageBox(result, message); + } + } + public void StartSound() => Sound.StartSound(); public void StopSound() => Sound.StopSound(); diff --git a/src/BizHawk.Client.EmuHawk/config/ControllerConfig.cs b/src/BizHawk.Client.EmuHawk/config/ControllerConfig.cs index cacc90d73b..10d042f178 100644 --- a/src/BizHawk.Client.EmuHawk/config/ControllerConfig.cs +++ b/src/BizHawk.Client.EmuHawk/config/ControllerConfig.cs @@ -475,7 +475,11 @@ namespace BizHawk.Client.EmuHawk SaveToDefaults(cd); - ConfigService.Save(Config.ControlDefaultPath, cd); + FileWriteResult saveResult = ConfigService.Save(Config.ControlDefaultPath, cd); + if (saveResult.IsError) + { + this.ErrorMessageBox(saveResult); + } } } diff --git a/src/BizHawk.Client.EmuHawk/movie/EditCommentsForm.cs b/src/BizHawk.Client.EmuHawk/movie/EditCommentsForm.cs index 0ddb2e07fa..c19de4fa71 100644 --- a/src/BizHawk.Client.EmuHawk/movie/EditCommentsForm.cs +++ b/src/BizHawk.Client.EmuHawk/movie/EditCommentsForm.cs @@ -55,7 +55,8 @@ namespace BizHawk.Client.EmuHawk _movie.Comments.Add(c.Value.ToString()); } - _movie.Save(); + FileWriteResult result = _movie.Save(); + if (result.IsError) throw result.Exception!; } private void Cancel_Click(object sender, EventArgs e) diff --git a/src/BizHawk.Client.EmuHawk/movie/RecordMovie.cs b/src/BizHawk.Client.EmuHawk/movie/RecordMovie.cs index 3f469ce93f..86eeb4c334 100644 --- a/src/BizHawk.Client.EmuHawk/movie/RecordMovie.cs +++ b/src/BizHawk.Client.EmuHawk/movie/RecordMovie.cs @@ -102,7 +102,7 @@ namespace BizHawk.Client.EmuHawk MaxDropDownItems = 32, Size = new(152, 21), }; - if (_emulator.HasSaveRam() && _emulator.AsSaveRam().CloneSaveRam(clearDirty: false) is not null) StartFromCombo.Items.Add(START_FROM_SAVERAM); + if (_emulator.HasSaveRam()) StartFromCombo.Items.Add(START_FROM_SAVERAM); if (_emulator.HasSavestates()) StartFromCombo.Items.Add(START_FROM_SAVESTATE); DefaultAuthorCheckBox = new() @@ -242,7 +242,6 @@ namespace BizHawk.Client.EmuHawk else if (selectedStartFromValue is START_FROM_SAVERAM && _emulator.HasSaveRam()) { var core = _emulator.AsSaveRam(); - movieToRecord.StartsFromSaveRam = true; movieToRecord.SaveRam = core.CloneSaveRam(clearDirty: false); } diff --git a/src/BizHawk.Client.EmuHawk/tools/CDL.cs b/src/BizHawk.Client.EmuHawk/tools/CDL.cs index 5c12ce5572..317a104128 100644 --- a/src/BizHawk.Client.EmuHawk/tools/CDL.cs +++ b/src/BizHawk.Client.EmuHawk/tools/CDL.cs @@ -214,19 +214,23 @@ namespace BizHawk.Client.EmuHawk { if (_currentFilename != null) { - RunSave(); + bool saveResult2 = RunSave(); ShutdownCDL(); - return true; + return saveResult2; } } - // TODO - I don't like this system. It's hard to figure out how to use it. It should be done in multiple passes. - var result = DialogController.ShowMessageBox2("Save changes to CDL session?", "CDL Auto Save", EMsgBoxIcon.Question); - if (!result) + var result = DialogController.ShowMessageBox3("Save changes to CDL session?", "CDL Save", EMsgBoxIcon.Question); + if (result == false) { ShutdownCDL(); return true; } + else if (result == null) + { + ShutdownCDL(); + return false; + } if (string.IsNullOrWhiteSpace(_currentFilename)) { @@ -240,9 +244,9 @@ namespace BizHawk.Client.EmuHawk return false; } - RunSave(); + bool saveResult = RunSave(); ShutdownCDL(); - return true; + return saveResult; } private bool _autoloading; @@ -341,11 +345,20 @@ namespace BizHawk.Client.EmuHawk LoadFile(file.FullName); } - private void RunSave() + /// + /// returns false if the operation was canceled + /// + private bool RunSave() { - _recent.Add(_currentFilename); - using var fs = new FileStream(_currentFilename, FileMode.Create, FileAccess.Write); - _cdl.Save(fs); + TryAgainResult result = this.DoWithTryAgainBox( + () => FileWriter.Write(_currentFilename, _cdl.Save), + "Failed to save CDL session."); + if (result == TryAgainResult.Saved) + { + _recent.Add(_currentFilename); + return true; + } + return result != TryAgainResult.Canceled; } private void SaveMenuItem_Click(object sender, EventArgs e) @@ -386,8 +399,7 @@ namespace BizHawk.Client.EmuHawk return false; SetCurrentFilename(file.FullName); - RunSave(); - return true; + return RunSave(); } private void SaveAsMenuItem_Click(object sender, EventArgs e) diff --git a/src/BizHawk.Client.EmuHawk/tools/Cheats/Cheats.cs b/src/BizHawk.Client.EmuHawk/tools/Cheats/Cheats.cs index 3c8447ea79..0addc928b8 100644 --- a/src/BizHawk.Client.EmuHawk/tools/Cheats/Cheats.cs +++ b/src/BizHawk.Client.EmuHawk/tools/Cheats/Cheats.cs @@ -142,7 +142,7 @@ namespace BizHawk.Client.EmuHawk } } - private bool SaveAs() + private FileWriteResult SaveAs() { var fileName = MainForm.CheatList.CurrentFileName; if (string.IsNullOrWhiteSpace(fileName)) @@ -156,7 +156,8 @@ namespace BizHawk.Client.EmuHawk CheatsFSFilterSet, this); - return file != null && MainForm.CheatList.SaveFile(file.FullName); + if (file == null) return new(); + else return MainForm.CheatList.SaveFile(file.FullName); } private void Cheats_Load(object sender, EventArgs e) @@ -361,7 +362,12 @@ namespace BizHawk.Client.EmuHawk { if (MainForm.CheatList.Changes) { - if (MainForm.CheatList.Save()) + FileWriteResult result = MainForm.CheatList.Save(); + if (result.IsError) + { + this.ErrorMessageBox(result); + } + else { UpdateMessageLabel(saved: true); } @@ -374,7 +380,12 @@ namespace BizHawk.Client.EmuHawk private void SaveAsMenuItem_Click(object sender, EventArgs e) { - if (SaveAs()) + FileWriteResult result = SaveAs(); + if (result.IsError) + { + this.ErrorMessageBox(result); + } + else { UpdateMessageLabel(saved: true); } diff --git a/src/BizHawk.Client.EmuHawk/tools/Lua/LuaConsole.cs b/src/BizHawk.Client.EmuHawk/tools/Lua/LuaConsole.cs index 7fdd0e5cd0..c74284f79f 100644 --- a/src/BizHawk.Client.EmuHawk/tools/Lua/LuaConsole.cs +++ b/src/BizHawk.Client.EmuHawk/tools/Lua/LuaConsole.cs @@ -681,15 +681,25 @@ namespace BizHawk.Client.EmuHawk return result is not null ? new FileInfo(result) : null; } - private void SaveSessionAs() + private FileWriteResult SaveSessionAs() { var file = GetSaveFileFromUser(); if (file != null) { - LuaImp.ScriptList.Save(file.FullName); - Config.RecentLuaSession.Add(file.FullName); - OutputMessages.Text = $"{file.Name} saved."; + FileWriteResult saveResult = LuaImp.ScriptList.Save(file.FullName); + if (saveResult.IsError) + { + this.ErrorMessageBox(saveResult); + OutputMessages.Text = $"Lua session could not be saved to {file.Name}"; + } + else + { + Config.RecentLuaSession.Add(file.FullName); + OutputMessages.Text = $"{file.Name} saved."; + } + return saveResult; } + return new(); } private void LoadSessionFromRecent(string path) @@ -717,7 +727,11 @@ namespace BizHawk.Client.EmuHawk icon: EMsgBoxIcon.Question, text: $"Save {WindowTitleStatic} session?")); if (result is null) return false; - if (result.Value) SaveOrSaveAs(); + if (result.Value) + { + TryAgainResult saveResult = this.DoWithTryAgainBox(SaveOrSaveAs, "Failed to save Lua session."); + return saveResult != TryAgainResult.Canceled; + } else LuaImp.ScriptList.Changes = false; return true; } @@ -732,16 +746,20 @@ namespace BizHawk.Client.EmuHawk } } - private void SaveOrSaveAs() + private FileWriteResult SaveOrSaveAs() { if (!string.IsNullOrWhiteSpace(LuaImp.ScriptList.Filename)) { - LuaImp.ScriptList.Save(LuaImp.ScriptList.Filename); - Config.RecentLuaSession.Add(LuaImp.ScriptList.Filename); + FileWriteResult result = LuaImp.ScriptList.Save(LuaImp.ScriptList.Filename); + if (!result.IsError) + { + Config.RecentLuaSession.Add(LuaImp.ScriptList.Filename); + } + return result; } else { - SaveSessionAs(); + return SaveSessionAs(); } } @@ -784,8 +802,16 @@ namespace BizHawk.Client.EmuHawk { if (LuaImp.ScriptList.Changes) { - SaveOrSaveAs(); - OutputMessages.Text = $"{Path.GetFileName(LuaImp.ScriptList.Filename)} saved."; + FileWriteResult result = SaveOrSaveAs(); + if (result.IsError) + { + this.ErrorMessageBox(result, "Failed to save Lua session."); + OutputMessages.Text = $"Failed to save {Path.GetFileName(LuaImp.ScriptList.Filename)}"; + } + else + { + OutputMessages.Text = $"{Path.GetFileName(LuaImp.ScriptList.Filename)} saved."; + } } } diff --git a/src/BizHawk.Client.EmuHawk/tools/Macros/MacroInput.cs b/src/BizHawk.Client.EmuHawk/tools/Macros/MacroInput.cs index dc865572e0..a3758c05ce 100644 --- a/src/BizHawk.Client.EmuHawk/tools/Macros/MacroInput.cs +++ b/src/BizHawk.Client.EmuHawk/tools/Macros/MacroInput.cs @@ -112,6 +112,7 @@ namespace BizHawk.Client.EmuHawk return true; } + // Intentionally not updating this to use FileWriter because this tool is going to be removed later. foreach (var zone in _unsavedZones) { SaveMacroAs(_zones[zone]); diff --git a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.IControlMainForm.cs b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.IControlMainForm.cs index 272afddc2b..d19e0bb1a7 100644 --- a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.IControlMainForm.cs +++ b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.IControlMainForm.cs @@ -59,13 +59,10 @@ public void StopMovie(bool suppressSave) { - if (!MainForm.GameIsClosing) - { - Activate(); - _suppressAskSave = suppressSave; - StartNewTasMovie(); - _suppressAskSave = false; - } + Activate(); + _suppressAskSave = suppressSave; + StartNewTasMovie(); + _suppressAskSave = false; } public bool WantsToControlRewind { get; private set; } = true; diff --git a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.IToolForm.cs b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.IToolForm.cs index 6c09b29823..16e9ea4302 100644 --- a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.IToolForm.cs +++ b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.IToolForm.cs @@ -155,12 +155,16 @@ namespace BizHawk.Client.EmuHawk StopSeeking(); if (CurrentTasMovie?.Changes is not true) return true; - var result = DialogController.DoWithTempMute(() => this.ModalMessageBox3( + var shouldSaveResult = DialogController.DoWithTempMute(() => this.ModalMessageBox3( caption: "Closing with Unsaved Changes", icon: EMsgBoxIcon.Question, text: $"Save {WindowTitleStatic} project?")); - if (result is null) return false; - if (result.Value) SaveTas(); + if (shouldSaveResult == true) + { + TryAgainResult saveResult = this.DoWithTryAgainBox(() => SaveTas(), "Failed to save movie."); + return saveResult != TryAgainResult.Canceled; + } + if (shouldSaveResult is null) return false; else CurrentTasMovie.ClearChanges(); return true; } diff --git a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.MenuItems.cs b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.MenuItems.cs index f9f037efd5..a19beb7c07 100644 --- a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.MenuItems.cs +++ b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.MenuItems.cs @@ -36,18 +36,22 @@ namespace BizHawk.Client.EmuHawk NewFromCurrentSaveRamMenuItem.Enabled = CurrentTasMovie.InputLogLength > 0 - && SaveRamEmulator != null; + && SaveRamEmulator?.SupportsSaveRam == true; } private void StartNewProjectFromNowMenuItem_Click(object sender, EventArgs e) { if (AskSaveChanges()) { - var newProject = CurrentTasMovie.ConvertToSavestateAnchoredMovie( + var result = CurrentTasMovie.ConvertToSavestateAnchoredMovie( Emulator.Frame, StatableEmulator.CloneSavestate()); + DisplayMessageIfFailed(() => result, "Failed to create movie."); - MainForm.PauseEmulator(); - LoadMovie(newProject, true); + if (result.Value is ITasMovie newProject) + { + MainForm.PauseEmulator(); + LoadMovie(newProject, true); + } } } @@ -55,11 +59,16 @@ namespace BizHawk.Client.EmuHawk { if (AskSaveChanges()) { - var saveRam = SaveRamEmulator?.CloneSaveRam(clearDirty: false) ?? throw new Exception("No SaveRam"); + var saveRam = SaveRamEmulator?.CloneSaveRam(clearDirty: false) ?? throw new Exception("No SaveRam; this button should have been disabled."); GoToFrame(TasView.AnyRowsSelected ? TasView.FirstSelectedRowIndex : 0); - var newProject = CurrentTasMovie.ConvertToSaveRamAnchoredMovie(saveRam); - MainForm.PauseEmulator(); - LoadMovie(newProject, true); + var result = CurrentTasMovie.ConvertToSaveRamAnchoredMovie(saveRam); + DisplayMessageIfFailed(() => result, "Failed to create movie."); + + if (result.Value is ITasMovie newProject) + { + MainForm.PauseEmulator(); + LoadMovie(newProject, true); + } } } @@ -115,30 +124,30 @@ namespace BizHawk.Client.EmuHawk private void SaveTasMenuItem_Click(object sender, EventArgs e) { - SaveTas(); + DisplayMessageIfFailed(() => SaveTas(), "Failed to save movie."); if (Settings.BackupPerFileSave) { - SaveTas(saveBackup: true); + DisplayMessageIfFailed(() => SaveTas(saveBackup: true), "Failed to save backup."); } } private void SaveAsTasMenuItem_Click(object sender, EventArgs e) { - SaveAsTas(); + DisplayMessageIfFailed(() => SaveAsTas(), "Failed to save movie."); if (Settings.BackupPerFileSave) { - SaveTas(saveBackup: true); + DisplayMessageIfFailed(() => SaveTas(saveBackup: true), "Failed to save backup."); } } private void SaveBackupMenuItem_Click(object sender, EventArgs e) { - SaveTas(saveBackup: true); + DisplayMessageIfFailed(() => SaveTas(saveBackup: true), "Failed to save backup."); } private void SaveBk2BackupMenuItem_Click(object sender, EventArgs e) { - SaveTas(saveAsBk2: true, saveBackup: true); + DisplayMessageIfFailed(() => SaveTas(saveAsBk2: true, saveBackup: true), "Failed to save backup."); } private void SaveSelectionToMacroMenuItem_Click(object sender, EventArgs e) @@ -220,13 +229,25 @@ namespace BizHawk.Client.EmuHawk { MessageStatusLabel.Text = "Exporting to .bk2..."; MessageStatusLabel.Owner.Update(); + Cursor = Cursors.WaitCursor; var bk2 = CurrentTasMovie.ToBk2(); bk2.Filename = fileInfo.FullName; bk2.Attach(Emulator); // required to be able to save the cycle count for ICycleTiming emulators - bk2.Save(); - MessageStatusLabel.Text = $"{bk2.Name} exported."; + FileWriteResult saveResult = bk2.Save(); Cursor = Cursors.Default; + + while (saveResult.IsError) + { + DialogResult d = MessageBox.Show( + $"Failed to save .bk2. {saveResult.UserFriendlyErrorMessage()}\nTry again?", + "Error", + MessageBoxButtons.YesNo); + if (d == DialogResult.Yes) saveResult = bk2.Save(); + else break; + } + if (!saveResult.IsError) MessageStatusLabel.Text = $"{bk2.Name} exported."; + } else { @@ -1320,7 +1341,7 @@ namespace BizHawk.Client.EmuHawk StartANewProjectFromSaveRamMenuItem.Visible = selectionIsSingleRow - && SaveRamEmulator != null + && SaveRamEmulator?.SupportsSaveRam == true && !CurrentTasMovie.StartsFromSavestate; StartFromNowSeparator.Visible = StartNewProjectFromNowMenuItem.Visible || StartANewProjectFromSaveRamMenuItem.Visible; diff --git a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.cs b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.cs index d3b21e63f0..2e900ccf14 100644 --- a/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.cs +++ b/src/BizHawk.Client.EmuHawk/tools/TAStudio/TAStudio.cs @@ -36,6 +36,7 @@ namespace BizHawk.Client.EmuHawk private const string FrameColumnName = "FrameColumn"; private UndoHistoryForm _undoForm; private Timer _autosaveTimer; + private bool _lastAutoSaveSuccess = true; private readonly int _defaultMainSplitDistance; private readonly int _defaultBranchMarkerSplitDistance; @@ -194,11 +195,7 @@ namespace BizHawk.Client.EmuHawk _autosaveTimer = new Timer(components); _autosaveTimer.Tick += AutosaveTimerEventProcessor; - if (Settings.AutosaveInterval > 0) - { - _autosaveTimer.Interval = (int)Settings.AutosaveInterval; - _autosaveTimer.Start(); - } + ScheduleAutoSave(Settings.AutosaveInterval); MainVertialSplit.SetDistanceOrDefault( Settings.MainVerticalSplitDistance, @@ -240,20 +237,27 @@ namespace BizHawk.Client.EmuHawk { changesString = "The current movie has unsaved changes. Would you like to save before closing it?"; } - var result = DialogController.ShowMessageBox3( + var shouldSaveResult = DialogController.ShowMessageBox3( "TAStudio will create a new project file from the current movie.\n\n" + changesString, "Convert movie", EMsgBoxIcon.Question); - if (result == true) + if (shouldSaveResult == true) { - MovieSession.Movie.Save(); + FileWriteResult saveResult = MovieSession.Movie.Save(); + if (saveResult.IsError) + { + DisplayMessageIfFailed(() => saveResult, "Failed to save movie."); + return false; + } } - else if (result == null) + else if (shouldSaveResult == null) { return false; } - var tasMovie = ConvertCurrentMovieToTasproj(); + var tasMovie = MovieSession.Movie.ToTasMovie(); + // No need to save new movie, as there are no changes. + // User will save future changes if they want (potentially via auto-save). success = LoadMovie(tasMovie); } @@ -294,6 +298,16 @@ namespace BizHawk.Client.EmuHawk return true; } + private void ScheduleAutoSave(uint secondsUntil) + { + _autosaveTimer.Stop(); + if (secondsUntil != 0) + { + _autosaveTimer.Interval = (int)secondsUntil; + _autosaveTimer.Start(); + } + } + private void AutosaveTimerEventProcessor(object sender, EventArgs e) { if (CurrentTasMovie == null) @@ -307,28 +321,55 @@ namespace BizHawk.Client.EmuHawk return; } + FileWriteResult saveResult; if (Settings.AutosaveAsBackupFile) { if (Settings.AutosaveAsBk2) { - SaveTas(saveAsBk2: true, saveBackup: true); + saveResult = SaveTas(saveAsBk2: true, saveBackup: true); } else { - SaveTas(saveBackup: true); + saveResult = SaveTas(saveBackup: true); } } else { if (Settings.AutosaveAsBk2) { - SaveTas(saveAsBk2: true); + saveResult = SaveTas(saveAsBk2: true); } else { - SaveTas(); + saveResult = SaveTas(); } } + + if (saveResult.IsError && _lastAutoSaveSuccess) + { + // Should we alert the user? + // Let's try again once after a bit, then alert if it fails again. + ScheduleAutoSave(60); + } + else if (saveResult.IsError) + { + _autosaveTimer.Stop(); + bool tryAgain = DialogController.ShowMessageBox2( + $"Failed to auto-save. {saveResult.UserFriendlyErrorMessage()}\n{saveResult.Exception.Message}\n\nTry again?", + "Error"); + if (tryAgain) + { + AutosaveTimerEventProcessor(null, null); + return; + } + ScheduleAutoSave(Settings.AutosaveInterval); + } + else + { + ScheduleAutoSave(Settings.AutosaveInterval); + } + + _lastAutoSaveSuccess = !saveResult.IsError; } private static readonly string[] N64CButtonSuffixes = { " C Up", " C Down", " C Left", " C Right" }; @@ -481,13 +522,6 @@ namespace BizHawk.Client.EmuHawk } } - private ITasMovie ConvertCurrentMovieToTasproj() - { - var tasMovie = MovieSession.Movie.ToTasMovie(); - tasMovie.Save(); // should this be done? - return tasMovie; - } - private bool LoadMovie(ITasMovie tasMovie, bool startsFromSavestate = false, int gotoFrame = 0) { _engaged = false; @@ -694,12 +728,23 @@ namespace BizHawk.Client.EmuHawk $"{Game.FilesystemSafeName()}.{MovieService.TasMovieExtension}"); } - private void SaveTas(bool saveAsBk2 = false, bool saveBackup = false) + private void DisplayMessageIfFailed(Func action, string message) + { + FileWriteResult result = action(); + if (result.IsError) + { + DialogController.ShowMessageBox( + $"{message}\n{result.UserFriendlyErrorMessage()}\n{result.Exception.Message}", + "Error", + EMsgBoxIcon.Error); + } + } + + private FileWriteResult SaveTas(bool saveAsBk2 = false, bool saveBackup = false) { if (string.IsNullOrEmpty(CurrentTasMovie.Filename) || CurrentTasMovie.Filename == DefaultTasProjName()) { - SaveAsTas(); - return; + return SaveAsTas(); } _autosaveTimer.Stop(); @@ -716,22 +761,29 @@ namespace BizHawk.Client.EmuHawk movieToSave.Attach(Emulator); } + FileWriteResult result; if (saveBackup) - movieToSave.SaveBackup(); + result = movieToSave.SaveBackup(); else - movieToSave.Save(); + result = movieToSave.Save(); - MessageStatusLabel.Text = saveBackup - ? $"Backup .{(saveAsBk2 ? MovieService.StandardMovieExtension : MovieService.TasMovieExtension)} saved to \"Movie backups\" path." - : "File saved."; - Cursor = Cursors.Default; - if (Settings.AutosaveInterval > 0) + if (!result.IsError) { - _autosaveTimer.Start(); + MessageStatusLabel.Text = saveBackup + ? $"Backup .{(saveAsBk2 ? MovieService.StandardMovieExtension : MovieService.TasMovieExtension)} saved to \"Movie backups\" path." + : "File saved."; } + else + { + MessageStatusLabel.Text = "Failed to save movie."; + } + + Cursor = Cursors.Default; + ScheduleAutoSave(Settings.AutosaveInterval); + return result; } - private void SaveAsTas() + private FileWriteResult SaveAsTas() { _autosaveTimer.Stop(); @@ -747,25 +799,33 @@ namespace BizHawk.Client.EmuHawk TAStudioProjectsFSFilterSet, this); + FileWriteResult saveResult = null; if (fileInfo != null) { MessageStatusLabel.Text = "Saving..."; MessageStatusLabel.Owner.Update(); Cursor = Cursors.WaitCursor; CurrentTasMovie.Filename = fileInfo.FullName; - CurrentTasMovie.Save(); + saveResult = CurrentTasMovie.Save(); Settings.RecentTas.Add(CurrentTasMovie.Filename); - MessageStatusLabel.Text = "File saved."; Cursor = Cursors.Default; - } - if (Settings.AutosaveInterval > 0) - { - _autosaveTimer.Start(); + if (!saveResult.IsError) + { + MessageStatusLabel.Text = "File saved."; + ScheduleAutoSave(Settings.AutosaveInterval); + } + else + { + MessageStatusLabel.Text = "Failed to save."; + } } UpdateWindowTitle(); // changing the movie's filename does not flag changes, so we need to ensure the window title is always updated MainForm.UpdateWindowTitle(); + + if (fileInfo != null) return saveResult; + else return new(); // user cancelled, so we were successful in not saving } protected override string WindowTitle diff --git a/src/BizHawk.Client.EmuHawk/tools/Watch/RamSearch.cs b/src/BizHawk.Client.EmuHawk/tools/Watch/RamSearch.cs index 6a303c2a95..07921b0b22 100644 --- a/src/BizHawk.Client.EmuHawk/tools/Watch/RamSearch.cs +++ b/src/BizHawk.Client.EmuHawk/tools/Watch/RamSearch.cs @@ -1008,7 +1008,13 @@ namespace BizHawk.Client.EmuHawk if (!string.IsNullOrWhiteSpace(watches.CurrentFileName)) { - if (watches.Save()) + FileWriteResult saveResult = watches.Save(); + if (saveResult.IsError) + { + this.ErrorMessageBox(saveResult); + MessageLabel.Text = $"Failed to save {Path.GetFileName(_currentFileName)}"; + } + else { _currentFileName = watches.CurrentFileName; MessageLabel.Text = $"{Path.GetFileName(_currentFileName)} saved"; @@ -1017,11 +1023,20 @@ namespace BizHawk.Client.EmuHawk } else { - var result = watches.SaveAs(GetWatchSaveFileFromUser(CurrentFileName())); - if (result) + FileInfo/*?*/ file = GetWatchSaveFileFromUser(CurrentFileName()); + if (file != null) { - MessageLabel.Text = $"{Path.GetFileName(_currentFileName)} saved"; - Settings.RecentSearches.Add(watches.CurrentFileName); + var result = watches.SaveAs(file); + if (result.IsError) + { + this.ErrorMessageBox(result); + MessageLabel.Text = $"Failed to save {Path.GetFileName(_currentFileName)}"; + } + else + { + MessageLabel.Text = $"{Path.GetFileName(_currentFileName)} saved"; + Settings.RecentSearches.Add(watches.CurrentFileName); + } } } } @@ -1035,7 +1050,15 @@ namespace BizHawk.Client.EmuHawk watches.Add(_searches[i]); } - if (watches.SaveAs(GetWatchSaveFileFromUser(CurrentFileName()))) + FileInfo/*?*/ file = GetWatchSaveFileFromUser(CurrentFileName()); + if (file == null) return; + FileWriteResult result = watches.SaveAs(file); + if (result.IsError) + { + this.ErrorMessageBox(result); + MessageLabel.Text = $"Failed to save {Path.GetFileName(_currentFileName)}"; + } + else { _currentFileName = watches.CurrentFileName; MessageLabel.Text = $"{Path.GetFileName(_currentFileName)} saved"; diff --git a/src/BizHawk.Client.EmuHawk/tools/Watch/RamWatch.cs b/src/BizHawk.Client.EmuHawk/tools/Watch/RamWatch.cs index e54bf3a62f..9555251765 100644 --- a/src/BizHawk.Client.EmuHawk/tools/Watch/RamWatch.cs +++ b/src/BizHawk.Client.EmuHawk/tools/Watch/RamWatch.cs @@ -213,15 +213,8 @@ namespace BizHawk.Client.EmuHawk if (result is null) return false; if (result.Value) { - if (string.IsNullOrWhiteSpace(_watches.CurrentFileName)) - { - SaveAs(); - } - else - { - _watches.Save(); - Config.RecentWatches.Add(_watches.CurrentFileName); - } + TryAgainResult saveResult = this.DoWithTryAgainBox(Save, "Failed to save watch list."); + return saveResult != TryAgainResult.Canceled; } else { @@ -591,14 +584,47 @@ namespace BizHawk.Client.EmuHawk : Game.FilesystemSafeName(); } - private void SaveAs() + private FileWriteResult SaveAs() { - var result = _watches.SaveAs(GetWatchSaveFileFromUser(CurrentFileName())); - if (result) + FileInfo/*?*/ file = GetWatchSaveFileFromUser(CurrentFileName()); + if (file == null) return new(); + + FileWriteResult result = _watches.SaveAs(file); + if (result.IsError) { - UpdateStatusBar(saved: true); - Config.RecentWatches.Add(_watches.CurrentFileName); + MessageLabel.Text = $"Failed to save {Path.GetFileName(_watches.CurrentFileName)}"; } + else + { + MessageLabel.Text = $"{Path.GetFileName(_watches.CurrentFileName)} saved"; + Config.RecentWatches.Add(_watches.CurrentFileName); + UpdateStatusBar(saved: true); + } + return result; + } + + private FileWriteResult Save() + { + if (string.IsNullOrWhiteSpace(_watches.CurrentFileName)) + { + return SaveAs(); + } + else + { + FileWriteResult saveResult = _watches.Save(); + if (saveResult.IsError) + { + MessageLabel.Text = $"Failed to save {Path.GetFileName(_watches.CurrentFileName)}"; + } + else + { + MessageLabel.Text = $"{Path.GetFileName(_watches.CurrentFileName)} saved"; + Config.RecentWatches.Add(_watches.CurrentFileName); + UpdateStatusBar(saved: true); + } + return saveResult; + } + } private void SaveConfigSettings() @@ -727,23 +753,20 @@ namespace BizHawk.Client.EmuHawk private void SaveMenuItem_Click(object sender, EventArgs e) { - if (!string.IsNullOrWhiteSpace(_watches.CurrentFileName)) + FileWriteResult saveResult = Save(); + if (saveResult.IsError) { - if (_watches.Save()) - { - Config.RecentWatches.Add(_watches.CurrentFileName); - UpdateStatusBar(saved: true); - } - } - else - { - SaveAs(); + this.ErrorMessageBox(saveResult, "Failed to save watch list."); } } private void SaveAsMenuItem_Click(object sender, EventArgs e) { - SaveAs(); + FileWriteResult saveResult = SaveAs(); + if (saveResult.IsError) + { + this.ErrorMessageBox(saveResult, "Failed to save watch list."); + } } private void RecentSubMenu_DropDownOpened(object sender, EventArgs e) diff --git a/src/BizHawk.Emulation.Common/Base Implementations/LinkedSaveRam.cs b/src/BizHawk.Emulation.Common/Base Implementations/LinkedSaveRam.cs index cd2ea92225..ccc86f5dba 100644 --- a/src/BizHawk.Emulation.Common/Base Implementations/LinkedSaveRam.cs +++ b/src/BizHawk.Emulation.Common/Base Implementations/LinkedSaveRam.cs @@ -55,13 +55,17 @@ namespace BizHawk.Emulation.Common int pos = 0; for (int i = 0; i < _numCores; i++) { - var toCopy = _linkedCores[i].AsSaveRam().CloneSaveRam(); // wait CloneSaveRam is already a copy, why are we copying it again - if (toCopy is null) continue; - var b = new byte[toCopy.Length]; + var numberBytesToCopy = _linkedCores[i].AsSaveRam().CloneSaveRam()?.Length; + if (numberBytesToCopy is null) continue; + var b = new byte[numberBytesToCopy.Value]; Buffer.BlockCopy(data, pos, b, 0, b.Length); pos += b.Length; _linkedCores[i].AsSaveRam().StoreSaveRam(b); } + + if (data.Length != pos) throw new InvalidOperationException("Incorrect sram size."); } + + public bool SupportsSaveRam => true; } } diff --git a/src/BizHawk.Emulation.Common/Extensions.cs b/src/BizHawk.Emulation.Common/Extensions.cs index c277ff125e..3c935d8af0 100644 --- a/src/BizHawk.Emulation.Common/Extensions.cs +++ b/src/BizHawk.Emulation.Common/Extensions.cs @@ -134,7 +134,7 @@ namespace BizHawk.Emulation.Common public static bool HasSaveRam(this IEmulator core) { - return core != null && core.ServiceProvider.HasService(); + return core != null && core.ServiceProvider.HasService() && core.AsSaveRam()!.SupportsSaveRam; } public static ISaveRam AsSaveRam(this IEmulator core) diff --git a/src/BizHawk.Emulation.Common/Interfaces/Services/ISaveRam.cs b/src/BizHawk.Emulation.Common/Interfaces/Services/ISaveRam.cs index d0fa8d67e8..e7c73e6153 100644 --- a/src/BizHawk.Emulation.Common/Interfaces/Services/ISaveRam.cs +++ b/src/BizHawk.Emulation.Common/Interfaces/Services/ISaveRam.cs @@ -10,16 +10,16 @@ { /// /// Returns a copy of the SaveRAM. Editing it won't do you any good unless you later call StoreSaveRam() - /// This IS allowed to return null. - /// Unfortunately, the core may think differently of a nonexisting (null) saveram vs a 0 size saveram. - /// Frontend users of the ISaveRam should treat null as nonexisting (and thus not even write the file, so that the "does not exist" condition can be roundtripped and not confused with an empty file) + /// This method must return null if and only if is false. /// /// Whether the saveram should be considered in a clean state after this call for purposes of byte[]? CloneSaveRam(bool clearDirty = true); /// - /// store new SaveRAM to the emu core. the data should be the same size as the return from ReadSaveRam() + /// Store new SaveRAM to the emu core. + /// The core must ignore calls to this method if is false. /// + /// The core may throw an exception if the given data is invalid. void StoreSaveRam(byte[] data); /// @@ -28,5 +28,10 @@ /// This value should be considered a hint more than an absolute truth. /// bool SaveRamModified { get; } + + /// + /// Certain cores may support SaveRam only in certain situations, for example only for certain games. + /// + bool SupportsSaveRam { get; } } } diff --git a/src/BizHawk.Emulation.Cores/Arcades/MAME/MAME.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Arcades/MAME/MAME.ISaveRam.cs index 7bd53d6ca4..e87e031a27 100644 --- a/src/BizHawk.Emulation.Cores/Arcades/MAME/MAME.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Arcades/MAME/MAME.ISaveRam.cs @@ -18,6 +18,8 @@ namespace BizHawk.Emulation.Cores.Arcades.MAME public bool SaveRamModified => _nvramFilenames.Count > 0; + public bool SupportsSaveRam => _nvramFilenames.Count != 0; + public byte[] CloneSaveRam(bool clearDirty) { if (_nvramFilenames.Count == 0) diff --git a/src/BizHawk.Emulation.Cores/Computers/AppleII/AppleII.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Computers/AppleII/AppleII.ISaveRam.cs index b686ccf80e..0df774f798 100644 --- a/src/BizHawk.Emulation.Cores/Computers/AppleII/AppleII.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Computers/AppleII/AppleII.ISaveRam.cs @@ -15,6 +15,7 @@ namespace BizHawk.Emulation.Cores.Computers.AppleII } public bool SaveRamModified => true; + public bool SupportsSaveRam => true; public byte[] CloneSaveRam(bool clearDirty) { diff --git a/src/BizHawk.Emulation.Cores/Computers/Commodore64/Cartridge/Mapper0020.cs b/src/BizHawk.Emulation.Cores/Computers/Commodore64/Cartridge/Mapper0020.cs index 469f1ddfc4..b241fb41ed 100644 --- a/src/BizHawk.Emulation.Cores/Computers/Commodore64/Cartridge/Mapper0020.cs +++ b/src/BizHawk.Emulation.Cores/Computers/Commodore64/Cartridge/Mapper0020.cs @@ -302,11 +302,15 @@ namespace BizHawk.Emulation.Cores.Computers.Commodore64.Cartridge var deltaBSize = reader.ReadInt32(); _deltaB = reader.ReadBytes(deltaBSize); + if (reader.BaseStream.Position != deltaASize + deltaBSize + 8) throw new InvalidOperationException("Incorrect sram size."); + DeltaSerializer.ApplyDelta(_originalMediaA, _chipA.Data, _deltaA); DeltaSerializer.ApplyDelta(_originalMediaB, _chipB.Data, _deltaB); _saveRamDirty = false; } public bool SaveRamModified => _saveRamDirty; + + public bool SupportsSaveRam => true; } } diff --git a/src/BizHawk.Emulation.Cores/Computers/Commodore64/Serial/Drive1541.SaveRam.cs b/src/BizHawk.Emulation.Cores/Computers/Commodore64/Serial/Drive1541.SaveRam.cs index a34b18e2f4..b3a6606387 100644 --- a/src/BizHawk.Emulation.Cores/Computers/Commodore64/Serial/Drive1541.SaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Computers/Commodore64/Serial/Drive1541.SaveRam.cs @@ -39,6 +39,8 @@ public sealed partial class Drive1541 : ISaveRam public bool SaveRamModified { get; private set; } = false; + public bool SupportsSaveRam => true; + public byte[] CloneSaveRam(bool clearDirty) { SaveDeltas(); diff --git a/src/BizHawk.Emulation.Cores/Computers/MSX/MSX.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Computers/MSX/MSX.ISaveRam.cs index 7b226140c1..c4869319fa 100644 --- a/src/BizHawk.Emulation.Cores/Computers/MSX/MSX.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Computers/MSX/MSX.ISaveRam.cs @@ -13,12 +13,15 @@ namespace BizHawk.Emulation.Cores.Computers.MSX { if (SaveRAM != null) { + if (data.Length != SaveRAM.Length) throw new InvalidOperationException("Incorrect sram size."); Array.Copy(data, SaveRAM, data.Length); } } public bool SaveRamModified { get; private set; } + public bool SupportsSaveRam => SaveRAM != null; + public byte[] SaveRAM; private byte SaveRamBank; } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Atari/A7800Hawk/A7800Hawk.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Atari/A7800Hawk/A7800Hawk.ISaveRam.cs index 9e8d938d52..a71c0c11fb 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Atari/A7800Hawk/A7800Hawk.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Atari/A7800Hawk/A7800Hawk.ISaveRam.cs @@ -11,9 +11,12 @@ namespace BizHawk.Emulation.Cores.Atari.A7800Hawk public void StoreSaveRam(byte[] data) { + if (data.Length != _hsram.Length) throw new InvalidOperationException("Incorrect sram size."); Buffer.BlockCopy(data, 0, _hsram, 0, data.Length); } public bool SaveRamModified => (_hsbios != null); + + public bool SupportsSaveRam => true; } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Atari/jaguar/VirtualJaguar.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Atari/jaguar/VirtualJaguar.ISaveRam.cs index 3992a453c1..a1dbd5a578 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Atari/jaguar/VirtualJaguar.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Atari/jaguar/VirtualJaguar.ISaveRam.cs @@ -8,6 +8,8 @@ namespace BizHawk.Emulation.Cores.Atari.Jaguar public new bool SaveRamModified => _saveRamSize > 0 && _core.SaveRamIsDirty(); + public new bool SupportsSaveRam => _saveRamSize != 0; + public new byte[] CloneSaveRam(bool clearDirty) { if (_saveRamSize == 0) diff --git a/src/BizHawk.Emulation.Cores/Consoles/Atari/lynx/Lynx.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Atari/lynx/Lynx.ISaveRam.cs index 086e37f98d..a81cfeb4a8 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Atari/lynx/Lynx.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Atari/lynx/Lynx.ISaveRam.cs @@ -22,7 +22,7 @@ namespace BizHawk.Emulation.Cores.Atari.Lynx { if (!LibLynx.GetSaveRamPtr(Core, out var size, out var data)) { - throw new InvalidOperationException(); + return; } if (srcData.Length != size) throw new ArgumentException(message: "buffer too small", paramName: nameof(srcData)); @@ -31,5 +31,7 @@ namespace BizHawk.Emulation.Cores.Atari.Lynx } public bool SaveRamModified => LibLynx.GetSaveRamPtr(Core, out int unused, out IntPtr unused2); + + public bool SupportsSaveRam => LibLynx.GetSaveRamPtr(Core, out int _, out IntPtr _); } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/GCE/Vectrex/VectrexHawk.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/GCE/Vectrex/VectrexHawk.ISaveRam.cs index c4b7eace2a..638ce514ae 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/GCE/Vectrex/VectrexHawk.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/GCE/Vectrex/VectrexHawk.ISaveRam.cs @@ -15,5 +15,7 @@ namespace BizHawk.Emulation.Cores.Consoles.Vectrex } public bool SaveRamModified => false; + + public bool SupportsSaveRam => false; } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Magnavox/Odyssey2/O2Hawk.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Magnavox/Odyssey2/O2Hawk.ISaveRam.cs index 4950fc6678..929aac06ff 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Magnavox/Odyssey2/O2Hawk.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Magnavox/Odyssey2/O2Hawk.ISaveRam.cs @@ -11,13 +11,16 @@ namespace BizHawk.Emulation.Cores.Consoles.O2Hawk public void StoreSaveRam(byte[] data) { - if (_syncSettings.Use_SRAM) + if (cart_RAM != null && _syncSettings.Use_SRAM) { + if (data.Length != cart_RAM.Length) throw new InvalidOperationException("Incorrect sram size."); Buffer.BlockCopy(data, 0, cart_RAM, 0, data.Length); Console.WriteLine("loading SRAM here"); } } public bool SaveRamModified => has_bat & _syncSettings.Use_SRAM; + + public bool SupportsSaveRam => cart_RAM != null; // The Use_SRAM setting implements behavior not officially supported by BizHawk. } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/BSNES/BsnesCore.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/BSNES/BsnesCore.ISaveRam.cs index 54fc08a59f..c0a2fcdd24 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/BSNES/BsnesCore.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/BSNES/BsnesCore.ISaveRam.cs @@ -11,6 +11,8 @@ namespace BizHawk.Emulation.Cores.Nintendo.BSNES public bool SaveRamModified => _saveRamSize != 0; + public bool SupportsSaveRam => _saveRamSize != 0; + public byte[] CloneSaveRam(bool clearDirty) { if (_saveRamSize == 0) return null; diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBA/MGBAHawk.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBA/MGBAHawk.ISaveRam.cs index 76afb36a47..b445a3a9d9 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBA/MGBAHawk.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBA/MGBAHawk.ISaveRam.cs @@ -33,6 +33,8 @@ namespace BizHawk.Emulation.Cores.Nintendo.GBA public void StoreSaveRam(byte[] data) { + if (!SupportsSaveRam) return; + if (data.AsSpan().Slice(0, 8).SequenceEqual(_legacyHeader)) { data = LegacyFix(data); @@ -43,6 +45,17 @@ namespace BizHawk.Emulation.Cores.Nintendo.GBA public bool SaveRamModified => LibmGBA.BizGetSaveRam(Core, _saveScratch, _saveScratch.Length) > 0; + public bool SupportsSaveRam + { + get + { + // Is all of this necessary? Someone who knows how the core works might know. + int len = LibmGBA.BizGetSaveRam(Core, _saveScratch, _saveScratch.Length); + len = TruncateRTCIfUsingDeterministicTime(len); + return len != 0; + } + } + private static byte[] LegacyFix(byte[] saveram) { // at one point vbanext-hawk had a special saveram format which we want to load. diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawk/GBHawk.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawk/GBHawk.ISaveRam.cs index 9843e303d1..518f190a81 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawk/GBHawk.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawk/GBHawk.ISaveRam.cs @@ -11,13 +11,16 @@ namespace BizHawk.Emulation.Cores.Nintendo.GBHawk public void StoreSaveRam(byte[] data) { - if (_syncSettings.Use_SRAM) + if (cart_RAM != null && _syncSettings.Use_SRAM) { + if (data.Length != cart_RAM.Length) throw new InvalidOperationException("Incorrect sram size."); Buffer.BlockCopy(data, 0, cart_RAM, 0, data.Length); Console.WriteLine("loading SRAM here"); } } public bool SaveRamModified => has_bat & _syncSettings.Use_SRAM; + + public bool SupportsSaveRam => cart_RAM != null; // The Use_SRAM setting implements behavior not officially supported by BizHawk. } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawkLink/GBHawkLink.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawkLink/GBHawkLink.ISaveRam.cs index f5e69eb708..cf25dc0e83 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawkLink/GBHawkLink.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawkLink/GBHawkLink.ISaveRam.cs @@ -54,14 +54,17 @@ namespace BizHawk.Emulation.Cores.Nintendo.GBHawkLink { if (L.cart_RAM != null && R.cart_RAM == null) { + if (data.Length != L.cart_RAM.Length) throw new InvalidOperationException("Incorrect sram size."); Buffer.BlockCopy(data, 0, L.cart_RAM, 0, L.cart_RAM.Length); } else if (R.cart_RAM != null && L.cart_RAM == null) { + if (data.Length != R.cart_RAM.Length) throw new InvalidOperationException("Incorrect sram size."); Buffer.BlockCopy(data, 0, R.cart_RAM, 0, R.cart_RAM.Length); } else if (R.cart_RAM != null && L.cart_RAM != null) { + if (data.Length != L.cart_RAM.Length + R.cart_RAM.Length) throw new InvalidOperationException("Incorrect sram size."); Buffer.BlockCopy(data, 0, L.cart_RAM, 0, L.cart_RAM.Length); Buffer.BlockCopy(data, L.cart_RAM.Length, R.cart_RAM, 0, R.cart_RAM.Length); } @@ -71,5 +74,7 @@ namespace BizHawk.Emulation.Cores.Nintendo.GBHawkLink } public bool SaveRamModified => (L.has_bat || R.has_bat) & linkSyncSettings.Use_SRAM; + + public bool SupportsSaveRam => L.cart_RAM != null || R.cart_RAM != null; // The Use_SRAM setting implements behavior not officially supported by BizHawk. } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawkLink3x/GBHawkLink3x.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawkLink3x/GBHawkLink3x.ISaveRam.cs index b4241b4730..6b9bd590da 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawkLink3x/GBHawkLink3x.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawkLink3x/GBHawkLink3x.ISaveRam.cs @@ -84,12 +84,17 @@ namespace BizHawk.Emulation.Cores.Nintendo.GBHawkLink3x if (R.cart_RAM != null) { Buffer.BlockCopy(data, temp, R.cart_RAM, 0, R.cart_RAM.Length); + temp += R.cart_RAM.Length; } + if (data.Length != temp) throw new InvalidOperationException("Incorrect sram size."); + Console.WriteLine("loading SRAM here"); } } public bool SaveRamModified => (L.has_bat || C.has_bat || R.has_bat) & Link3xSyncSettings.Use_SRAM; + + public bool SupportsSaveRam => L.cart_RAM != null || C.cart_RAM != null || R.cart_RAM != null; // The Use_SRAM setting implements behavior not officially supported by BizHawk. } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawkLink4x/GBHawkLink4x.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawkLink4x/GBHawkLink4x.ISaveRam.cs index e6fbe4a945..aac9d7009b 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawkLink4x/GBHawkLink4x.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/GBHawkLink4x/GBHawkLink4x.ISaveRam.cs @@ -105,12 +105,17 @@ namespace BizHawk.Emulation.Cores.Nintendo.GBHawkLink4x if (D.cart_RAM != null) { Buffer.BlockCopy(data, temp, D.cart_RAM, 0, D.cart_RAM.Length); + temp += D.cart_RAM.Length; } + if (data.Length != temp) throw new InvalidOperationException("Incorrect sram size."); + Console.WriteLine("loading SRAM here"); } } public bool SaveRamModified => (A.has_bat || B.has_bat || C.has_bat || D.has_bat) & Link4xSyncSettings.Use_SRAM; + + public bool SupportsSaveRam => A.cart_RAM != null || B.cart_RAM != null || C.cart_RAM != null || D.cart_RAM != null; // The Use_SRAM setting implements behavior not officially supported by BizHawk. } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/Gameboy/Gambatte.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/Gameboy/Gambatte.ISaveRam.cs index 213ccd0d9d..5df9066091 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/Gameboy/Gambatte.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/Gameboy/Gambatte.ISaveRam.cs @@ -7,6 +7,8 @@ namespace BizHawk.Emulation.Cores.Nintendo.Gameboy // need to wire more stuff into the core to actually know this public bool SaveRamModified => LibGambatte.gambatte_getsavedatalength(GambatteState) != 0; + public bool SupportsSaveRam => LibGambatte.gambatte_getsavedatalength(GambatteState) != 0; + public byte[] CloneSaveRam(bool clearDirty) { var length = LibGambatte.gambatte_getsavedatalength(GambatteState); @@ -24,6 +26,7 @@ namespace BizHawk.Emulation.Cores.Nintendo.Gameboy public void StoreSaveRam(byte[] data) { var expected = LibGambatte.gambatte_getsavedatalength(GambatteState); + if (expected == 0) return; if (data.Length != expected) throw new ArgumentException(message: "Size of saveram data does not match expected!", paramName: nameof(data)); LibGambatte.gambatte_loadsavedata(GambatteState, data); diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/N64/N64.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/N64/N64.ISaveRam.cs index 974f913376..02649f83b1 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/N64/N64.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/N64/N64.ISaveRam.cs @@ -15,5 +15,7 @@ namespace BizHawk.Emulation.Cores.Nintendo.N64 } public bool SaveRamModified => true; + + public bool SupportsSaveRam => true; } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/NDS/MelonDS.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/NDS/MelonDS.ISaveRam.cs index 0364b53905..1acb9e86f7 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/NDS/MelonDS.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/NDS/MelonDS.ISaveRam.cs @@ -9,6 +9,8 @@ namespace BizHawk.Emulation.Cores.Consoles.Nintendo.NDS public new bool SaveRamModified => IsDSiWare ? DSiWareSaveLength != 0 : _core.SaveRamIsDirty(); + public new bool SupportsSaveRam => IsDSiWare ? DSiWareSaveLength != 0 : _core.GetSaveRamLength(_console) != 0; + public new byte[] CloneSaveRam(bool clearDirty) { if (IsDSiWare) @@ -54,6 +56,7 @@ namespace BizHawk.Emulation.Cores.Consoles.Nintendo.NDS { if (IsDSiWare) { + if (DSiWareSaveLength == 0) return; if (data.Length == DSiWareSaveLength) { if (PublicSavSize > 0) _exe.AddReadonlyFile(data.AsSpan().Slice(0, PublicSavSize).ToArray(), "public.sav"); @@ -66,6 +69,10 @@ namespace BizHawk.Emulation.Cores.Consoles.Nintendo.NDS if (PrivateSavSize > 0) _exe.RemoveReadonlyFile("private.sav"); if (BannerSavSize > 0) _exe.RemoveReadonlyFile("banner.sav"); } + else + { + throw new InvalidOperationException("Incorrect sram size."); + } } else if (data.Length > 0) { diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/NES/NES.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/NES/NES.ISaveRam.cs index 67a9563a02..a07014ebb6 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/NES/NES.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/NES/NES.ISaveRam.cs @@ -15,6 +15,17 @@ namespace BizHawk.Emulation.Cores.Nintendo.NES } } + public bool SupportsSaveRam + { + get + { + if (Board == null) return false; + if (Board is FDS) return true; + if (Board.SaveRam == null) return false; + return true; + } + } + public byte[] CloneSaveRam(bool clearDirty) { if (Board is FDS fds) @@ -38,6 +49,7 @@ namespace BizHawk.Emulation.Cores.Nintendo.NES return; } + if (data.Length != Board.SaveRam.Length) throw new InvalidOperationException("Incorrect sram size."); Array.Copy(data, Board.SaveRam, data.Length); } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/QuickNES/QuickNES.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/QuickNES/QuickNES.ISaveRam.cs index b7fdd5b966..797a0dc188 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/QuickNES/QuickNES.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/QuickNES/QuickNES.ISaveRam.cs @@ -21,6 +21,8 @@ namespace BizHawk.Emulation.Cores.Consoles.Nintendo.QuickNES public bool SaveRamModified => QN.qn_has_battery_ram(Context); + public bool SupportsSaveRam => QN.qn_has_battery_ram(Context); + private byte[] _saveRamBuff; private void InitSaveRamBuff() diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/SNES/LibsnesCore.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/SNES/LibsnesCore.ISaveRam.cs index bae5d8c2a6..5629c00247 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/SNES/LibsnesCore.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/SNES/LibsnesCore.ISaveRam.cs @@ -11,6 +11,10 @@ namespace BizHawk.Emulation.Cores.Nintendo.SNES Api.QUERY_get_memory_size(LibsnesApi.SNES_MEMORY.CARTRIDGE_RAM) != 0 || Api.QUERY_get_memory_size(LibsnesApi.SNES_MEMORY.SGB_CARTRAM) != 0; + public bool SupportsSaveRam => + Api.QUERY_get_memory_data(LibsnesApi.SNES_MEMORY.CARTRIDGE_RAM) != null + || Api.QUERY_get_memory_data(LibsnesApi.SNES_MEMORY.SGB_CARTRAM) != null; + public byte[] CloneSaveRam(bool clearDirty) { using (Api.EnterExit()) @@ -46,7 +50,7 @@ namespace BizHawk.Emulation.Cores.Nintendo.SNES size = Api.QUERY_get_memory_size(LibsnesApi.SNES_MEMORY.SGB_CARTRAM); } - if (size == 0) + if (size == 0 || buf == null) { return; } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/SameBoy/SameBoy.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/SameBoy/SameBoy.ISaveRam.cs index c1d2c36954..d8eba45f1c 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/SameBoy/SameBoy.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/SameBoy/SameBoy.ISaveRam.cs @@ -6,6 +6,8 @@ namespace BizHawk.Emulation.Cores.Nintendo.Sameboy { public bool SaveRamModified => LibSameboy.sameboy_sramlen(SameboyState) != 0; + public bool SupportsSaveRam => LibSameboy.sameboy_sramlen(SameboyState) != 0; + public byte[] CloneSaveRam(bool clearDirty) { int length = LibSameboy.sameboy_sramlen(SameboyState); @@ -23,12 +25,10 @@ namespace BizHawk.Emulation.Cores.Nintendo.Sameboy public void StoreSaveRam(byte[] data) { int expected = LibSameboy.sameboy_sramlen(SameboyState); + if (expected == 0) return; if (data.Length != expected) throw new ArgumentException(message: "Size of saveram data does not match expected!", paramName: nameof(data)); - if (expected > 0) - { - LibSameboy.sameboy_loadsram(SameboyState, data, data.Length); - } + LibSameboy.sameboy_loadsram(SameboyState, data, data.Length); } } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/PC Engine/PCEngine.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/PC Engine/PCEngine.ISaveRam.cs index 0dd85e1a64..041ad68f81 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/PC Engine/PCEngine.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/PC Engine/PCEngine.ISaveRam.cs @@ -6,6 +6,8 @@ namespace BizHawk.Emulation.Cores.PCEngine { public bool SaveRamModified { get; private set; } + public bool SupportsSaveRam => BRAM != null; + public byte[] CloneSaveRam(bool clearDirty) { if (clearDirty) SaveRamModified = false; @@ -16,6 +18,7 @@ namespace BizHawk.Emulation.Cores.PCEngine { if (BRAM != null) { + if (data.Length != BRAM.Length) throw new InvalidOperationException("Incorrect sram size."); Array.Copy(data, BRAM, data.Length); } diff --git a/src/BizHawk.Emulation.Cores/Consoles/SNK/NeoGeoPort.cs b/src/BizHawk.Emulation.Cores/Consoles/SNK/NeoGeoPort.cs index 87975014f8..f81cde30b6 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/SNK/NeoGeoPort.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/SNK/NeoGeoPort.cs @@ -51,6 +51,8 @@ namespace BizHawk.Emulation.Cores.Consoles.SNK } } + public new bool SupportsSaveRam => true; + public new byte[] CloneSaveRam(bool clearDirty) { _exe.AddTransientFile(new byte[0], "SAV:flash"); diff --git a/src/BizHawk.Emulation.Cores/Consoles/Sega/GGHawkLink/GGHawkLink.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Sega/GGHawkLink/GGHawkLink.ISaveRam.cs index 30967d7797..d7b93f3132 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Sega/GGHawkLink/GGHawkLink.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Sega/GGHawkLink/GGHawkLink.ISaveRam.cs @@ -52,14 +52,17 @@ namespace BizHawk.Emulation.Cores.Sega.GGHawkLink { if (L.SaveRAM != null && R.SaveRAM == null) { + if (data.Length != L.SaveRAM.Length) throw new InvalidOperationException("Incorrect sram size."); Buffer.BlockCopy(data, 0, L.SaveRAM, 0, L.SaveRAM.Length); } else if (R.SaveRAM != null && L.SaveRAM == null) { + if (data.Length != R.SaveRAM.Length) throw new InvalidOperationException("Incorrect sram size."); Buffer.BlockCopy(data, 0, R.SaveRAM, 0, R.SaveRAM.Length); } else if (R.SaveRAM != null && L.SaveRAM != null) { + if (data.Length != L.SaveRAM.Length + R.SaveRAM.Length) throw new InvalidOperationException("Incorrect sram size."); Buffer.BlockCopy(data, 0, L.SaveRAM, 0, L.SaveRAM.Length); Buffer.BlockCopy(data, L.SaveRAM.Length, R.SaveRAM, 0, R.SaveRAM.Length); } @@ -68,5 +71,7 @@ namespace BizHawk.Emulation.Cores.Sega.GGHawkLink } public bool SaveRamModified => linkSyncSettings.Use_SRAM; + + public bool SupportsSaveRam => L.SaveRAM != null || R.SaveRAM != null; // The Use_SRAM setting implements behavior not officially supported by BizHawk. } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Sega/SMS/SMS.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Sega/SMS/SMS.ISaveRam.cs index 936a037a54..811c2ed2b3 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Sega/SMS/SMS.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Sega/SMS/SMS.ISaveRam.cs @@ -14,6 +14,7 @@ namespace BizHawk.Emulation.Cores.Sega.MasterSystem { if (SaveRAM != null) { + if (data.Length != SaveRAM.Length) throw new InvalidOperationException("Incorrect sram size."); Array.Copy(data, SaveRAM, data.Length); } @@ -22,6 +23,8 @@ namespace BizHawk.Emulation.Cores.Sega.MasterSystem public bool SaveRamModified { get; private set; } + public bool SupportsSaveRam => SaveRAM != null; + public byte[] SaveRAM; private byte SaveRamBank; } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Sega/Saturn/Saturnus.cs b/src/BizHawk.Emulation.Cores/Consoles/Sega/Saturn/Saturnus.cs index b8ddca7175..3ed83d569b 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Sega/Saturn/Saturnus.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Sega/Saturn/Saturnus.cs @@ -118,6 +118,8 @@ namespace BizHawk.Emulation.Cores.Consoles.Sega.Saturn public new bool SaveRamModified => true; + public new bool SupportsSaveRam => true; + public new byte[] CloneSaveRam(bool clearDirty) { var data = new byte[_saturnus.GetSaveRamLength()]; @@ -126,7 +128,9 @@ namespace BizHawk.Emulation.Cores.Consoles.Sega.Saturn } public new void StoreSaveRam(byte[] data) - => _saturnus.PutSaveRam(data, data.Length); + { + _saturnus.PutSaveRam(data, data.Length); + } public bool IsSTV => _isArcade; } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Sega/gpgx64/GPGX.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/Sega/gpgx64/GPGX.ISaveRam.cs index 37ad060240..db5db5cf4f 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Sega/gpgx64/GPGX.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Sega/gpgx64/GPGX.ISaveRam.cs @@ -33,6 +33,11 @@ namespace BizHawk.Emulation.Cores.Consoles.Sega.gpgx return; } + if (!SupportsSaveRam) + { + return; + } + if (!Core.gpgx_put_sram(data, data.Length)) { throw new Exception("Core rejected saveram"); @@ -48,5 +53,15 @@ namespace BizHawk.Emulation.Cores.Consoles.Sega.gpgx return size > 0 && area != IntPtr.Zero; } } + + public bool SupportsSaveRam + { + get + { + var size = 0; + var area = Core.gpgx_get_sram(ref size); + return size == 0 || area == IntPtr.Zero; + } + } } } diff --git a/src/BizHawk.Emulation.Cores/Consoles/Sony/PSX/Octoshock.cs b/src/BizHawk.Emulation.Cores/Consoles/Sony/PSX/Octoshock.cs index 5a6d547160..309f668b0b 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/Sony/PSX/Octoshock.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/Sony/PSX/Octoshock.cs @@ -986,6 +986,8 @@ namespace BizHawk.Emulation.Cores.Sony.PSX } } + public bool SupportsSaveRam => true; + //THIS IS STILL AWFUL diff --git a/src/BizHawk.Emulation.Cores/Consoles/WonderSwan/WonderSwan.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Consoles/WonderSwan/WonderSwan.ISaveRam.cs index e338da7986..d1fdeb601d 100644 --- a/src/BizHawk.Emulation.Cores/Consoles/WonderSwan/WonderSwan.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Consoles/WonderSwan/WonderSwan.ISaveRam.cs @@ -30,5 +30,7 @@ namespace BizHawk.Emulation.Cores.WonderSwan } public bool SaveRamModified => BizSwan.bizswan_saveramsize(Core) > 0; + + public bool SupportsSaveRam => true; } } diff --git a/src/BizHawk.Emulation.Cores/Libretro/Libretro.ISaveRam.cs b/src/BizHawk.Emulation.Cores/Libretro/Libretro.ISaveRam.cs index 06d03f776f..95ac053ba0 100644 --- a/src/BizHawk.Emulation.Cores/Libretro/Libretro.ISaveRam.cs +++ b/src/BizHawk.Emulation.Cores/Libretro/Libretro.ISaveRam.cs @@ -12,6 +12,8 @@ namespace BizHawk.Emulation.Cores.Libretro public bool SaveRamModified => _saveramSize > 0; + public bool SupportsSaveRam => _saveramSize > 0; + public byte[] CloneSaveRam(bool clearDirty) { if (_saveramSize > 0) @@ -39,6 +41,8 @@ namespace BizHawk.Emulation.Cores.Libretro Marshal.Copy(data, index, m.Data, (int)m.Size); index += (int)m.Size; } + + if (data.Length != index) throw new InvalidOperationException("Incorrect sram size."); } } } diff --git a/src/BizHawk.Emulation.Cores/Waterbox/WaterboxCore.cs b/src/BizHawk.Emulation.Cores/Waterbox/WaterboxCore.cs index 9cbc2981b5..1022c808a3 100644 --- a/src/BizHawk.Emulation.Cores/Waterbox/WaterboxCore.cs +++ b/src/BizHawk.Emulation.Cores/Waterbox/WaterboxCore.cs @@ -158,6 +158,8 @@ namespace BizHawk.Emulation.Cores.Waterbox } } + public bool SupportsSaveRam => _saveramSize != 0; + public byte[] CloneSaveRam(bool clearDirty) { if (_saveramSize == 0) @@ -176,6 +178,8 @@ namespace BizHawk.Emulation.Cores.Waterbox public void StoreSaveRam(byte[] data) { + if (!SupportsSaveRam) return; + // Checking if the size of the SaveRAM provided coincides with that expected. This is important for cores whose SaveRAM size can vary depending on their configuration. if (data.Length != _saveramSize) {