diff --git a/CHANGELIST.md b/CHANGELIST.md
index 07732457f..3a66022b4 100644
--- a/CHANGELIST.md
+++ b/CHANGELIST.md
@@ -56,6 +56,7 @@
- Add man page output to the command-line programs (gmipf)
- Add a man page for the graphical frontend (gmipf)
- Normalize CLI feature handling
+- Root the default output path when the current folder is unwritable (gmipf)
### 3.8.3 (2026-07-06)
diff --git a/MPF.Frontend.Test/OptionsTests.cs b/MPF.Frontend.Test/OptionsTests.cs
new file mode 100644
index 000000000..4ae5d769e
--- /dev/null
+++ b/MPF.Frontend.Test/OptionsTests.cs
@@ -0,0 +1,79 @@
+using System.IO;
+using SabreTools.IO;
+using Xunit;
+
+namespace MPF.Frontend.Test
+{
+ public class OptionsTests
+ {
+ ///
+ /// A writable current directory keeps the relative default
+ ///
+ [Fact]
+ public void GetDefaultOutputPath_WritableDirectory_ReturnsRelativePath()
+ {
+ string currentDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(currentDirectory);
+
+ try
+ {
+ string actual = DumpSettings.GetDefaultOutputPath(currentDirectory);
+ Assert.Equal("ISO", actual);
+ }
+ finally
+ {
+ Directory.Delete(currentDirectory);
+ }
+ }
+
+ ///
+ /// A current directory that can not hold a file falls back to the home directory
+ ///
+ ///
+ /// A file stands in for the directory because that is the only way of making the creation
+ /// fail on every platform. Denied permissions end up in the same branch.
+ ///
+ [Fact]
+ public void GetDefaultOutputPath_UnwritableDirectory_ReturnsHomePath()
+ {
+ string currentDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ File.Create(currentDirectory).Dispose();
+
+ try
+ {
+ string actual = DumpSettings.GetDefaultOutputPath(currentDirectory);
+ Assert.Equal(Path.Combine(PathTool.GetHomeDirectory(), "ISO"), actual);
+ }
+ finally
+ {
+ File.Delete(currentDirectory);
+ }
+ }
+
+ ///
+ /// A missing current directory falls back to the home directory
+ ///
+ [Fact]
+ public void GetDefaultOutputPath_MissingDirectory_ReturnsHomePath()
+ {
+ string currentDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+
+ string actual = DumpSettings.GetDefaultOutputPath(currentDirectory);
+
+ Assert.Equal(Path.Combine(PathTool.GetHomeDirectory(), "ISO"), actual);
+ }
+
+ ///
+ /// Clearing the default output path restores the default instead of leaving it empty
+ ///
+ [Fact]
+ public void DefaultOutputPath_SetToNull_RestoresDefault()
+ {
+ var options = new Options();
+
+ options.Dumping.DefaultOutputPath = null;
+
+ Assert.Equal(DumpSettings.DefaultOutputPathValue, options.Dumping.DefaultOutputPath);
+ }
+ }
+}
diff --git a/MPF.Frontend/Options.cs b/MPF.Frontend/Options.cs
index 884005c8c..d1707f1f6 100644
--- a/MPF.Frontend/Options.cs
+++ b/MPF.Frontend/Options.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using Newtonsoft.Json;
+using SabreTools.IO;
using SabreTools.RedumpLib.Data;
using AaruDumpSettings = MPF.ExecutionContexts.Aaru.DumpSettings;
using DiscImageCreatorDumpSettings = MPF.ExecutionContexts.DiscImageCreator.DumpSettings;
@@ -179,6 +180,11 @@ public Options(Options? source)
///
public class DumpSettings
{
+ ///
+ /// Default output directory name
+ ///
+ private const string DefaultOutputDirectoryName = "ISO";
+
#region Default Paths
///
@@ -281,6 +287,57 @@ internal static string DefaultRedumperPath
}
}
+ ///
+ /// Default output path for dumps
+ ///
+ ///
+ /// The relative path is kept wherever the current directory can be written to, which is the
+ /// portable layout. It only resolves to the home directory when the current directory can
+ /// not be written to, such as an installed copy launched from a read-only location.
+ ///
+ [JsonIgnore]
+ internal static string DefaultOutputPathValue
+ {
+ get
+ {
+ if (field is not null)
+ return field;
+
+ field = GetDefaultOutputPath(Environment.CurrentDirectory);
+ return field;
+ }
+ }
+
+ ///
+ /// Attempt to determine the default output path
+ ///
+ /// Directory that a relative path would resolve against
+ internal static string GetDefaultOutputPath(string currentDirectory)
+ {
+ // Portable output -- keep the relative path wherever the current directory can hold a
+ // file. Creating one is the only portable way of asking; the probe is removed again, and
+ // only the creation decides so that a failed cleanup does not read as unwritable.
+ if (!string.IsNullOrEmpty(currentDirectory))
+ {
+ try
+ {
+ string probePath = Path.Combine(currentDirectory, Path.GetRandomFileName());
+ File.Create(probePath).Dispose();
+ try { File.Delete(probePath); } catch { }
+ return DefaultOutputDirectoryName;
+ }
+ catch { }
+ }
+
+ // User output
+ string homeDir = PathTool.GetHomeDirectory();
+ if (!string.IsNullOrEmpty(homeDir))
+ return Path.Combine(homeDir, DefaultOutputDirectoryName);
+
+ // This should not happen
+ return DefaultOutputDirectoryName;
+ }
+
#endregion
#region Internal Program
@@ -350,9 +407,9 @@ public string? DefaultOutputPath
get;
set
{
- field = value ?? "ISO";
+ field = value ?? DefaultOutputPathValue;
}
- } = "ISO";
+ } = DefaultOutputPathValue;
///
/// Default system if none can be detected
diff --git a/MPF.Frontend/Tools/OptionsLoader.cs b/MPF.Frontend/Tools/OptionsLoader.cs
index 05362a26a..64cfd8d23 100644
--- a/MPF.Frontend/Tools/OptionsLoader.cs
+++ b/MPF.Frontend/Tools/OptionsLoader.cs
@@ -322,7 +322,7 @@ private static Options ConvertFromDictionary(Dictionary? source
options.Dumping.DreamdumpPath = GetStringSetting(source, "DreamdumpPath", DumpSettings.DefaultDreamdumpPath) ?? DumpSettings.DefaultDreamdumpPath;
options.Dumping.RedumperPath = GetStringSetting(source, "RedumperPath", DumpSettings.DefaultRedumperPath) ?? DumpSettings.DefaultRedumperPath;
- options.Dumping.DefaultOutputPath = GetStringSetting(source, "DefaultOutputPath", "ISO");
+ options.Dumping.DefaultOutputPath = GetStringSetting(source, "DefaultOutputPath", DumpSettings.DefaultOutputPathValue);
valueString = GetStringSetting(source, "DefaultSystem", PhysicalSystem.IBMPCcompatible.ToString());
options.Dumping.DefaultSystem = (valueString ?? string.Empty).ToPhysicalSystem();
options.Dumping.DumpSpeeds.CD = GetInt32Setting(source, "PreferredDumpSpeedCD", 24);