diff --git a/CHANGELIST.md b/CHANGELIST.md
index 616c1a2ca..6d1649405 100644
--- a/CHANGELIST.md
+++ b/CHANGELIST.md
@@ -8,6 +8,9 @@
- Be more verbose about program path resolution
- Use updated version of ResolvePath from IO
- Make program support sets more obvious
+- Detect and list USB floppy drives on Linux (gmipf)
+- Fix media type dropdown not updating dumping parameters (gmipf)
+- Fix media type sticking when switching drives (gmipf)
### 3.8.2 (2026-07-01)
diff --git a/MPF.Avalonia/Windows/MainWindow.axaml b/MPF.Avalonia/Windows/MainWindow.axaml
index b617e5707..76645ce5c 100644
--- a/MPF.Avalonia/Windows/MainWindow.axaml
+++ b/MPF.Avalonia/Windows/MainWindow.axaml
@@ -122,7 +122,7 @@
diff --git a/MPF.Frontend.Test/DriveTests.cs b/MPF.Frontend.Test/DriveTests.cs
index 3d673583b..b64e2d32a 100644
--- a/MPF.Frontend.Test/DriveTests.cs
+++ b/MPF.Frontend.Test/DriveTests.cs
@@ -107,6 +107,107 @@ public void EnumerateUnixOpticalBlockPaths_OnlyMatchesSrFollowedByDigits()
#endregion
+ #region EnumerateUnixFloppyBlockPaths
+
+ [Fact]
+ public void EnumerateUnixFloppyBlockPaths_NullOrEmpty_ReturnsEmpty()
+ {
+ Assert.Empty(Drive.EnumerateUnixFloppyBlockPaths(null!));
+ Assert.Empty(Drive.EnumerateUnixFloppyBlockPaths(string.Empty));
+ }
+
+ [Fact]
+ public void EnumerateUnixFloppyBlockPaths_MissingDirectory_ReturnsEmpty()
+ {
+ string missing = Path.Combine(Path.GetTempPath(), "mpf-test-missing-" + Guid.NewGuid().ToString("N"));
+ Assert.Empty(Drive.EnumerateUnixFloppyBlockPaths(missing));
+ }
+
+ [Fact]
+ public void EnumerateUnixFloppyBlockPaths_OnlyMatchesFdFollowedByDigits()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "mpf-test-dev-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ try
+ {
+ File.WriteAllText(Path.Combine(root, "fd0"), string.Empty);
+ File.WriteAllText(Path.Combine(root, "fd1"), string.Empty);
+ File.WriteAllText(Path.Combine(root, "fd7"), string.Empty);
+ File.WriteAllText(Path.Combine(root, "fd0u1440"), string.Empty); // format-specific node, skipped
+ File.WriteAllText(Path.Combine(root, "fda"), string.Empty); // name not all-digits, skipped
+ File.WriteAllText(Path.Combine(root, "fd"), string.Empty); // no trailing digits, skipped
+ File.WriteAllText(Path.Combine(root, "sdb"), string.Empty); // unrelated device, skipped
+
+ var actual = Drive.EnumerateUnixFloppyBlockPaths(root);
+
+ var actualNames = new List();
+ foreach (var p in actual)
+ {
+ actualNames.Add(Path.GetFileName(p));
+ }
+ actualNames.Sort(StringComparer.Ordinal);
+
+ Assert.Equal(new[] { "fd0", "fd1", "fd7" }, actualNames);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ #endregion
+
+ #region EnumerateUnixUsbFloppyBlockPaths
+
+ [Fact]
+ public void EnumerateUnixUsbFloppyBlockPaths_NullOrEmpty_ReturnsEmpty()
+ {
+ Assert.Empty(Drive.EnumerateUnixUsbFloppyBlockPaths(null!, "/dev"));
+ Assert.Empty(Drive.EnumerateUnixUsbFloppyBlockPaths(string.Empty, "/dev"));
+ Assert.Empty(Drive.EnumerateUnixUsbFloppyBlockPaths("/sys/block", null!));
+ Assert.Empty(Drive.EnumerateUnixUsbFloppyBlockPaths("/sys/block", string.Empty));
+ }
+
+ [Fact]
+ public void EnumerateUnixUsbFloppyBlockPaths_MissingDirectory_ReturnsEmpty()
+ {
+ string missing = Path.Combine(Path.GetTempPath(), "mpf-test-missing-" + Guid.NewGuid().ToString("N"));
+ Assert.Empty(Drive.EnumerateUnixUsbFloppyBlockPaths(missing, "/dev"));
+ }
+
+ [Fact]
+ public void EnumerateUnixUsbFloppyBlockPaths_OnlyMatchesRemovableFloppySizedDisks()
+ {
+ string sysfs = Path.Combine(Path.GetTempPath(), "mpf-test-sysblock-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(sysfs);
+ try
+ {
+ WriteSysBlockDevice(sysfs, "sdh", removable: "1", sizeSectors: "2880"); // USB floppy, 1.44 MB, matched
+ WriteSysBlockDevice(sysfs, "sdi", removable: "1\n", sizeSectors: "1440\n"); // USB floppy, 720 KB (trailing newline), matched
+ WriteSysBlockDevice(sysfs, "sda", removable: "0", sizeSectors: "2880"); // fixed disk of floppy size, not removable, skipped
+ WriteSysBlockDevice(sysfs, "sdb", removable: "1", sizeSectors: "30310400"); // USB flash drive, not a floppy size, skipped
+ WriteSysBlockDevice(sysfs, "sdc", removable: "1", sizeSectors: "0"); // empty floppy drive (no media), skipped
+ WriteSysBlockDevice(sysfs, "nvme0n1", removable: "1", sizeSectors: "2880"); // not an sd* device, not scanned
+
+ var actual = Drive.EnumerateUnixUsbFloppyBlockPaths(sysfs, "/dev");
+
+ var actualNames = new List();
+ foreach (var p in actual)
+ {
+ actualNames.Add(Path.GetFileName(p));
+ }
+ actualNames.Sort(StringComparer.Ordinal);
+
+ Assert.Equal(new[] { "sdh", "sdi" }, actualNames);
+ }
+ finally
+ {
+ Directory.Delete(sysfs, recursive: true);
+ }
+ }
+
+ #endregion
+
#region EnumerateUnixOpticalGenericPaths
[Fact]
diff --git a/MPF.Frontend/Drive.Linux.cs b/MPF.Frontend/Drive.Linux.cs
index 7b53de3b3..3b772b5f2 100644
--- a/MPF.Frontend/Drive.Linux.cs
+++ b/MPF.Frontend/Drive.Linux.cs
@@ -11,15 +11,15 @@ public partial class Drive
#region Fields
///
- /// sysfs block-device name prefixes that are not user-facing dump targets: loopback
- /// images, ramdisks, device-mapper and software RAID virtual devices, floppy and
- /// network block devices, ZFS volumes, and optical drives (surfaced separately via
- /// the optical enumerator).
+ /// sysfs block-device name prefixes that the fixed-drive enumerator does not surface:
+ /// loopback images, ramdisks, device-mapper and software RAID virtual devices, network
+ /// block devices, ZFS volumes, and the floppy and optical drives (each surfaced
+ /// separately by its own enumerator rather than as a fixed or removable disk).
///
private static readonly string[] _excludedUnixBlockPrefixes =
[
"dm-", // device-mapper (LVM/LUKS, etc.) virtual devices
- "fd", // floppy disk
+ "fd", // floppy drive (surfaced separately by the floppy enumerator)
"loop", // loopback-mounted image
"md", // software RAID (mdadm)
"nbd", // network block device
@@ -29,6 +29,21 @@ public partial class Drive
"zram", // compressed ramdisk
];
+ ///
+ /// Floppy media sizes in bytes for the standard 5.25" and 3.5" PC formats
+ /// (360 KB, 720 KB, 1.2 MB, 1.44 MB, 2.88 MB). A removable SCSI disk reporting one of
+ /// these exact capacities is a USB floppy with media inserted; no other removable
+ /// storage uses these sizes, so this doubles as the floppy identity check.
+ ///
+ private static readonly HashSet _unixFloppyMediaSizes = new HashSet
+ {
+ 368640, // 360 KB (5.25" DD)
+ 737280, // 720 KB (3.5" DD)
+ 1228800, // 1.2 MB (5.25" HD)
+ 1474560, // 1.44 MB (3.5" HD)
+ 2949120, // 2.88 MB (3.5" ED)
+ };
+
#endregion
#region Linux Helpers
@@ -96,6 +111,71 @@ private static Drive[] AppendUnixOpticalDrives(Drive[] drives)
return combined;
}
+ ///
+ /// Append Linux floppy drives that DriveInfo did not surface. Two kinds are covered:
+ /// legacy on-board floppy nodes (/dev/fd0, /dev/fd1, ...) and USB floppy drives, which
+ /// the kernel exposes as ordinary SCSI disks (/dev/sd*). Neither is ever a mount point,
+ /// so DriveInfo does not list them; they are enumerated directly so users can dump disks
+ /// without mounting them first. Like optical drives, floppy drives are surfaced
+ /// regardless of the fixed-drive toggle, mirroring how Windows tags floppy media
+ /// (Win32_LogicalDisk.MediaType) in MarkWindowsFloppyDrives.
+ ///
+ /// Drives already discovered via DriveInfo
+ /// The drive array, extended with any floppy drives not already present
+ private static Drive[] AppendUnixFloppyDrives(Drive[] drives)
+ {
+ // Defensive: this reads Linux /dev paths, so never run it off-Unix
+ if (Environment.OSVersion.Platform != PlatformID.Unix)
+ return drives;
+
+ var existingNames = new HashSet();
+ foreach (var d in drives)
+ {
+ if (d?.Name is not null)
+ existingNames.Add(d.Name);
+ }
+
+ // Legacy /dev/fd* nodes plus USB floppy drives exposed as SCSI disks (/dev/sd*)
+ var devicePaths = new List();
+ devicePaths.AddRange(EnumerateUnixFloppyBlockPaths("/dev"));
+ devicePaths.AddRange(EnumerateUnixUsbFloppyBlockPaths("/sys/block", "/dev"));
+
+ var extra = new List();
+ foreach (var devicePath in devicePaths)
+ {
+ // Skip paths already surfaced by DriveInfo or an earlier enumerator
+ if (!existingNames.Add(devicePath))
+ continue;
+
+ try
+ {
+ var d = Create(Frontend.InternalDriveType.Floppy, devicePath);
+ if (d != null)
+ {
+ // A Linux floppy /dev node is never a mount point, so DriveInfo always
+ // reports it as not-ready. Mark it active like optical drives; whether a
+ // disk is actually inserted is left to the dumping program rather than
+ // probing the device here, since floppy media state is not reliably
+ // exposed through sysfs.
+ d.MarkedActive = true;
+ extra.Add(d);
+ }
+ }
+ catch
+ {
+ // Skip devices that can't be opened
+ }
+ }
+
+ if (extra.Count == 0)
+ return drives;
+
+ var combined = new Drive[drives.Length + extra.Count];
+ Array.Copy(drives, combined, drives.Length);
+ extra.CopyTo(combined, drives.Length);
+ return combined;
+ }
+
///
/// Enumerate Linux optical block devices under a directory by matching
/// the kernel convention "sr" followed by one or more digits (sr0, sr1, ...).
@@ -127,6 +207,82 @@ internal static List EnumerateUnixOpticalBlockPaths(string devRoot)
return result;
}
+ ///
+ /// Enumerate Linux floppy block devices under a directory by matching the kernel
+ /// convention "fd" followed by one or more digits (fd0, fd1, ...). This deliberately
+ /// rejects the "/dev/fd" symlink (no trailing digits) and format-specific nodes such
+ /// as "fd0u1440" (embedded non-digit characters), which are not whole-device targets.
+ ///
+ /// Root directory to scan (typically "/dev")
+ /// Device paths, or an empty list when the directory is unreadable
+ internal static List EnumerateUnixFloppyBlockPaths(string devRoot)
+ {
+ var result = new List();
+ if (string.IsNullOrEmpty(devRoot) || !Directory.Exists(devRoot))
+ return result;
+
+ string[] candidates;
+ try
+ {
+ candidates = Directory.GetFiles(devRoot, "fd*");
+ }
+ catch
+ {
+ return result;
+ }
+
+ foreach (var path in candidates)
+ {
+ if (HasDeviceIndexSuffix(Path.GetFileName(path), "fd"))
+ result.Add(path);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Enumerate USB floppy drives, which the kernel exposes as ordinary SCSI disks
+ /// (/dev/sd*) rather than /dev/fd* nodes. A disk is treated as a floppy when it reports
+ /// itself removable and its media is one of the standard floppy sizes. This mirrors how
+ /// Windows identifies a floppy from the inserted medium (Win32_LogicalDisk.MediaType)
+ /// and, like that path, only recognizes the drive while floppy media is present.
+ ///
+ /// sysfs block directory (typically "/sys/block")
+ /// Root directory device nodes live under (typically "/dev")
+ /// Device paths, or an empty list when the directory is unreadable
+ internal static List EnumerateUnixUsbFloppyBlockPaths(string sysBlockRoot, string devRoot)
+ {
+ var result = new List();
+ if (string.IsNullOrEmpty(sysBlockRoot) || string.IsNullOrEmpty(devRoot))
+ return result;
+ if (!Directory.Exists(sysBlockRoot))
+ return result;
+
+ string[] entries;
+ try
+ {
+ entries = Directory.GetFileSystemEntries(sysBlockRoot, "sd*");
+ }
+ catch
+ {
+ return result;
+ }
+
+ foreach (var entry in entries)
+ {
+ // A floppy reports itself removable and its media is a standard floppy size;
+ // both come from world-readable sysfs, so no elevated privileges are needed.
+ if (!ReadUnixRemovableFlag(entry))
+ continue;
+ if (!_unixFloppyMediaSizes.Contains(ReadUnixBlockDeviceSize(entry)))
+ continue;
+
+ result.Add(Path.Combine(devRoot, Path.GetFileName(entry)));
+ }
+
+ return result;
+ }
+
///
/// Enumerate Linux optical drives via their generic SCSI nodes (/dev/sg*).
/// Unlike the block nodes, "sg" is not optical-specific, so each candidate is
diff --git a/MPF.Frontend/Drive.cs b/MPF.Frontend/Drive.cs
index 2abc9b93a..840b15c0b 100644
--- a/MPF.Frontend/Drive.cs
+++ b/MPF.Frontend/Drive.cs
@@ -322,9 +322,18 @@ private static List GetDriveList(bool ignoreFixedDrives)
}
else if (isUnix)
{
+ // Optical drives are removable dump targets, so surface them regardless of the
+ // fixed-drive toggle. Floppy drives are hidden unless the user opts to show
+ // fixed drives, matching Windows (where floppies enumerate as removable media
+ // that is only queried alongside fixed drives) and because the default program,
+ // Redumper, does not support them. Floppy runs before the fixed enumerator so a
+ // USB floppy is tagged Floppy and then skipped there.
drives = AppendUnixOpticalDrives(drives);
if (!ignoreFixedDrives)
+ {
+ drives = AppendUnixFloppyDrives(drives);
drives = AppendUnixFixedDrives(drives);
+ }
}
return [.. drives];
diff --git a/MPF.Frontend/ViewModels/MainViewModel.cs b/MPF.Frontend/ViewModels/MainViewModel.cs
index 9f666fb43..fc1993360 100644
--- a/MPF.Frontend/ViewModels/MainViewModel.cs
+++ b/MPF.Frontend/ViewModels/MainViewModel.cs
@@ -1217,6 +1217,12 @@ private void CacheCurrentMediaType()
if (CurrentDrive is null)
return;
+ // Forget the type detected for any previously selected drive; only a fresh
+ // successful detection below sets a new one. Without this reset a type like
+ // FloppyDisk would stick when switching to a drive whose media cannot be read
+ // (e.g. an empty optical drive), overriding the correct system default.
+ _detectedPhysicalMediaType = null;
+
// Get reasonable default values based on the current system
var mediaTypes = CurrentSystem.MediaTypes();
PhysicalMediaType? defaultPhysicalMediaType = mediaTypes.Count > 0 ? mediaTypes[0] : PhysicalMediaType.CDROM;
diff --git a/MPF.UI/Windows/MainWindow.xaml b/MPF.UI/Windows/MainWindow.xaml
index e74bccc3c..e0def587f 100644
--- a/MPF.UI/Windows/MainWindow.xaml
+++ b/MPF.UI/Windows/MainWindow.xaml
@@ -209,7 +209,7 @@