Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion MPF.Avalonia/Windows/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
<ComboBox x:Name="MediaTypeComboBox"
Grid.Column="1"
ItemsSource="{Binding MediaTypes}"
SelectedItem="{Binding CurrentMediaType, Converter={StaticResource ElementConverter}, Mode=TwoWay}"
SelectedItem="{Binding CurrentPhysicalMediaType, Converter={StaticResource ElementConverter}, Mode=TwoWay}"
IsEnabled="{Binding MediaTypeComboBoxEnabled}" />
</Grid>

Expand Down
101 changes: 101 additions & 0 deletions MPF.Frontend.Test/DriveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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<string>();
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]
Expand Down
166 changes: 161 additions & 5 deletions MPF.Frontend/Drive.Linux.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ public partial class Drive
#region Fields

/// <summary>
/// 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).
/// </summary>
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
Expand All @@ -29,6 +29,21 @@ public partial class Drive
"zram", // compressed ramdisk
];

/// <summary>
/// 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.
/// </summary>
private static readonly HashSet<long> _unixFloppyMediaSizes = new HashSet<long>
{
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
Expand Down Expand Up @@ -96,6 +111,71 @@ private static Drive[] AppendUnixOpticalDrives(Drive[] drives)
return combined;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="drives">Drives already discovered via DriveInfo</param>
/// <returns>The drive array, extended with any floppy drives not already present</returns>
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<string>();
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<string>();
devicePaths.AddRange(EnumerateUnixFloppyBlockPaths("/dev"));
devicePaths.AddRange(EnumerateUnixUsbFloppyBlockPaths("/sys/block", "/dev"));

var extra = new List<Drive>();
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;
}

/// <summary>
/// Enumerate Linux optical block devices under a directory by matching
/// the kernel convention "sr" followed by one or more digits (sr0, sr1, ...).
Expand Down Expand Up @@ -127,6 +207,82 @@ internal static List<string> EnumerateUnixOpticalBlockPaths(string devRoot)
return result;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="devRoot">Root directory to scan (typically "/dev")</param>
/// <returns>Device paths, or an empty list when the directory is unreadable</returns>
internal static List<string> EnumerateUnixFloppyBlockPaths(string devRoot)
{
var result = new List<string>();
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;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="sysBlockRoot">sysfs block directory (typically "/sys/block")</param>
/// <param name="devRoot">Root directory device nodes live under (typically "/dev")</param>
/// <returns>Device paths, or an empty list when the directory is unreadable</returns>
internal static List<string> EnumerateUnixUsbFloppyBlockPaths(string sysBlockRoot, string devRoot)
{
var result = new List<string>();
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;
}

/// <summary>
/// Enumerate Linux optical drives via their generic SCSI nodes (/dev/sg*).
/// Unlike the block nodes, "sg" is not optical-specific, so each candidate is
Expand Down
9 changes: 9 additions & 0 deletions MPF.Frontend/Drive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,18 @@ private static List<Drive> 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];
Expand Down
6 changes: 6 additions & 0 deletions MPF.Frontend/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1217,6 +1217,12 @@ private void CacheCurrentMediaType()
if (CurrentDrive is null)
return;

// Forget the type detected for any previously selected drive; only a fresh

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, that actually makes a lot of sense. That probably explains a lot of weird interactions.

// 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;
Expand Down
2 changes: 1 addition & 1 deletion MPF.UI/Windows/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@
</ComboBox.ItemContainerStyle>
</ComboBox>
<ComboBox x:Name="MediaTypeComboBox" Grid.Row="0" Grid.Column="1" Height="22" Width="140" HorizontalAlignment="Right"
ItemsSource="{Binding MediaTypes}" SelectedItem="{Binding Path=CurrentMediaType, Converter={StaticResource ElementConverter}, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
ItemsSource="{Binding MediaTypes}" SelectedItem="{Binding Path=CurrentPhysicalMediaType, Converter={StaticResource ElementConverter}, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thank you!

IsEnabled="{Binding MediaTypeComboBoxEnabled}" Style="{DynamicResource CustomComboBoxStyle}"
Visibility="Hidden" />

Expand Down